@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,279 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pick the next (project, subreddit) pair for an original Reddit thread.
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
- Only consider projects with threads.enabled=true.
|
|
6
|
+
- A project's own_community (if set) is a candidate every run (subject to its
|
|
7
|
+
own floor_days override, default 1 day for own community).
|
|
8
|
+
- External subreddits are subject to the default 3-day floor (configurable via
|
|
9
|
+
threads.external_floor_days).
|
|
10
|
+
- Entry filter: skip any subreddit where this account has posted an original
|
|
11
|
+
thread (thread_url == our_url) within that sub's floor window.
|
|
12
|
+
- Also skip any subreddit listed in subreddit_bans.thread_blocked.
|
|
13
|
+
- Among eligible candidates, prefer own_community if present. Otherwise, weight
|
|
14
|
+
projects by config weight.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python3 scripts/pick_thread_target.py # stdout: PROJECT\tSUBREDDIT
|
|
18
|
+
python3 scripts/pick_thread_target.py --json # full context
|
|
19
|
+
python3 scripts/pick_thread_target.py --show-all # debug view
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import random
|
|
26
|
+
import sys
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
|
|
29
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
30
|
+
from http_api import api_get
|
|
31
|
+
|
|
32
|
+
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
33
|
+
DEFAULT_OWN_FLOOR_DAYS = 1
|
|
34
|
+
DEFAULT_EXTERNAL_FLOOR_DAYS = 3
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_config():
|
|
38
|
+
with open(CONFIG_PATH) as f:
|
|
39
|
+
return json.load(f)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_dt(s):
|
|
43
|
+
"""Parse an ISO posted_at string to an aware datetime, or None."""
|
|
44
|
+
if not s:
|
|
45
|
+
return None
|
|
46
|
+
s = str(s)
|
|
47
|
+
if s.endswith("Z"):
|
|
48
|
+
s = s[:-1] + "+00:00"
|
|
49
|
+
try:
|
|
50
|
+
dt = datetime.fromisoformat(s)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
53
|
+
if dt.tzinfo is None:
|
|
54
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
55
|
+
return dt
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _fetch_own_reddit_threads(days):
|
|
59
|
+
"""Fetch our original Reddit threads (thread_url == our_url) posted in the
|
|
60
|
+
last `days` days via the HTTP API. Returns a list of (thread_url, days_ago)
|
|
61
|
+
tuples. Replaces the former direct posts SELECT (own_threads_only mirrors
|
|
62
|
+
the thread_url = our_url predicate)."""
|
|
63
|
+
cutoff = datetime.now(timezone.utc).timestamp() - int(days) * 86400
|
|
64
|
+
since = datetime.fromtimestamp(cutoff, tz=timezone.utc).isoformat()
|
|
65
|
+
resp = api_get(
|
|
66
|
+
"/api/v1/posts",
|
|
67
|
+
query={
|
|
68
|
+
"platform": "reddit",
|
|
69
|
+
"own_threads_only": "true",
|
|
70
|
+
"since": since,
|
|
71
|
+
"order_by": "posted_at",
|
|
72
|
+
"order_dir": "desc",
|
|
73
|
+
"limit": 500,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
posts = (resp.get("data") or {}).get("posts") or []
|
|
77
|
+
now_ts = datetime.now(timezone.utc).timestamp()
|
|
78
|
+
out = []
|
|
79
|
+
for p in posts:
|
|
80
|
+
dt = _parse_dt(p.get("posted_at"))
|
|
81
|
+
if dt is None:
|
|
82
|
+
continue
|
|
83
|
+
days_ago = (now_ts - dt.timestamp()) / 86400.0
|
|
84
|
+
out.append((p.get("thread_url"), days_ago, p.get("project_name")))
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def norm_sub(s):
|
|
89
|
+
if not s:
|
|
90
|
+
return ""
|
|
91
|
+
s = s.strip()
|
|
92
|
+
if s.lower().startswith("r/"):
|
|
93
|
+
s = s[2:]
|
|
94
|
+
return s.lower()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ban_entry_to_slug(entry):
|
|
98
|
+
"""Extract the sub slug from a comment_blocked / thread_blocked entry.
|
|
99
|
+
|
|
100
|
+
Entries are either bare strings (pre-2026-05-11) or audit dicts
|
|
101
|
+
{"sub": ..., "added_at": ..., "reason": ..., "project": ...}.
|
|
102
|
+
Returns lowercased slug (no r/ prefix) or empty string.
|
|
103
|
+
"""
|
|
104
|
+
if isinstance(entry, str):
|
|
105
|
+
return norm_sub(entry)
|
|
106
|
+
if isinstance(entry, dict):
|
|
107
|
+
return norm_sub(entry.get("sub") or "")
|
|
108
|
+
return ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_thread_blocked_subs(config):
|
|
112
|
+
"""Load subreddits where we cannot create new threads.
|
|
113
|
+
|
|
114
|
+
Reads subreddit_bans.thread_blocked. For the thread-creation pipeline
|
|
115
|
+
only, the comment pipeline uses subreddit_bans.comment_blocked via
|
|
116
|
+
reddit_tools._load_comment_blocked_subs().
|
|
117
|
+
|
|
118
|
+
Handles both ban-list shapes: bare string (pre-2026-05-11) and audit
|
|
119
|
+
dict {"sub": ..., "added_at": ..., "reason": ..., "project": ...}.
|
|
120
|
+
"""
|
|
121
|
+
bans = config.get("subreddit_bans") or {}
|
|
122
|
+
out = set()
|
|
123
|
+
if isinstance(bans, dict):
|
|
124
|
+
for entry in bans.get("thread_blocked") or []:
|
|
125
|
+
slug = _ban_entry_to_slug(entry)
|
|
126
|
+
if slug:
|
|
127
|
+
out.add(slug)
|
|
128
|
+
elif isinstance(bans, list):
|
|
129
|
+
# Legacy flat-list form, treat as thread_blocked.
|
|
130
|
+
for entry in bans:
|
|
131
|
+
slug = _ban_entry_to_slug(entry)
|
|
132
|
+
if slug:
|
|
133
|
+
out.add(slug)
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def recent_posts_by_sub(max_days):
|
|
138
|
+
"""Return dict: sub_slug (lowercased) -> days_since_last_our_thread."""
|
|
139
|
+
rows = _fetch_own_reddit_threads(max_days)
|
|
140
|
+
latest = {}
|
|
141
|
+
for url, days_ago, _project in rows:
|
|
142
|
+
if not url or "/r/" not in url:
|
|
143
|
+
continue
|
|
144
|
+
sub = url.split("/r/", 1)[1].split("/", 1)[0].lower()
|
|
145
|
+
if sub not in latest or days_ago < latest[sub]:
|
|
146
|
+
latest[sub] = float(days_ago)
|
|
147
|
+
return latest
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def recent_posts_by_project(days=7):
|
|
151
|
+
"""Return dict: project_name -> count of original threads posted in last N days."""
|
|
152
|
+
rows = _fetch_own_reddit_threads(days)
|
|
153
|
+
counts = {}
|
|
154
|
+
for _url, _days_ago, project in rows:
|
|
155
|
+
if not project:
|
|
156
|
+
continue
|
|
157
|
+
counts[project] = counts.get(project, 0) + 1
|
|
158
|
+
return counts
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def build_candidates(config):
|
|
162
|
+
recent = recent_posts_by_sub(max_days=max(
|
|
163
|
+
DEFAULT_OWN_FLOOR_DAYS, DEFAULT_EXTERNAL_FLOOR_DAYS, 14))
|
|
164
|
+
thread_blocked = load_thread_blocked_subs(config)
|
|
165
|
+
candidates = []
|
|
166
|
+
for p in config.get("projects", []):
|
|
167
|
+
t = p.get("threads") or {}
|
|
168
|
+
if not t.get("enabled"):
|
|
169
|
+
continue
|
|
170
|
+
ext_floor = int(t.get("external_floor_days", DEFAULT_EXTERNAL_FLOOR_DAYS))
|
|
171
|
+
# Own community
|
|
172
|
+
own = t.get("own_community")
|
|
173
|
+
if own:
|
|
174
|
+
if isinstance(own, dict):
|
|
175
|
+
sub_display = own.get("subreddit")
|
|
176
|
+
own_floor = int(own.get("floor_days", DEFAULT_OWN_FLOOR_DAYS))
|
|
177
|
+
else:
|
|
178
|
+
sub_display = own
|
|
179
|
+
own_floor = DEFAULT_OWN_FLOOR_DAYS
|
|
180
|
+
slug = norm_sub(sub_display)
|
|
181
|
+
if sub_display and slug not in thread_blocked:
|
|
182
|
+
last = recent.get(slug)
|
|
183
|
+
if last is None or last >= own_floor:
|
|
184
|
+
candidates.append((p, sub_display, True, own_floor, last))
|
|
185
|
+
# External subs
|
|
186
|
+
for sub in t.get("external_subreddits") or []:
|
|
187
|
+
slug = norm_sub(sub)
|
|
188
|
+
if slug in thread_blocked:
|
|
189
|
+
continue
|
|
190
|
+
last = recent.get(slug)
|
|
191
|
+
if last is not None and last < ext_floor:
|
|
192
|
+
continue
|
|
193
|
+
candidates.append((p, sub, False, ext_floor, last))
|
|
194
|
+
return candidates, recent, thread_blocked
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def pick(candidates, recent_project_counts=None):
|
|
198
|
+
own_candidates = [c for c in candidates if c[2]]
|
|
199
|
+
if own_candidates:
|
|
200
|
+
return random.choice(own_candidates)
|
|
201
|
+
if not candidates:
|
|
202
|
+
return None
|
|
203
|
+
recent_project_counts = recent_project_counts or {}
|
|
204
|
+
by_project = {}
|
|
205
|
+
for p, sub, is_own, floor, last in candidates:
|
|
206
|
+
by_project.setdefault(p["name"], {"project": p, "entries": []})
|
|
207
|
+
by_project[p["name"]]["entries"].append((sub, is_own, floor, last))
|
|
208
|
+
names = list(by_project.keys())
|
|
209
|
+
# Inverse recent-share weighting: keep config weight as the prior, but
|
|
210
|
+
# penalise projects that already posted a lot in the last 7 days.
|
|
211
|
+
# effective = base_weight / (1 + posts_last_7d). 0 posts => no change,
|
|
212
|
+
# each recent post halves the odds relative to a never-posted peer at 1.
|
|
213
|
+
weights = [
|
|
214
|
+
by_project[n]["project"].get("weight", 1)
|
|
215
|
+
/ (1 + recent_project_counts.get(n, 0))
|
|
216
|
+
for n in names
|
|
217
|
+
]
|
|
218
|
+
chosen_name = random.choices(names, weights=weights, k=1)[0]
|
|
219
|
+
proj = by_project[chosen_name]["project"]
|
|
220
|
+
sub, is_own, floor, last = random.choice(by_project[chosen_name]["entries"])
|
|
221
|
+
return (proj, sub, is_own, floor, last)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main():
|
|
225
|
+
ap = argparse.ArgumentParser()
|
|
226
|
+
ap.add_argument("--json", action="store_true")
|
|
227
|
+
ap.add_argument("--show-all", action="store_true")
|
|
228
|
+
args = ap.parse_args()
|
|
229
|
+
|
|
230
|
+
config = load_config()
|
|
231
|
+
candidates, recent, thread_blocked = build_candidates(config)
|
|
232
|
+
recent_project_counts = recent_posts_by_project(days=7)
|
|
233
|
+
|
|
234
|
+
if args.show_all:
|
|
235
|
+
print(f"Thread-blocked subs ({len(thread_blocked)}): {sorted(thread_blocked)}")
|
|
236
|
+
print(f"Recent thread subs: {len(recent)}")
|
|
237
|
+
for sub, days in sorted(recent.items(), key=lambda x: x[1]):
|
|
238
|
+
print(f" {sub}: {days:.2f}d ago")
|
|
239
|
+
eligible_projects = {}
|
|
240
|
+
for p, sub, is_own, floor, last in candidates:
|
|
241
|
+
eligible_projects.setdefault(p["name"], p)
|
|
242
|
+
print(f"\nProject weights (base / posts_7d / effective):")
|
|
243
|
+
rows = []
|
|
244
|
+
for name, p in eligible_projects.items():
|
|
245
|
+
base = p.get("weight", 1)
|
|
246
|
+
posts_7d = recent_project_counts.get(name, 0)
|
|
247
|
+
eff = base / (1 + posts_7d)
|
|
248
|
+
rows.append((name, base, posts_7d, eff))
|
|
249
|
+
for name, base, posts_7d, eff in sorted(rows, key=lambda r: -r[3]):
|
|
250
|
+
print(f" {name:25} base={base:>3} posts_7d={posts_7d:>2} effective={eff:.3f}")
|
|
251
|
+
print(f"\nEligible candidates: {len(candidates)}")
|
|
252
|
+
for p, sub, is_own, floor, last in candidates:
|
|
253
|
+
tag = "OWN" if is_own else "ext"
|
|
254
|
+
last_str = f"last={last:.2f}d" if last is not None else "last=never"
|
|
255
|
+
print(f" [{tag}] {p['name']:25} {sub:30} floor={floor}d {last_str}")
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
choice = pick(candidates, recent_project_counts=recent_project_counts)
|
|
259
|
+
if not choice:
|
|
260
|
+
print("NO_ELIGIBLE_TARGET", file=sys.stderr)
|
|
261
|
+
sys.exit(2)
|
|
262
|
+
|
|
263
|
+
proj, sub, is_own, floor, last = choice
|
|
264
|
+
if args.json:
|
|
265
|
+
print(json.dumps({
|
|
266
|
+
"project": proj,
|
|
267
|
+
"subreddit": sub,
|
|
268
|
+
"is_own_community": is_own,
|
|
269
|
+
"floor_days": floor,
|
|
270
|
+
"last_posted_days_ago": last,
|
|
271
|
+
"eligible_count": len(candidates),
|
|
272
|
+
"thread_blocked_count": len(thread_blocked),
|
|
273
|
+
}, indent=2))
|
|
274
|
+
else:
|
|
275
|
+
print(f"{proj['name']}\t{sub}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__":
|
|
279
|
+
main()
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pick the next (project, topic_angle) pair for an original Twitter thread.
|
|
3
|
+
|
|
4
|
+
Mirrors scripts/pick_thread_target.py (Reddit), adapted for Twitter:
|
|
5
|
+
|
|
6
|
+
Differences vs the Reddit picker:
|
|
7
|
+
- No subreddit dimension. The natural floor unit is (project, topic_angle).
|
|
8
|
+
- Hard global daily cap. Across all projects, never post more than
|
|
9
|
+
TWITTER_DAILY_CAP original threads in a UTC calendar day. Enforced via a
|
|
10
|
+
COUNT(*) of posts where platform='twitter' AND thread_url=our_url AND
|
|
11
|
+
posted_at::date = CURRENT_DATE. If hit, exit non-zero so the orchestrator
|
|
12
|
+
cleanly skips the launchd fire.
|
|
13
|
+
- Per-project per-angle floor window (twitter_threads.topic_floor_days,
|
|
14
|
+
default 2). Picks an angle that is either never-used or older than the
|
|
15
|
+
floor for the given project.
|
|
16
|
+
- Project weight + inverse recent-share weighting (same as Reddit picker)
|
|
17
|
+
so we don't pile every fire on one project.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
python3 scripts/pick_twitter_thread_target.py # PROJECT\tANGLE
|
|
21
|
+
python3 scripts/pick_twitter_thread_target.py --json # full context
|
|
22
|
+
python3 scripts/pick_twitter_thread_target.py --show-all # debug view
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import random
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
32
|
+
from http_api import api_get # noqa: E402
|
|
33
|
+
|
|
34
|
+
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
35
|
+
DEFAULT_TOPIC_FLOOR_DAYS = 2
|
|
36
|
+
TWITTER_DAILY_CAP = 3 # hard global cap. user requirement, do not raise without explicit ask.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config():
|
|
40
|
+
with open(CONFIG_PATH) as f:
|
|
41
|
+
return json.load(f)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fetch_picker_context(angle_window_days=14, counts_window_days=7):
|
|
45
|
+
"""Single call to /api/v1/twitter/picker-context for all three reads.
|
|
46
|
+
|
|
47
|
+
Replaces the previous trio of direct-DB SELECTs (daily_count_today,
|
|
48
|
+
recent_angles_by_project, recent_posts_by_project) with one HTTP roundtrip.
|
|
49
|
+
Returned by the route already trimmed to the same row shape we used to
|
|
50
|
+
derive in Python: { daily_count_today: int, recent_posts_by_project:
|
|
51
|
+
{name: int}, project_angles: {name: [{summary, days_ago}, ...]} }.
|
|
52
|
+
"""
|
|
53
|
+
resp = api_get(
|
|
54
|
+
"/api/v1/twitter/picker-context",
|
|
55
|
+
query={
|
|
56
|
+
"angle_window_days": angle_window_days,
|
|
57
|
+
"counts_window_days": counts_window_days,
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
return resp.get("data") or {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def recent_angles_by_project(project_angles_payload):
|
|
64
|
+
"""Reshape the route's project_angles dict into the same {project: {_rows: [...]}}
|
|
65
|
+
structure the old direct-DB helper produced, so angle_recency() works
|
|
66
|
+
unchanged.
|
|
67
|
+
"""
|
|
68
|
+
out = {}
|
|
69
|
+
for project_name, rows in (project_angles_payload or {}).items():
|
|
70
|
+
bucket = out.setdefault(project_name, {})
|
|
71
|
+
bucket.setdefault("_rows", []).extend(
|
|
72
|
+
(r.get("summary") or "", float(r.get("days_ago") or 0)) for r in rows
|
|
73
|
+
)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def angle_recency(project_recents, angle_text):
|
|
78
|
+
"""Given project_recents[project] (a dict with '_rows' list of
|
|
79
|
+
(summary, days_ago)), return the smallest days_ago for any row whose
|
|
80
|
+
summary contains the first 60 chars of angle_text. None if never used.
|
|
81
|
+
"""
|
|
82
|
+
rows = (project_recents or {}).get("_rows") or []
|
|
83
|
+
needle = (angle_text or "").strip()[:60].lower()
|
|
84
|
+
if not needle:
|
|
85
|
+
return None
|
|
86
|
+
best = None
|
|
87
|
+
for summary, days_ago in rows:
|
|
88
|
+
if needle in (summary or "").lower():
|
|
89
|
+
if best is None or days_ago < best:
|
|
90
|
+
best = days_ago
|
|
91
|
+
return best
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_candidates(config, project_recents):
|
|
95
|
+
candidates = [] # (project_dict, angle_text, floor_days, last_used_days_ago_or_None)
|
|
96
|
+
for p in config.get("projects", []):
|
|
97
|
+
tt = p.get("twitter_threads") or {}
|
|
98
|
+
if not tt.get("enabled"):
|
|
99
|
+
continue
|
|
100
|
+
floor = int(tt.get("topic_floor_days", DEFAULT_TOPIC_FLOOR_DAYS))
|
|
101
|
+
angles = tt.get("topic_angles") or []
|
|
102
|
+
if not angles:
|
|
103
|
+
continue
|
|
104
|
+
recents_for_proj = project_recents.get(p["name"], {})
|
|
105
|
+
for angle in angles:
|
|
106
|
+
last = angle_recency(recents_for_proj, angle)
|
|
107
|
+
if last is not None and last < floor:
|
|
108
|
+
continue # too recent
|
|
109
|
+
candidates.append((p, angle, floor, last))
|
|
110
|
+
return candidates, project_recents
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def pick(candidates, recent_project_counts=None):
|
|
114
|
+
if not candidates:
|
|
115
|
+
return None
|
|
116
|
+
recent_project_counts = recent_project_counts or {}
|
|
117
|
+
by_project = {}
|
|
118
|
+
for p, angle, floor, last in candidates:
|
|
119
|
+
by_project.setdefault(p["name"], {"project": p, "entries": []})
|
|
120
|
+
by_project[p["name"]]["entries"].append((angle, floor, last))
|
|
121
|
+
names = list(by_project.keys())
|
|
122
|
+
# Inverse recent-share: keep config weight as the prior, penalise projects
|
|
123
|
+
# that already posted a lot in the last 7d.
|
|
124
|
+
weights = [
|
|
125
|
+
by_project[n]["project"].get("weight", 1)
|
|
126
|
+
/ (1 + recent_project_counts.get(n, 0))
|
|
127
|
+
for n in names
|
|
128
|
+
]
|
|
129
|
+
chosen_name = random.choices(names, weights=weights, k=1)[0]
|
|
130
|
+
proj = by_project[chosen_name]["project"]
|
|
131
|
+
angle, floor, last = random.choice(by_project[chosen_name]["entries"])
|
|
132
|
+
return (proj, angle, floor, last)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main():
|
|
136
|
+
ap = argparse.ArgumentParser()
|
|
137
|
+
ap.add_argument("--json", action="store_true")
|
|
138
|
+
ap.add_argument("--show-all", action="store_true")
|
|
139
|
+
args = ap.parse_args()
|
|
140
|
+
|
|
141
|
+
config = load_config()
|
|
142
|
+
|
|
143
|
+
# One HTTP roundtrip for all picker context (daily count, recent angles
|
|
144
|
+
# per project, recent post counts per project). Was three separate
|
|
145
|
+
# psycopg2 SELECTs on `posts` before the 2026-05-18 routes migration.
|
|
146
|
+
ctx = _fetch_picker_context(angle_window_days=14, counts_window_days=7)
|
|
147
|
+
|
|
148
|
+
# Hard daily cap. Check FIRST so the picker exits cheap when the day is
|
|
149
|
+
# already saturated.
|
|
150
|
+
today_count = int(ctx.get("daily_count_today") or 0)
|
|
151
|
+
if today_count >= TWITTER_DAILY_CAP and not args.show_all:
|
|
152
|
+
print(f"DAILY_CAP_REACHED: {today_count}/{TWITTER_DAILY_CAP} posts today",
|
|
153
|
+
file=sys.stderr)
|
|
154
|
+
sys.exit(3)
|
|
155
|
+
|
|
156
|
+
project_recents = recent_angles_by_project(ctx.get("project_angles"))
|
|
157
|
+
candidates = build_candidates(config, project_recents)
|
|
158
|
+
recent_project_counts = ctx.get("recent_posts_by_project") or {}
|
|
159
|
+
|
|
160
|
+
if args.show_all:
|
|
161
|
+
print(f"Daily cap: {today_count}/{TWITTER_DAILY_CAP} posts today (UTC)")
|
|
162
|
+
eligible_projects = {}
|
|
163
|
+
for p, angle, floor, last in candidates:
|
|
164
|
+
eligible_projects.setdefault(p["name"], p)
|
|
165
|
+
print(f"\nProject weights (base / posts_7d / effective):")
|
|
166
|
+
rows = []
|
|
167
|
+
for name, p in eligible_projects.items():
|
|
168
|
+
base = p.get("weight", 1)
|
|
169
|
+
posts_7d = recent_project_counts.get(name, 0)
|
|
170
|
+
eff = base / (1 + posts_7d)
|
|
171
|
+
rows.append((name, base, posts_7d, eff))
|
|
172
|
+
for name, base, posts_7d, eff in sorted(rows, key=lambda r: -r[3]):
|
|
173
|
+
print(f" {name:25} base={base:>3} posts_7d={posts_7d:>2} effective={eff:.3f}")
|
|
174
|
+
print(f"\nEligible candidates: {len(candidates)}")
|
|
175
|
+
for p, angle, floor, last in candidates:
|
|
176
|
+
last_str = f"last={last:.2f}d" if last is not None else "last=never"
|
|
177
|
+
angle_short = (angle[:70] + "...") if len(angle) > 73 else angle
|
|
178
|
+
print(f" {p['name']:20} floor={floor}d {last_str:14} {angle_short}")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
choice = pick(candidates, recent_project_counts=recent_project_counts)
|
|
182
|
+
if not choice:
|
|
183
|
+
print("NO_ELIGIBLE_TARGET", file=sys.stderr)
|
|
184
|
+
sys.exit(2)
|
|
185
|
+
|
|
186
|
+
proj, angle, floor, last = choice
|
|
187
|
+
if args.json:
|
|
188
|
+
print(json.dumps({
|
|
189
|
+
"project": proj,
|
|
190
|
+
"topic_angle": angle,
|
|
191
|
+
"floor_days": floor,
|
|
192
|
+
"last_used_days_ago": last,
|
|
193
|
+
"eligible_count": len(candidates),
|
|
194
|
+
"daily_count_today": today_count,
|
|
195
|
+
"daily_cap": TWITTER_DAILY_CAP,
|
|
196
|
+
}, indent=2))
|
|
197
|
+
else:
|
|
198
|
+
print(f"{proj['name']}\t{angle}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
OUT=/Users/matthewdi/social-autoposter/scripts/podlog_threads_out.txt
|
|
3
|
+
> "$OUT"
|
|
4
|
+
URLS=(
|
|
5
|
+
"https://old.reddit.com/r/selfhosted/comments/1tddgg7/i_want_to_automatically_scrape_my_news_podcasts/"
|
|
6
|
+
"https://old.reddit.com/r/podcasting/comments/1sqx0hy/thoughts_on_the_rise_of_ai_generated_podcasts/"
|
|
7
|
+
"https://old.reddit.com/r/webdev/comments/1tcnil0/at_what_point_did_web_development_start_feeling/"
|
|
8
|
+
"https://old.reddit.com/r/selfhosted/comments/1teic8d/anyone_enjoying_using_ai_to_manage_your_homelab/"
|
|
9
|
+
"https://old.reddit.com/r/selfhosted/comments/1tcxb1b/services_with_actually_generous_free_tiers_for/"
|
|
10
|
+
"https://old.reddit.com/r/opensource/comments/1t7fx4d/i_contributed_to_open_source_for_the_first_time/"
|
|
11
|
+
"https://old.reddit.com/r/opensource/comments/1t5h3j6/how_do_i_start_contributing_to_open_source_devops/"
|
|
12
|
+
"https://old.reddit.com/r/opensource/comments/1tfm90j/condenseit_selfhosted_ai_news_digest_mit_licensed/"
|
|
13
|
+
"https://old.reddit.com/r/Entrepreneur/comments/1sthfgz/how_do_you_decide_between_code_and_marketing_in/"
|
|
14
|
+
)
|
|
15
|
+
for URL in "${URLS[@]}"; do
|
|
16
|
+
echo "===URL=== $URL" >> "$OUT"
|
|
17
|
+
TRIES=0
|
|
18
|
+
while [ $TRIES -lt 4 ]; do
|
|
19
|
+
RESP=$(python3 /Users/matthewdi/social-autoposter/scripts/reddit_tools.py fetch "$URL" 2>&1)
|
|
20
|
+
if echo "$RESP" | grep -q '"rate_limited"'; then
|
|
21
|
+
WAIT=$(echo "$RESP" | grep -o '"wait_seconds": *[0-9]*' | grep -o '[0-9]*')
|
|
22
|
+
[ -z "$WAIT" ] && WAIT=300
|
|
23
|
+
sleep $((WAIT + 15))
|
|
24
|
+
TRIES=$((TRIES + 1))
|
|
25
|
+
else
|
|
26
|
+
echo "$RESP" >> "$OUT"
|
|
27
|
+
break
|
|
28
|
+
fi
|
|
29
|
+
done
|
|
30
|
+
sleep 280
|
|
31
|
+
done
|
|
32
|
+
echo "===DONE===" >> "$OUT"
|