@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,288 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministically capture + persist + format thread media for the prep step.
|
|
3
|
+
|
|
4
|
+
Companion to the main Twitter posting cycle (run-twitter-cycle.sh Phase 2b-prep,
|
|
5
|
+
2026-06-03 thread-media feature). The prep prompt forbids the model from calling
|
|
6
|
+
twitter_browser.py, so the SHELL pre-fetches the media of every candidate the
|
|
7
|
+
model is about to draft against, in ONE cheap browser pass, then:
|
|
8
|
+
|
|
9
|
+
1. persists each candidate's media into twitter_candidates.thread_media (so the
|
|
10
|
+
record survives independent of the model), and
|
|
11
|
+
2. emits a "MEDIA CONTEXT" prompt block to stdout so the reply-writer can "see"
|
|
12
|
+
the image / video / GIF / link-card it is replying to instead of replying
|
|
13
|
+
text-blind.
|
|
14
|
+
|
|
15
|
+
Input: a TSV file, one `candidate_id<TAB>tweet_url` per line (built by the
|
|
16
|
+
CANDIDATE_BLOCK loop in run-twitter-cycle.sh).
|
|
17
|
+
|
|
18
|
+
Media shape per item: {url, alt, type}, type in image|video|gif|card. An empty
|
|
19
|
+
list [] is valid and meaningful ("captured, none found", distinct from NULL =
|
|
20
|
+
"never captured").
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
python3 scripts/capture_thread_media.py --urls-file /tmp/urls.tsv \\
|
|
24
|
+
[--scroll 1] [--no-persist]
|
|
25
|
+
|
|
26
|
+
Output:
|
|
27
|
+
stdout -> the MEDIA CONTEXT prompt block (empty string if no media at all)
|
|
28
|
+
stderr -> per-candidate diagnostics + a final JSON summary line
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
37
|
+
from http_api import api_get, api_patch # noqa: E402
|
|
38
|
+
|
|
39
|
+
# Imported lazily inside main() so --help works without a browser / playwright.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_pairs(urls_file):
|
|
43
|
+
"""Return [(candidate_id:str, url:str)] from a `cid<TAB>url` TSV file."""
|
|
44
|
+
pairs = []
|
|
45
|
+
with open(urls_file) as f:
|
|
46
|
+
for line in f:
|
|
47
|
+
line = line.rstrip("\n")
|
|
48
|
+
if not line.strip():
|
|
49
|
+
continue
|
|
50
|
+
if "\t" in line:
|
|
51
|
+
cid, url = line.split("\t", 1)
|
|
52
|
+
else:
|
|
53
|
+
# Tolerate a bare-URL line (no cid); skip it, we can't key it.
|
|
54
|
+
continue
|
|
55
|
+
cid = cid.strip()
|
|
56
|
+
url = url.strip()
|
|
57
|
+
if cid and url:
|
|
58
|
+
pairs.append((cid, url))
|
|
59
|
+
return pairs
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _persist(candidate_id, media, repost=None):
|
|
63
|
+
"""Persist media (+ repost provenance) onto twitter_candidates via set_media.
|
|
64
|
+
|
|
65
|
+
repost is {"is_repost": bool, "reposted_by": str} or None.
|
|
66
|
+
|
|
67
|
+
POSITIVE-ONLY for repost: we send is_repost/reposted_by ONLY when we detected
|
|
68
|
+
a repost (is_repost True). The candidate URL is the ORIGINAL tweet permalink,
|
|
69
|
+
where X does NOT render the "<X> reposted" timeline banner, so fresh detection
|
|
70
|
+
here is almost always False; sending that False would clobber the authoritative
|
|
71
|
+
flag set at discovery time (the server COALESCEs only on null, not on false).
|
|
72
|
+
Omitting the keys leaves COALESCE(null, is_repost) = the stored value intact.
|
|
73
|
+
"""
|
|
74
|
+
payload = {"id": int(candidate_id), "action": "set_media", "thread_media": media}
|
|
75
|
+
if repost is not None and bool(repost.get("is_repost", False)):
|
|
76
|
+
payload["is_repost"] = True
|
|
77
|
+
payload["reposted_by"] = repost.get("reposted_by", "") or ""
|
|
78
|
+
resp = api_patch(
|
|
79
|
+
"/api/v1/twitter-candidates/by-id", payload,
|
|
80
|
+
ok_on_conflict=True, ok_on_404=True,
|
|
81
|
+
)
|
|
82
|
+
if (resp or {}).get("_not_found"):
|
|
83
|
+
return False, "CANDIDATE_NOT_FOUND"
|
|
84
|
+
if not (resp or {}).get("ok"):
|
|
85
|
+
return False, (resp or {}).get("error") or "SET_MEDIA_FAILED"
|
|
86
|
+
return True, None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _fetch_stored_repost(candidate_id):
|
|
90
|
+
"""Read the repost flag stored at discovery time for one candidate.
|
|
91
|
+
|
|
92
|
+
Repost provenance is detected and stored by the DISCOVERY scan (the timeline
|
|
93
|
+
is the only place X renders the "<X> reposted" banner). The prep step runs on
|
|
94
|
+
the original-tweet permalink, which never shows that banner, so we read the
|
|
95
|
+
authoritative stored value here to surface it to the model in the prompt block.
|
|
96
|
+
|
|
97
|
+
Returns {"is_repost": bool, "reposted_by": str}; defaults to a non-repost on
|
|
98
|
+
any error / missing candidate (fail-open: never block the cycle).
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
resp = api_get(
|
|
102
|
+
"/api/v1/twitter-candidates/by-id",
|
|
103
|
+
{"id": int(candidate_id)}, ok_on_404=True,
|
|
104
|
+
)
|
|
105
|
+
except Exception:
|
|
106
|
+
return {"is_repost": False, "reposted_by": ""}
|
|
107
|
+
cand = (resp or {}).get("candidate") or {}
|
|
108
|
+
return {
|
|
109
|
+
"is_repost": bool(cand.get("is_repost", False)),
|
|
110
|
+
"reposted_by": cand.get("reposted_by", "") or "",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _format_item(item):
|
|
115
|
+
"""One ' - <type>: "<alt>" (<url>)' line for the prompt block."""
|
|
116
|
+
t = (item.get("type") or "media").strip()
|
|
117
|
+
alt = (item.get("alt") or "").strip()
|
|
118
|
+
url = (item.get("url") or "").strip()
|
|
119
|
+
alt_part = f'"{alt}"' if alt else "[no description]"
|
|
120
|
+
return f" - {t}: {alt_part} ({url})"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _build_block(captured):
|
|
124
|
+
"""captured: list of (candidate_id, media_list, repost). Returns prompt block.
|
|
125
|
+
|
|
126
|
+
A section is emitted for any candidate that has media OR is a repost, so the
|
|
127
|
+
model is told about repost provenance even when the tweet carries no media.
|
|
128
|
+
"""
|
|
129
|
+
sections = []
|
|
130
|
+
for cid, media, repost in captured:
|
|
131
|
+
is_repost = bool((repost or {}).get("is_repost"))
|
|
132
|
+
if not media and not is_repost:
|
|
133
|
+
continue
|
|
134
|
+
body = []
|
|
135
|
+
if is_repost:
|
|
136
|
+
rb = ((repost or {}).get("reposted_by") or "").strip()
|
|
137
|
+
who = f"@{rb}" if rb else "another account"
|
|
138
|
+
body.append(
|
|
139
|
+
f" - REPOST: this is a repost surfaced by {who}. The tweet text "
|
|
140
|
+
"and any media below were written by the ORIGINAL author, not the "
|
|
141
|
+
"reposter. Reply to the original author's content; do not address "
|
|
142
|
+
"the reposter."
|
|
143
|
+
)
|
|
144
|
+
if media:
|
|
145
|
+
body.extend(_format_item(it) for it in media)
|
|
146
|
+
sections.append(f"Candidate {cid}:\n" + "\n".join(body))
|
|
147
|
+
if not sections:
|
|
148
|
+
return ""
|
|
149
|
+
header = (
|
|
150
|
+
"## MEDIA IN THESE THREADS\n"
|
|
151
|
+
"Some candidate threads contain images, videos, GIFs, link-cards, or are "
|
|
152
|
+
"reposts. This is part of the content you are replying to: react to what "
|
|
153
|
+
"the tweet VISUALLY shows, not just its text, and treat reposted content "
|
|
154
|
+
"as the original author's. A candidate NOT listed here had no media and is "
|
|
155
|
+
"not a repost (or capture was skipped); reply to its text as usual. "
|
|
156
|
+
"Descriptions marked [no description] mean the media had no alt-text, so "
|
|
157
|
+
"infer from the thread text and the media type."
|
|
158
|
+
)
|
|
159
|
+
return header + "\n\n" + "\n".join(sections) + "\n"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main():
|
|
163
|
+
p = argparse.ArgumentParser()
|
|
164
|
+
p.add_argument("--urls-file", required=True,
|
|
165
|
+
help="TSV: one candidate_id<TAB>tweet_url per line.")
|
|
166
|
+
p.add_argument("--scroll", type=int, default=1,
|
|
167
|
+
help="scroll_count passed to the batch scraper (default 1).")
|
|
168
|
+
p.add_argument("--no-persist", action="store_true",
|
|
169
|
+
help="Skip writing thread_media to the DB (format only).")
|
|
170
|
+
args = p.parse_args()
|
|
171
|
+
|
|
172
|
+
pairs = _load_pairs(args.urls_file)
|
|
173
|
+
if not pairs:
|
|
174
|
+
# Nothing to do; emit empty block, exit clean so the shell continues.
|
|
175
|
+
print("", end="")
|
|
176
|
+
print(json.dumps({"captured": 0, "persisted": 0, "with_media": 0, "reposts": 0}), file=sys.stderr)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# Lazy import so an empty/short-circuit run never pays the playwright cost.
|
|
180
|
+
from twitter_browser import scrape_many_thread_media
|
|
181
|
+
|
|
182
|
+
urls = [url for _cid, url in pairs]
|
|
183
|
+
try:
|
|
184
|
+
batch = scrape_many_thread_media(urls, scroll_count=args.scroll)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
# Browser failure must NOT break the cycle: emit empty block, log, exit 0.
|
|
187
|
+
print("", end="")
|
|
188
|
+
print(json.dumps({"error": "SCRAPE_FAILED", "detail": str(e)}), file=sys.stderr)
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
# Map url -> {media, repost} (results echo the input url verbatim as thread_url).
|
|
192
|
+
by_url = {}
|
|
193
|
+
for r in (batch or {}).get("results", []):
|
|
194
|
+
by_url[r.get("thread_url")] = {
|
|
195
|
+
"media": r.get("media") or [],
|
|
196
|
+
"repost": {
|
|
197
|
+
"is_repost": bool(r.get("is_repost", False)),
|
|
198
|
+
"reposted_by": r.get("reposted_by", "") or "",
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
captured = [] # (cid, media, repost) for ALL pairs (media may be [])
|
|
203
|
+
persisted = 0
|
|
204
|
+
with_media = 0
|
|
205
|
+
reposts = 0
|
|
206
|
+
access_checked = 0
|
|
207
|
+
access_not_visible = 0
|
|
208
|
+
access_check_limit = int(os.environ.get("S4L_TWITTER_EMPTY_MEDIA_ACCESS_CHECKS", "3"))
|
|
209
|
+
access_wait_ms = int(os.environ.get("S4L_TWITTER_EMPTY_MEDIA_ACCESS_WAIT_MS", "4000"))
|
|
210
|
+
empty_capture_unreliable = None
|
|
211
|
+
for cid, url in pairs:
|
|
212
|
+
rec = by_url.get(url) or {}
|
|
213
|
+
media = rec.get("media", [])
|
|
214
|
+
access = None
|
|
215
|
+
if not media:
|
|
216
|
+
# Empty media is only meaningful when the tweet itself rendered.
|
|
217
|
+
# If x.com served an empty app shell / block / protected page, do
|
|
218
|
+
# NOT persist [] because [] means "captured successfully, no media";
|
|
219
|
+
# leaving NULL lets a later cycle retry instead of poisoning the row.
|
|
220
|
+
if empty_capture_unreliable:
|
|
221
|
+
access = empty_capture_unreliable
|
|
222
|
+
elif access_checked < access_check_limit:
|
|
223
|
+
try:
|
|
224
|
+
from twitter_access_check import diagnose_tweet_access
|
|
225
|
+
access = diagnose_tweet_access(
|
|
226
|
+
url, wait_ms=access_wait_ms, include_public=False,
|
|
227
|
+
)
|
|
228
|
+
access_checked += 1
|
|
229
|
+
except Exception as e:
|
|
230
|
+
access = {"status": "access_check_failed", "reason": str(e)}
|
|
231
|
+
access_checked += 1
|
|
232
|
+
else:
|
|
233
|
+
access = {"status": "unchecked", "reason": "empty_media_access_check_cap"}
|
|
234
|
+
status = access.get("status")
|
|
235
|
+
if status not in ("visible", "visible_no_anchor", "unchecked"):
|
|
236
|
+
access_not_visible += 1
|
|
237
|
+
if status in ("app_not_hydrated", "app_error", "logged_out", "access_check_failed", "unknown"):
|
|
238
|
+
empty_capture_unreliable = access
|
|
239
|
+
print(
|
|
240
|
+
f"[capture_thread_media] cid={cid} url={url} "
|
|
241
|
+
f"access_status={status} reason={access.get('reason')} "
|
|
242
|
+
"leaving thread_media NULL",
|
|
243
|
+
file=sys.stderr,
|
|
244
|
+
)
|
|
245
|
+
captured.append((cid, media, {"is_repost": False, "reposted_by": ""}))
|
|
246
|
+
continue
|
|
247
|
+
fresh = rec.get("repost", {"is_repost": False, "reposted_by": ""})
|
|
248
|
+
# Authoritative repost flag comes from discovery (stored). Fresh permalink
|
|
249
|
+
# detection is a rare bonus; prefer stored, fall back to fresh.
|
|
250
|
+
stored = _fetch_stored_repost(cid)
|
|
251
|
+
if stored.get("is_repost"):
|
|
252
|
+
repost = stored
|
|
253
|
+
elif fresh.get("is_repost"):
|
|
254
|
+
repost = fresh
|
|
255
|
+
else:
|
|
256
|
+
repost = {"is_repost": False, "reposted_by": ""}
|
|
257
|
+
captured.append((cid, media, repost))
|
|
258
|
+
if media:
|
|
259
|
+
with_media += 1
|
|
260
|
+
if repost.get("is_repost"):
|
|
261
|
+
reposts += 1
|
|
262
|
+
if not args.no_persist:
|
|
263
|
+
# _persist is positive-only for repost: pass the FRESH detection (so a
|
|
264
|
+
# newly-seen banner is recorded) but never the stored value back (no
|
|
265
|
+
# round-trip clobber). Media is always persisted.
|
|
266
|
+
ok, err = _persist(cid, media, fresh)
|
|
267
|
+
if ok:
|
|
268
|
+
persisted += 1
|
|
269
|
+
else:
|
|
270
|
+
print(f"[capture_thread_media] persist failed cid={cid}: {err}",
|
|
271
|
+
file=sys.stderr)
|
|
272
|
+
|
|
273
|
+
block = _build_block(captured)
|
|
274
|
+
# stdout = the prompt block ONLY (shell captures it verbatim).
|
|
275
|
+
sys.stdout.write(block)
|
|
276
|
+
print(json.dumps({
|
|
277
|
+
"captured": len(captured),
|
|
278
|
+
"persisted": persisted,
|
|
279
|
+
"with_media": with_media,
|
|
280
|
+
"reposts": reposts,
|
|
281
|
+
"access_checked": access_checked,
|
|
282
|
+
"access_not_visible": access_not_visible,
|
|
283
|
+
"urls_visited": (batch or {}).get("urls_visited", 0),
|
|
284
|
+
}), file=sys.stderr)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Health check for the browser session-lock fix (2026-06-16).
|
|
3
|
+
# Full context: docs/twitter_browser_lock.md. Logic test: scripts/test_browser_lock.py.
|
|
4
|
+
#
|
|
5
|
+
# Prints [ok ] / [BAD] / [i ] lines so you can tell at a glance whether the fix is
|
|
6
|
+
# present in code AND behaving in production. Exits non-zero if any [BAD] is found.
|
|
7
|
+
#
|
|
8
|
+
# bash scripts/check_browser_lock_health.sh [HOURS] # default lookback 24h
|
|
9
|
+
#
|
|
10
|
+
# What it checks (see docs §4/§5 for the why):
|
|
11
|
+
# 1. fix still present in twitter_browser.py + linkedin_browser.py (catch a revert)
|
|
12
|
+
# 2. defect-b `rm -f` has NOT crept back into skill/*.sh
|
|
13
|
+
# 3. reclaim markers firing = fix actively catching dead holders (positive signal)
|
|
14
|
+
# 4. starvation giveups WITHOUT the peer-alive tell = defect (a) recurring (BAD)
|
|
15
|
+
# 5. shell-lock trap_rm owner=OTHER = a pipeline deleted a LIVE peer's lock (BAD)
|
|
16
|
+
set -u
|
|
17
|
+
cd "$(dirname "$0")/.."
|
|
18
|
+
LOGS="skill/logs"
|
|
19
|
+
HOURS="${1:-24}"
|
|
20
|
+
bad=0
|
|
21
|
+
|
|
22
|
+
# Dated per-run logs that are BOTH within the lookback window AND newer than the lock
|
|
23
|
+
# code file. The `-newer` clause is the key: it counts only runs that started after the
|
|
24
|
+
# fix (or, if the fix is ever reverted, after that revert) -- so day-one pre-fix
|
|
25
|
+
# starvation noise is excluded automatically, and a real regression still surfaces.
|
|
26
|
+
# (NOT launchd-*.log: those are append-only, so their mtime says nothing about when a
|
|
27
|
+
# line inside was written.)
|
|
28
|
+
recent=$(find "$LOGS" -maxdepth 1 -name '*2026-*.log' -mmin "-$((HOURS*60))" \
|
|
29
|
+
-newer scripts/twitter_browser.py 2>/dev/null | grep -v 'launchd-' || true)
|
|
30
|
+
[ -z "$recent" ] && recent="/dev/null" # guard: never let grep read stdin
|
|
31
|
+
|
|
32
|
+
echo "== browser-lock health (last ${HOURS}h of dated per-run logs) =="
|
|
33
|
+
|
|
34
|
+
# 1. fix present in code
|
|
35
|
+
if grep -q _is_python_holder_alive scripts/twitter_browser.py 2>/dev/null \
|
|
36
|
+
&& grep -q _is_python_holder_alive scripts/linkedin_browser.py 2>/dev/null; then
|
|
37
|
+
echo "[ok ] fix present (twitter_browser.py + linkedin_browser.py)"
|
|
38
|
+
else
|
|
39
|
+
echo "[BAD] fix MISSING from code -> reverted (re-apply from docs/twitter_browser_lock.md)"; bad=1
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# 2. defect-b rm -f gone from shells (anchored so the explanatory comment is not a hit).
|
|
43
|
+
# Scope to *.sh only -- never recurse skill/ (it holds a huge claude-sessions/ tree).
|
|
44
|
+
if grep -hEq '^[[:space:]]*rm -f .*twitter-browser-lock\.json' skill/*.sh skill/lib/*.sh 2>/dev/null; then
|
|
45
|
+
echo "[BAD] defect-b: an actual 'rm -f ...twitter-browser-lock.json' is back in skill/*.sh"; bad=1
|
|
46
|
+
else
|
|
47
|
+
echo "[ok ] no rm -f of the session lock in skill/*.sh"
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# 3. positive: reclaim markers (each = a dead holder caught that USED to starve the fleet)
|
|
51
|
+
rec=$(grep -hoE '\[browser_lock\] reclaimed .*reason=[a-z_]+' $recent 2>/dev/null | wc -l | tr -d ' ')
|
|
52
|
+
recd=$(grep -hoE '\[browser_lock\] reclaimed .*reason=dead_python' $recent 2>/dev/null | wc -l | tr -d ' ')
|
|
53
|
+
echo "[i ] reclaim markers fired: ${rec:-0} (of which dead_python: ${recd:-0}) -- 0 is fine if nothing crashed"
|
|
54
|
+
|
|
55
|
+
# 4. starvation: twitter giveup without 'peer alive' / linkedin profile_locked without 'peer_alive'
|
|
56
|
+
bad_tw=$(grep -hE 'locked by session .* giving up' $recent 2>/dev/null | grep -vc 'peer alive' || true)
|
|
57
|
+
bad_li=$(grep -hE 'profile_locked' $recent 2>/dev/null | grep -vc 'peer_alive' || true)
|
|
58
|
+
bad_tw=${bad_tw:-0}; bad_li=${bad_li:-0}
|
|
59
|
+
if [ "$bad_tw" -gt 0 ] || [ "$bad_li" -gt 0 ]; then
|
|
60
|
+
echo "[BAD] old-format starvation giveups: twitter=$bad_tw linkedin=$bad_li (defect a recurring?)"; bad=1
|
|
61
|
+
else
|
|
62
|
+
echo "[ok ] no old-format starvation giveups (twitter + linkedin)"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# 5. shell-lock: dangerous trap_rm owner=OTHER (deleting a live peer's shell lock).
|
|
66
|
+
# NOTE: 'event=stale_reclaim ... owner=OTHER' is LEGITIMATE (reclaiming a dead holder),
|
|
67
|
+
# only 'event=trap_rm ... owner=OTHER' is the bad one.
|
|
68
|
+
if [ -f "$LOGS/lock-events.log" ]; then
|
|
69
|
+
to=$(grep -cE 'event=trap_rm .*owner=OTHER' "$LOGS/lock-events.log" 2>/dev/null); to=${to:-0}
|
|
70
|
+
if [ "$to" -gt 0 ]; then
|
|
71
|
+
echo "[BAD] shell trap_rm owner=OTHER x$to (a pipeline deleted a LIVE peer's shell lock)"; bad=1
|
|
72
|
+
else
|
|
73
|
+
echo "[ok ] shell-lock: no trap_rm owner=OTHER (live-lock deletes)"
|
|
74
|
+
fi
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
echo "=================================================="
|
|
78
|
+
if [ "$bad" -ne 0 ]; then
|
|
79
|
+
echo "RESULT: ATTENTION NEEDED (see [BAD] above)"; exit 1
|
|
80
|
+
fi
|
|
81
|
+
echo "RESULT: HEALTHY"
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Email-alert when any external_short_links pool runs low.
|
|
3
|
+
|
|
4
|
+
For every project with `external_short_links: true` in config.json, checks the
|
|
5
|
+
(project, platform) pool depth against two thresholds:
|
|
6
|
+
|
|
7
|
+
WARN -- available / total <= 0.20 (i.e., 80% of the pool has been claimed)
|
|
8
|
+
CRITICAL -- available == 0 (pool exhausted, next post returns
|
|
9
|
+
{ok: false, error: 'pool_exhausted'})
|
|
10
|
+
|
|
11
|
+
Emails go to i@m13v.com via the Gmail DWD lane. State lives in the
|
|
12
|
+
`external_pool_alerts` table so we don't spam: same (project, platform,
|
|
13
|
+
severity) is suppressed for 24h after a send.
|
|
14
|
+
|
|
15
|
+
Designed to run on launchd every 30 min. The 20% threshold gives 7-30 days of
|
|
16
|
+
runway warning before a CRITICAL fires (at typical 5-15 posts/day burn). The
|
|
17
|
+
CRITICAL alert is the "on error" case the user asked for; it fires at most
|
|
18
|
+
once per 24h per (project, platform).
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
python3 scripts/check_external_pool_depth.py # check + alert
|
|
22
|
+
python3 scripts/check_external_pool_depth.py --dry-run # report only, no email/state writes
|
|
23
|
+
python3 scripts/check_external_pool_depth.py --force # ignore 24h cooldown
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from email.message import EmailMessage
|
|
32
|
+
import base64
|
|
33
|
+
|
|
34
|
+
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
35
|
+
sys.path.insert(0, os.path.join(REPO_DIR, 'scripts'))
|
|
36
|
+
sys.path.insert(0, os.path.expanduser('~/gmail-api'))
|
|
37
|
+
|
|
38
|
+
from http_api import api_get, api_post # noqa: E402
|
|
39
|
+
|
|
40
|
+
WARN_REMAINING_RATIO = 0.20
|
|
41
|
+
COOLDOWN_HOURS = 24
|
|
42
|
+
NOTIFICATION_EMAIL = os.environ.get('NOTIFICATION_EMAIL', 'i@m13v.com')
|
|
43
|
+
PLATFORMS = ['reddit', 'twitter', 'linkedin', 'github_issues', 'moltbook']
|
|
44
|
+
CONFIG_PATH = os.path.join(REPO_DIR, 'config.json')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _scrub_dashes(s: str) -> str:
|
|
48
|
+
return s.replace('—', ',').replace('–', ',') if s else s
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_external_projects() -> list[dict]:
|
|
52
|
+
with open(CONFIG_PATH) as f:
|
|
53
|
+
cfg = json.load(f)
|
|
54
|
+
return [p for p in cfg.get('projects', []) if p.get('external_short_links')]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pool_depth(project: str, platform: str) -> tuple[int, int]:
|
|
58
|
+
resp = api_get(
|
|
59
|
+
"/api/v1/post-links/pool-depth",
|
|
60
|
+
query={"project_name": project, "platform": platform},
|
|
61
|
+
)
|
|
62
|
+
d = resp.get("data") or {}
|
|
63
|
+
return int(d.get("available") or 0), int(d.get("total") or 0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _recent_alert_exists(project: str, platform: str, severity: str) -> bool:
|
|
67
|
+
resp = api_get(
|
|
68
|
+
"/api/v1/external-pool-alerts",
|
|
69
|
+
query={
|
|
70
|
+
"project_name": project,
|
|
71
|
+
"platform": platform,
|
|
72
|
+
"severity": severity,
|
|
73
|
+
"within_hours": COOLDOWN_HOURS,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
return bool((resp.get("data") or {}).get("recent"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _record_alert(project: str, platform: str, severity: str,
|
|
80
|
+
available: int, total: int, ratio: float) -> None:
|
|
81
|
+
api_post(
|
|
82
|
+
"/api/v1/external-pool-alerts",
|
|
83
|
+
{
|
|
84
|
+
"project_name": project,
|
|
85
|
+
"platform": platform,
|
|
86
|
+
"severity": severity,
|
|
87
|
+
"available": available,
|
|
88
|
+
"total": total,
|
|
89
|
+
"ratio": ratio,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _gmail_send(subject: str, body: str) -> None:
|
|
95
|
+
from gmail_dwd_client import gmail_for
|
|
96
|
+
msg = EmailMessage()
|
|
97
|
+
msg['Subject'] = _scrub_dashes(subject)
|
|
98
|
+
msg['From'] = 'social-autoposter <i@m13v.com>'
|
|
99
|
+
msg['To'] = NOTIFICATION_EMAIL
|
|
100
|
+
msg.set_content(_scrub_dashes(body))
|
|
101
|
+
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode('ascii')
|
|
102
|
+
client = gmail_for('i@m13v.com')
|
|
103
|
+
client.service.users().messages().send(userId='me', body={'raw': raw}).execute()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format_subject(project: str, platform: str, severity: str,
|
|
107
|
+
available: int, total: int) -> str:
|
|
108
|
+
pct = f"{(available / total * 100):.0f}%" if total else "0%"
|
|
109
|
+
return f"[POOL {severity}] {project}/{platform}: {available}/{total} left ({pct})"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _format_body(project: str, platform: str, severity: str,
|
|
113
|
+
available: int, total: int, ratio: float,
|
|
114
|
+
destinations: list[dict]) -> str:
|
|
115
|
+
lines = [
|
|
116
|
+
f"Severity: {severity}",
|
|
117
|
+
f"Project: {project}",
|
|
118
|
+
f"Platform: {platform}",
|
|
119
|
+
f"Available: {available}",
|
|
120
|
+
f"Total minted: {total}",
|
|
121
|
+
f"Remaining: {ratio*100:.1f}%",
|
|
122
|
+
"",
|
|
123
|
+
]
|
|
124
|
+
if severity == 'CRITICAL':
|
|
125
|
+
lines += [
|
|
126
|
+
"The pool is exhausted. The next post for this (project, platform) will",
|
|
127
|
+
"return {ok: false, error: 'pool_exhausted'} and skip. Refill IMMEDIATELY:",
|
|
128
|
+
"",
|
|
129
|
+
]
|
|
130
|
+
else:
|
|
131
|
+
lines += [
|
|
132
|
+
f"Pool has dropped under {int(WARN_REMAINING_RATIO*100)}% remaining. Schedule a refill",
|
|
133
|
+
"in the next few days to avoid hitting pool_exhausted on the next cycle.",
|
|
134
|
+
"",
|
|
135
|
+
]
|
|
136
|
+
lines += [
|
|
137
|
+
"Refill commands (in ~/social-autoposter):",
|
|
138
|
+
" python3 scripts/mint_kent_pool.py # Kent clients (Runner/Agora/Podlog)",
|
|
139
|
+
" # for other external clients: extend mint_kent_pool.py SITE_CONFIG first",
|
|
140
|
+
"",
|
|
141
|
+
"Pool status snapshot:",
|
|
142
|
+
" python3 scripts/mint_kent_pool.py --status",
|
|
143
|
+
"",
|
|
144
|
+
]
|
|
145
|
+
if destinations:
|
|
146
|
+
lines += ["Per-destination breakdown for this slice:"]
|
|
147
|
+
for d in destinations[:20]:
|
|
148
|
+
lines.append(
|
|
149
|
+
f" {d['minted_session'][-65:]:<65} "
|
|
150
|
+
f"avail={d['available']:>5} claimed={d['claimed']:>5}"
|
|
151
|
+
)
|
|
152
|
+
if len(destinations) > 20:
|
|
153
|
+
lines.append(f" ... and {len(destinations) - 20} more destinations")
|
|
154
|
+
lines.append("")
|
|
155
|
+
lines += [
|
|
156
|
+
f"Cooldown: {COOLDOWN_HOURS}h per (project, platform, severity)",
|
|
157
|
+
f"Re-fire: python3 scripts/check_external_pool_depth.py --force",
|
|
158
|
+
]
|
|
159
|
+
return "\n".join(lines)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _destinations_for_slice(project: str, platform: str) -> list[dict]:
|
|
163
|
+
resp = api_get(
|
|
164
|
+
"/api/v1/post-links/pool-depth",
|
|
165
|
+
query={
|
|
166
|
+
"project_name": project,
|
|
167
|
+
"platform": platform,
|
|
168
|
+
"with_destinations": "1",
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
return (resp.get("data") or {}).get("destinations") or []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def check(dry_run: bool = False, force: bool = False,
|
|
175
|
+
warn_ratio: float = WARN_REMAINING_RATIO,
|
|
176
|
+
limit: int | None = None) -> dict:
|
|
177
|
+
projects = _load_external_projects()
|
|
178
|
+
fired: list[dict] = []
|
|
179
|
+
skipped_cooldown: list[dict] = []
|
|
180
|
+
healthy: list[dict] = []
|
|
181
|
+
for p in projects:
|
|
182
|
+
project_name = p['name']
|
|
183
|
+
for platform in PLATFORMS:
|
|
184
|
+
available, total = _pool_depth(project_name, platform)
|
|
185
|
+
if total == 0:
|
|
186
|
+
continue
|
|
187
|
+
ratio = available / total if total > 0 else 0.0
|
|
188
|
+
if available == 0:
|
|
189
|
+
severity = 'CRITICAL'
|
|
190
|
+
elif ratio <= warn_ratio:
|
|
191
|
+
severity = 'WARN'
|
|
192
|
+
else:
|
|
193
|
+
healthy.append({
|
|
194
|
+
'project': project_name, 'platform': platform,
|
|
195
|
+
'available': available, 'total': total, 'ratio': ratio,
|
|
196
|
+
})
|
|
197
|
+
continue
|
|
198
|
+
key = (project_name, platform, severity)
|
|
199
|
+
if not force and _recent_alert_exists(*key):
|
|
200
|
+
skipped_cooldown.append({
|
|
201
|
+
'project': project_name, 'platform': platform,
|
|
202
|
+
'severity': severity, 'available': available, 'total': total,
|
|
203
|
+
})
|
|
204
|
+
continue
|
|
205
|
+
fired_row = {
|
|
206
|
+
'project': project_name, 'platform': platform,
|
|
207
|
+
'severity': severity, 'available': available, 'total': total,
|
|
208
|
+
'ratio': ratio,
|
|
209
|
+
}
|
|
210
|
+
fired.append(fired_row)
|
|
211
|
+
if dry_run:
|
|
212
|
+
continue
|
|
213
|
+
if limit is not None and len(fired) > limit:
|
|
214
|
+
continue
|
|
215
|
+
destinations = _destinations_for_slice(project_name, platform)
|
|
216
|
+
subject = _format_subject(project_name, platform, severity, available, total)
|
|
217
|
+
body = _format_body(project_name, platform, severity, available, total,
|
|
218
|
+
ratio, destinations)
|
|
219
|
+
try:
|
|
220
|
+
_gmail_send(subject, body)
|
|
221
|
+
_record_alert(project_name, platform, severity,
|
|
222
|
+
available, total, ratio)
|
|
223
|
+
except Exception as e:
|
|
224
|
+
fired_row['send_error'] = str(e)
|
|
225
|
+
print(f"[pool-check] email send failed for {project_name}/{platform}: {e}",
|
|
226
|
+
file=sys.stderr)
|
|
227
|
+
return {
|
|
228
|
+
'checked_at': datetime.now(timezone.utc).isoformat(),
|
|
229
|
+
'fired': fired,
|
|
230
|
+
'skipped_cooldown': skipped_cooldown,
|
|
231
|
+
'healthy_count': len(healthy),
|
|
232
|
+
'dry_run': dry_run,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main():
|
|
237
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
238
|
+
ap.add_argument('--dry-run', action='store_true',
|
|
239
|
+
help='compute and report, do not email or write state')
|
|
240
|
+
ap.add_argument('--force', action='store_true',
|
|
241
|
+
help='ignore 24h cooldown, re-fire matching alerts')
|
|
242
|
+
ap.add_argument('--warn-ratio', type=float, default=WARN_REMAINING_RATIO,
|
|
243
|
+
help=f'WARN threshold for available/total (default {WARN_REMAINING_RATIO})')
|
|
244
|
+
ap.add_argument('--limit', type=int, default=None,
|
|
245
|
+
help='cap the number of alert emails per run (smoke testing)')
|
|
246
|
+
args = ap.parse_args()
|
|
247
|
+
result = check(dry_run=args.dry_run, force=args.force,
|
|
248
|
+
warn_ratio=args.warn_ratio, limit=args.limit)
|
|
249
|
+
print(json.dumps(result, indent=2, default=str))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == '__main__':
|
|
253
|
+
main()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check for web-chat threads with unread visitor messages (HTTP-only).
|
|
3
|
+
|
|
4
|
+
Reads GET /api/v1/web-chat/unread, which (1) recovers stuck threads and
|
|
5
|
+
(2) returns each unread, claimable thread with its first 200 messages embedded
|
|
6
|
+
in one round trip. Replaces the inline psycopg2 reads.
|
|
7
|
+
|
|
8
|
+
Prints a JSON array of:
|
|
9
|
+
{ thread_id, project, visitor_email, visitor_name, unread, last_message,
|
|
10
|
+
page_url, messages: [{ id, text, sender, sender_name, created_at, read }] }
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
18
|
+
from http_api import api_get
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
resp = api_get("/api/v1/web-chat/unread")
|
|
23
|
+
threads = (resp.get("data") or {}).get("threads") or []
|
|
24
|
+
print(json.dumps(threads))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
main()
|