@m13v/s4l 1.6.197-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Restore the harness Chrome's Twitter session from the durable cookie mirror.
|
|
3
|
+
|
|
4
|
+
This is the keychain-independent recovery path (Gap B). On a persistent Mac the
|
|
5
|
+
harness Chrome can come up logged out: a hard restart or a macOS keychain
|
|
6
|
+
re-lock can leave Chrome unable to decrypt its cookie store, so it wipes it to
|
|
7
|
+
an empty schema. The cycle preflight calls this to heal that automatically:
|
|
8
|
+
|
|
9
|
+
1. Attach to the harness Chrome (TWITTER_CDP_URL, default 127.0.0.1:9555 —
|
|
10
|
+
the Mac harness port; AppMaker VMs override it to :9222 via the env file).
|
|
11
|
+
2. Navigate to x.com/home; if it redirects to /login, the session is gone.
|
|
12
|
+
3. Load cookies from the local 0600 mirror written on every connect
|
|
13
|
+
(keychain-independent).
|
|
14
|
+
4. Inject them via CDP Network.setCookies and reload.
|
|
15
|
+
5. Verify we land on /home (logged in).
|
|
16
|
+
|
|
17
|
+
Idempotent + safe to run every cycle preflight: if already logged in, it's a
|
|
18
|
+
no-op. Exits 0 on logged-in (restored or already), 1 on failure (caller can
|
|
19
|
+
fall back to alerting for a manual re-login).
|
|
20
|
+
|
|
21
|
+
Run: python3 scripts/restore_twitter_session.py
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
import urllib.request
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
32
|
+
|
|
33
|
+
# Local 0600 cookie mirror — the keychain-independent restore source (Gap B).
|
|
34
|
+
# It is the ONLY cookie source; the VM-era server store
|
|
35
|
+
# (/api/v1/twitter/session-cookies) was removed 2026-06-17. Stdlib-only;
|
|
36
|
+
# guarded so a path quirk never breaks the cycle preflight.
|
|
37
|
+
try:
|
|
38
|
+
import twitter_cookie_mirror # noqa: E402
|
|
39
|
+
except Exception:
|
|
40
|
+
twitter_cookie_mirror = None
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from websocket import create_connection
|
|
44
|
+
except ImportError:
|
|
45
|
+
print("restore_twitter_session: websocket-client not installed", file=sys.stderr)
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
CDP = os.environ.get("TWITTER_CDP_URL", "http://127.0.0.1:9555").rstrip("/")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _attach():
|
|
52
|
+
targets = json.load(urllib.request.urlopen(f"{CDP}/json", timeout=10))
|
|
53
|
+
page = next((t for t in targets if t.get("type") == "page"), None)
|
|
54
|
+
if not page:
|
|
55
|
+
# create a tab if none
|
|
56
|
+
new = json.load(urllib.request.urlopen(
|
|
57
|
+
urllib.request.Request(f"{CDP}/json/new?about:blank", method="PUT"), timeout=10))
|
|
58
|
+
page = new
|
|
59
|
+
# suppress_origin: Chrome 111+ enforces CDP WebSocket origin checking and
|
|
60
|
+
# rejects the handshake with 403 unless Chrome was launched with
|
|
61
|
+
# --remote-allow-origins. The harness Chrome (twitter-backend.sh) is launched
|
|
62
|
+
# without that flag, so we must suppress the Origin header (localhost CDP is
|
|
63
|
+
# already privileged), matching setup_twitter_auth.py / copy_browser_cookies.py.
|
|
64
|
+
ws = create_connection(page["webSocketDebuggerUrl"], timeout=20, suppress_origin=True)
|
|
65
|
+
state = {"id": 0}
|
|
66
|
+
|
|
67
|
+
def send(method, params=None):
|
|
68
|
+
state["id"] += 1
|
|
69
|
+
ws.send(json.dumps({"id": state["id"], "method": method, "params": params or {}}))
|
|
70
|
+
while True:
|
|
71
|
+
msg = json.loads(ws.recv())
|
|
72
|
+
if msg.get("id") == state["id"]:
|
|
73
|
+
return msg
|
|
74
|
+
return ws, send
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _current_url(send):
|
|
78
|
+
r = send("Runtime.evaluate", {"expression": "location.href", "returnByValue": True})
|
|
79
|
+
return (r.get("result", {}).get("result", {}) or {}).get("value", "") or ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _has_auth_cookie(send):
|
|
83
|
+
"""The reliable logged-in signal: an auth_token cookie on x.com.
|
|
84
|
+
URL heuristics are unreliable — x.com/ (root) is the logged-OUT landing,
|
|
85
|
+
not a login URL, so a URL-only check false-positives."""
|
|
86
|
+
r = send("Network.getAllCookies")
|
|
87
|
+
cks = r.get("result", {}).get("cookies", []) or []
|
|
88
|
+
return any(
|
|
89
|
+
c.get("name") == "auth_token" and "x.com" in (c.get("domain") or "")
|
|
90
|
+
for c in cks
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _logged_in(send):
|
|
95
|
+
send("Network.enable")
|
|
96
|
+
if _has_auth_cookie(send):
|
|
97
|
+
return True
|
|
98
|
+
# No auth cookie in the current store — navigate to force x.com to set/clear
|
|
99
|
+
# session cookies, then re-check.
|
|
100
|
+
send("Page.enable")
|
|
101
|
+
send("Page.navigate", {"url": "https://x.com/home"})
|
|
102
|
+
for _ in range(15):
|
|
103
|
+
time.sleep(1)
|
|
104
|
+
if _has_auth_cookie(send):
|
|
105
|
+
return True
|
|
106
|
+
u = _current_url(send)
|
|
107
|
+
if "/login" in u or "/i/flow/login" in u or u.rstrip("/") == "https://x.com":
|
|
108
|
+
return False
|
|
109
|
+
return _has_auth_cookie(send)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _inject(send, cookies) -> int:
|
|
113
|
+
"""Inject CDP-shaped cookies via Network.setCookie. Returns accepted count."""
|
|
114
|
+
send("Network.enable")
|
|
115
|
+
ok_count = 0
|
|
116
|
+
for c in cookies:
|
|
117
|
+
params = {k: c[k] for k in (
|
|
118
|
+
"name", "value", "domain", "path", "secure", "httpOnly",
|
|
119
|
+
"sameSite", "expires") if k in c and c[k] is not None}
|
|
120
|
+
r = send("Network.setCookie", params)
|
|
121
|
+
if r.get("result", {}).get("success", True):
|
|
122
|
+
ok_count += 1
|
|
123
|
+
return ok_count
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _stored_cookies():
|
|
127
|
+
"""Return (cookies, source) from the LOCAL 0600 mirror, or ([], None).
|
|
128
|
+
|
|
129
|
+
The mirror is the only cookie source; the VM-era server store
|
|
130
|
+
(/api/v1/twitter/session-cookies) was removed 2026-06-17."""
|
|
131
|
+
if twitter_cookie_mirror is not None:
|
|
132
|
+
try:
|
|
133
|
+
mirrored = twitter_cookie_mirror.load_cookies()
|
|
134
|
+
except Exception:
|
|
135
|
+
mirrored = []
|
|
136
|
+
if mirrored:
|
|
137
|
+
return mirrored, f"local mirror ({twitter_cookie_mirror.MIRROR_PATH.name})"
|
|
138
|
+
return [], None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main():
|
|
142
|
+
try:
|
|
143
|
+
ws, send = _attach()
|
|
144
|
+
except Exception as e:
|
|
145
|
+
print(f"restore_twitter_session: cannot attach to {CDP}: {e}", file=sys.stderr)
|
|
146
|
+
return 1
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
if _logged_in(send):
|
|
150
|
+
print("restore_twitter_session: already logged in; no-op")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
cookies, source = _stored_cookies()
|
|
154
|
+
if not cookies:
|
|
155
|
+
print("restore_twitter_session: no stored cookies (local mirror empty); "
|
|
156
|
+
"manual connect_x required", file=sys.stderr)
|
|
157
|
+
return 1
|
|
158
|
+
|
|
159
|
+
print(f"restore_twitter_session: logged out, restoring from {source}...")
|
|
160
|
+
ok_count = _inject(send, cookies)
|
|
161
|
+
print(f"restore_twitter_session: injected {ok_count}/{len(cookies)} cookies")
|
|
162
|
+
|
|
163
|
+
if _logged_in(send):
|
|
164
|
+
print(f"restore_twitter_session: RESTORED session from {source}")
|
|
165
|
+
return 0
|
|
166
|
+
print("restore_twitter_session: injection done but still logged out "
|
|
167
|
+
"(cookies may be expired); manual connect_x required", file=sys.stderr)
|
|
168
|
+
return 1
|
|
169
|
+
finally:
|
|
170
|
+
try:
|
|
171
|
+
ws.close()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
sys.exit(main())
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ripen_reddit_plan.py
|
|
4
|
+
|
|
5
|
+
Reddit equivalent of Twitter's Phase 2a (T1 re-poll + delta gate). Reads a
|
|
6
|
+
plan JSON written by `post_reddit.py --phase discover`, captures T0 score/comments
|
|
7
|
+
for each target_thread_url, sleeps SLEEP_SECONDS (default 300), re-polls T1,
|
|
8
|
+
computes composite delta = Δupvotes + W_COMMENTS * Δcomments, and drops
|
|
9
|
+
decisions whose composite <= FLOOR (default 5).
|
|
10
|
+
|
|
11
|
+
Survivors are written to --out as a new plan JSON consumed by
|
|
12
|
+
`post_reddit.py --phase post`. Dropped decisions are logged to stderr and
|
|
13
|
+
into the output JSON under `ripen_dropped_details`.
|
|
14
|
+
|
|
15
|
+
Defaults match the design agreed on 2026-05-06, with a 2026-05-10 product-intent
|
|
16
|
+
boost added to mirror the Twitter cycle's hybrid sort:
|
|
17
|
+
raw_composite = Δup + 4*Δcomments
|
|
18
|
+
intent_boost = +5 if title/selftext matches a product-discussion regex
|
|
19
|
+
(asking for a tool, venting a pain, comparing alternatives,
|
|
20
|
+
"anyone know a way to...", etc), else 0
|
|
21
|
+
composite = raw_composite + intent_boost
|
|
22
|
+
floor = composite >= 1 (any positive momentum OR an on-theme
|
|
23
|
+
intent signal passes; +1 upvote OR a clearly stated need
|
|
24
|
+
is enough to reach the LLM relevance gate)
|
|
25
|
+
sleep = 300s (5 min) by default; run-reddit-search.sh sets 1800s
|
|
26
|
+
|
|
27
|
+
Failure modes:
|
|
28
|
+
- T0 fetch fails for a URL: drop that decision (fail-closed; we cannot
|
|
29
|
+
measure delta without T0)
|
|
30
|
+
- All T0 fetches fail: bail with passthrough (likely Reddit-wide rate
|
|
31
|
+
limit; better to post stale than nothing on a bad-network cycle)
|
|
32
|
+
- T1 fetch fails for a URL: drop that decision (same logic)
|
|
33
|
+
"""
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
import time
|
|
41
|
+
|
|
42
|
+
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
43
|
+
SCRIPTS_DIR = os.path.join(REPO_DIR, "scripts")
|
|
44
|
+
|
|
45
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
46
|
+
|
|
47
|
+
# Mirrors the Twitter cycle's product-discussion intent regex (run-twitter-cycle.sh
|
|
48
|
+
# Phase 2b). When a thread title or selftext contains an explicit "asking for a
|
|
49
|
+
# tool / venting a pain point / comparing alternatives" signal, add a +5 boost
|
|
50
|
+
# to the composite delta before the floor check. This lets quiet on-theme
|
|
51
|
+
# threads ("anyone know a way to track Claude Code usage?" with 1 upvote and 0
|
|
52
|
+
# new comments in 30 min) compete with viral drama on raw growth.
|
|
53
|
+
_INTENT_REGEX = re.compile(
|
|
54
|
+
r"\b("
|
|
55
|
+
r"wish|need a|need an|looking for|recommend|alternative to|frustrated|"
|
|
56
|
+
r"hate (that|when)|should exist|would pay|missing.*(feature|tool|app)|"
|
|
57
|
+
r"why (is there no|doesn't|don't)|anyone (know|use|tried|using)|"
|
|
58
|
+
r"how do you|what do you use|best (tool|app|way)|any (good|decent) (tool|app|way)"
|
|
59
|
+
r")\b",
|
|
60
|
+
re.IGNORECASE,
|
|
61
|
+
)
|
|
62
|
+
INTENT_BOOST = 5.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _intent_boost(title, selftext):
|
|
66
|
+
"""Return INTENT_BOOST if the title shows product-discussion intent, else 0.
|
|
67
|
+
|
|
68
|
+
TITLE-ONLY by design. Earlier versions matched title+selftext, but Reddit
|
|
69
|
+
selftext can be 30k chars of narrative (camping ghost stories, long reviews)
|
|
70
|
+
where words like "looking for", "wish", "recommend" appear in their plain-
|
|
71
|
+
English sense ("looking for a spot to hang a bear bag"), causing 30%+ false-
|
|
72
|
+
positive rates. Titles are short, deliberate, and intent-rich; if "anyone
|
|
73
|
+
know" appears in the title, it's almost always a real product ask. The
|
|
74
|
+
`selftext` arg is kept in the signature for future use but ignored today.
|
|
75
|
+
|
|
76
|
+
The LLM relevance gate downstream (post_reddit.py draft phase, surfaces as
|
|
77
|
+
`draft_gate_omit`) is still the real safety net for the small remaining
|
|
78
|
+
false-positive rate. The boost only changes ranking + lets zero-momentum
|
|
79
|
+
on-theme threads clear the floor; it does not auto-post anything.
|
|
80
|
+
"""
|
|
81
|
+
if not title:
|
|
82
|
+
return 0.0
|
|
83
|
+
return INTENT_BOOST if _INTENT_REGEX.search(title) else 0.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fetch_thread_text_map(thread_urls):
|
|
87
|
+
"""Batch-fetch (thread_title, thread_selftext) via /api/v1/reddit-candidates.
|
|
88
|
+
|
|
89
|
+
Returns {url: (title, selftext)}. Missing rows return ('', '').
|
|
90
|
+
"""
|
|
91
|
+
if not thread_urls:
|
|
92
|
+
return {}
|
|
93
|
+
try:
|
|
94
|
+
from http_api import api_get
|
|
95
|
+
# The route accepts a CSV `thread_urls` query param (up to 500 URLs).
|
|
96
|
+
resp = api_get(
|
|
97
|
+
"/api/v1/reddit-candidates",
|
|
98
|
+
query={
|
|
99
|
+
"thread_urls": ",".join(thread_urls),
|
|
100
|
+
"limit": 500,
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
rows = ((resp or {}).get("data") or {}).get("candidates") or []
|
|
104
|
+
return {
|
|
105
|
+
r.get("thread_url"): (r.get("thread_title") or "", r.get("thread_selftext") or "")
|
|
106
|
+
for r in rows if r.get("thread_url")
|
|
107
|
+
}
|
|
108
|
+
except Exception as e:
|
|
109
|
+
print(f"[ripen] _fetch_thread_text_map: {e}", file=sys.stderr)
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _db_update_ripen_metrics(thread_url, t0_score, t0_comments,
|
|
114
|
+
t1_score, t1_comments, composite, bump_attempt):
|
|
115
|
+
"""Persist T0/T1/delta via /api/v1/reddit-candidates/by-thread-url action=set_ripen.
|
|
116
|
+
|
|
117
|
+
Server-side: bump_attempt=True bumps attempt_count, sets
|
|
118
|
+
last_failure_reason='ripen_floor_miss', and flips status='failed' (one-strike
|
|
119
|
+
rule from 2026-05-07). bump_attempt=False just records the metrics.
|
|
120
|
+
"""
|
|
121
|
+
if not thread_url:
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
from http_api import api_patch
|
|
125
|
+
api_patch(
|
|
126
|
+
"/api/v1/reddit-candidates/by-thread-url",
|
|
127
|
+
{
|
|
128
|
+
"thread_url": thread_url,
|
|
129
|
+
"action": "set_ripen",
|
|
130
|
+
"score_t0": int(t0_score) if t0_score is not None else None,
|
|
131
|
+
"comments_t0": int(t0_comments) if t0_comments is not None else None,
|
|
132
|
+
"score_t1": int(t1_score) if t1_score is not None else None,
|
|
133
|
+
"comments_t1": int(t1_comments) if t1_comments is not None else None,
|
|
134
|
+
"delta_score": float(composite) if composite is not None else None,
|
|
135
|
+
"bump_attempt": bool(bump_attempt),
|
|
136
|
+
},
|
|
137
|
+
ok_on_404=True,
|
|
138
|
+
)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
print(f"[ripen] WARN: db update failed for {thread_url}: {e}",
|
|
141
|
+
file=sys.stderr)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _db_load_persisted_t0(urls):
|
|
145
|
+
"""Load score_t0 / comments_t0 via /api/v1/reddit-candidates.
|
|
146
|
+
|
|
147
|
+
Returns dict {url: {"score": s, "comments": c, "ok": True}} for every row
|
|
148
|
+
where BOTH score_t0 and comments_t0 are non-null. URLs without persisted
|
|
149
|
+
T0 are absent so callers fall back to a live fetch.
|
|
150
|
+
"""
|
|
151
|
+
if not urls:
|
|
152
|
+
return {}
|
|
153
|
+
try:
|
|
154
|
+
from http_api import api_get
|
|
155
|
+
resp = api_get(
|
|
156
|
+
"/api/v1/reddit-candidates",
|
|
157
|
+
query={
|
|
158
|
+
"thread_urls": ",".join(urls),
|
|
159
|
+
"has_t0": "true",
|
|
160
|
+
"limit": 500,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
rows = ((resp or {}).get("data") or {}).get("candidates") or []
|
|
164
|
+
out = {}
|
|
165
|
+
for r in rows:
|
|
166
|
+
url = r.get("thread_url")
|
|
167
|
+
if not url:
|
|
168
|
+
continue
|
|
169
|
+
s = r.get("score_t0")
|
|
170
|
+
c = r.get("comments_t0")
|
|
171
|
+
if s is None or c is None:
|
|
172
|
+
continue
|
|
173
|
+
out[url] = {"score": int(s), "comments": int(c), "ok": True}
|
|
174
|
+
return out
|
|
175
|
+
except Exception as e:
|
|
176
|
+
print(f"[ripen] WARN: load_persisted_t0 failed: {e}",
|
|
177
|
+
file=sys.stderr)
|
|
178
|
+
return {}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _db_mark_html_locked(thread_url, state):
|
|
182
|
+
"""Mark a candidate as permanently failed via the action=mark_html_locked
|
|
183
|
+
lane. The server flips status='failed', sets last_failure_reason='html_<state>',
|
|
184
|
+
and stamps last_attempt_at=NOW().
|
|
185
|
+
"""
|
|
186
|
+
if not thread_url:
|
|
187
|
+
return
|
|
188
|
+
try:
|
|
189
|
+
from http_api import api_patch
|
|
190
|
+
api_patch(
|
|
191
|
+
"/api/v1/reddit-candidates/by-thread-url",
|
|
192
|
+
{
|
|
193
|
+
"thread_url": thread_url,
|
|
194
|
+
"action": "mark_html_locked",
|
|
195
|
+
"state": state,
|
|
196
|
+
},
|
|
197
|
+
ok_on_404=True,
|
|
198
|
+
)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
print(f"[ripen] WARN: html_locked db update failed for {thread_url}: {e}",
|
|
201
|
+
file=sys.stderr)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def repoll(urls, timeout=120):
|
|
205
|
+
"""Call reddit_tools.py repoll with the given URLs. Returns the parsed
|
|
206
|
+
{"results": {url: {ok, score, comments}}} dict (or {} on hard failure)."""
|
|
207
|
+
if not urls:
|
|
208
|
+
return {}
|
|
209
|
+
payload = json.dumps({"urls": urls})
|
|
210
|
+
try:
|
|
211
|
+
proc = subprocess.run(
|
|
212
|
+
["python3", os.path.join(SCRIPTS_DIR, "reddit_tools.py"), "repoll"],
|
|
213
|
+
input=payload,
|
|
214
|
+
capture_output=True,
|
|
215
|
+
text=True,
|
|
216
|
+
timeout=timeout,
|
|
217
|
+
)
|
|
218
|
+
except subprocess.TimeoutExpired:
|
|
219
|
+
print(f"[ripen] ERROR: repoll subprocess timeout", file=sys.stderr)
|
|
220
|
+
return {}
|
|
221
|
+
if proc.returncode != 0:
|
|
222
|
+
print(f"[ripen] ERROR: repoll exit={proc.returncode} stderr={proc.stderr[:200]}",
|
|
223
|
+
file=sys.stderr)
|
|
224
|
+
return {}
|
|
225
|
+
try:
|
|
226
|
+
out = json.loads(proc.stdout)
|
|
227
|
+
except json.JSONDecodeError as e:
|
|
228
|
+
print(f"[ripen] ERROR: repoll bad JSON: {e}", file=sys.stderr)
|
|
229
|
+
return {}
|
|
230
|
+
return out.get("results") or {}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main():
|
|
234
|
+
p = argparse.ArgumentParser()
|
|
235
|
+
p.add_argument("--in", dest="in_path", required=True, help="Input plan JSON path")
|
|
236
|
+
p.add_argument("--out", required=True, help="Output filtered plan JSON path")
|
|
237
|
+
p.add_argument("--floor", type=float, default=1.0,
|
|
238
|
+
help="Composite delta must be GREATER THAN OR EQUAL to this "
|
|
239
|
+
"(default: 1.0). composite = Δup + 4*Δcomments; +1 upvote in 5min "
|
|
240
|
+
"is enough signal that the thread is still alive.")
|
|
241
|
+
p.add_argument("--w-comments", type=float, default=4.0,
|
|
242
|
+
help="Comment weight in composite formula (default: 4.0)")
|
|
243
|
+
p.add_argument("--sleep", type=int, default=300,
|
|
244
|
+
help="Seconds to sleep between T0 and T1 (default: 300)")
|
|
245
|
+
p.add_argument("--no-sleep", action="store_true",
|
|
246
|
+
help="Skip the sleep (for tests)")
|
|
247
|
+
args = p.parse_args()
|
|
248
|
+
|
|
249
|
+
with open(args.in_path) as f:
|
|
250
|
+
plan = json.load(f)
|
|
251
|
+
|
|
252
|
+
decisions = plan.get("decisions") or []
|
|
253
|
+
if not decisions:
|
|
254
|
+
print(f"[ripen] empty plan, passthrough", file=sys.stderr)
|
|
255
|
+
with open(args.out, "w") as f:
|
|
256
|
+
json.dump(plan, f)
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
urls = []
|
|
260
|
+
for d in decisions:
|
|
261
|
+
# post_reddit.py writes the field as `thread_url` (not target_thread_url).
|
|
262
|
+
# Tolerate both for safety in case the schema ever changes.
|
|
263
|
+
u = (d.get("thread_url") or d.get("target_thread_url") or "").strip()
|
|
264
|
+
if u:
|
|
265
|
+
urls.append(u)
|
|
266
|
+
|
|
267
|
+
if not urls:
|
|
268
|
+
print(f"[ripen] no thread_urls in {len(decisions)} decisions; passthrough",
|
|
269
|
+
file=sys.stderr)
|
|
270
|
+
with open(args.out, "w") as f:
|
|
271
|
+
json.dump(plan, f)
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
# ---- T0 capture ---------------------------------------------------------
|
|
275
|
+
# Always prefer PERSISTED T0 from reddit_candidates (captured at discover
|
|
276
|
+
# time from the search response, no extra HTTP), falling back to a fresh
|
|
277
|
+
# live fetch for URLs that don't have one yet. This unifies the salvage
|
|
278
|
+
# and fresh-discover paths and mirrors twitter's behavior:
|
|
279
|
+
# - Fresh discoveries: T0 was just captured seconds ago at INSERT time,
|
|
280
|
+
# so cumulative delta over the upcoming 5-min sleep ≈ a fresh window.
|
|
281
|
+
# - Salvaged rows: T0 is the FIRST-SIGHTING value (could be hours
|
|
282
|
+
# old), so delta is cumulative since discovery — catches slow-trickle
|
|
283
|
+
# threads a fresh 5-min window would miss.
|
|
284
|
+
# Live fetch fallback only fires for URLs the orchestrator never INSERTed
|
|
285
|
+
# (e.g. legacy tmpfiles from before the candidates migration). Pure
|
|
286
|
+
# safety net.
|
|
287
|
+
is_salvaged = bool(plan.get("salvaged"))
|
|
288
|
+
persisted = _db_load_persisted_t0(urls)
|
|
289
|
+
missing = [u for u in urls if u not in persisted]
|
|
290
|
+
print(f"[ripen] T0: {len(persisted)} from reddit_candidates, "
|
|
291
|
+
f"{len(missing)} need live fetch (salvaged={'yes' if is_salvaged else 'no'})",
|
|
292
|
+
file=sys.stderr)
|
|
293
|
+
if missing:
|
|
294
|
+
live = repoll(missing)
|
|
295
|
+
for u, r in live.items():
|
|
296
|
+
if r.get("ok"):
|
|
297
|
+
persisted[u] = r
|
|
298
|
+
t0_ok = persisted
|
|
299
|
+
if not t0_ok:
|
|
300
|
+
print(f"[ripen] WARN: 0 of {len(urls)} T0 fetches succeeded; "
|
|
301
|
+
"passthrough (likely rate limit)", file=sys.stderr)
|
|
302
|
+
with open(args.out, "w") as f:
|
|
303
|
+
json.dump(plan, f)
|
|
304
|
+
return 0
|
|
305
|
+
print(f"[ripen] T0: {len(t0_ok)}/{len(urls)} succeeded "
|
|
306
|
+
f"(salvaged={'yes' if is_salvaged else 'no'})", file=sys.stderr)
|
|
307
|
+
|
|
308
|
+
# ---- Sleep --------------------------------------------------------------
|
|
309
|
+
if not args.no_sleep:
|
|
310
|
+
print(f"[ripen] sleeping {args.sleep}s for engagement to develop...",
|
|
311
|
+
file=sys.stderr)
|
|
312
|
+
time.sleep(args.sleep)
|
|
313
|
+
|
|
314
|
+
# ---- T1 re-poll ---------------------------------------------------------
|
|
315
|
+
print(f"[ripen] T1: re-fetching {len(t0_ok)} thread(s)...", file=sys.stderr)
|
|
316
|
+
t1 = repoll(list(t0_ok.keys()))
|
|
317
|
+
|
|
318
|
+
# ---- Batch-fetch title+selftext for product-intent boost ----------------
|
|
319
|
+
# Mirrors the Twitter cycle's hybrid sort: a thread asking for a tool /
|
|
320
|
+
# venting a pain point gets +5 added to composite, so quiet on-theme rows
|
|
321
|
+
# clear the floor and rank above pure noise of equivalent raw growth.
|
|
322
|
+
intent_text_map = _fetch_thread_text_map(
|
|
323
|
+
[(d.get("thread_url") or d.get("target_thread_url") or "").strip()
|
|
324
|
+
for d in decisions]
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# ---- Filter -------------------------------------------------------------
|
|
328
|
+
survivors = []
|
|
329
|
+
drops = []
|
|
330
|
+
for d in decisions:
|
|
331
|
+
url = (d.get("thread_url") or d.get("target_thread_url") or "").strip()
|
|
332
|
+
t0r = t0_ok.get(url)
|
|
333
|
+
t1r = t1.get(url, {}) if t1 else {}
|
|
334
|
+
if not t0r:
|
|
335
|
+
drops.append({"url": url, "reason": "no_t0"})
|
|
336
|
+
continue
|
|
337
|
+
if not t1r.get("ok"):
|
|
338
|
+
drops.append({
|
|
339
|
+
"url": url,
|
|
340
|
+
"reason": f"t1_fail:{t1r.get('error', 'unknown')}",
|
|
341
|
+
})
|
|
342
|
+
continue
|
|
343
|
+
d_up = int(t1r["score"]) - int(t0r["score"])
|
|
344
|
+
d_co = int(t1r["comments"]) - int(t0r["comments"])
|
|
345
|
+
raw_composite = d_up + args.w_comments * d_co
|
|
346
|
+
title, selftext = intent_text_map.get(url, ("", ""))
|
|
347
|
+
intent = _intent_boost(title, selftext)
|
|
348
|
+
composite = raw_composite + intent
|
|
349
|
+
# Annotate decision with measurement (always, even if dropped — useful
|
|
350
|
+
# for downstream analysis/debug). Both raw and boosted composites are
|
|
351
|
+
# surfaced so post-hoc analysis can separate growth signal from intent.
|
|
352
|
+
d["ripen"] = {
|
|
353
|
+
"t0_score": t0r["score"],
|
|
354
|
+
"t0_comments": t0r["comments"],
|
|
355
|
+
"t1_score": t1r["score"],
|
|
356
|
+
"t1_comments": t1r["comments"],
|
|
357
|
+
"delta_up": d_up,
|
|
358
|
+
"delta_comments": d_co,
|
|
359
|
+
"raw_composite": raw_composite,
|
|
360
|
+
"intent_boost": intent,
|
|
361
|
+
"composite": composite,
|
|
362
|
+
"window_sec": args.sleep if not args.no_sleep else 0,
|
|
363
|
+
"floor": args.floor,
|
|
364
|
+
"w_comments": args.w_comments,
|
|
365
|
+
}
|
|
366
|
+
if composite >= args.floor:
|
|
367
|
+
survivors.append(d)
|
|
368
|
+
# Persist T0/T1/delta for the survivor; do NOT bump attempt_count
|
|
369
|
+
# — passing the floor isn't an "attempt" against the post budget.
|
|
370
|
+
_db_update_ripen_metrics(url, t0r["score"], t0r["comments"],
|
|
371
|
+
t1r["score"], t1r["comments"],
|
|
372
|
+
composite, bump_attempt=False)
|
|
373
|
+
print(f"[ripen] PASS composite={composite:.1f} (Δup={d_up}, Δcomm={d_co}) "
|
|
374
|
+
f"{url}", file=sys.stderr)
|
|
375
|
+
else:
|
|
376
|
+
drops.append({
|
|
377
|
+
"url": url,
|
|
378
|
+
"reason": f"composite={composite:.1f} < floor={args.floor}",
|
|
379
|
+
"delta_up": d_up,
|
|
380
|
+
"delta_comments": d_co,
|
|
381
|
+
})
|
|
382
|
+
# Floor miss counts against the candidate's attempt budget so a
|
|
383
|
+
# chronically-flat thread eventually drops out of the salvage
|
|
384
|
+
# rotation. Phase 0's MAX_ATTEMPTS=3 ceiling auto-promotes it.
|
|
385
|
+
_db_update_ripen_metrics(url, t0r["score"], t0r["comments"],
|
|
386
|
+
t1r["score"], t1r["comments"],
|
|
387
|
+
composite, bump_attempt=True)
|
|
388
|
+
print(f"[ripen] DROP composite={composite:.1f} (Δup={d_up}, Δcomm={d_co}) "
|
|
389
|
+
f"{url}", file=sys.stderr)
|
|
390
|
+
|
|
391
|
+
# 2026-05-10: top-k cap removed. The cap was disabled (--top-k 0) since
|
|
392
|
+
# 2026-05-08 because trimming survivors before the LLM relevance gate threw
|
|
393
|
+
# away potentially-good fits below the engagement-velocity cutoff. The
|
|
394
|
+
# final cap now lives in _post_iteration via S4L_REDDIT_MAX_POSTS_PER_CYCLE
|
|
395
|
+
# (default 10), which sorts decisions by ripen composite DESC.
|
|
396
|
+
|
|
397
|
+
# ---- HTML lock pre-flight for delta-gate survivors ----------------------
|
|
398
|
+
# cmd_repoll checks the JSON locked flag, but Reddit's AutoMod sometimes
|
|
399
|
+
# renders .locked-tagline without setting locked=true in the JSON API
|
|
400
|
+
# (observed on r/Entrepreneur). One unauthenticated GET per survivor (~1s).
|
|
401
|
+
# Failures in the lock check are non-fatal: we log a warning and keep the
|
|
402
|
+
# survivor rather than fail-closed on a network blip.
|
|
403
|
+
check_locked_bin = os.path.join(SCRIPTS_DIR, "reddit_tools.py")
|
|
404
|
+
if survivors:
|
|
405
|
+
print(f"[ripen] HTML lock pre-flight for {len(survivors)} survivor(s)...",
|
|
406
|
+
file=sys.stderr)
|
|
407
|
+
clean_survivors = []
|
|
408
|
+
for d in survivors:
|
|
409
|
+
url = (d.get("thread_url") or d.get("target_thread_url") or "").strip()
|
|
410
|
+
try:
|
|
411
|
+
proc = subprocess.run(
|
|
412
|
+
["python3", check_locked_bin, "check-locked", url],
|
|
413
|
+
capture_output=True, text=True, timeout=20,
|
|
414
|
+
)
|
|
415
|
+
out = json.loads(proc.stdout.strip()) if proc.stdout.strip() else {}
|
|
416
|
+
state = out.get("state", "ok")
|
|
417
|
+
if state in ("locked", "archived"):
|
|
418
|
+
print(f"[ripen] HTML-{state}: dropping survivor {url}",
|
|
419
|
+
file=sys.stderr)
|
|
420
|
+
drops.append({"url": url, "reason": f"html_{state}"})
|
|
421
|
+
# Permanent failure in the queue: Phase 0 salvage skips
|
|
422
|
+
# status='failed', and the dashboard renders the reason
|
|
423
|
+
# via last_failure_reason. No retry on locked threads.
|
|
424
|
+
_db_mark_html_locked(url, state)
|
|
425
|
+
continue
|
|
426
|
+
except Exception as e:
|
|
427
|
+
print(f"[ripen] WARN: check-locked failed for {url}: {e}; keeping survivor",
|
|
428
|
+
file=sys.stderr)
|
|
429
|
+
clean_survivors.append(d)
|
|
430
|
+
survivors = clean_survivors
|
|
431
|
+
|
|
432
|
+
plan["decisions"] = survivors
|
|
433
|
+
plan["ripen_summary"] = {
|
|
434
|
+
"input_count": len(decisions),
|
|
435
|
+
"survivors": len(survivors),
|
|
436
|
+
"drops": len(drops),
|
|
437
|
+
"floor": args.floor,
|
|
438
|
+
"w_comments": args.w_comments,
|
|
439
|
+
"sleep_sec": args.sleep if not args.no_sleep else 0,
|
|
440
|
+
}
|
|
441
|
+
plan["ripen_dropped_details"] = drops
|
|
442
|
+
|
|
443
|
+
with open(args.out, "w") as f:
|
|
444
|
+
json.dump(plan, f)
|
|
445
|
+
|
|
446
|
+
# Compact, parseable summary marker for the dashboard's
|
|
447
|
+
# enrichPostCommentsRedditRuns() in bin/server.js. Field order matters; keep
|
|
448
|
+
# in sync with the regex on the JS side.
|
|
449
|
+
best_composite = None
|
|
450
|
+
best_d_up = None
|
|
451
|
+
best_d_co = None
|
|
452
|
+
for d in survivors:
|
|
453
|
+
rip = d.get("ripen") or {}
|
|
454
|
+
c = rip.get("composite")
|
|
455
|
+
if c is None:
|
|
456
|
+
continue
|
|
457
|
+
if best_composite is None or c > best_composite:
|
|
458
|
+
best_composite = c
|
|
459
|
+
best_d_up = rip.get("delta_up")
|
|
460
|
+
best_d_co = rip.get("delta_comments")
|
|
461
|
+
bc = "" if best_composite is None else f"{best_composite:.1f}"
|
|
462
|
+
bu = "" if best_d_up is None else str(best_d_up)
|
|
463
|
+
bk = "" if best_d_co is None else str(best_d_co)
|
|
464
|
+
print(
|
|
465
|
+
f"[ripen] summary input={len(decisions)} survivors={len(survivors)} "
|
|
466
|
+
f"drops={len(drops)} floor={args.floor} w_comments={args.w_comments} "
|
|
467
|
+
f"window_sec={args.sleep if not args.no_sleep else 0} "
|
|
468
|
+
f"best_composite={bc} best_d_up={bu} best_d_co={bk}",
|
|
469
|
+
file=sys.stderr,
|
|
470
|
+
)
|
|
471
|
+
print(f"[ripen] done: {len(survivors)} survivors, {len(drops)} drops "
|
|
472
|
+
f"(floor>={args.floor}, w_comments={args.w_comments})",
|
|
473
|
+
file=sys.stderr)
|
|
474
|
+
return 0
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
if __name__ == "__main__":
|
|
478
|
+
sys.exit(main())
|