@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,215 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
merge_review_queue.py — deliver a DRAFT_ONLY cycle's plan into the approval cards.
|
|
4
|
+
|
|
5
|
+
The deterministic pipeline (run-twitter-cycle.sh DRAFT_ONLY) writes its drafts to
|
|
6
|
+
a per-batch plan file (/tmp/twitter_cycle_plan_<batch>.json) and prints
|
|
7
|
+
`DRAFT_ONLY_PLAN=<path>`. On a customer box NOTHING used to consume that — the
|
|
8
|
+
only writer of the review-queue cards was the (now-removed) host-draft
|
|
9
|
+
submit_drafts path. This script closes that gap: it merges the batch plan's
|
|
10
|
+
candidates into the single review-queue plan the menu-bar cards read, deduped by
|
|
11
|
+
thread/candidate URL, and refreshes the review-request marker the menu bar polls.
|
|
12
|
+
|
|
13
|
+
This is the SAME merge submit_drafts did, reimplemented in Python so the launchd
|
|
14
|
+
kicker (no node/MCP) can run it after the cycle. ONE pipeline, one set of cards.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
merge_review_queue.py --plan /tmp/twitter_cycle_plan_<batch>.json [--project NAME]
|
|
18
|
+
merge_review_queue.py --plan-from-marker '<stdout containing DRAFT_ONLY_PLAN=...>'
|
|
19
|
+
|
|
20
|
+
State dir (for review-request.json) honors $S4L_STATE_DIR; the review-queue plan
|
|
21
|
+
lives in $S4L_TMP_DIR or /tmp (matching the MCP's planPath()).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
REVIEW_QUEUE_ID = "review-queue"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def tmp_dir() -> str:
|
|
37
|
+
return os.environ.get("S4L_TMP_DIR") or "/tmp"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def state_dir() -> str:
|
|
41
|
+
return os.environ.get("S4L_STATE_DIR") or os.path.join(
|
|
42
|
+
os.path.expanduser("~"), ".social-autoposter-mcp"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def plan_path(batch_id: str) -> str:
|
|
47
|
+
return os.path.join(tmp_dir(), f"twitter_cycle_plan_{batch_id}.json")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def review_request_path() -> str:
|
|
51
|
+
return os.path.join(state_dir(), "review-request.json")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _atomic_write(path: str, obj) -> None:
|
|
55
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
56
|
+
tmp = f"{path}.tmp.{os.getpid()}"
|
|
57
|
+
with open(tmp, "w") as f:
|
|
58
|
+
json.dump(obj, f, indent=2)
|
|
59
|
+
os.replace(tmp, path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _dedup_key(c: dict) -> str:
|
|
63
|
+
"""Match submit_drafts: dedup by the thread/candidate URL, else candidate_id."""
|
|
64
|
+
for k in ("candidate_url", "tweet_url", "thread_url", "candidate_id"):
|
|
65
|
+
v = c.get(k)
|
|
66
|
+
if v:
|
|
67
|
+
return str(v)
|
|
68
|
+
# last resort: the reply text, so identical drafts don't double up
|
|
69
|
+
return (c.get("reply_text") or "")[:120]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _thread_url(c: dict) -> str:
|
|
73
|
+
for k in ("candidate_url", "tweet_url", "thread_url"):
|
|
74
|
+
v = c.get(k)
|
|
75
|
+
if v:
|
|
76
|
+
return str(v)
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Discovery-time author/engagement fields stamped onto each plan candidate so the
|
|
81
|
+
# approval card can show them. All already captured on the twitter_candidates row
|
|
82
|
+
# by the discovery pipeline (and refreshed at T1); no scrape happens here.
|
|
83
|
+
STATS_KEYS = (
|
|
84
|
+
"author_handle",
|
|
85
|
+
"author_followers",
|
|
86
|
+
"likes",
|
|
87
|
+
"retweets",
|
|
88
|
+
"replies",
|
|
89
|
+
"views",
|
|
90
|
+
"virality_score",
|
|
91
|
+
"tweet_posted_at",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _enrich_with_stats(cands: list) -> int:
|
|
96
|
+
"""Stamp a `stats` sidecar onto plan candidates that lack one, from the
|
|
97
|
+
twitter_candidates rows the discovery pipeline already wrote. ONE listing
|
|
98
|
+
call (/api/v1/twitter-candidates?tweet_urls=...) covers the whole queue.
|
|
99
|
+
Best-effort: any failure (offline box, missing identity, API error) leaves
|
|
100
|
+
candidates unstamped and NEVER blocks card delivery. Returns count stamped."""
|
|
101
|
+
want = [c for c in cands if not c.get("stats") and not c.get("posted") and _thread_url(c)]
|
|
102
|
+
if not want:
|
|
103
|
+
return 0
|
|
104
|
+
urls = sorted({_thread_url(c) for c in want})[:500]
|
|
105
|
+
try:
|
|
106
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
107
|
+
from http_api import api_get
|
|
108
|
+
|
|
109
|
+
resp = api_get(
|
|
110
|
+
"/api/v1/twitter-candidates",
|
|
111
|
+
query={"tweet_urls": ",".join(urls), "limit": 500},
|
|
112
|
+
)
|
|
113
|
+
rows = (resp.get("data") or {}).get("candidates") or []
|
|
114
|
+
except BaseException as e: # http_api raises SystemExit on terminal failure
|
|
115
|
+
print(f"[merge_review_queue] stats enrichment skipped: {e}", file=sys.stderr)
|
|
116
|
+
return 0
|
|
117
|
+
by_url = {str(r.get("tweet_url")): r for r in rows if r.get("tweet_url")}
|
|
118
|
+
stamped = 0
|
|
119
|
+
for c in want:
|
|
120
|
+
row = by_url.get(_thread_url(c))
|
|
121
|
+
if not row:
|
|
122
|
+
continue
|
|
123
|
+
c["stats"] = {k: row.get(k) for k in STATS_KEYS}
|
|
124
|
+
stamped += 1
|
|
125
|
+
return stamped
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main() -> int:
|
|
129
|
+
ap = argparse.ArgumentParser(description="Merge a DRAFT_ONLY plan into the review-queue cards")
|
|
130
|
+
ap.add_argument("--plan", help="path to the per-batch DRAFT_ONLY plan file")
|
|
131
|
+
ap.add_argument(
|
|
132
|
+
"--plan-from-marker",
|
|
133
|
+
help="text containing a DRAFT_ONLY_PLAN=<path> marker (e.g. cycle stdout)",
|
|
134
|
+
)
|
|
135
|
+
ap.add_argument("--project", default=None, help="project name for the review-request marker")
|
|
136
|
+
ns = ap.parse_args()
|
|
137
|
+
|
|
138
|
+
src = ns.plan
|
|
139
|
+
if not src and ns.plan_from_marker:
|
|
140
|
+
m = re.search(r"DRAFT_ONLY_PLAN=(\S+\.json)", ns.plan_from_marker)
|
|
141
|
+
if m:
|
|
142
|
+
src = m.group(1)
|
|
143
|
+
if not src:
|
|
144
|
+
print("[merge_review_queue] no source plan (need --plan or a DRAFT_ONLY_PLAN marker)", file=sys.stderr)
|
|
145
|
+
return 2
|
|
146
|
+
if not os.path.exists(src):
|
|
147
|
+
print(f"[merge_review_queue] source plan not found: {src}", file=sys.stderr)
|
|
148
|
+
return 2
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
with open(src) as f:
|
|
152
|
+
batch = json.load(f)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
print(f"[merge_review_queue] could not read source plan: {e}", file=sys.stderr)
|
|
155
|
+
return 2
|
|
156
|
+
|
|
157
|
+
new_cands = batch.get("candidates") or []
|
|
158
|
+
if not new_cands:
|
|
159
|
+
print("[merge_review_queue] source plan has 0 candidates; nothing to merge", file=sys.stderr)
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
dst = plan_path(REVIEW_QUEUE_ID)
|
|
163
|
+
existing = []
|
|
164
|
+
if os.path.exists(dst):
|
|
165
|
+
try:
|
|
166
|
+
with open(dst) as f:
|
|
167
|
+
existing = json.load(f).get("candidates") or []
|
|
168
|
+
except Exception:
|
|
169
|
+
existing = []
|
|
170
|
+
|
|
171
|
+
seen = {_dedup_key(c) for c in existing}
|
|
172
|
+
added = 0
|
|
173
|
+
merged = list(existing)
|
|
174
|
+
for c in new_cands:
|
|
175
|
+
k = _dedup_key(c)
|
|
176
|
+
if k in seen:
|
|
177
|
+
continue
|
|
178
|
+
seen.add(k)
|
|
179
|
+
merged.append(c)
|
|
180
|
+
added += 1
|
|
181
|
+
|
|
182
|
+
stamped = _enrich_with_stats(merged)
|
|
183
|
+
if stamped:
|
|
184
|
+
print(f"[merge_review_queue] stamped stats on {stamped} candidate(s)", file=sys.stderr)
|
|
185
|
+
|
|
186
|
+
_atomic_write(dst, {"candidates": merged})
|
|
187
|
+
|
|
188
|
+
# Refresh the review-request marker the menu bar polls (count = pending, not posted).
|
|
189
|
+
pending = len([c for c in merged if not c.get("posted")])
|
|
190
|
+
project = ns.project or batch.get("project") or (new_cands[0].get("matched_project") if new_cands else None)
|
|
191
|
+
_atomic_write(
|
|
192
|
+
review_request_path(),
|
|
193
|
+
{
|
|
194
|
+
"batch_id": REVIEW_QUEUE_ID,
|
|
195
|
+
"project": project,
|
|
196
|
+
"count": pending,
|
|
197
|
+
"plan_path": dst,
|
|
198
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
print(
|
|
202
|
+
f"[merge_review_queue] merged {added} new draft(s) into {REVIEW_QUEUE_ID} "
|
|
203
|
+
f"({pending} pending total) from {os.path.basename(src)}",
|
|
204
|
+
file=sys.stderr,
|
|
205
|
+
)
|
|
206
|
+
# Clean up the consumed batch plan so /tmp doesn't fill with orphans.
|
|
207
|
+
try:
|
|
208
|
+
os.remove(src)
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
sys.exit(main())
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pre-mint a pool of `post_links` codes for projects whose redirector lives on
|
|
3
|
+
the CLIENT'S domain (external_short_links=true in config.json).
|
|
4
|
+
|
|
5
|
+
Why this exists: for projects where we own the domain (fazm.ai, cyrano.systems,
|
|
6
|
+
etc.) we ship a /r/[code] route via @m13v/seo-components and resolve codes
|
|
7
|
+
live by hitting our DB. For projects where the CLIENT owns the domain and
|
|
8
|
+
doesn't want a PR (Kent: runner.now, agora.xyz, podlog.io), we hand them a
|
|
9
|
+
static CSV of `code -> destination` pairs they drop into their own redirector.
|
|
10
|
+
|
|
11
|
+
The pool is rows in `post_links` with post_id IS NULL AND reply_id IS NULL
|
|
12
|
+
and minted_session LIKE 'pool:%'. When a pipeline posts for a project with
|
|
13
|
+
external_short_links=true, wrap_text_for_post pops the next unclaimed pool
|
|
14
|
+
row matching (project_name, platform) instead of minting a fresh code, so the
|
|
15
|
+
client's CSV stays valid forever (until the pool runs dry, then we top up).
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 scripts/mint_external_pool.py \
|
|
19
|
+
--project Runner --platforms reddit,twitter,linkedin,github_issues,moltbook \
|
|
20
|
+
--per-platform 250
|
|
21
|
+
|
|
22
|
+
python3 scripts/mint_external_pool.py --status # show pool depth
|
|
23
|
+
python3 scripts/mint_external_pool.py --export-csv DIR # write CSVs
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
import argparse
|
|
28
|
+
import csv
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import secrets
|
|
32
|
+
import sys
|
|
33
|
+
from urllib.parse import urlencode
|
|
34
|
+
|
|
35
|
+
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
36
|
+
sys.path.insert(0, os.path.join(REPO_DIR, 'scripts'))
|
|
37
|
+
|
|
38
|
+
from http_api import api_get, api_post # noqa: E402
|
|
39
|
+
from dm_short_links import CODE_ALPHABET, CODE_LEN, _load_projects # noqa: E402
|
|
40
|
+
|
|
41
|
+
POOL_SESSION_PREFIX = 'pool:'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _slug(name: str) -> str:
|
|
45
|
+
return ''.join(c.lower() if c.isalnum() else '-' for c in name).strip('-')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _website(projects: list, project_name: str) -> str:
|
|
49
|
+
for p in projects:
|
|
50
|
+
if p.get('name') == project_name:
|
|
51
|
+
return (p.get('website') or '').rstrip('/')
|
|
52
|
+
raise SystemExit(f"project '{project_name}' not found in config.json")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _gen_code() -> str:
|
|
56
|
+
return ''.join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LEN))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_target(homepage: str, *, platform: str, slug: str, code: str) -> str:
|
|
60
|
+
# Canonical UTM scheme: utm_source='s4l' identifies the agency, utm_term
|
|
61
|
+
# carries the platform. Keep aligned with dm_short_links._build_target_url
|
|
62
|
+
# / _build_target_url_for_post. utm_content stays as <code> so the customer's
|
|
63
|
+
# static-CSV redirector can PostHog-join clicks back to post_links.code.
|
|
64
|
+
params = {
|
|
65
|
+
'utm_source': 's4l',
|
|
66
|
+
'utm_medium': 'post',
|
|
67
|
+
'utm_campaign': slug,
|
|
68
|
+
'utm_term': platform,
|
|
69
|
+
'utm_content': code,
|
|
70
|
+
}
|
|
71
|
+
sep = '&' if '?' in homepage else '?'
|
|
72
|
+
return f"{homepage}{sep}{urlencode(params)}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def mint_pool(*, project_name: str, platforms: list, per_platform: int,
|
|
76
|
+
session_tag: str | None = None) -> dict:
|
|
77
|
+
projects = _load_projects()
|
|
78
|
+
homepage = _website(projects, project_name)
|
|
79
|
+
slug = _slug(project_name)
|
|
80
|
+
session = session_tag or f"{POOL_SESSION_PREFIX}{slug}-{platforms[0] if len(platforms)==1 else 'multi'}"
|
|
81
|
+
|
|
82
|
+
minted = {plat: 0 for plat in platforms}
|
|
83
|
+
skipped = {plat: 0 for plat in platforms}
|
|
84
|
+
for platform in platforms:
|
|
85
|
+
tries = 0
|
|
86
|
+
while minted[platform] < per_platform and tries < per_platform * 3:
|
|
87
|
+
tries += 1
|
|
88
|
+
code = _gen_code()
|
|
89
|
+
target = _build_target(homepage, platform=platform, slug=slug, code=code)
|
|
90
|
+
result = api_post(
|
|
91
|
+
"/api/v1/post-links/mint",
|
|
92
|
+
{
|
|
93
|
+
"code": code,
|
|
94
|
+
"platform": platform,
|
|
95
|
+
"project_name": project_name,
|
|
96
|
+
"target_url": target,
|
|
97
|
+
"kind": "website",
|
|
98
|
+
"project_at_mint": project_name,
|
|
99
|
+
"minted_session": f"{POOL_SESSION_PREFIX}{slug}-{platform}",
|
|
100
|
+
},
|
|
101
|
+
ok_on_conflict=True,
|
|
102
|
+
)
|
|
103
|
+
if not result.get("ok", True):
|
|
104
|
+
# code_collision (409): retry with a fresh random code.
|
|
105
|
+
skipped[platform] += 1
|
|
106
|
+
continue
|
|
107
|
+
minted[platform] += 1
|
|
108
|
+
return {
|
|
109
|
+
'project': project_name,
|
|
110
|
+
'homepage': homepage,
|
|
111
|
+
'minted': minted,
|
|
112
|
+
'skipped_collisions': skipped,
|
|
113
|
+
'total_minted': sum(minted.values()),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def pool_status(project_filter: str | None = None) -> list[dict]:
|
|
118
|
+
query = {"project_name": project_filter} if project_filter else None
|
|
119
|
+
resp = api_get("/api/v1/post-links/pool-status", query=query)
|
|
120
|
+
return (resp.get("data") or {}).get("rows") or []
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def export_csv(out_dir: str, project_filter: str | None = None) -> dict:
|
|
124
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
125
|
+
query = {"project_name": project_filter} if project_filter else None
|
|
126
|
+
resp = api_get("/api/v1/post-links/pool-export", query=query)
|
|
127
|
+
all_rows = (resp.get("data") or {}).get("rows") or []
|
|
128
|
+
|
|
129
|
+
by_project: dict[str, list[dict]] = {}
|
|
130
|
+
for r in all_rows:
|
|
131
|
+
by_project.setdefault(r['project_name'], []).append(r)
|
|
132
|
+
|
|
133
|
+
written = {}
|
|
134
|
+
for project_name, rows in by_project.items():
|
|
135
|
+
slug = _slug(project_name)
|
|
136
|
+
path = os.path.join(out_dir, f"kent-shortlinks-{slug}.csv")
|
|
137
|
+
with open(path, 'w', newline='') as f:
|
|
138
|
+
w = csv.writer(f)
|
|
139
|
+
w.writerow(['short_path', 'destination_url', 'platform', 'project'])
|
|
140
|
+
for r in rows:
|
|
141
|
+
w.writerow([f"/r/{r['code']}", r['target_url'], r['platform'], project_name])
|
|
142
|
+
written[project_name] = {'path': path, 'rows': len(rows)}
|
|
143
|
+
return written
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main():
|
|
147
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
148
|
+
ap.add_argument('--project', help='project_name from config.json')
|
|
149
|
+
ap.add_argument('--platforms', default='reddit,twitter,linkedin,github_issues,moltbook',
|
|
150
|
+
help='comma-separated platforms to seed')
|
|
151
|
+
ap.add_argument('--per-platform', type=int, default=250,
|
|
152
|
+
help='codes to mint per platform (default 250)')
|
|
153
|
+
ap.add_argument('--status', action='store_true', help='print pool depth per project/platform')
|
|
154
|
+
ap.add_argument('--export-csv', metavar='DIR', help='export CSVs to DIR (one per project)')
|
|
155
|
+
args = ap.parse_args()
|
|
156
|
+
|
|
157
|
+
if args.status:
|
|
158
|
+
rows = pool_status(args.project)
|
|
159
|
+
if not rows:
|
|
160
|
+
print('no pool rows found')
|
|
161
|
+
return
|
|
162
|
+
print(f"{'project':<22} {'platform':<14} {'avail':>6} {'claim':>6} {'total':>6} {'last_mint'}")
|
|
163
|
+
for r in rows:
|
|
164
|
+
print(f"{r['project_name']:<22} {r['platform']:<14} {r['available']:>6} {r['claimed']:>6} {r['total']:>6} {r['last_minted']}")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
if args.export_csv:
|
|
168
|
+
out = export_csv(args.export_csv, args.project)
|
|
169
|
+
print(json.dumps(out, indent=2, default=str))
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
if not args.project:
|
|
173
|
+
ap.error('--project required (unless using --status or --export-csv)')
|
|
174
|
+
|
|
175
|
+
platforms = [p.strip() for p in args.platforms.split(',') if p.strip()]
|
|
176
|
+
result = mint_pool(project_name=args.project, platforms=platforms,
|
|
177
|
+
per_platform=args.per_platform)
|
|
178
|
+
print(json.dumps(result, indent=2, default=str))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == '__main__':
|
|
182
|
+
main()
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mint Kent's external short-link pool: 10k codes per site, 75% homepage,
|
|
3
|
+
25% across discovered subpages, distributed evenly across 5 platforms.
|
|
4
|
+
|
|
5
|
+
Designed to be fast (bulk INSERT + ON CONFLICT DO NOTHING, no per-row commit)
|
|
6
|
+
so the full 30k mint completes in seconds rather than the ~20min the legacy
|
|
7
|
+
mint_external_pool.py took for 3,750 rows.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python3 scripts/mint_kent_pool.py --dry-run # preview the plan
|
|
11
|
+
python3 scripts/mint_kent_pool.py # mint it
|
|
12
|
+
python3 scripts/mint_kent_pool.py --status # show pool depth by destination
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import date
|
|
22
|
+
from typing import Any
|
|
23
|
+
from urllib.parse import urlencode
|
|
24
|
+
|
|
25
|
+
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
sys.path.insert(0, os.path.join(REPO_DIR, 'scripts'))
|
|
27
|
+
|
|
28
|
+
from http_api import api_get, api_post # noqa: E402
|
|
29
|
+
from dm_short_links import CODE_ALPHABET, CODE_LEN # noqa: E402
|
|
30
|
+
|
|
31
|
+
PLATFORMS = ['reddit', 'twitter', 'linkedin', 'github_issues', 'moltbook']
|
|
32
|
+
TOTAL_PER_SITE = 10_000
|
|
33
|
+
HOME_FRACTION = 0.75
|
|
34
|
+
|
|
35
|
+
SITE_CONFIG: dict[str, dict[str, Any]] = {
|
|
36
|
+
'Runner': {
|
|
37
|
+
'origin': 'https://runner.now',
|
|
38
|
+
'slug': 'runner',
|
|
39
|
+
'subpages': [
|
|
40
|
+
'/download/',
|
|
41
|
+
'/workflows/',
|
|
42
|
+
'/apps/',
|
|
43
|
+
'/runner-for-business/',
|
|
44
|
+
'/blog/',
|
|
45
|
+
'/changelog/',
|
|
46
|
+
'/workflows/ai-executive-assistant/',
|
|
47
|
+
'/workflows/morning-founder-briefing/',
|
|
48
|
+
'/workflows/meeting-notes-to-action-items/',
|
|
49
|
+
'/workflows/ai-email-assistant/',
|
|
50
|
+
'/workflows/qualify-inbound-leads-from-gmail-hubspot/',
|
|
51
|
+
'/workflows/stakeholder-research-after-sales-call/',
|
|
52
|
+
'/apps/gmail/',
|
|
53
|
+
'/apps/google-calendar/',
|
|
54
|
+
'/apps/slack/',
|
|
55
|
+
'/apps/notion/',
|
|
56
|
+
'/apps/hubspot/',
|
|
57
|
+
'/apps/granola/',
|
|
58
|
+
'/apps/linear/',
|
|
59
|
+
'/blog/best-ai-apps-2026/',
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
'Agora': {
|
|
63
|
+
'origin': 'https://www.agora.xyz',
|
|
64
|
+
'slug': 'agora',
|
|
65
|
+
'subpages': [
|
|
66
|
+
'/about',
|
|
67
|
+
'/blogs',
|
|
68
|
+
'/talk-to-our-team',
|
|
69
|
+
'/jobs',
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
'Podlog': {
|
|
73
|
+
'origin': 'https://podlog.io',
|
|
74
|
+
'slug': 'podlog',
|
|
75
|
+
'subpages': [],
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
POOL_PREFIX = 'pool:'
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _slug_path(path: str) -> str:
|
|
83
|
+
if not path or path == '/':
|
|
84
|
+
return 'home'
|
|
85
|
+
cleaned = path.strip('/').replace('/', '-')
|
|
86
|
+
return cleaned or 'home'
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _build_target(origin: str, path: str, *, platform: str, campaign_slug: str, code: str) -> str:
|
|
90
|
+
base = origin.rstrip('/') + path
|
|
91
|
+
# Canonical UTM scheme: see dm_short_links._build_target_url for rationale.
|
|
92
|
+
params = {
|
|
93
|
+
'utm_source': 's4l',
|
|
94
|
+
'utm_medium': 'post',
|
|
95
|
+
'utm_campaign': campaign_slug,
|
|
96
|
+
'utm_term': platform,
|
|
97
|
+
'utm_content': code,
|
|
98
|
+
}
|
|
99
|
+
sep = '&' if '?' in base else '?'
|
|
100
|
+
return f"{base}{sep}{urlencode(params)}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _session_tag(today_iso: str, slug: str, path: str, platform: str) -> str:
|
|
104
|
+
return f"{POOL_PREFIX}kent-{today_iso}:{slug}:{_slug_path(path)}:{platform}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _gen_unique_codes(n: int, existing: set[str]) -> list[str]:
|
|
108
|
+
out: set[str] = set()
|
|
109
|
+
while len(out) < n:
|
|
110
|
+
c = ''.join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_LEN))
|
|
111
|
+
if c not in existing and c not in out:
|
|
112
|
+
out.add(c)
|
|
113
|
+
return list(out)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _plan(per_site: int = TOTAL_PER_SITE, home_frac: float = HOME_FRACTION) -> list[dict]:
|
|
117
|
+
"""Compute (project, platform, path, count) tuples for the full mint."""
|
|
118
|
+
rows = []
|
|
119
|
+
per_platform = per_site // len(PLATFORMS)
|
|
120
|
+
home_per_platform = int(per_platform * home_frac)
|
|
121
|
+
subpage_per_platform = per_platform - home_per_platform
|
|
122
|
+
for project, cfg in SITE_CONFIG.items():
|
|
123
|
+
subpages = cfg['subpages']
|
|
124
|
+
if not subpages:
|
|
125
|
+
actual_home = per_platform
|
|
126
|
+
actual_sub_each = 0
|
|
127
|
+
else:
|
|
128
|
+
actual_home = home_per_platform
|
|
129
|
+
actual_sub_each = subpage_per_platform // len(subpages)
|
|
130
|
+
remainder = subpage_per_platform - actual_sub_each * len(subpages)
|
|
131
|
+
actual_home += remainder
|
|
132
|
+
for platform in PLATFORMS:
|
|
133
|
+
rows.append({
|
|
134
|
+
'project': project,
|
|
135
|
+
'platform': platform,
|
|
136
|
+
'path': '/',
|
|
137
|
+
'count': actual_home,
|
|
138
|
+
})
|
|
139
|
+
for path in subpages:
|
|
140
|
+
rows.append({
|
|
141
|
+
'project': project,
|
|
142
|
+
'platform': platform,
|
|
143
|
+
'path': path,
|
|
144
|
+
'count': actual_sub_each,
|
|
145
|
+
})
|
|
146
|
+
return rows
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def mint_all(*, dry_run: bool = False) -> dict:
|
|
150
|
+
plan = _plan()
|
|
151
|
+
if dry_run:
|
|
152
|
+
per_site_totals: dict[str, int] = {}
|
|
153
|
+
for r in plan:
|
|
154
|
+
per_site_totals[r['project']] = per_site_totals.get(r['project'], 0) + r['count']
|
|
155
|
+
return {
|
|
156
|
+
'plan_rows': len(plan),
|
|
157
|
+
'codes_by_site': per_site_totals,
|
|
158
|
+
'sample': plan[:3] + plan[-3:],
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
today = date.today().isoformat()
|
|
162
|
+
minted = {p: 0 for p in SITE_CONFIG}
|
|
163
|
+
total = 0
|
|
164
|
+
# Server enforces uniqueness via ON CONFLICT (code) DO NOTHING; we only
|
|
165
|
+
# dedup within this run so a single batch never carries two identical codes.
|
|
166
|
+
existing: set[str] = set()
|
|
167
|
+
BATCH = 1000
|
|
168
|
+
for entry in plan:
|
|
169
|
+
project = entry['project']
|
|
170
|
+
platform = entry['platform']
|
|
171
|
+
path = entry['path']
|
|
172
|
+
n = entry['count']
|
|
173
|
+
if n <= 0:
|
|
174
|
+
continue
|
|
175
|
+
cfg = SITE_CONFIG[project]
|
|
176
|
+
slug = cfg['slug']
|
|
177
|
+
session_tag = _session_tag(today, slug, path, platform)
|
|
178
|
+
codes = _gen_unique_codes(n, existing)
|
|
179
|
+
existing.update(codes)
|
|
180
|
+
rows = []
|
|
181
|
+
for code in codes:
|
|
182
|
+
target = _build_target(
|
|
183
|
+
cfg['origin'], path,
|
|
184
|
+
platform=platform, campaign_slug=slug, code=code,
|
|
185
|
+
)
|
|
186
|
+
rows.append({
|
|
187
|
+
"code": code,
|
|
188
|
+
"platform": platform,
|
|
189
|
+
"project_name": project,
|
|
190
|
+
"target_url": target,
|
|
191
|
+
"kind": "website",
|
|
192
|
+
"project_at_mint": project,
|
|
193
|
+
"minted_session": session_tag,
|
|
194
|
+
})
|
|
195
|
+
inserted_here = 0
|
|
196
|
+
for i in range(0, len(rows), BATCH):
|
|
197
|
+
chunk = rows[i:i + BATCH]
|
|
198
|
+
resp = api_post("/api/v1/post-links/mint-batch", {"rows": chunk})
|
|
199
|
+
inserted_here += int((resp.get("data") or {}).get("inserted") or 0)
|
|
200
|
+
minted[project] += inserted_here
|
|
201
|
+
total += inserted_here
|
|
202
|
+
print(f" + {project:<8} {platform:<14} {path:<60} count={inserted_here}", flush=True)
|
|
203
|
+
return {
|
|
204
|
+
'minted_total': total,
|
|
205
|
+
'minted_by_project': minted,
|
|
206
|
+
'session_date': today,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def pool_status_detailed() -> list[dict]:
|
|
211
|
+
resp = api_get(
|
|
212
|
+
"/api/v1/post-links/pool-status",
|
|
213
|
+
query={"session_like": "pool:kent-%", "group_by_session": "1"},
|
|
214
|
+
)
|
|
215
|
+
return (resp.get("data") or {}).get("rows") or []
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main():
|
|
219
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
220
|
+
ap.add_argument('--dry-run', action='store_true', help='print the plan, do not write')
|
|
221
|
+
ap.add_argument('--status', action='store_true', help='print pool depth grouped by destination')
|
|
222
|
+
args = ap.parse_args()
|
|
223
|
+
|
|
224
|
+
if args.status:
|
|
225
|
+
rows = pool_status_detailed()
|
|
226
|
+
if not rows:
|
|
227
|
+
print('no kent pool rows found')
|
|
228
|
+
return
|
|
229
|
+
print(f"{'project':<10} {'platform':<14} {'session':<70} {'avail':>6} {'claim':>6}")
|
|
230
|
+
for r in rows:
|
|
231
|
+
sess = (r['minted_session'] or '')[-70:]
|
|
232
|
+
print(f"{r['project_name']:<10} {r['platform']:<14} {sess:<70} {r['available']:>6} {r['claimed']:>6}")
|
|
233
|
+
totals: dict[str, int] = {}
|
|
234
|
+
for r in rows:
|
|
235
|
+
totals[r['project_name']] = totals.get(r['project_name'], 0) + r['available']
|
|
236
|
+
print('---')
|
|
237
|
+
for k, v in sorted(totals.items()):
|
|
238
|
+
print(f" {k}: {v} available")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
if args.dry_run:
|
|
242
|
+
print(json.dumps(mint_all(dry_run=True), indent=2))
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
print(json.dumps(mint_all(), indent=2, default=str))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == '__main__':
|
|
249
|
+
main()
|