@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,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Slim snapshot of the S4L autopilot scheduled-task registry state.
|
|
3
|
+
|
|
4
|
+
Answers, per install, the question we were previously blind to: "are the
|
|
5
|
+
queue-worker scheduled tasks actually running from the dedicated ~/.s4l-worker
|
|
6
|
+
folder (so their once-a-minute sessions don't flood the user's project history),
|
|
7
|
+
or are they still mislocated, and is the deprecated autopilot task lingering?"
|
|
8
|
+
|
|
9
|
+
The heartbeat (scripts/heartbeat.sh + mcp/src/telemetry.ts) attaches the
|
|
10
|
+
`--summary` output as the top-level `scheduled_tasks` field of the heartbeat
|
|
11
|
+
body, so the state lands on the installations row centrally, keyed by
|
|
12
|
+
install_id, with no SSH needed.
|
|
13
|
+
|
|
14
|
+
Read-only: never edits a registry (that is the menubar's
|
|
15
|
+
`_rewrite_scheduled_task_cwd` job). Stdlib only, /usr/bin/python3 compatible.
|
|
16
|
+
|
|
17
|
+
Kept in sync with mcp/menubar/s4l_menubar.py (WORKER_TASK_IDS,
|
|
18
|
+
DEPRECATED_TASK_IDS, WORKER_CWD, SCHED_REGISTRY_GLOB) and scripts/s4l_box_update.sh.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import glob
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
# --- Kept in sync with mcp/menubar/s4l_menubar.py ---------------------------
|
|
30
|
+
WORKER_TASK_IDS = ("saps-phase1-query", "saps-phase2b-draft")
|
|
31
|
+
DEPRECATED_TASK_IDS = ("social-autoposter-autopilot",)
|
|
32
|
+
WORKER_CWD = os.path.join(os.path.expanduser("~"), ".s4l-worker")
|
|
33
|
+
# "Claude*": the host app can run with a custom --user-data-dir (per-account
|
|
34
|
+
# dirs like "Claude-mediar"), putting the live registry outside plain "Claude/".
|
|
35
|
+
# Keep in sync with scripts/schedule_state.py::SCHED_REGISTRY_GLOB.
|
|
36
|
+
SCHED_REGISTRY_GLOB = os.path.join(
|
|
37
|
+
os.path.expanduser("~"), "Library", "Application Support", "Claude*",
|
|
38
|
+
"claude-code-sessions", "*", "*", "scheduled-tasks.json",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _cwd_tail(cwd: str) -> str:
|
|
43
|
+
"""Last path component only, so we surface WHERE a mislocated task points
|
|
44
|
+
(e.g. 's4lsetup' vs '.s4l-worker') without shipping the full home path /
|
|
45
|
+
username off-box."""
|
|
46
|
+
if not cwd:
|
|
47
|
+
return ""
|
|
48
|
+
return os.path.basename(os.path.normpath(cwd))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_summary() -> dict:
|
|
52
|
+
"""Scan every scheduled-tasks.json registry and summarize the S4L worker
|
|
53
|
+
tasks' folder state. Never raises; a broken/absent registry yields an empty
|
|
54
|
+
(but well-formed) summary so the heartbeat body is always valid."""
|
|
55
|
+
tasks: list[dict] = []
|
|
56
|
+
registries = 0
|
|
57
|
+
deprecated_present = False
|
|
58
|
+
seen_ids: set[str] = set()
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
files = glob.glob(SCHED_REGISTRY_GLOB)
|
|
62
|
+
except Exception:
|
|
63
|
+
files = []
|
|
64
|
+
|
|
65
|
+
for f in files:
|
|
66
|
+
try:
|
|
67
|
+
with open(f) as fh:
|
|
68
|
+
d = json.load(fh)
|
|
69
|
+
except Exception:
|
|
70
|
+
continue
|
|
71
|
+
registries += 1
|
|
72
|
+
for t in (d.get("scheduledTasks") or []):
|
|
73
|
+
tid = t.get("id")
|
|
74
|
+
if tid in DEPRECATED_TASK_IDS:
|
|
75
|
+
deprecated_present = True
|
|
76
|
+
continue
|
|
77
|
+
if tid not in WORKER_TASK_IDS:
|
|
78
|
+
continue
|
|
79
|
+
cwd = t.get("cwd") or ""
|
|
80
|
+
in_worker = os.path.normpath(cwd) == os.path.normpath(WORKER_CWD) if cwd else False
|
|
81
|
+
seen_ids.add(tid)
|
|
82
|
+
tasks.append({
|
|
83
|
+
"id": tid,
|
|
84
|
+
"enabled": bool(t.get("enabled")),
|
|
85
|
+
"in_worker_dir": in_worker,
|
|
86
|
+
"cwd_tail": _cwd_tail(cwd),
|
|
87
|
+
"last_run_at": t.get("lastRunAt"),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
mislocated = sum(1 for t in tasks if not t["in_worker_dir"])
|
|
91
|
+
return {
|
|
92
|
+
"worker_dir_tail": _cwd_tail(WORKER_CWD),
|
|
93
|
+
"registries": registries,
|
|
94
|
+
"worker_tasks": len(tasks),
|
|
95
|
+
"missing_worker_tasks": sorted(set(WORKER_TASK_IDS) - seen_ids),
|
|
96
|
+
"mislocated": mislocated,
|
|
97
|
+
# all_in_worker_dir is False when there are zero worker tasks too, since
|
|
98
|
+
# "no autopilot registered" is itself a state worth seeing centrally.
|
|
99
|
+
"all_in_worker_dir": bool(tasks) and mislocated == 0,
|
|
100
|
+
"deprecated_present": deprecated_present,
|
|
101
|
+
"tasks": tasks,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main() -> int:
|
|
106
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--summary",
|
|
109
|
+
action="store_true",
|
|
110
|
+
help="Print a slim JSON summary to stdout and exit. Used by the heartbeat.",
|
|
111
|
+
)
|
|
112
|
+
args = parser.parse_args()
|
|
113
|
+
|
|
114
|
+
summary = build_summary()
|
|
115
|
+
if args.summary:
|
|
116
|
+
sys.stdout.write(json.dumps(summary, separators=(",", ":")))
|
|
117
|
+
else:
|
|
118
|
+
sys.stdout.write(json.dumps(summary, indent=2) + "\n")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main())
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
score_linkedin_candidates.py
|
|
4
|
+
|
|
5
|
+
Reads a JSON array of LinkedIn SERP candidates (from stdin or --file),
|
|
6
|
+
computes engagement velocity + LinkedIn-tuned virality score, and upserts
|
|
7
|
+
into linkedin_candidates. Also expires + prunes old rows.
|
|
8
|
+
|
|
9
|
+
Why this exists, vs Twitter's score_twitter_candidates.py:
|
|
10
|
+
|
|
11
|
+
Twitter's pipeline runs every 20 min and uses a two-phase delta-momentum
|
|
12
|
+
gate (T0 scan, sleep 5 min, T1 rescan, score = delta engagement / 5 min).
|
|
13
|
+
LinkedIn is ad-hoc and we cannot afford the 5-min wait per cycle, so the
|
|
14
|
+
single-shot substitute is *engagement velocity since post creation*:
|
|
15
|
+
|
|
16
|
+
velocity = (reactions + 2*comments + 3*reposts) / max(age_hours, 0.5)
|
|
17
|
+
|
|
18
|
+
Comments weighted higher than reposts than reactions because comments
|
|
19
|
+
signal a live conversation a reply can join. The 0.5-hour floor stops
|
|
20
|
+
brand-new posts from infinity-spiking.
|
|
21
|
+
|
|
22
|
+
The full virality score layers in author follower reach + age decay so a
|
|
23
|
+
trending post from a sub-50K-follower practitioner outranks a stale
|
|
24
|
+
influencer post with the same raw velocity.
|
|
25
|
+
|
|
26
|
+
Input JSON shape (one element per candidate, scraped via the
|
|
27
|
+
mcp__linkedin-agent walk in run-linkedin.sh Phase B):
|
|
28
|
+
|
|
29
|
+
[
|
|
30
|
+
{
|
|
31
|
+
"post_url": "https://www.linkedin.com/feed/update/urn:li:activity:...",
|
|
32
|
+
"activity_id": "1234567890123456789",
|
|
33
|
+
"all_urns": ["1234567890123456789", "..."],
|
|
34
|
+
"author_name": "First Last",
|
|
35
|
+
"author_profile_url": "https://www.linkedin.com/in/SLUG/",
|
|
36
|
+
"author_followers": 12345,
|
|
37
|
+
"post_text": "first 500 chars",
|
|
38
|
+
"age_hours": 6.5,
|
|
39
|
+
"reactions": 42,
|
|
40
|
+
"comments": 7,
|
|
41
|
+
"reposts": 3,
|
|
42
|
+
"search_topic": "AI agents in production",
|
|
43
|
+
"search_query": "ai agents production",
|
|
44
|
+
"matched_project": "fazm",
|
|
45
|
+
"language": "en",
|
|
46
|
+
"serp_quality_score": 7.5
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
Usage:
|
|
51
|
+
python3 scripts/score_linkedin_candidates.py --batch-id <id> < candidates.json
|
|
52
|
+
python3 scripts/score_linkedin_candidates.py --file /tmp/c.json --batch-id <id>
|
|
53
|
+
python3 scripts/score_linkedin_candidates.py --expire-only
|
|
54
|
+
|
|
55
|
+
Pair with: top_linkedin_queries.py, top_dud_linkedin_queries.py,
|
|
56
|
+
log_linkedin_search_attempts.py.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
import argparse
|
|
60
|
+
import json
|
|
61
|
+
import math
|
|
62
|
+
import os
|
|
63
|
+
import re
|
|
64
|
+
import sys
|
|
65
|
+
from datetime import datetime, timezone
|
|
66
|
+
|
|
67
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
68
|
+
from http_api import api_get, api_post
|
|
69
|
+
try: # author exclusion is fail-open: never let it break scoring
|
|
70
|
+
from linkedin_exclusions import load_exclusions, classify_author
|
|
71
|
+
except Exception: # pragma: no cover - helper missing -> exclusion is a no-op
|
|
72
|
+
def load_exclusions(platform="linkedin"):
|
|
73
|
+
return {"hard_slugs": set(), "soft_slugs": set(), "soft_names": set()}
|
|
74
|
+
|
|
75
|
+
def classify_author(author_name, author_profile_url, excl=None):
|
|
76
|
+
return None, ""
|
|
77
|
+
try:
|
|
78
|
+
from account_resolver import resolve as _resolve_account
|
|
79
|
+
except Exception:
|
|
80
|
+
def _resolve_account(_platform): # type: ignore[unused-arg]
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Engagement weights. Comments worth more than reposts worth more than
|
|
85
|
+
# reactions because comments are the strongest "this thread is alive"
|
|
86
|
+
# signal for an outbound reply.
|
|
87
|
+
W_REACTIONS = 1.0
|
|
88
|
+
W_COMMENTS = 2.0
|
|
89
|
+
W_REPOSTS = 3.0
|
|
90
|
+
|
|
91
|
+
# Floor on age_hours so freshly-posted (<30 min old) posts cannot
|
|
92
|
+
# infinity-spike the velocity score. 0.5 = 30 min.
|
|
93
|
+
AGE_FLOOR_HOURS = 0.5
|
|
94
|
+
|
|
95
|
+
# Maximum age we'll consider. Posts older than this are too cold —
|
|
96
|
+
# the conversation has moved on, our reply lands in a graveyard. Mirrors
|
|
97
|
+
# Twitter's 18h ceiling, scaled up because LinkedIn threads stay live
|
|
98
|
+
# longer (multi-day).
|
|
99
|
+
MAX_AGE_HOURS = 96.0 # 4 days
|
|
100
|
+
|
|
101
|
+
# Freshness gate. We flip stale pending rows to 'expired' so they stop
|
|
102
|
+
# burning judgment tokens, but we NEVER delete rows. Per user instruction
|
|
103
|
+
# 2026-05-08, terminal rows are kept forever for analytics.
|
|
104
|
+
EXPIRE_PENDING_AFTER_HOURS = 96.0 # match MAX_AGE_HOURS
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def calculate_velocity_score(cand):
|
|
108
|
+
"""Return (velocity, virality, age_hours_clamped).
|
|
109
|
+
|
|
110
|
+
velocity is the raw weighted-engagement-per-hour signal. virality
|
|
111
|
+
layers in follower reach + age decay so the candidate-picker can
|
|
112
|
+
rank across a SERP regardless of absolute size.
|
|
113
|
+
"""
|
|
114
|
+
reactions = int(cand.get("reactions", 0) or 0)
|
|
115
|
+
comments = int(cand.get("comments", 0) or 0)
|
|
116
|
+
reposts = int(cand.get("reposts", 0) or 0)
|
|
117
|
+
followers = int(cand.get("author_followers", 0) or 0)
|
|
118
|
+
|
|
119
|
+
age_hours = float(cand.get("age_hours", 0) or 0)
|
|
120
|
+
if age_hours < AGE_FLOOR_HOURS:
|
|
121
|
+
age_hours = AGE_FLOOR_HOURS
|
|
122
|
+
|
|
123
|
+
weighted_eng = (
|
|
124
|
+
W_REACTIONS * reactions
|
|
125
|
+
+ W_COMMENTS * comments
|
|
126
|
+
+ W_REPOSTS * reposts
|
|
127
|
+
)
|
|
128
|
+
velocity = weighted_eng / age_hours
|
|
129
|
+
|
|
130
|
+
# Author reach multiplier. LinkedIn-specific tuning: practitioner
|
|
131
|
+
# accounts (5K-50K followers) are the sweet spot for outbound
|
|
132
|
+
# replies — they have audience but aren't influencer-saturated, so
|
|
133
|
+
# our reply has a real chance of being seen.
|
|
134
|
+
if followers <= 0:
|
|
135
|
+
# Unknown follower count: don't penalize, just don't reward.
|
|
136
|
+
reach_mult = 0.8
|
|
137
|
+
elif followers < 500:
|
|
138
|
+
reach_mult = 0.4
|
|
139
|
+
elif followers < 2000:
|
|
140
|
+
reach_mult = 0.7
|
|
141
|
+
elif followers < 5000:
|
|
142
|
+
reach_mult = 0.95
|
|
143
|
+
elif followers < 50000:
|
|
144
|
+
reach_mult = 1.0 # sweet spot
|
|
145
|
+
elif followers < 200000:
|
|
146
|
+
reach_mult = 1.2
|
|
147
|
+
elif followers < 500000:
|
|
148
|
+
reach_mult = 1.0
|
|
149
|
+
else:
|
|
150
|
+
reach_mult = 0.85 # mega accounts: lower hit rate, drowned out
|
|
151
|
+
|
|
152
|
+
# Age decay. Half-life 24h on LinkedIn (vs 6h on Twitter): threads
|
|
153
|
+
# stay live longer. ln(2)/24 ≈ 0.0289.
|
|
154
|
+
# 12h = 71%, 24h = 50%, 48h = 25%, 96h = 6%.
|
|
155
|
+
age_decay = math.exp(-0.0289 * age_hours)
|
|
156
|
+
|
|
157
|
+
# Discussion-quality bonus: comments-to-reactions ratio. High ratio
|
|
158
|
+
# (>10%) means it's an actual conversation, not a one-way like dump.
|
|
159
|
+
if reactions > 0:
|
|
160
|
+
disc_ratio = comments / reactions
|
|
161
|
+
else:
|
|
162
|
+
disc_ratio = 0
|
|
163
|
+
disc_bonus = min(disc_ratio * 5, 1.0) # up to +1.0x
|
|
164
|
+
|
|
165
|
+
virality = velocity * reach_mult * age_decay * (1.0 + disc_bonus)
|
|
166
|
+
|
|
167
|
+
return round(velocity, 2), round(virality, 2), round(age_hours, 2)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _normalize_post_url(url):
|
|
171
|
+
"""Normalize a LinkedIn post URL to the canonical /feed/update/<urn> form,
|
|
172
|
+
preserving the URN namespace (activity vs share vs ugcPost).
|
|
173
|
+
|
|
174
|
+
Reality check (verified 2026-05-01 with Andreas Mautsch's "Apple Container"
|
|
175
|
+
post): activity / share / ugcPost URNs for the same logical post are
|
|
176
|
+
DIFFERENT numeric IDs and LinkedIn does NOT auto-redirect across them.
|
|
177
|
+
/feed/update/urn:li:activity:<share_id>/ returns "Post not found" if the
|
|
178
|
+
numeric is actually a share ID. So we MUST keep the original namespace,
|
|
179
|
+
not collapse to activity. See linkedin_url.py docstring for context.
|
|
180
|
+
|
|
181
|
+
Inputs accepted:
|
|
182
|
+
* /feed/update/urn:li:activity:NUMERIC/
|
|
183
|
+
* /feed/update/urn:li:share:NUMERIC/
|
|
184
|
+
* /feed/update/urn:li:ugcPost:NUMERIC/
|
|
185
|
+
* /posts/SLUG-activity-NUMERIC-RANDOM (3-dot-menu copy-link form)
|
|
186
|
+
* /posts/SLUG-share-NUMERIC-RANDOM
|
|
187
|
+
* /posts/SLUG-ugcPost-NUMERIC-RANDOM
|
|
188
|
+
"""
|
|
189
|
+
if not url:
|
|
190
|
+
return None
|
|
191
|
+
m = re.search(r"urn:li:(activity|share|ugcPost):(\d{16,19})", url)
|
|
192
|
+
if m:
|
|
193
|
+
return f"https://www.linkedin.com/feed/update/urn:li:{m.group(1)}:{m.group(2)}/"
|
|
194
|
+
# Slug form from "Copy link to post" 3-dot menu. The URN type is
|
|
195
|
+
# encoded in the slug as -activity-NUM-, -share-NUM-, or -ugcPost-NUM-.
|
|
196
|
+
m = re.search(r"-(activity|share|ugcPost)-(\d{16,19})\b", url, re.IGNORECASE)
|
|
197
|
+
if m:
|
|
198
|
+
# Normalize the type token's case (LinkedIn always emits ugcPost
|
|
199
|
+
# camel-cased; activity/share lowercase).
|
|
200
|
+
urn_type = m.group(1)
|
|
201
|
+
if urn_type.lower() == "ugcpost":
|
|
202
|
+
urn_type = "ugcPost"
|
|
203
|
+
else:
|
|
204
|
+
urn_type = urn_type.lower()
|
|
205
|
+
return f"https://www.linkedin.com/feed/update/urn:li:{urn_type}:{m.group(2)}/"
|
|
206
|
+
return url.strip().rstrip("/") + "/"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _parse_age_hours(cand):
|
|
210
|
+
"""Pull age_hours out of the candidate, falling back to post_posted_at.
|
|
211
|
+
|
|
212
|
+
Phase B's scrape generally writes age_hours directly (parsed from the
|
|
213
|
+
relative timestamp string LinkedIn renders, e.g. "5h", "2d"). If the
|
|
214
|
+
LLM instead wrote an ISO timestamp, derive age from it.
|
|
215
|
+
"""
|
|
216
|
+
raw = cand.get("age_hours")
|
|
217
|
+
if raw is not None:
|
|
218
|
+
try:
|
|
219
|
+
return float(raw)
|
|
220
|
+
except (TypeError, ValueError):
|
|
221
|
+
pass
|
|
222
|
+
posted_at = cand.get("post_posted_at") or cand.get("posted_at")
|
|
223
|
+
if posted_at:
|
|
224
|
+
try:
|
|
225
|
+
dt = datetime.fromisoformat(str(posted_at).replace("Z", "+00:00"))
|
|
226
|
+
return (datetime.now(timezone.utc) - dt).total_seconds() / 3600.0
|
|
227
|
+
except (ValueError, TypeError):
|
|
228
|
+
pass
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _fetch_posted_urls():
|
|
233
|
+
"""Return the set of normalized LinkedIn thread URLs we've already posted
|
|
234
|
+
on, scoped per-account when an account name is configured (falls back to
|
|
235
|
+
unscoped for legacy single-account behavior).
|
|
236
|
+
|
|
237
|
+
Migrated 2026-06-01 from a direct `SELECT thread_url FROM posts` to the
|
|
238
|
+
s4l.ai HTTP API (GET /api/v1/posts/thread-urls).
|
|
239
|
+
"""
|
|
240
|
+
_li_name = _resolve_account("linkedin")
|
|
241
|
+
resp = api_get(
|
|
242
|
+
"/api/v1/posts/thread-urls",
|
|
243
|
+
query={"platform": "linkedin", "our_account": _li_name},
|
|
244
|
+
)
|
|
245
|
+
urls = (resp.get("data") or {}).get("thread_urls") or []
|
|
246
|
+
posted = set()
|
|
247
|
+
for u in urls:
|
|
248
|
+
norm = _normalize_post_url(u)
|
|
249
|
+
if norm:
|
|
250
|
+
posted.add(norm)
|
|
251
|
+
return posted
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def upsert_candidates(candidates, batch_id=None):
|
|
255
|
+
"""Score and upsert LinkedIn candidates. Returns (inserted, skipped, errors).
|
|
256
|
+
|
|
257
|
+
Migrated 2026-06-01 from direct psycopg2 INSERT...ON CONFLICT to the s4l.ai
|
|
258
|
+
HTTP API (POST /api/v1/linkedin-candidates, which mirrors the upsert
|
|
259
|
+
server-side). The dedup-against-posted query moved to
|
|
260
|
+
GET /api/v1/posts/thread-urls. Scoring stays client-side (pure Python).
|
|
261
|
+
"""
|
|
262
|
+
# Dedupe against already-posted LinkedIn threads (the engaged-id check
|
|
263
|
+
# in run-linkedin.sh covers URN-level dedup, but this catches URL-level
|
|
264
|
+
# dupes too in case someone hand-feeds candidates).
|
|
265
|
+
posted_urls = _fetch_posted_urls()
|
|
266
|
+
|
|
267
|
+
inserted = skipped = errors = 0
|
|
268
|
+
_excl = load_exclusions()
|
|
269
|
+
|
|
270
|
+
for cand in candidates:
|
|
271
|
+
if not isinstance(cand, dict):
|
|
272
|
+
continue
|
|
273
|
+
# Author exclusion (config.json + author_blocklist, slug-keyed). The
|
|
274
|
+
# POST-rail discover step already drops these, but guard here too so the
|
|
275
|
+
# linkedin_candidates table never accrues an excluded author regardless
|
|
276
|
+
# of which caller fed us (hand-fed batches, future rails, etc.).
|
|
277
|
+
if classify_author(cand.get("author_name"),
|
|
278
|
+
cand.get("author_profile_url"), _excl)[0] == "hard":
|
|
279
|
+
skipped += 1
|
|
280
|
+
continue
|
|
281
|
+
post_url = _normalize_post_url(cand.get("post_url"))
|
|
282
|
+
if not post_url:
|
|
283
|
+
errors += 1
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
# Skip URLs we already posted on
|
|
287
|
+
if post_url in posted_urls:
|
|
288
|
+
skipped += 1
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
age_hours = _parse_age_hours(cand)
|
|
292
|
+
if age_hours is None:
|
|
293
|
+
# Unknown age = treat as cold so it ranks below known-fresh,
|
|
294
|
+
# but don't auto-reject (LinkedIn relative timestamps fail to
|
|
295
|
+
# parse on long-tail formats like "1mo").
|
|
296
|
+
age_hours = MAX_AGE_HOURS
|
|
297
|
+
|
|
298
|
+
if age_hours > MAX_AGE_HOURS:
|
|
299
|
+
skipped += 1
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
cand["age_hours"] = age_hours
|
|
303
|
+
velocity, virality, age_clamped = calculate_velocity_score(cand)
|
|
304
|
+
|
|
305
|
+
# Resolve post_posted_at if not provided (we can derive from age)
|
|
306
|
+
post_posted_at = cand.get("post_posted_at") or cand.get("posted_at")
|
|
307
|
+
if not post_posted_at and age_hours is not None:
|
|
308
|
+
try:
|
|
309
|
+
from datetime import timedelta
|
|
310
|
+
post_posted_at = (
|
|
311
|
+
datetime.now(timezone.utc) - timedelta(hours=age_hours)
|
|
312
|
+
).isoformat()
|
|
313
|
+
except Exception:
|
|
314
|
+
post_posted_at = None
|
|
315
|
+
|
|
316
|
+
all_urns = cand.get("all_urns") or []
|
|
317
|
+
if isinstance(all_urns, list):
|
|
318
|
+
all_urns_str = ",".join(str(u) for u in all_urns if u)
|
|
319
|
+
else:
|
|
320
|
+
all_urns_str = str(all_urns)
|
|
321
|
+
|
|
322
|
+
payload = {
|
|
323
|
+
"post_url": post_url,
|
|
324
|
+
"activity_id": cand.get("activity_id") or None,
|
|
325
|
+
"all_urns": all_urns_str or None,
|
|
326
|
+
"author_name": cand.get("author_name") or None,
|
|
327
|
+
"author_profile_url": cand.get("author_profile_url") or None,
|
|
328
|
+
"author_followers": int(cand.get("author_followers") or 0) or None,
|
|
329
|
+
"post_text": (cand.get("post_text") or "") or None,
|
|
330
|
+
"post_posted_at": post_posted_at,
|
|
331
|
+
"age_hours": age_clamped,
|
|
332
|
+
"reactions": int(cand.get("reactions") or 0),
|
|
333
|
+
"comments": int(cand.get("comments") or 0),
|
|
334
|
+
"reposts": int(cand.get("reposts") or 0),
|
|
335
|
+
"engagement_velocity": velocity, # raw
|
|
336
|
+
"velocity_score": virality, # post-multiplier
|
|
337
|
+
"serp_quality_score": (
|
|
338
|
+
float(cand["serp_quality_score"])
|
|
339
|
+
if cand.get("serp_quality_score") is not None else None
|
|
340
|
+
),
|
|
341
|
+
"search_topic": cand.get("search_topic") or None,
|
|
342
|
+
"search_query": cand.get("search_query") or None,
|
|
343
|
+
"matched_project": cand.get("matched_project") or None,
|
|
344
|
+
"language": cand.get("language") or "en",
|
|
345
|
+
"batch_id": batch_id,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
# The endpoint mirrors the ON CONFLICT (post_url) DO UPDATE upsert:
|
|
350
|
+
# COALESCE-bumps discovery fields, overwrites engagement metrics,
|
|
351
|
+
# and preserves terminal statuses ('posted'/'skipped') while
|
|
352
|
+
# resetting everything else to 'pending'.
|
|
353
|
+
api_post("/api/v1/linkedin-candidates", payload)
|
|
354
|
+
inserted += 1
|
|
355
|
+
except SystemExit as e:
|
|
356
|
+
print(f" Error inserting {post_url}: {e}", file=sys.stderr)
|
|
357
|
+
errors += 1
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
expire_and_prune()
|
|
361
|
+
return inserted, skipped, errors
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def expire_and_prune(_conn=None):
|
|
365
|
+
"""Flip stale pending rows to 'expired'. We do NOT prune terminal rows
|
|
366
|
+
by age (per user instruction 2026-05-08); every linkedin_candidates row
|
|
367
|
+
is kept forever so we can audit skip reasons, engagement dynamics, and
|
|
368
|
+
project routing across time. Function name kept for caller compatibility.
|
|
369
|
+
|
|
370
|
+
Migrated 2026-06-01 to POST /api/v1/linkedin-candidates/expire-stale.
|
|
371
|
+
The optional _conn arg is ignored (legacy signature compatibility).
|
|
372
|
+
"""
|
|
373
|
+
api_post(
|
|
374
|
+
"/api/v1/linkedin-candidates/expire-stale",
|
|
375
|
+
{"hours": int(EXPIRE_PENDING_AFTER_HOURS)},
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def main():
|
|
380
|
+
parser = argparse.ArgumentParser()
|
|
381
|
+
parser.add_argument("--file", help="Read JSON from a file instead of stdin")
|
|
382
|
+
parser.add_argument("--batch-id", help="Tag this batch on every row")
|
|
383
|
+
parser.add_argument("--expire-only", action="store_true",
|
|
384
|
+
help="Only run expire/prune, no scoring or insert")
|
|
385
|
+
parser.add_argument("--quiet", action="store_true",
|
|
386
|
+
help="Suppress final stdout summary line")
|
|
387
|
+
args = parser.parse_args()
|
|
388
|
+
|
|
389
|
+
if args.expire_only:
|
|
390
|
+
expire_and_prune()
|
|
391
|
+
if not args.quiet:
|
|
392
|
+
print("Expired/pruned old linkedin_candidates")
|
|
393
|
+
return 0
|
|
394
|
+
|
|
395
|
+
if args.file:
|
|
396
|
+
with open(args.file) as f:
|
|
397
|
+
data = json.load(f)
|
|
398
|
+
else:
|
|
399
|
+
raw = sys.stdin.read().strip()
|
|
400
|
+
if not raw:
|
|
401
|
+
print("score_linkedin_candidates: empty stdin, nothing to score",
|
|
402
|
+
file=sys.stderr)
|
|
403
|
+
return 0
|
|
404
|
+
data = json.loads(raw)
|
|
405
|
+
|
|
406
|
+
if not isinstance(data, list):
|
|
407
|
+
data = [data]
|
|
408
|
+
|
|
409
|
+
inserted, skipped, errors = upsert_candidates(data, batch_id=args.batch_id)
|
|
410
|
+
if not args.quiet:
|
|
411
|
+
print(
|
|
412
|
+
f"score_linkedin_candidates: upserted={inserted} "
|
|
413
|
+
f"skipped={skipped} errors={errors} batch={args.batch_id}"
|
|
414
|
+
)
|
|
415
|
+
return 0
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
sys.exit(main())
|