@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
package/skill/stats.sh
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# stats.sh — Full stats pipeline:
|
|
3
|
+
# Step 1: Reddit profile scrape (headless Playwright, views + upvotes + comments_count)
|
|
4
|
+
# Step 2: API stats (deletion/removal detection + stats fallback) via Python
|
|
5
|
+
# Step 3: X/Twitter stats via Claude + Playwright (browser required)
|
|
6
|
+
# Step 4: LinkedIn stats via Claude + Playwright (browser required)
|
|
7
|
+
# Called by launchd every 6 hours.
|
|
8
|
+
#
|
|
9
|
+
# Args (any order):
|
|
10
|
+
# --platform <reddit|twitter|linkedin|moltbook> Run only the steps for one platform.
|
|
11
|
+
# --quiet Minimal Python output.
|
|
12
|
+
# If --platform is omitted, all steps run (backward-compatible default).
|
|
13
|
+
|
|
14
|
+
set -uo pipefail
|
|
15
|
+
|
|
16
|
+
# Portable platform helpers (defines gtimeout shim for Linux). This is sourced
|
|
17
|
+
# early so the `gtimeout` function is available. Note: platform.sh exports a
|
|
18
|
+
# variable also named PLATFORM (darwin/linux), which stats.sh's arg parser
|
|
19
|
+
# immediately overwrites with the social-platform name below; that is fine
|
|
20
|
+
# because stats.sh never calls stat_mtime/platform_notify after arg parsing.
|
|
21
|
+
# shellcheck source=/dev/null
|
|
22
|
+
source "$(dirname "${BASH_SOURCE[0]}")/lib/platform.sh"
|
|
23
|
+
|
|
24
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
25
|
+
SKILL_FILE="$REPO_DIR/SKILL.md"
|
|
26
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
27
|
+
|
|
28
|
+
# shellcheck source=/dev/null
|
|
29
|
+
source "$(dirname "${BASH_SOURCE[0]}")/lock.sh"
|
|
30
|
+
|
|
31
|
+
# Parse args (support --platform <name> and --quiet in any order).
|
|
32
|
+
QUIET=""
|
|
33
|
+
PLATFORM=""
|
|
34
|
+
while [ $# -gt 0 ]; do
|
|
35
|
+
case "$1" in
|
|
36
|
+
--platform)
|
|
37
|
+
PLATFORM="${2:-}"
|
|
38
|
+
shift 2
|
|
39
|
+
;;
|
|
40
|
+
--platform=*)
|
|
41
|
+
PLATFORM="${1#--platform=}"
|
|
42
|
+
shift
|
|
43
|
+
;;
|
|
44
|
+
--quiet)
|
|
45
|
+
QUIET="--quiet"
|
|
46
|
+
shift
|
|
47
|
+
;;
|
|
48
|
+
*)
|
|
49
|
+
# Unknown arg: ignore (keeps backward compatibility with callers).
|
|
50
|
+
shift
|
|
51
|
+
;;
|
|
52
|
+
esac
|
|
53
|
+
done
|
|
54
|
+
|
|
55
|
+
# Validate --platform if provided.
|
|
56
|
+
case "$PLATFORM" in
|
|
57
|
+
""|reddit|twitter|linkedin|moltbook)
|
|
58
|
+
;;
|
|
59
|
+
*)
|
|
60
|
+
echo "stats.sh: invalid --platform '$PLATFORM' (expected reddit, twitter, linkedin, or moltbook)" >&2
|
|
61
|
+
exit 2
|
|
62
|
+
;;
|
|
63
|
+
esac
|
|
64
|
+
|
|
65
|
+
# Decide which steps to run.
|
|
66
|
+
# Variable naming: RUN_STEP1 = Reddit profile scrape, RUN_STEP2 = API stats.
|
|
67
|
+
# No --platform means "all" (legacy behavior, kept for manual invocations).
|
|
68
|
+
if [ -z "$PLATFORM" ]; then
|
|
69
|
+
RUN_STEP1=1; RUN_STEP2=1; RUN_STEP3=1; RUN_STEP4=1
|
|
70
|
+
else
|
|
71
|
+
# Per-platform mode: default everything off, then enable per platform.
|
|
72
|
+
RUN_STEP1=0; RUN_STEP2=0; RUN_STEP3=0; RUN_STEP4=0
|
|
73
|
+
case "$PLATFORM" in
|
|
74
|
+
reddit) RUN_STEP1=1; RUN_STEP2=1 ;; # scrape then API.
|
|
75
|
+
twitter) RUN_STEP3=1 ;; # Step 3 handles Twitter API directly.
|
|
76
|
+
linkedin) RUN_STEP4=1 ;; # LinkedIn has no cheap API leg.
|
|
77
|
+
moltbook) RUN_STEP2=1 ;; # API-only, covered by Step 2.
|
|
78
|
+
esac
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# Load secrets (MOLTBOOK_API_KEY, DATABASE_URL, etc.)
|
|
82
|
+
# shellcheck source=/dev/null
|
|
83
|
+
[ -f "$REPO_DIR/.env" ] && source "$REPO_DIR/.env"
|
|
84
|
+
|
|
85
|
+
mkdir -p "$LOG_DIR"
|
|
86
|
+
# Include platform in log filename so the dashboard can distinguish per-platform runs.
|
|
87
|
+
LOG_TAG="${PLATFORM:-all}"
|
|
88
|
+
LOGFILE="$LOG_DIR/stats-${LOG_TAG}-$(date +%Y-%m-%d_%H%M%S).log"
|
|
89
|
+
|
|
90
|
+
log() { echo "[$(date +%H:%M:%S)] $*" >> "$LOGFILE"; echo "[$(date +%H:%M:%S)] $*"; }
|
|
91
|
+
|
|
92
|
+
RUN_START=$(date +%s)
|
|
93
|
+
STEP1_EXIT=0; STEP2_EXIT=0; STEP3_EXIT=0; STEP4_EXIT=0
|
|
94
|
+
|
|
95
|
+
log "=== Stats Pipeline Run: $(date) ==="
|
|
96
|
+
if [ -n "$PLATFORM" ]; then
|
|
97
|
+
log "Platform filter: $PLATFORM (step1=$RUN_STEP1 step2=$RUN_STEP2 step3=$RUN_STEP3 step4=$RUN_STEP4)"
|
|
98
|
+
else
|
|
99
|
+
log "Platform filter: (none, running all steps)"
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
# ═══════════════════════════════════════════════════════
|
|
103
|
+
# STEP 1: Reddit profile scrape (headless Playwright, no Claude session).
|
|
104
|
+
# Runs BEFORE Step 2 so thread + comment rows get views/upvotes/comments_count
|
|
105
|
+
# in a single no-API pass. Step 2 then skips rows refreshed within the last 4h
|
|
106
|
+
# and spends the API budget only on deletion detection + unmatched rows.
|
|
107
|
+
# ═══════════════════════════════════════════════════════
|
|
108
|
+
if [ "$RUN_STEP1" -eq 1 ]; then
|
|
109
|
+
log "Step 1: Reddit profile scrape (headless Playwright)"
|
|
110
|
+
|
|
111
|
+
# Serialize with other reddit-agent consumers (post_reddit, run-reddit-threads,
|
|
112
|
+
# engage-dm-replies, audit-reddit*). Unified Python lease (2026-05-10) —
|
|
113
|
+
# TTL-aware, MCP-proxy heartbeated during reddit-agent calls.
|
|
114
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" acquire --timeout 3600 --ttl 90 2>&1 || \
|
|
115
|
+
log "WARNING: reddit_browser_lock.py acquire failed; proceeding without lease."
|
|
116
|
+
|
|
117
|
+
REDDIT_USERNAME=$(python3 -c "import json; print(json.load(open('$REPO_DIR/config.json'))['accounts']['reddit']['username'])" 2>/dev/null || echo "")
|
|
118
|
+
|
|
119
|
+
if [ -n "$REDDIT_USERNAME" ]; then
|
|
120
|
+
SCRAPE_OUT=$(mktemp)
|
|
121
|
+
gtimeout 900 python3 "$REPO_DIR/scripts/reddit_browser.py" scrape-views "$REDDIT_USERNAME" 300 > "$SCRAPE_OUT" 2>> "$LOGFILE"
|
|
122
|
+
STEP1_EXIT=$?
|
|
123
|
+
if [ "$STEP1_EXIT" -eq 124 ]; then
|
|
124
|
+
log "Step 1: TIMEOUT (15 min limit reached)"
|
|
125
|
+
rm -f "$SCRAPE_OUT"
|
|
126
|
+
elif [ "$STEP1_EXIT" -ne 0 ]; then
|
|
127
|
+
log "Step 1: FAILED scrape-views (exit $STEP1_EXIT)"
|
|
128
|
+
head -c 500 "$SCRAPE_OUT" >> "$LOGFILE" 2>/dev/null || true
|
|
129
|
+
rm -f "$SCRAPE_OUT"
|
|
130
|
+
else
|
|
131
|
+
# Extract the .results array into the format scrape_reddit_views.py expects.
|
|
132
|
+
python3 -c "
|
|
133
|
+
import json, sys
|
|
134
|
+
with open('$SCRAPE_OUT') as f:
|
|
135
|
+
data = json.load(f)
|
|
136
|
+
if not data.get('ok'):
|
|
137
|
+
print('scrape_views returned ok=false:', data.get('error', 'unknown'), file=sys.stderr)
|
|
138
|
+
sys.exit(2)
|
|
139
|
+
with open('/tmp/reddit_views.json', 'w') as f:
|
|
140
|
+
json.dump(data.get('results', []), f)
|
|
141
|
+
print(f\"scraped {data.get('total', 0)} urls, {data.get('with_views', 0)} with views, {data.get('with_score', 0)} with score, {data.get('with_comments_count', 0)} with comments_count\")
|
|
142
|
+
" >> "$LOGFILE" 2>&1
|
|
143
|
+
EXTRACT_EXIT=$?
|
|
144
|
+
rm -f "$SCRAPE_OUT"
|
|
145
|
+
if [ "$EXTRACT_EXIT" -ne 0 ]; then
|
|
146
|
+
log "Step 1: FAILED extract (exit $EXTRACT_EXIT)"
|
|
147
|
+
else
|
|
148
|
+
python3 "$REPO_DIR/scripts/scrape_reddit_views.py" --from-json /tmp/reddit_views.json $QUIET >> "$LOGFILE" 2>&1
|
|
149
|
+
UPDATE_EXIT=$?
|
|
150
|
+
if [ "$UPDATE_EXIT" -ne 0 ]; then
|
|
151
|
+
log "Step 1: FAILED DB update (exit $UPDATE_EXIT)"
|
|
152
|
+
else
|
|
153
|
+
log "Step 1: Done"
|
|
154
|
+
fi
|
|
155
|
+
fi
|
|
156
|
+
fi
|
|
157
|
+
else
|
|
158
|
+
log "Step 1: SKIPPED, no Reddit username in config.json"
|
|
159
|
+
fi
|
|
160
|
+
# Release the reddit-browser lock NOW. Step 2 (stats.py --reddit-only)
|
|
161
|
+
# is pure unauthenticated HTTPS to old.reddit.com/api/info.json: no Playwright,
|
|
162
|
+
# no logged-in session, different rate-limit bucket from reddit-agent. Holding
|
|
163
|
+
# the lock through Step 2's paced API loop (~100 req / 10 min) starves the
|
|
164
|
+
# post_reddit + run-reddit-search + dm-replies queue for 5-15 min every cycle.
|
|
165
|
+
# Releasing here keeps Step 1's serialization guarantee for the actual browser
|
|
166
|
+
# work and frees the queue immediately.
|
|
167
|
+
python3 "$REPO_DIR/scripts/reddit_browser_lock.py" release 2>/dev/null || true
|
|
168
|
+
log "Step 1: released reddit-browser lease (Step 2 is HTTP-only)"
|
|
169
|
+
else
|
|
170
|
+
log "Step 1: SKIPPED (platform=$PLATFORM)"
|
|
171
|
+
fi
|
|
172
|
+
|
|
173
|
+
# ═══════════════════════════════════════════════════════
|
|
174
|
+
# STEP 2: API stats — deletion/removal detection and stats fallback for any
|
|
175
|
+
# row Step 1 couldn't cover. Rows refreshed by Step 1 within the last 4h
|
|
176
|
+
# are skipped via the engagement_updated_at freshness window.
|
|
177
|
+
# ═══════════════════════════════════════════════════════
|
|
178
|
+
# Sidecar JSON written by stats.py --reply-summary so we can forward
|
|
179
|
+
# the per-platform reply-refresh count to log_run.py at the end of the run.
|
|
180
|
+
# The Python side writes {reddit, twitter, github} integers (zeros if a
|
|
181
|
+
# platform's reply pass didn't run).
|
|
182
|
+
REPLY_SUMMARY_FILE=$(mktemp -t fazm-reply-summary.XXXXXX)
|
|
183
|
+
# Sidecar JSON written by scrape_linkedin_stats.py --summary so we can forward
|
|
184
|
+
# LinkedIn-specific counters (refreshed/removed/unavailable/not_found) into
|
|
185
|
+
# log_run.py. Step 4's Claude-driven prompt invokes the Python script with
|
|
186
|
+
# --summary "$LINKEDIN_SUMMARY_FILE", so the file is populated only if Step 4
|
|
187
|
+
# ran end-to-end. Empty file means LinkedIn contributed 0 to every counter.
|
|
188
|
+
LINKEDIN_SUMMARY_FILE=$(mktemp -t fazm-linkedin-summary.XXXXXX)
|
|
189
|
+
# Chain lock cleanup. A plain `trap '...' EXIT` would REPLACE lock.sh's
|
|
190
|
+
# `trap _sa_release_locks EXIT INT TERM HUP`, orphaning the platform-browser
|
|
191
|
+
# lock across runs. Cover all four signals so watchdog SIGTERM also frees it.
|
|
192
|
+
trap 'rm -f "$REPLY_SUMMARY_FILE" "$LINKEDIN_SUMMARY_FILE"; _sa_release_locks' EXIT INT TERM HUP
|
|
193
|
+
|
|
194
|
+
if [ "$RUN_STEP2" -eq 1 ]; then
|
|
195
|
+
# Narrow the Python call per platform. Without --platform we run the
|
|
196
|
+
# default all-platforms pass (kept for manual invocations only).
|
|
197
|
+
STEP2_ARGS=()
|
|
198
|
+
[ "$QUIET" = "--quiet" ] && STEP2_ARGS+=("--quiet")
|
|
199
|
+
STEP2_ARGS+=("--reply-summary" "$REPLY_SUMMARY_FILE")
|
|
200
|
+
case "$PLATFORM" in
|
|
201
|
+
reddit) STEP2_ARGS+=("--reddit-only") ;;
|
|
202
|
+
moltbook) STEP2_ARGS+=("--moltbook-only") ;;
|
|
203
|
+
twitter) STEP2_ARGS+=("--twitter-only") ;;
|
|
204
|
+
esac
|
|
205
|
+
|
|
206
|
+
log "Step 2: API stats (Python) ${STEP2_ARGS[*]:-}"
|
|
207
|
+
python3 "$REPO_DIR/scripts/stats.py" "${STEP2_ARGS[@]}" >> "$LOGFILE" 2>&1
|
|
208
|
+
STEP2_EXIT=$?
|
|
209
|
+
if [ "$STEP2_EXIT" -ne 0 ]; then
|
|
210
|
+
log "Step 2: FAILED (exit $STEP2_EXIT), continuing to next step"
|
|
211
|
+
else
|
|
212
|
+
log "Step 2: Done"
|
|
213
|
+
fi
|
|
214
|
+
else
|
|
215
|
+
log "Step 2: SKIPPED (platform=$PLATFORM)"
|
|
216
|
+
fi
|
|
217
|
+
|
|
218
|
+
# ═══════════════════════════════════════════════════════
|
|
219
|
+
# STEP 3: X/Twitter stats (API via fxtwitter, no browser needed)
|
|
220
|
+
# ═══════════════════════════════════════════════════════
|
|
221
|
+
if [ "$RUN_STEP3" -eq 1 ]; then
|
|
222
|
+
log "Step 3: X/Twitter stats (API via fxtwitter)"
|
|
223
|
+
STEP3_ARGS=("--twitter-only" "--reply-summary" "$REPLY_SUMMARY_FILE")
|
|
224
|
+
[ "$QUIET" = "--quiet" ] && STEP3_ARGS+=("--quiet")
|
|
225
|
+
python3 "$REPO_DIR/scripts/stats.py" "${STEP3_ARGS[@]}" >> "$LOGFILE" 2>&1
|
|
226
|
+
STEP3_EXIT=$?
|
|
227
|
+
if [ "$STEP3_EXIT" -ne 0 ]; then
|
|
228
|
+
log "Step 3: FAILED (exit $STEP3_EXIT)"
|
|
229
|
+
else
|
|
230
|
+
log "Step 3: Done"
|
|
231
|
+
fi
|
|
232
|
+
else
|
|
233
|
+
log "Step 3: SKIPPED (platform=$PLATFORM)"
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
# ═══════════════════════════════════════════════════════
|
|
237
|
+
# STEP 4: LinkedIn stats (Python CDP-attach to linkedin-agent MCP)
|
|
238
|
+
#
|
|
239
|
+
# Cutover 2026-05-04: replaced the Claude-driven `run_claude.sh stats-step4`
|
|
240
|
+
# heredoc-prompt path with a direct Python script that CDP-attaches to the
|
|
241
|
+
# already-running linkedin-agent MCP, scrapes per-comment reactions, and
|
|
242
|
+
# applies the same DB write-path (scrape_linkedin_stats.update_linkedin_stats).
|
|
243
|
+
# Same data, $0 cost instead of $1-3 per run, 3-5 min instead of 5-10 min.
|
|
244
|
+
# get_run_cost.py --scripts stats-step4 will return $0 going forward; that
|
|
245
|
+
# is correct, not a missed run.
|
|
246
|
+
#
|
|
247
|
+
# Prereqs: linkedin-agent MCP must be alive (Chrome with --remote-debugging-port
|
|
248
|
+
# already running). The post pipeline fires every 15min and primes the browser,
|
|
249
|
+
# so in steady state DevToolsActivePort is always live. If MCP is cold the
|
|
250
|
+
# script returns mcp_not_running / exit 1; stats.sh logs the leg as failed.
|
|
251
|
+
#
|
|
252
|
+
# Lock policy: acquire the bash linkedin-browser lock for 1800s so we
|
|
253
|
+
# serialize against run-linkedin.sh / engage-linkedin.sh /
|
|
254
|
+
# dm-outreach-linkedin.sh / engage-dm-replies.sh (all of which acquire the
|
|
255
|
+
# same lock for 3600s). The earlier Claude-driven Step 4 did NOT acquire
|
|
256
|
+
# this lock, which let it race the post pipeline; the cutover closes that
|
|
257
|
+
# gap. The lock's own ppid==1 orphan-Chrome sweep handles dead Chromes;
|
|
258
|
+
# ensure_browser_healthy is intentionally NOT called here (see inline note
|
|
259
|
+
# at the call site for the --remote-debugging-port=0 incompatibility).
|
|
260
|
+
# ═══════════════════════════════════════════════════════
|
|
261
|
+
if [ "$RUN_STEP4" -eq 1 ]; then
|
|
262
|
+
log "Step 4: LinkedIn stats (Python CDP-attach to linkedin-agent)"
|
|
263
|
+
|
|
264
|
+
# PATH hardening: launchd / nohup / cron environments don't inherit the
|
|
265
|
+
# interactive shell PATH, so `gtimeout` and `python3` may not resolve.
|
|
266
|
+
# Pin to absolute Homebrew + system paths. /usr/bin/python3 is the only
|
|
267
|
+
# python on this Mac with playwright installed in user-site (see CLAUDE.md
|
|
268
|
+
# "Programmatic Gmail Access" + engage-dm-replies.sh:1314 for the same
|
|
269
|
+
# convention). 2026-05-05 cutover bug: bare `python3` in nohup shells
|
|
270
|
+
# resolved to /opt/homebrew/bin/python3 which has psycopg2 but NOT
|
|
271
|
+
# playwright, causing ModuleNotFoundError on every Step 4 fire.
|
|
272
|
+
GTIMEOUT_BIN="/opt/homebrew/bin/gtimeout"
|
|
273
|
+
PY_BIN="/usr/bin/python3"
|
|
274
|
+
|
|
275
|
+
# HTTP-only lane (2026-06-01): the LinkedIn refresh-eligibility count goes
|
|
276
|
+
# through the s4l.ai API via scripts/stats_helper.py. No DATABASE_URL, no
|
|
277
|
+
# psql, no fallback. Prints the same integer the old psql COUNT(*) did.
|
|
278
|
+
LINKEDIN_POSTS=$(python3 "$REPO_DIR/scripts/stats_helper.py" linkedin-refresh-count 2>/dev/null || echo "0")
|
|
279
|
+
|
|
280
|
+
if [ "$LINKEDIN_POSTS" -gt 0 ]; then
|
|
281
|
+
acquire_lock "linkedin-browser" 1800
|
|
282
|
+
# Deliberately do NOT call ensure_browser_healthy here. That helper
|
|
283
|
+
# reads --remote-debugging-port from the Chrome cmdline, but the
|
|
284
|
+
# linkedin-agent MCP launches Chrome with `--remote-debugging-port=0`
|
|
285
|
+
# (let Chrome pick a random port; actual port written to
|
|
286
|
+
# DevToolsActivePort). Result: ensure_browser_healthy reads `0`, probes
|
|
287
|
+
# http://localhost:0, fails, then KILLS the perfectly healthy Chrome —
|
|
288
|
+
# which is the opposite of what we want. The bash lock's orphan-Chrome
|
|
289
|
+
# sweep (ppid==1 filter) already handles the truly-dead case, and our
|
|
290
|
+
# Python script CDP-attaches via DevToolsActivePort so it discovers the
|
|
291
|
+
# real port without needing the cmdline value. If MCP is genuinely cold,
|
|
292
|
+
# the script falls back to launch_persistent_context on the SAME
|
|
293
|
+
# ~/.claude/browser-profiles/linkedin profile the MCP uses (verified
|
|
294
|
+
# against linkedin_browser.PROFILE_DIR), so cookies + fingerprint match
|
|
295
|
+
# the post pipeline regardless of which lifecycle mode is active.
|
|
296
|
+
|
|
297
|
+
# 2026-05-11: scrape_linkedin_stats_browser.py was deprecated 2026-05-05
|
|
298
|
+
# (the per-permalink scrape loop pattern triggered LinkedIn's anti-bot on
|
|
299
|
+
# 2026-05-05). It still exits 2. We now call skill/stats-linkedin.sh, the
|
|
300
|
+
# unified orchestrator: one CDP-attached scrape of /in/me/recent-activity/
|
|
301
|
+
# comments/, two DB writers (replies + posts tables sharing one feed).
|
|
302
|
+
# The orchestrator manages its OWN linkedin-browser lock acquire/release
|
|
303
|
+
# internally, so we release ours first to avoid a self-deadlock.
|
|
304
|
+
release_lock "linkedin-browser"
|
|
305
|
+
"$GTIMEOUT_BIN" 1800 bash "$REPO_DIR/skill/stats-linkedin.sh" \
|
|
306
|
+
>> "$LOGFILE" 2>&1
|
|
307
|
+
STEP4_EXIT=$?
|
|
308
|
+
# Bridge the unified-orchestrator's summary into the legacy
|
|
309
|
+
# $LINKEDIN_SUMMARY_FILE shape that the dashboard parser downstream still
|
|
310
|
+
# expects. The orchestrator writes its posts-table summary internally
|
|
311
|
+
# then deletes it; for the dashboard, surface the combined refresh count
|
|
312
|
+
# via a fresh sidecar populated from the orchestrator's per-fire log.
|
|
313
|
+
LAST_STATS_LOG=$(ls -t "$REPO_DIR/skill/logs/stats-linkedin-"*.log 2>/dev/null | head -1)
|
|
314
|
+
if [ -n "$LAST_STATS_LOG" ] && [ -f "$LAST_STATS_LOG" ]; then
|
|
315
|
+
REFRESHED_TOTAL=$(grep -oE 'total=[0-9]+' "$LAST_STATS_LOG" | tail -1 | sed 's/total=//' || echo 0)
|
|
316
|
+
NOT_FOUND_TOTAL=$(grep -oE 'unmatched=[0-9]+' "$LAST_STATS_LOG" | tail -1 | sed 's/unmatched=//' || echo 0)
|
|
317
|
+
printf '{"refreshed":%s,"removed":0,"unavailable":0,"not_found":%s}\n' \
|
|
318
|
+
"${REFRESHED_TOTAL:-0}" "${NOT_FOUND_TOTAL:-0}" \
|
|
319
|
+
> "$LINKEDIN_SUMMARY_FILE" 2>/dev/null || true
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
if [ "$STEP4_EXIT" -eq 124 ]; then
|
|
323
|
+
log "Step 4: TIMEOUT (30 min limit reached)"
|
|
324
|
+
elif [ "$STEP4_EXIT" -ne 0 ]; then
|
|
325
|
+
log "Step 4: FAILED (exit $STEP4_EXIT)"
|
|
326
|
+
else
|
|
327
|
+
log "Step 4: Done"
|
|
328
|
+
fi
|
|
329
|
+
else
|
|
330
|
+
log "Step 4: SKIPPED, no LinkedIn posts need stats update ($LINKEDIN_POSTS found)"
|
|
331
|
+
fi
|
|
332
|
+
else
|
|
333
|
+
log "Step 4: SKIPPED (platform=$PLATFORM)"
|
|
334
|
+
fi
|
|
335
|
+
|
|
336
|
+
log "=== Stats Pipeline complete: $(date) ==="
|
|
337
|
+
|
|
338
|
+
# Log run to persistent monitor (matches audit.sh pattern so run_monitor.log
|
|
339
|
+
# covers every launchd job). SCRIPT_TAG uses underscores so the dashboard
|
|
340
|
+
# regex in bin/server.js (^stats_(\w+)$) classifies the row correctly.
|
|
341
|
+
RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
|
|
342
|
+
STATS_FAILED=$(( (STEP1_EXIT != 0 ? 1 : 0) + (STEP2_EXIT != 0 ? 1 : 0) + (STEP3_EXIT != 0 ? 1 : 0) + (STEP4_EXIT != 0 ? 1 : 0) ))
|
|
343
|
+
SCRIPT_TAG="stats${PLATFORM:+_$PLATFORM}"
|
|
344
|
+
|
|
345
|
+
# Parse the per-run log to extract REAL counters for the dashboard. Before
|
|
346
|
+
# 2026-04-28 we logged `--posted "$ACTIVE"` (total active posts in the DB),
|
|
347
|
+
# which was meaningless and made every stats row read like "posted=18216".
|
|
348
|
+
# Now we extract the real per-run counters from the structured summary lines
|
|
349
|
+
# each step prints:
|
|
350
|
+
#
|
|
351
|
+
# Step 1 (Reddit views leg):
|
|
352
|
+
# Reddit Views: <N> had views, <M> DB posts updated, <U> unmatched
|
|
353
|
+
# Step 2 (Reddit detail leg):
|
|
354
|
+
# Reddit: <T> total, <S> skipped, <C> checked, <U> updated, <D> deleted, <R> removed, <E> errors [...]
|
|
355
|
+
# Step 3 (Twitter):
|
|
356
|
+
# Twitter: <T> total, <S> skipped, <C> checked, <U> updated, <D> deleted, <E> errors
|
|
357
|
+
# Step 2 --moltbook-only:
|
|
358
|
+
# Moltbook: <C> checked, <U> updated, <D> deleted, <E> errors
|
|
359
|
+
# Step 4 (LinkedIn): no stdout summary; counters are read from the JSON
|
|
360
|
+
# sidecar file written by scrape_linkedin_stats.py --summary.
|
|
361
|
+
#
|
|
362
|
+
# Missing platforms simply contribute 0 to each total. awk handles parsing
|
|
363
|
+
# robustly even when commas/brackets vary.
|
|
364
|
+
extract_field() {
|
|
365
|
+
# Usage: extract_field <line> <field>
|
|
366
|
+
# Pulls the integer that precedes <field> in a comma-separated counter
|
|
367
|
+
# line such as "Reddit: 4346 total, 1696 skipped, ..." Echoes 0 when the
|
|
368
|
+
# field isn't present.
|
|
369
|
+
#
|
|
370
|
+
# Strips the leading "Platform:" prefix before splitting on commas so the
|
|
371
|
+
# first segment ("Moltbook: 50 checked") doesn't break the leading-integer
|
|
372
|
+
# match. Without this, fields living in the first comma-segment always
|
|
373
|
+
# return 0 (the leading prefix is not numeric).
|
|
374
|
+
local line="$1" field="$2"
|
|
375
|
+
echo "$line" | awk -v f=" $field" '{
|
|
376
|
+
sub(/^[A-Za-z][A-Za-z ]*:[[:space:]]*/, "", $0)
|
|
377
|
+
n = split($0, parts, ",")
|
|
378
|
+
for (i = 1; i <= n; i++) {
|
|
379
|
+
if (index(parts[i], f) > 0) {
|
|
380
|
+
# Strip leading whitespace, then the leading integer is the value.
|
|
381
|
+
gsub(/^[[:space:]]+/, "", parts[i])
|
|
382
|
+
if (match(parts[i], /^[0-9]+/)) {
|
|
383
|
+
print substr(parts[i], RSTART, RLENGTH)
|
|
384
|
+
exit
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
print 0
|
|
389
|
+
}'
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
REDDIT_VIEWS_LINE=$(grep -E "^Reddit Views:" "$LOGFILE" 2>/dev/null | tail -1)
|
|
393
|
+
REDDIT_DETAIL_LINE=$(grep -E "^Reddit: [0-9]+ total" "$LOGFILE" 2>/dev/null | tail -1)
|
|
394
|
+
TWITTER_LINE=$(grep -E "^Twitter: [0-9]+ total" "$LOGFILE" 2>/dev/null | tail -1)
|
|
395
|
+
# Moltbook prints `Moltbook: N checked, N updated, N deleted, N errors` (no
|
|
396
|
+
# "total" prefix), so it gets its own grep. LinkedIn doesn't print a
|
|
397
|
+
# structured stdout line; its counters come from $LINKEDIN_SUMMARY_FILE.
|
|
398
|
+
MOLTBOOK_LINE=$(grep -E "^Moltbook: [0-9]+ checked" "$LOGFILE" 2>/dev/null | tail -1)
|
|
399
|
+
|
|
400
|
+
# Reddit views leg: "<M> DB posts updated" — only the "updated" leg matters here.
|
|
401
|
+
REDDIT_VIEWS_UPDATED=0
|
|
402
|
+
if [ -n "$REDDIT_VIEWS_LINE" ]; then
|
|
403
|
+
REDDIT_VIEWS_UPDATED=$(echo "$REDDIT_VIEWS_LINE" | awk '{
|
|
404
|
+
for (i = 1; i <= NF; i++) {
|
|
405
|
+
if ($i == "DB" && $(i+1) == "posts" && $(i+2) == "updated,") {
|
|
406
|
+
print $(i-1); exit
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
print 0
|
|
410
|
+
}')
|
|
411
|
+
fi
|
|
412
|
+
|
|
413
|
+
# 2026-05-18 relabel pass. stats.py's structured stdout lines now
|
|
414
|
+
# emit five cleanly-separated fields per platform: total / skipped /
|
|
415
|
+
# checked / changed / errors. Map them to the new dashboard pills:
|
|
416
|
+
# REDDIT_SCANNED -> 'scanned' pill (total considered)
|
|
417
|
+
# REDDIT_SKIPPED -> 'skipped' pill (stable + fresh combined)
|
|
418
|
+
# REDDIT_CHECKED -> 'checked' pill (rows actually hit the API)
|
|
419
|
+
# REDDIT_CHANGED -> 'changed' pill (metric-moved subset)
|
|
420
|
+
# REDDIT_VIEWS_UPDATED -> 'views' pill (Step 1 scrape leg, separate)
|
|
421
|
+
# `updated` is the legacy field name; if stats.py is mid-deploy and
|
|
422
|
+
# the new `changed` field is missing on the line, fall back to `updated`.
|
|
423
|
+
REDDIT_SCANNED=$(extract_field "$REDDIT_DETAIL_LINE" "total")
|
|
424
|
+
REDDIT_CHECKED=$(extract_field "$REDDIT_DETAIL_LINE" "checked")
|
|
425
|
+
REDDIT_CHANGED=$(extract_field "$REDDIT_DETAIL_LINE" "changed")
|
|
426
|
+
if [ "$REDDIT_CHANGED" = "0" ]; then
|
|
427
|
+
# Back-compat: pre-relabel lines used `updated` for the same value.
|
|
428
|
+
REDDIT_CHANGED=$(extract_field "$REDDIT_DETAIL_LINE" "updated")
|
|
429
|
+
fi
|
|
430
|
+
REDDIT_DETAIL_UPDATED="$REDDIT_CHANGED" # legacy alias
|
|
431
|
+
REDDIT_DELETED=$(extract_field "$REDDIT_DETAIL_LINE" "deleted")
|
|
432
|
+
REDDIT_REMOVED_FIELD=$(extract_field "$REDDIT_DETAIL_LINE" "removed")
|
|
433
|
+
REDDIT_SKIPPED=$(extract_field "$REDDIT_DETAIL_LINE" "skipped")
|
|
434
|
+
REDDIT_ERRORS=$(extract_field "$REDDIT_DETAIL_LINE" "errors")
|
|
435
|
+
|
|
436
|
+
TWITTER_SCANNED=$(extract_field "$TWITTER_LINE" "total")
|
|
437
|
+
TWITTER_CHECKED=$(extract_field "$TWITTER_LINE" "checked")
|
|
438
|
+
TWITTER_CHANGED=$(extract_field "$TWITTER_LINE" "changed")
|
|
439
|
+
if [ "$TWITTER_CHANGED" = "0" ]; then
|
|
440
|
+
TWITTER_CHANGED=$(extract_field "$TWITTER_LINE" "updated")
|
|
441
|
+
fi
|
|
442
|
+
TWITTER_UPDATED="$TWITTER_CHANGED" # legacy alias
|
|
443
|
+
TWITTER_DELETED=$(extract_field "$TWITTER_LINE" "deleted")
|
|
444
|
+
TWITTER_SKIPPED=$(extract_field "$TWITTER_LINE" "skipped")
|
|
445
|
+
TWITTER_ERRORS=$(extract_field "$TWITTER_LINE" "errors")
|
|
446
|
+
|
|
447
|
+
MOLTBOOK_CHECKED=$(extract_field "$MOLTBOOK_LINE" "checked")
|
|
448
|
+
MOLTBOOK_UPDATED=$(extract_field "$MOLTBOOK_LINE" "updated")
|
|
449
|
+
MOLTBOOK_DELETED=$(extract_field "$MOLTBOOK_LINE" "deleted")
|
|
450
|
+
MOLTBOOK_ERRORS=$(extract_field "$MOLTBOOK_LINE" "errors")
|
|
451
|
+
|
|
452
|
+
# LinkedIn counters live in a JSON sidecar (no structured stdout line). The
|
|
453
|
+
# file is written by scrape_linkedin_stats.py --summary; absent or empty
|
|
454
|
+
# means the LinkedIn leg didn't run or wrote nothing, so all counters are 0.
|
|
455
|
+
LINKEDIN_REFRESHED=0
|
|
456
|
+
LINKEDIN_REMOVED=0
|
|
457
|
+
LINKEDIN_UNAVAILABLE=0
|
|
458
|
+
LINKEDIN_NOT_FOUND=0
|
|
459
|
+
if [ -s "$LINKEDIN_SUMMARY_FILE" ]; then
|
|
460
|
+
LINKEDIN_REFRESHED=$(python3 -c "import json,sys; d=json.load(open('$LINKEDIN_SUMMARY_FILE')); print(int(d.get('refreshed', 0) or 0))" 2>/dev/null || echo 0)
|
|
461
|
+
LINKEDIN_REMOVED=$(python3 -c "import json,sys; d=json.load(open('$LINKEDIN_SUMMARY_FILE')); print(int(d.get('removed', 0) or 0))" 2>/dev/null || echo 0)
|
|
462
|
+
LINKEDIN_UNAVAILABLE=$(python3 -c "import json,sys; d=json.load(open('$LINKEDIN_SUMMARY_FILE')); print(int(d.get('unavailable', 0) or 0))" 2>/dev/null || echo 0)
|
|
463
|
+
LINKEDIN_NOT_FOUND=$(python3 -c "import json,sys; d=json.load(open('$LINKEDIN_SUMMARY_FILE')); print(int(d.get('not_found', 0) or 0))" 2>/dev/null || echo 0)
|
|
464
|
+
fi
|
|
465
|
+
|
|
466
|
+
CHECKED=$(( REDDIT_CHECKED + TWITTER_CHECKED + MOLTBOOK_CHECKED + LINKEDIN_REFRESHED ))
|
|
467
|
+
# 2026-05-18 relabel: the legacy `UPDATED` summed Reddit's Step 1 view-scrape
|
|
468
|
+
# leg into the same pill as Step 2's "metric actually changed" leg, which
|
|
469
|
+
# silently inflated the number. Keep `UPDATED` wired for back-compat (the
|
|
470
|
+
# log line still emits `updated=N`), but it is now the same value as
|
|
471
|
+
# `CHANGED` so old dashboards behave sanely. New dashboards read the
|
|
472
|
+
# explicit `changed=` and `views_refreshed=` fields instead.
|
|
473
|
+
CHANGED=$(( REDDIT_CHANGED + TWITTER_CHANGED + MOLTBOOK_UPDATED + LINKEDIN_REFRESHED ))
|
|
474
|
+
VIEWS_REFRESHED=$REDDIT_VIEWS_UPDATED
|
|
475
|
+
UPDATED=$CHANGED
|
|
476
|
+
# `SCANNED` is the total rows the run considered, across all platforms.
|
|
477
|
+
# Moltbook has no skip class so its "scanned" == "checked"; LinkedIn ditto.
|
|
478
|
+
SCANNED=$(( REDDIT_SCANNED + TWITTER_SCANNED + MOLTBOOK_CHECKED + LINKEDIN_REFRESHED ))
|
|
479
|
+
REMOVED=$(( REDDIT_DELETED + REDDIT_REMOVED_FIELD + TWITTER_DELETED + MOLTBOOK_DELETED + LINKEDIN_REMOVED ))
|
|
480
|
+
SKIPPED_REAL=$(( REDDIT_SKIPPED + TWITTER_SKIPPED ))
|
|
481
|
+
UNAVAILABLE=$LINKEDIN_UNAVAILABLE
|
|
482
|
+
NOT_FOUND=$LINKEDIN_NOT_FOUND
|
|
483
|
+
# API errors are surfaced via a per-platform counter but are folded into the
|
|
484
|
+
# "failed" pill alongside step-exit counts. Stays bounded since API errors
|
|
485
|
+
# cap at a few hundred and step exits are 0-4.
|
|
486
|
+
FAILED_REAL=$(( STATS_FAILED + REDDIT_ERRORS + TWITTER_ERRORS + MOLTBOOK_ERRORS ))
|
|
487
|
+
|
|
488
|
+
# Pull the reply-refresh count for this platform out of the sidecar JSON.
|
|
489
|
+
# Defaults to 0 if the file is missing or the platform's pass didn't run.
|
|
490
|
+
REPLIES_REFRESHED=0
|
|
491
|
+
if [ -s "$REPLY_SUMMARY_FILE" ]; then
|
|
492
|
+
KEY="${PLATFORM:-reddit}" # all-platforms run reports reddit + twitter + github separately;
|
|
493
|
+
# without --platform we just total them.
|
|
494
|
+
if [ -n "$PLATFORM" ]; then
|
|
495
|
+
REPLIES_REFRESHED=$(python3 -c "import json,sys; d=json.load(open('$REPLY_SUMMARY_FILE')); print(d.get('$KEY', 0))" 2>/dev/null || echo 0)
|
|
496
|
+
else
|
|
497
|
+
REPLIES_REFRESHED=$(python3 -c "import json,sys; d=json.load(open('$REPLY_SUMMARY_FILE')); print(sum(d.values()))" 2>/dev/null || echo 0)
|
|
498
|
+
fi
|
|
499
|
+
fi
|
|
500
|
+
|
|
501
|
+
_COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START" --scripts "stats-step4" 2>/dev/null || echo "0.0000")
|
|
502
|
+
python3 "$REPO_DIR/scripts/log_run.py" \
|
|
503
|
+
--script "$SCRIPT_TAG" \
|
|
504
|
+
--posted 0 \
|
|
505
|
+
--skipped "$SKIPPED_REAL" \
|
|
506
|
+
--failed "$FAILED_REAL" \
|
|
507
|
+
--replies-refreshed "$REPLIES_REFRESHED" \
|
|
508
|
+
--checked "$CHECKED" \
|
|
509
|
+
--updated "$UPDATED" \
|
|
510
|
+
--removed "$REMOVED" \
|
|
511
|
+
--unavailable "$UNAVAILABLE" \
|
|
512
|
+
--not-found "$NOT_FOUND" \
|
|
513
|
+
--scanned "$SCANNED" \
|
|
514
|
+
--changed "$CHANGED" \
|
|
515
|
+
--views-refreshed "$VIEWS_REFRESHED" \
|
|
516
|
+
--cost "$_COST" \
|
|
517
|
+
--elapsed "$RUN_ELAPSED"
|
|
518
|
+
|
|
519
|
+
# Clean up old logs (keep last 7 days). Covers both new `stats-<platform>-*`
|
|
520
|
+
# and any legacy `stats-YYYY-*` filenames.
|
|
521
|
+
find "$LOG_DIR" -name "stats-*.log" -mtime +7 -delete 2>/dev/null || true
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# strike-alert.sh — sweep posts for unalerted strikes (status flipped to
|
|
3
|
+
# 'deleted' or 'removed') and email i@m13v.com one notification per strike.
|
|
4
|
+
# Idempotent via posts.strike_email_sent_at. Wired by
|
|
5
|
+
# launchd/com.m13v.social-strike-alert.plist (hourly).
|
|
6
|
+
|
|
7
|
+
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
8
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
9
|
+
mkdir -p "$LOG_DIR"
|
|
10
|
+
LOG_FILE="$LOG_DIR/strike-alert-$(date +%Y%m%d).log"
|
|
11
|
+
|
|
12
|
+
cd "$REPO_DIR" || exit 1
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
echo "=== $(date -u +%Y-%m-%dT%H:%M:%SZ) strike-alert sweep ==="
|
|
16
|
+
/usr/bin/env python3 scripts/strike_alert.py --sweep
|
|
17
|
+
echo
|
|
18
|
+
} >> "$LOG_FILE" 2>&1
|
package/skill/styles.sh
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Shared engagement styles helper.
|
|
3
|
+
# Usage:
|
|
4
|
+
# source styles.sh
|
|
5
|
+
# ASSIGN_FILE=$(mktemp -t saps_style_assign_XXXXXX.json)
|
|
6
|
+
# ASSIGNMENT=$(saps_pick_style twitter posting "$ASSIGN_FILE")
|
|
7
|
+
# PICKED_STYLE=$(echo "$ASSIGNMENT" | python3 -c "import json,sys; print((json.load(sys.stdin).get('style') or ''))")
|
|
8
|
+
# STYLES_BLOCK=$(saps_render_style_block "$ASSIGN_FILE" twitter posting)
|
|
9
|
+
# Requires REPO_DIR to be set before sourcing.
|
|
10
|
+
#
|
|
11
|
+
# Architecture (2026-05-19 picker rollout):
|
|
12
|
+
# - saps_pick_style: programmatic style picker. Emits the assignment JSON to
|
|
13
|
+
# stdout AND writes it to the optional outfile path so a sibling shell var
|
|
14
|
+
# can keep the path around and re-read it later. Replaces the legacy
|
|
15
|
+
# "show all styles, let the model pick" pattern.
|
|
16
|
+
# - saps_render_style_block: turns an assignment JSON file into the compact
|
|
17
|
+
# prompt block (one assigned style + description + example + note, or the
|
|
18
|
+
# invent block with top-N references) plus content rules + anti-patterns
|
|
19
|
+
# + grounding rule.
|
|
20
|
+
# - generate_styles_block: legacy wrapper (pick + render in one go). Retained
|
|
21
|
+
# for shell callers that don't need the picked style downstream (rare; most
|
|
22
|
+
# now want it to filter top_performers and to log drift).
|
|
23
|
+
|
|
24
|
+
# Pick a style and emit the assignment as JSON to stdout. Optionally also
|
|
25
|
+
# writes the JSON to $3 (an outfile path).
|
|
26
|
+
saps_pick_style() {
|
|
27
|
+
local platform="$1"
|
|
28
|
+
local context="${2:-posting}"
|
|
29
|
+
local outfile="${3:-}"
|
|
30
|
+
python3 -c "
|
|
31
|
+
import json, sys
|
|
32
|
+
sys.path.insert(0, '$REPO_DIR/scripts')
|
|
33
|
+
from engagement_styles import pick_style_for_post
|
|
34
|
+
assignment = pick_style_for_post('$platform', context='$context')
|
|
35
|
+
out = '$outfile'
|
|
36
|
+
if out:
|
|
37
|
+
with open(out, 'w') as f:
|
|
38
|
+
json.dump(assignment, f)
|
|
39
|
+
print(json.dumps(assignment))
|
|
40
|
+
" 2>/dev/null || echo '{"mode":"use","style":null,"description":null,"example":null,"note":null,"reference_styles":[],"distribution_snapshot":[]}'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Render the compact prompt block from an assignment JSON file.
|
|
44
|
+
# Includes the styles block + content rules + anti-patterns + grounding rule
|
|
45
|
+
# (the grounding rule is bundled inside get_assigned_style_prompt) +
|
|
46
|
+
# voice relationship rule (introduced 2026-05-27 so the model knows whether
|
|
47
|
+
# to speak AS the matched project's maker or as an outside observer; per
|
|
48
|
+
# project the rule reads voice_relationship in config.json).
|
|
49
|
+
saps_render_style_block() {
|
|
50
|
+
local assign_file="$1"
|
|
51
|
+
local platform="$2"
|
|
52
|
+
local context="${3:-posting}"
|
|
53
|
+
python3 -c "
|
|
54
|
+
import json, sys
|
|
55
|
+
sys.path.insert(0, '$REPO_DIR/scripts')
|
|
56
|
+
from engagement_styles import (
|
|
57
|
+
get_assigned_style_prompt, get_content_rules, get_anti_patterns,
|
|
58
|
+
get_voice_relationship_rule,
|
|
59
|
+
)
|
|
60
|
+
with open('$assign_file', 'r') as f:
|
|
61
|
+
assignment = json.load(f)
|
|
62
|
+
print(get_assigned_style_prompt('$platform', assignment, context='$context'))
|
|
63
|
+
print()
|
|
64
|
+
print(get_voice_relationship_rule())
|
|
65
|
+
print()
|
|
66
|
+
print('## Content rules')
|
|
67
|
+
print(get_content_rules('$platform'))
|
|
68
|
+
print()
|
|
69
|
+
print(get_anti_patterns())
|
|
70
|
+
" 2>/dev/null || echo "(style module unavailable)"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Legacy: pick + render in one call, no assignment exposed to the caller.
|
|
74
|
+
# Equivalent to the pre-2026-05-19 behavior except the prompt now assigns one
|
|
75
|
+
# style instead of listing all of them.
|
|
76
|
+
generate_styles_block() {
|
|
77
|
+
local platform="$1"
|
|
78
|
+
local context="${2:-posting}"
|
|
79
|
+
local tmpfile
|
|
80
|
+
tmpfile=$(mktemp -t saps_style_assign_XXXXXX.json) || {
|
|
81
|
+
echo "(style module unavailable: mktemp failed)"
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
saps_pick_style "$platform" "$context" "$tmpfile" >/dev/null
|
|
85
|
+
saps_render_style_block "$tmpfile" "$platform" "$context"
|
|
86
|
+
rm -f "$tmpfile"
|
|
87
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# sweep-link-clicks.sh — launchd wrapper for scripts/sweep_post_link_clicks.py.
|
|
3
|
+
#
|
|
4
|
+
# Fires every 30 min from com.m13v.social-sweep-link-clicks.plist.
|
|
5
|
+
#
|
|
6
|
+
# Re-classifies post_link_clicks rows as is_bot=true based on behavioral
|
|
7
|
+
# patterns the per-hit UA regex can't detect:
|
|
8
|
+
#
|
|
9
|
+
# R1 same ip_hash + same code + >=3 hits (repeat-tap on a single link)
|
|
10
|
+
# R2 clicks > views * platform_ctr_ceiling (impossible CTR)
|
|
11
|
+
# R3 ip_hash hits >=5 different codes (crawler sweep)
|
|
12
|
+
# R4 no referrer + dirty-IP companion (suspicious naked GET)
|
|
13
|
+
# R5 >=4 codes within 60s from one ip_hash (burst fan-out)
|
|
14
|
+
#
|
|
15
|
+
# Records the rule in post_link_clicks.bot_reason and rebuilds
|
|
16
|
+
# post_links.clicks (humans only) for affected codes.
|
|
17
|
+
#
|
|
18
|
+
# Single-flight: takes the project lock so a slow run can't stack with
|
|
19
|
+
# the next launchd fire.
|
|
20
|
+
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
24
|
+
|
|
25
|
+
# shellcheck source=/dev/null
|
|
26
|
+
[ -f "$REPO_DIR/.env" ] && source "$REPO_DIR/.env"
|
|
27
|
+
|
|
28
|
+
cd "$REPO_DIR" || exit 2
|
|
29
|
+
|
|
30
|
+
# shellcheck source=lock.sh
|
|
31
|
+
source "$REPO_DIR/skill/lock.sh"
|
|
32
|
+
acquire_lock sweep-link-clicks 5
|
|
33
|
+
|
|
34
|
+
RUN_START=$(date +%s)
|
|
35
|
+
/opt/homebrew/bin/python3.11 "$REPO_DIR/scripts/sweep_post_link_clicks.py" --cron
|
|
36
|
+
EXIT_CODE=$?
|
|
37
|
+
RUN_ELAPSED=$(( $(date +%s) - RUN_START ))
|
|
38
|
+
|
|
39
|
+
echo "[$(date +%H:%M:%S)] === done in ${RUN_ELAPSED}s (exit=${EXIT_CODE}) ==="
|
|
40
|
+
exit "$EXIT_CODE"
|