@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
|
+
#!/opt/homebrew/bin/python3.11
|
|
2
|
+
"""Pick which Instagram account should post next.
|
|
3
|
+
|
|
4
|
+
Mirrors scripts/pick_project.py: inverse-recent-share weighting over
|
|
5
|
+
enabled `instagram.accounts` entries in config.json. Effective weight =
|
|
6
|
+
config_weight / (1 + posts in the last `recent_window_days`). An account
|
|
7
|
+
that has been posting heavily damps toward under-posted ones; never
|
|
8
|
+
selected above its raw weight; settles toward the configured weight ratio
|
|
9
|
+
over time.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
pick_ig_account.py # print chosen username
|
|
13
|
+
pick_ig_account.py --json # full account record as JSON
|
|
14
|
+
pick_ig_account.py --account NAME # force a specific account (must be enabled)
|
|
15
|
+
pick_ig_account.py --show-weights # diagnostic table of weights vs recent posts
|
|
16
|
+
pick_ig_account.py --list # all enabled accounts, JSON array
|
|
17
|
+
|
|
18
|
+
Exit codes:
|
|
19
|
+
0 picked successfully
|
|
20
|
+
2 no enabled accounts (returns the legacy single-account default if any
|
|
21
|
+
account is found, else exits 2)
|
|
22
|
+
3 --account requested an unknown / disabled account
|
|
23
|
+
4 config / DB error
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import random
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
from http_api import api_get
|
|
35
|
+
|
|
36
|
+
CONFIG_PATH = Path.home() / "social-autoposter" / "config.json"
|
|
37
|
+
ENV_PATH = Path.home() / "social-autoposter" / ".env"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_env():
|
|
41
|
+
env = {}
|
|
42
|
+
if ENV_PATH.exists():
|
|
43
|
+
for ln in ENV_PATH.read_text().splitlines():
|
|
44
|
+
ln = ln.strip()
|
|
45
|
+
if ln and not ln.startswith("#") and "=" in ln:
|
|
46
|
+
k, v = ln.split("=", 1)
|
|
47
|
+
env[k.strip()] = v.strip()
|
|
48
|
+
return env
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_ig_cfg():
|
|
52
|
+
cfg = json.loads(CONFIG_PATH.read_text())
|
|
53
|
+
ig = cfg.get("instagram") or {}
|
|
54
|
+
accounts = ig.get("accounts") or []
|
|
55
|
+
window_days = int(ig.get("recent_window_days", 7))
|
|
56
|
+
return accounts, window_days
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def recent_posts_by_account(window_days):
|
|
60
|
+
"""Return {target_account: post count over last `window_days`} from
|
|
61
|
+
media_posts WHERE status='posted' AND posted_urls ? 'instagram'.
|
|
62
|
+
|
|
63
|
+
media_posts is the per-platform table for IG; we don't need the unified
|
|
64
|
+
`posts` table here because all IG posts route through this pipeline.
|
|
65
|
+
Served via the HTTP API (account-post-counts) so no DATABASE_URL is needed.
|
|
66
|
+
"""
|
|
67
|
+
resp = api_get(
|
|
68
|
+
"/api/v1/media-posts/account-post-counts",
|
|
69
|
+
query={"window_days": int(window_days)},
|
|
70
|
+
)
|
|
71
|
+
return (resp.get("data") or {}).get("account_counts") or {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def pick_account(accounts, window_days):
|
|
75
|
+
"""Inverse-recent-share weighted draw from enabled accounts.
|
|
76
|
+
|
|
77
|
+
Effective weight is `posts_per_day * weight` (defaults: posts_per_day=
|
|
78
|
+
global instagram.posts_per_account_per_day, weight=1). Dividing by
|
|
79
|
+
(1 + recent_posts) damps over-posting toward the target rate.
|
|
80
|
+
"""
|
|
81
|
+
enabled = [a for a in accounts if a.get("enabled") and float(a.get("weight", 0)) > 0]
|
|
82
|
+
if not enabled:
|
|
83
|
+
return None, {}, {}
|
|
84
|
+
counts = recent_posts_by_account(window_days)
|
|
85
|
+
# Pull global default for posts_per_day fallback.
|
|
86
|
+
cfg = json.loads(CONFIG_PATH.read_text())
|
|
87
|
+
global_ppd = int((cfg.get("instagram") or {}).get("posts_per_account_per_day", 5))
|
|
88
|
+
effective = {
|
|
89
|
+
a["username"]: (
|
|
90
|
+
float(a["weight"]) * float(a.get("posts_per_day", global_ppd))
|
|
91
|
+
) / (1 + counts.get(a["username"], 0))
|
|
92
|
+
for a in enabled
|
|
93
|
+
}
|
|
94
|
+
names = list(effective.keys())
|
|
95
|
+
ws = [effective[n] for n in names]
|
|
96
|
+
chosen_name = random.choices(names, weights=ws, k=1)[0]
|
|
97
|
+
chosen = next(a for a in enabled if a["username"] == chosen_name)
|
|
98
|
+
return chosen, counts, effective
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main():
|
|
102
|
+
ap = argparse.ArgumentParser(description="Pick next IG account to post for")
|
|
103
|
+
ap.add_argument("--json", action="store_true", help="emit full account record")
|
|
104
|
+
ap.add_argument("--account", help="force a specific account (must be enabled)")
|
|
105
|
+
ap.add_argument("--show-weights", action="store_true", help="diagnostic table")
|
|
106
|
+
ap.add_argument("--list", action="store_true", help="list enabled accounts as JSON array")
|
|
107
|
+
args = ap.parse_args()
|
|
108
|
+
|
|
109
|
+
accounts, window_days = load_ig_cfg()
|
|
110
|
+
|
|
111
|
+
if args.list:
|
|
112
|
+
enabled = [a for a in accounts if a.get("enabled")]
|
|
113
|
+
print(json.dumps(enabled, indent=2))
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
if args.show_weights:
|
|
117
|
+
counts = recent_posts_by_account(window_days)
|
|
118
|
+
cfg = json.loads(CONFIG_PATH.read_text())
|
|
119
|
+
global_ppd = int((cfg.get("instagram") or {}).get("posts_per_account_per_day", 5))
|
|
120
|
+
print(f"{'Account':25} {'Enabled':>8} {'Weight':>7} {'PPD':>5} {'Recent':>7} {'Effective':>10}")
|
|
121
|
+
print("-" * 70)
|
|
122
|
+
for a in accounts:
|
|
123
|
+
ppd = int(a.get("posts_per_day", global_ppd))
|
|
124
|
+
eff = (
|
|
125
|
+
(float(a.get("weight", 0)) * ppd) / (1 + counts.get(a["username"], 0))
|
|
126
|
+
if a.get("enabled") else 0
|
|
127
|
+
)
|
|
128
|
+
print(
|
|
129
|
+
f"{a['username']:25} {str(a.get('enabled', False)):>8} "
|
|
130
|
+
f"{a.get('weight', 0):>7} {ppd:>5} {counts.get(a['username'], 0):>7} "
|
|
131
|
+
f"{eff:>10.3f}"
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
if args.account:
|
|
136
|
+
match = next(
|
|
137
|
+
(a for a in accounts if a.get("username", "").lower() == args.account.lower()),
|
|
138
|
+
None,
|
|
139
|
+
)
|
|
140
|
+
if not match:
|
|
141
|
+
sys.stderr.write(f"unknown account: {args.account}\n")
|
|
142
|
+
sys.exit(3)
|
|
143
|
+
if not match.get("enabled"):
|
|
144
|
+
sys.stderr.write(f"account disabled: {args.account}\n")
|
|
145
|
+
sys.exit(3)
|
|
146
|
+
chosen = match
|
|
147
|
+
else:
|
|
148
|
+
chosen, _, _ = pick_account(accounts, window_days)
|
|
149
|
+
if chosen is None:
|
|
150
|
+
# Legacy fallback: if config has no enabled accounts but the
|
|
151
|
+
# single-account env vars exist, fall back to matt_diak so a
|
|
152
|
+
# misconfigured config doesn't take the pipeline down. Exit 2
|
|
153
|
+
# signals the caller it was a fallback so the harness can log.
|
|
154
|
+
env = load_env()
|
|
155
|
+
if env.get("IG_USER_ID") and env.get("IG_LONG_TOKEN"):
|
|
156
|
+
sys.stderr.write(
|
|
157
|
+
"no enabled accounts in config; falling back to legacy IG_USER_ID/IG_LONG_TOKEN as 'matt_diak'\n"
|
|
158
|
+
)
|
|
159
|
+
chosen = {
|
|
160
|
+
"username": "matt_diak",
|
|
161
|
+
"ig_user_id_env": "IG_USER_ID",
|
|
162
|
+
"ig_long_token_env": "IG_LONG_TOKEN",
|
|
163
|
+
"weight": 1,
|
|
164
|
+
"enabled": True,
|
|
165
|
+
}
|
|
166
|
+
else:
|
|
167
|
+
sys.stderr.write("no enabled accounts and no legacy env vars\n")
|
|
168
|
+
sys.exit(2)
|
|
169
|
+
|
|
170
|
+
if args.json:
|
|
171
|
+
print(json.dumps(chosen, indent=2))
|
|
172
|
+
else:
|
|
173
|
+
print(chosen["username"])
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
main()
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pick which project(s) to post about. Shared across every platform.
|
|
3
|
+
|
|
4
|
+
Inverse-recent-share weighting: a project's selection weight is its config
|
|
5
|
+
`weight` divided by (1 + its posts in the last RECENT_WINDOW_DAYS), so a
|
|
6
|
+
project that has been posting heavily is dampened toward under-posted ones
|
|
7
|
+
(but never selected above its raw weight). Single-pick (pick_project) and
|
|
8
|
+
multi-pick (pick_projects / --count N) share one code path, so Twitter,
|
|
9
|
+
GitHub and Reddit all select projects the same way.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python3 scripts/pick_project.py # one project, any platform
|
|
13
|
+
python3 scripts/pick_project.py --platform reddit # one project for a platform
|
|
14
|
+
python3 scripts/pick_project.py --json # one project, full JSON
|
|
15
|
+
python3 scripts/pick_project.py --platform twitter --count 8 --json # N projects, JSON array
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import random
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
25
|
+
|
|
26
|
+
from project_topics import topics_for_project # noqa: E402
|
|
27
|
+
|
|
28
|
+
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
29
|
+
|
|
30
|
+
# Rolling window (days) for inverse-recent-share weighting in pick_projects().
|
|
31
|
+
RECENT_WINDOW_DAYS = 7
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_config():
|
|
35
|
+
with open(CONFIG_PATH) as f:
|
|
36
|
+
return json.load(f)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _counts_via_api(platform=None):
|
|
40
|
+
from http_api import api_get
|
|
41
|
+
query = {"platform": platform} if platform else None
|
|
42
|
+
resp = api_get("/api/v1/posts/counts-today-by-project", query=query)
|
|
43
|
+
data = (resp or {}).get("data") or {}
|
|
44
|
+
counts = data.get("counts") or {}
|
|
45
|
+
return {k: int(v) for k, v in counts.items()}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_posts_today_by_project(platform=None):
|
|
49
|
+
"""Return dict of project_name -> post count for today.
|
|
50
|
+
|
|
51
|
+
Routes through /api/v1/posts/counts-today-by-project (HTTP-only).
|
|
52
|
+
"""
|
|
53
|
+
return _counts_via_api(platform)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _recent_counts_via_api(platform=None, days=RECENT_WINDOW_DAYS):
|
|
57
|
+
from http_api import api_get
|
|
58
|
+
query = {"days": str(int(days))}
|
|
59
|
+
if platform:
|
|
60
|
+
query["platform"] = platform
|
|
61
|
+
resp = api_get("/api/v1/posts/counts-by-project-window", query=query)
|
|
62
|
+
data = (resp or {}).get("data") or {}
|
|
63
|
+
counts = data.get("counts") or {}
|
|
64
|
+
return {k: int(v) for k, v in counts.items()}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def recent_posts_by_project(platform=None, days=RECENT_WINDOW_DAYS):
|
|
68
|
+
"""Return {project_name: post count} over the last `days` days.
|
|
69
|
+
|
|
70
|
+
Routes through /api/v1/posts/counts-by-project-window (HTTP-only).
|
|
71
|
+
Feeds the inverse-recent-share weighting in pick_projects().
|
|
72
|
+
"""
|
|
73
|
+
return _recent_counts_via_api(platform, days)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _eligible_pool(config, platform=None, exclude=None):
|
|
77
|
+
"""Projects eligible for selection: enabled, weight>0, platform-compatible."""
|
|
78
|
+
pool = [
|
|
79
|
+
p for p in config.get("projects", [])
|
|
80
|
+
if p.get("enabled", True) and p.get("weight", 0) > 0
|
|
81
|
+
]
|
|
82
|
+
if exclude:
|
|
83
|
+
excluded = {n.lower() for n in exclude}
|
|
84
|
+
pool = [p for p in pool if p.get("name", "").lower() not in excluded]
|
|
85
|
+
# Explicit per-project platforms_disabled deny list.
|
|
86
|
+
if platform:
|
|
87
|
+
pool = [p for p in pool if platform not in (p.get("platforms_disabled") or [])]
|
|
88
|
+
# twitter/linkedin/github draft a search query, so they need seed topics
|
|
89
|
+
# (DB-backed project_search_topics, post 2026-05-27 config.json removal).
|
|
90
|
+
if platform in ("twitter", "linkedin", "github"):
|
|
91
|
+
pool = [p for p in pool if topics_for_project(p.get("name") or "")]
|
|
92
|
+
return pool
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def pick_projects(config, platform=None, n=1, exclude=None):
|
|
96
|
+
"""Pick up to `n` distinct projects. Shared by every platform's pipeline.
|
|
97
|
+
|
|
98
|
+
Inverse-recent-share weighting: effective_weight = weight / (1 + posts in
|
|
99
|
+
the last RECENT_WINDOW_DAYS). Sampled without replacement, so a project
|
|
100
|
+
that has been posting heavily is dampened in favor of under-posted ones,
|
|
101
|
+
but a project is never selected above its raw `weight`. Returns a list of
|
|
102
|
+
project dicts (shorter than `n` only when the eligible pool is smaller).
|
|
103
|
+
"""
|
|
104
|
+
pool = _eligible_pool(config, platform, exclude)
|
|
105
|
+
if not pool:
|
|
106
|
+
return []
|
|
107
|
+
counts = recent_posts_by_project(platform)
|
|
108
|
+
chosen = []
|
|
109
|
+
remaining = list(pool)
|
|
110
|
+
for _ in range(min(n, len(remaining))):
|
|
111
|
+
weights = [p["weight"] / (1 + counts.get(p["name"], 0)) for p in remaining]
|
|
112
|
+
idx = random.choices(range(len(remaining)), weights=weights, k=1)[0]
|
|
113
|
+
chosen.append(remaining.pop(idx))
|
|
114
|
+
return chosen
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def pick_project(config, platform=None, exclude=None):
|
|
118
|
+
"""Pick a single project. Thin wrapper around pick_projects() kept for the
|
|
119
|
+
existing callers (post_reddit.py, the bare CLI, --json, etc.)."""
|
|
120
|
+
picks = pick_projects(config, platform, n=1, exclude=exclude)
|
|
121
|
+
if picks:
|
|
122
|
+
return picks[0]
|
|
123
|
+
if exclude:
|
|
124
|
+
return None
|
|
125
|
+
# No eligible project at all: legacy fallback to any project in config.
|
|
126
|
+
projects = config.get("projects", [])
|
|
127
|
+
return random.choice(projects) if projects else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main():
|
|
131
|
+
parser = argparse.ArgumentParser(description="Pick next project to post about")
|
|
132
|
+
parser.add_argument("--platform", default=None, help="Platform to check distribution for")
|
|
133
|
+
parser.add_argument("--json", action="store_true", help="Output full project config as JSON")
|
|
134
|
+
parser.add_argument("--project", default=None, help="Select a specific project by name")
|
|
135
|
+
parser.add_argument("--show-weights", action="store_true", help="Show all projects and their current distribution")
|
|
136
|
+
parser.add_argument("--distribution", action="store_true", help="Show compact distribution for LLM prompts")
|
|
137
|
+
parser.add_argument("--exclude", default=None, help="Comma-separated project names to exclude from picking")
|
|
138
|
+
parser.add_argument("--count", type=int, default=1, help="Number of projects to pick; >1 emits a JSON array")
|
|
139
|
+
args = parser.parse_args()
|
|
140
|
+
|
|
141
|
+
exclude = None
|
|
142
|
+
if args.exclude:
|
|
143
|
+
exclude = [n.strip() for n in args.exclude.split(",") if n.strip()]
|
|
144
|
+
|
|
145
|
+
config = load_config()
|
|
146
|
+
|
|
147
|
+
if args.distribution:
|
|
148
|
+
projects = config.get("projects", [])
|
|
149
|
+
weighted = [p for p in projects if p.get("weight", 0) > 0]
|
|
150
|
+
if args.platform:
|
|
151
|
+
weighted = [p for p in weighted if args.platform not in (p.get("platforms_disabled") or [])]
|
|
152
|
+
total_weight = sum(p.get("weight", 0) for p in weighted)
|
|
153
|
+
counts = get_posts_today_by_project(args.platform)
|
|
154
|
+
lines = []
|
|
155
|
+
for p in sorted(weighted, key=lambda x: x["weight"], reverse=True):
|
|
156
|
+
target_pct = (p["weight"] / total_weight * 100) if total_weight else 0
|
|
157
|
+
actual = counts.get(p["name"], 0)
|
|
158
|
+
lines.append(f"{p['name']}: {actual} posts today (target {target_pct:.0f}%)")
|
|
159
|
+
print("\n".join(lines))
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if args.show_weights:
|
|
163
|
+
projects = config.get("projects", [])
|
|
164
|
+
weighted = [p for p in projects if p.get("weight", 0) > 0]
|
|
165
|
+
if args.platform:
|
|
166
|
+
weighted = [p for p in weighted if args.platform not in (p.get("platforms_disabled") or [])]
|
|
167
|
+
total_weight = sum(p.get("weight", 0) for p in weighted)
|
|
168
|
+
counts = get_posts_today_by_project(args.platform)
|
|
169
|
+
total_posts = sum(counts.values()) or 1
|
|
170
|
+
|
|
171
|
+
print(f"{'Project':25} {'Weight':>8} {'Target%':>8} {'Today':>6} {'Actual%':>8} {'Deficit':>8}")
|
|
172
|
+
print("-" * 73)
|
|
173
|
+
for p in sorted(weighted, key=lambda x: x["weight"], reverse=True):
|
|
174
|
+
target_pct = (p["weight"] / total_weight * 100) if total_weight else 0
|
|
175
|
+
actual = counts.get(p["name"], 0)
|
|
176
|
+
actual_pct = (actual / total_posts * 100) if total_posts > 0 else 0
|
|
177
|
+
deficit = target_pct - actual_pct
|
|
178
|
+
print(f"{p['name']:25} {p['weight']:>8} {target_pct:>7.1f}% {actual:>6} {actual_pct:>7.1f}% {deficit:>+7.1f}%")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
if args.count and args.count > 1:
|
|
182
|
+
picks = pick_projects(config, args.platform, n=args.count, exclude=exclude)
|
|
183
|
+
print(json.dumps(picks, indent=2))
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
if args.project:
|
|
187
|
+
project = None
|
|
188
|
+
for p in config.get("projects", []):
|
|
189
|
+
if p.get("name", "").lower() == args.project.lower():
|
|
190
|
+
project = p
|
|
191
|
+
break
|
|
192
|
+
if not project:
|
|
193
|
+
print(f"Unknown project: {args.project}", file=sys.stderr)
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
else:
|
|
196
|
+
project = pick_project(config, args.platform, exclude=exclude)
|
|
197
|
+
if project is None:
|
|
198
|
+
print("No eligible project (all excluded)", file=sys.stderr)
|
|
199
|
+
sys.exit(2)
|
|
200
|
+
|
|
201
|
+
if args.json:
|
|
202
|
+
print(json.dumps(project, indent=2))
|
|
203
|
+
else:
|
|
204
|
+
print(project["name"])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
main()
|