@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,377 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scan Reddit inbox for new replies, then engage up to N of them.
|
|
3
|
+
|
|
4
|
+
Replaces the legacy per-post anonymous scan that was rate-limited by
|
|
5
|
+
old.reddit.com. Reads /message/inbox/.json with the logged-in
|
|
6
|
+
reddit-agent profile cookies (refreshed by bootstrap_reddit_cookies.py),
|
|
7
|
+
inserts new rows into `replies`, and immediately fires engage_reddit.py
|
|
8
|
+
with --limit so the loop runs end-to-end every 5 min.
|
|
9
|
+
|
|
10
|
+
Inbox cannot tell us depth/parent_reply_id (it shows comment-replies and
|
|
11
|
+
post-replies identically). We insert depth=1 / parent_reply_id=NULL; the
|
|
12
|
+
engage step reads the live thread URL anyway.
|
|
13
|
+
|
|
14
|
+
Items older than BACKFILL_HOURS that aren't already in the DB are marked
|
|
15
|
+
status='skipped' / skip_reason='backfill_old' so they show in the
|
|
16
|
+
dashboard without being responded to.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
python3 scripts/scan_reddit_replies.py [--reddit-account NAME]
|
|
20
|
+
[--engage-limit N]
|
|
21
|
+
[--no-engage]
|
|
22
|
+
[--no-jitter]
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import random
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
|
|
36
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
37
|
+
from http_api import api_get
|
|
38
|
+
from reply_insert import insert_reply as _insert_reply
|
|
39
|
+
|
|
40
|
+
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
41
|
+
COOKIES_PATH = os.path.expanduser("~/.config/social-autoposter/reddit-cookies.json")
|
|
42
|
+
ENGAGE_SCRIPT = os.path.expanduser("~/social-autoposter/scripts/engage_reddit.py")
|
|
43
|
+
|
|
44
|
+
INBOX_URL = "https://old.reddit.com/message/inbox/.json"
|
|
45
|
+
PAGE_LIMIT = 100
|
|
46
|
+
MAX_PAGES = 10 # caps pagination at ~1000 items; inbox retention is shorter than that anyway
|
|
47
|
+
BACKFILL_HOURS = 48
|
|
48
|
+
JITTER_MAX_SECS = 60
|
|
49
|
+
PAGE_PAUSE_SECS = 1.5
|
|
50
|
+
OWN_COMMENTS_PAGES = 20 # hard cap on pagination depth (max 2000 items)
|
|
51
|
+
OWN_COMMENTS_LOOKBACK_DAYS = 30 # stop once we pass this many days back
|
|
52
|
+
|
|
53
|
+
THREAD_ID_RE = re.compile(r"/comments/([a-z0-9]+)/")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_config():
|
|
57
|
+
if os.path.exists(CONFIG_PATH):
|
|
58
|
+
with open(CONFIG_PATH) as f:
|
|
59
|
+
return json.load(f)
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_cookies():
|
|
64
|
+
if not os.path.exists(COOKIES_PATH):
|
|
65
|
+
return None
|
|
66
|
+
with open(COOKIES_PATH) as f:
|
|
67
|
+
cookies = json.load(f)
|
|
68
|
+
return "; ".join(f"{c['name']}={c['value']}" for c in cookies)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fetch_inbox(cookie_header, user_agent, after=None):
|
|
72
|
+
url = f"{INBOX_URL}?limit={PAGE_LIMIT}"
|
|
73
|
+
if after:
|
|
74
|
+
url += f"&after={after}"
|
|
75
|
+
req = urllib.request.Request(
|
|
76
|
+
url,
|
|
77
|
+
headers={
|
|
78
|
+
"User-Agent": user_agent,
|
|
79
|
+
"Cookie": cookie_header,
|
|
80
|
+
"Accept": "application/json",
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
84
|
+
ct = resp.headers.get("Content-Type", "")
|
|
85
|
+
if "application/json" not in ct:
|
|
86
|
+
raise SessionInvalidError(f"non-JSON response (likely login redirect): {ct}")
|
|
87
|
+
data = json.loads(resp.read())
|
|
88
|
+
if data.get("kind") != "Listing":
|
|
89
|
+
raise SessionInvalidError(f"unexpected kind: {data.get('kind')}")
|
|
90
|
+
return data["data"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SessionInvalidError(Exception):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fetch_own_replies(reddit_account, cookie_header, user_agent,
|
|
98
|
+
pages=OWN_COMMENTS_PAGES, lookback_days=OWN_COMMENTS_LOOKBACK_DAYS):
|
|
99
|
+
"""Build {parent_comment_id: {reply_id, reply_url, reply_content, replied_at}}
|
|
100
|
+
by paging /user/<account>/comments.json. Used to detect comments the account
|
|
101
|
+
already replied to outside the pipeline (e.g., manual browser replies).
|
|
102
|
+
Stops when a page's oldest comment is older than lookback_days, or after
|
|
103
|
+
`pages` pages, whichever comes first."""
|
|
104
|
+
out = {}
|
|
105
|
+
after = None
|
|
106
|
+
cutoff = time.time() - lookback_days * 86400
|
|
107
|
+
url_base = f"https://old.reddit.com/user/{reddit_account}/comments/.json?limit={PAGE_LIMIT}"
|
|
108
|
+
for page in range(pages):
|
|
109
|
+
url = url_base + (f"&after={after}" if after else "")
|
|
110
|
+
req = urllib.request.Request(url, headers={
|
|
111
|
+
"User-Agent": user_agent, "Cookie": cookie_header, "Accept": "application/json",
|
|
112
|
+
})
|
|
113
|
+
try:
|
|
114
|
+
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
115
|
+
if "application/json" not in resp.headers.get("Content-Type", ""):
|
|
116
|
+
return out # non-fatal; just skip the map
|
|
117
|
+
data = json.loads(resp.read()).get("data", {})
|
|
118
|
+
except Exception as e:
|
|
119
|
+
print(f" own-replies fetch failed on page {page+1}: {e}")
|
|
120
|
+
return out
|
|
121
|
+
children = data.get("children", []) or []
|
|
122
|
+
oldest_on_page = 0
|
|
123
|
+
for c in children:
|
|
124
|
+
d = c.get("data") or {}
|
|
125
|
+
created = float(d.get("created_utc") or 0)
|
|
126
|
+
if created and (oldest_on_page == 0 or created < oldest_on_page):
|
|
127
|
+
oldest_on_page = created
|
|
128
|
+
parent = (d.get("parent_id") or "")
|
|
129
|
+
if not parent.startswith("t1_"):
|
|
130
|
+
continue # only comment-parents; post-parents handled via inbox matching
|
|
131
|
+
parent_id = parent.removeprefix("t1_")
|
|
132
|
+
if parent_id in out:
|
|
133
|
+
continue
|
|
134
|
+
reply_id = d.get("id")
|
|
135
|
+
permalink = d.get("permalink")
|
|
136
|
+
out[parent_id] = {
|
|
137
|
+
"our_reply_id": reply_id,
|
|
138
|
+
"our_reply_url": f"https://old.reddit.com{permalink}" if permalink else None,
|
|
139
|
+
"our_reply_content": d.get("body") or "",
|
|
140
|
+
"replied_at": created or None,
|
|
141
|
+
}
|
|
142
|
+
after = data.get("after")
|
|
143
|
+
if not after:
|
|
144
|
+
break
|
|
145
|
+
if oldest_on_page and oldest_on_page < cutoff:
|
|
146
|
+
break # we've reached lookback horizon
|
|
147
|
+
time.sleep(PAGE_PAUSE_SECS)
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class InboxScanner:
|
|
152
|
+
def __init__(self, reddit_account, user_agent, cookie_header, excluded_authors=None,
|
|
153
|
+
own_replies_map=None):
|
|
154
|
+
# No DB handle anymore — every read/write hits the API. The `db` field
|
|
155
|
+
# is kept for back-compat with `_insert_reply(self.db, ...)` callers
|
|
156
|
+
# (they pass it through unchanged); the helper itself ignores the value.
|
|
157
|
+
self.db = None
|
|
158
|
+
self.reddit_account = reddit_account
|
|
159
|
+
self.reddit_account_lower = reddit_account.lower()
|
|
160
|
+
self.user_agent = user_agent
|
|
161
|
+
self.cookie_header = cookie_header
|
|
162
|
+
self.excluded = {a.lower() for a in (excluded_authors or set())}
|
|
163
|
+
self.excluded.update({"automoderator", "[deleted]", self.reddit_account_lower})
|
|
164
|
+
self.own_replies_map = own_replies_map or {}
|
|
165
|
+
# Cache thread_id -> post_id lookups across a single scan so we don't
|
|
166
|
+
# hit /api/v1/posts once per inbox entry (the same thread often
|
|
167
|
+
# appears multiple times in a single page).
|
|
168
|
+
self._post_id_cache = {}
|
|
169
|
+
self.discovered = 0
|
|
170
|
+
self.skipped_old = 0
|
|
171
|
+
self.skipped_other = 0
|
|
172
|
+
self.already_replied = 0
|
|
173
|
+
self.unmatched = 0
|
|
174
|
+
self.total_seen = 0
|
|
175
|
+
|
|
176
|
+
def _post_id_for_context(self, context):
|
|
177
|
+
m = THREAD_ID_RE.search(context or "")
|
|
178
|
+
if not m:
|
|
179
|
+
return None
|
|
180
|
+
thread_id = m.group(1)
|
|
181
|
+
if thread_id in self._post_id_cache:
|
|
182
|
+
return self._post_id_cache[thread_id]
|
|
183
|
+
# /api/v1/posts GET supports a platform filter but not LIKE on
|
|
184
|
+
# thread_url. We fetch a window of recent reddit posts and match
|
|
185
|
+
# locally on the thread_id substring, falling back to the lookup
|
|
186
|
+
# endpoint with the same thread_id prefix.
|
|
187
|
+
post_id = None
|
|
188
|
+
try:
|
|
189
|
+
resp = api_get(
|
|
190
|
+
"/api/v1/posts",
|
|
191
|
+
query={"platform": "reddit", "limit": 500},
|
|
192
|
+
)
|
|
193
|
+
posts = ((resp or {}).get("data") or {}).get("posts") or []
|
|
194
|
+
for p in posts:
|
|
195
|
+
tu = (p.get("thread_url") or "").lower()
|
|
196
|
+
if f"/comments/{thread_id}/" in tu:
|
|
197
|
+
post_id = int(p.get("id"))
|
|
198
|
+
break
|
|
199
|
+
except Exception:
|
|
200
|
+
post_id = None
|
|
201
|
+
self._post_id_cache[thread_id] = post_id
|
|
202
|
+
return post_id
|
|
203
|
+
|
|
204
|
+
def _insert(self, post_id, comment_id, author, content, comment_url, status, skip_reason=None):
|
|
205
|
+
override = self.own_replies_map.get(comment_id)
|
|
206
|
+
if override:
|
|
207
|
+
from datetime import datetime, timezone
|
|
208
|
+
ts = override.get("replied_at")
|
|
209
|
+
replied_at = datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None
|
|
210
|
+
result = _insert_reply(
|
|
211
|
+
self.db, post_id, "reddit", comment_id, author, content, comment_url,
|
|
212
|
+
parent_reply_id=None, depth=1, status="replied", skip_reason=None,
|
|
213
|
+
our_reply_id=override.get("our_reply_id"),
|
|
214
|
+
our_reply_content=override.get("our_reply_content"),
|
|
215
|
+
our_reply_url=override.get("our_reply_url"),
|
|
216
|
+
replied_at=replied_at,
|
|
217
|
+
)
|
|
218
|
+
if result == "replied":
|
|
219
|
+
self.already_replied += 1
|
|
220
|
+
return
|
|
221
|
+
result = _insert_reply(
|
|
222
|
+
self.db, post_id, "reddit", comment_id, author, content, comment_url,
|
|
223
|
+
parent_reply_id=None, depth=1, status=status, skip_reason=skip_reason,
|
|
224
|
+
)
|
|
225
|
+
if result == "pending":
|
|
226
|
+
self.discovered += 1
|
|
227
|
+
elif result == "skipped":
|
|
228
|
+
self.skipped_old += 1
|
|
229
|
+
|
|
230
|
+
def scan(self):
|
|
231
|
+
print(f"Scanning inbox for u/{self.reddit_account}...")
|
|
232
|
+
backfill_cutoff = time.time() - BACKFILL_HOURS * 3600
|
|
233
|
+
after = None
|
|
234
|
+
consecutive_known = 0
|
|
235
|
+
for page in range(1, MAX_PAGES + 1):
|
|
236
|
+
data = fetch_inbox(self.cookie_header, self.user_agent, after=after)
|
|
237
|
+
children = data.get("children", [])
|
|
238
|
+
print(f" page {page}: {len(children)} items (after={after or 'start'})")
|
|
239
|
+
if not children:
|
|
240
|
+
break
|
|
241
|
+
for c in children:
|
|
242
|
+
self.total_seen += 1
|
|
243
|
+
d = c.get("data", {})
|
|
244
|
+
comment_id = (d.get("name") or "").removeprefix("t1_").removeprefix("t4_")
|
|
245
|
+
if not comment_id:
|
|
246
|
+
continue
|
|
247
|
+
author = d.get("author") or "[deleted]"
|
|
248
|
+
if author.lower() in self.excluded:
|
|
249
|
+
self.skipped_other += 1
|
|
250
|
+
continue
|
|
251
|
+
context = d.get("context") or ""
|
|
252
|
+
post_id = self._post_id_for_context(context)
|
|
253
|
+
if not post_id:
|
|
254
|
+
self.unmatched += 1
|
|
255
|
+
continue
|
|
256
|
+
comment_url = "https://old.reddit.com" + context.split("?")[0]
|
|
257
|
+
content = d.get("body") or ""
|
|
258
|
+
created = float(d.get("created_utc") or 0)
|
|
259
|
+
if created and created < backfill_cutoff:
|
|
260
|
+
pre = self.discovered + self.skipped_old
|
|
261
|
+
self._insert(post_id, comment_id, author, content, comment_url,
|
|
262
|
+
status="skipped", skip_reason="backfill_old")
|
|
263
|
+
if (self.discovered + self.skipped_old) == pre:
|
|
264
|
+
consecutive_known += 1
|
|
265
|
+
else:
|
|
266
|
+
consecutive_known = 0
|
|
267
|
+
else:
|
|
268
|
+
pre = self.discovered
|
|
269
|
+
self._insert(post_id, comment_id, author, content, comment_url,
|
|
270
|
+
status="pending")
|
|
271
|
+
if self.discovered == pre:
|
|
272
|
+
consecutive_known += 1
|
|
273
|
+
else:
|
|
274
|
+
consecutive_known = 0
|
|
275
|
+
# Always finish processing the current page before deciding whether
|
|
276
|
+
# to fetch the next one. Bailing mid-page (the previous behavior)
|
|
277
|
+
# could miss out-of-order items on the same page; the cost of
|
|
278
|
+
# finishing the page is essentially zero (idempotent INSERTs only).
|
|
279
|
+
# The 50-consecutive-known threshold now gates pagination only.
|
|
280
|
+
if consecutive_known >= 50:
|
|
281
|
+
print(f" hit {consecutive_known} consecutive already-known items on page {page}, stopping pagination")
|
|
282
|
+
return
|
|
283
|
+
after = data.get("after")
|
|
284
|
+
if not after:
|
|
285
|
+
break
|
|
286
|
+
if page < MAX_PAGES:
|
|
287
|
+
time.sleep(PAGE_PAUSE_SECS)
|
|
288
|
+
|
|
289
|
+
def finish(self):
|
|
290
|
+
# All writes go through the HTTP API; nothing to commit/close locally.
|
|
291
|
+
print(
|
|
292
|
+
f"Inbox scan complete: seen={self.total_seen} "
|
|
293
|
+
f"new_pending={self.discovered} backfill_skipped={self.skipped_old} "
|
|
294
|
+
f"already_replied={self.already_replied} "
|
|
295
|
+
f"excluded_author={self.skipped_other} unmatched_thread={self.unmatched}"
|
|
296
|
+
)
|
|
297
|
+
return {
|
|
298
|
+
"discovered": self.discovered,
|
|
299
|
+
"backfill_skipped": self.skipped_old,
|
|
300
|
+
"already_replied": self.already_replied,
|
|
301
|
+
"excluded": self.skipped_other,
|
|
302
|
+
"unmatched": self.unmatched,
|
|
303
|
+
"total_seen": self.total_seen,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def run_engage(limit, timeout):
|
|
308
|
+
print(f"\nFiring engage_reddit.py --platform reddit --limit {limit}...")
|
|
309
|
+
proc = subprocess.run(
|
|
310
|
+
["python3", ENGAGE_SCRIPT, "--platform", "reddit", "--limit", str(limit), "--timeout", str(timeout)],
|
|
311
|
+
cwd=os.path.dirname(ENGAGE_SCRIPT),
|
|
312
|
+
)
|
|
313
|
+
print(f"engage_reddit exit code: {proc.returncode}")
|
|
314
|
+
return proc.returncode
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def main():
|
|
318
|
+
parser = argparse.ArgumentParser(description="Scan Reddit inbox for new replies, then engage")
|
|
319
|
+
parser.add_argument("--reddit-account", default=None)
|
|
320
|
+
parser.add_argument("--engage-limit", type=int, default=5,
|
|
321
|
+
help="Max replies to post per run (default: 5; 0 = skip engage)")
|
|
322
|
+
parser.add_argument("--engage-timeout", type=int, default=600,
|
|
323
|
+
help="Total seconds for the engage subprocess (default: 600)")
|
|
324
|
+
parser.add_argument("--no-engage", action="store_true",
|
|
325
|
+
help="Discovery only, don't fire engage_reddit.py")
|
|
326
|
+
parser.add_argument("--no-jitter", action="store_true",
|
|
327
|
+
help="Skip the random startup jitter (use for manual runs)")
|
|
328
|
+
args = parser.parse_args()
|
|
329
|
+
|
|
330
|
+
config = load_config()
|
|
331
|
+
reddit_account = args.reddit_account or config.get("accounts", {}).get("reddit", {}).get("username", "")
|
|
332
|
+
if not reddit_account:
|
|
333
|
+
print("ERROR: Reddit account not configured. Set it in config.json or pass --reddit-account")
|
|
334
|
+
sys.exit(1)
|
|
335
|
+
|
|
336
|
+
if not args.no_jitter:
|
|
337
|
+
jitter = random.uniform(0, JITTER_MAX_SECS)
|
|
338
|
+
print(f"Jitter: sleeping {jitter:.1f}s before scan")
|
|
339
|
+
time.sleep(jitter)
|
|
340
|
+
|
|
341
|
+
cookie_header = load_cookies()
|
|
342
|
+
if not cookie_header:
|
|
343
|
+
print(f"SESSION_INVALID: no cookie file at {COOKIES_PATH}. Run bootstrap_reddit_cookies.py.")
|
|
344
|
+
sys.exit(0)
|
|
345
|
+
|
|
346
|
+
user_agent = f"social-autoposter/1.0 (u/{reddit_account} inbox-scan)"
|
|
347
|
+
excluded_authors = {a for a in config.get("exclusions", {}).get("authors", [])}
|
|
348
|
+
own_replies_map = fetch_own_replies(reddit_account, cookie_header, user_agent)
|
|
349
|
+
print(f"Own-replies map: {len(own_replies_map)} parent comment_ids we've already replied to")
|
|
350
|
+
scanner = InboxScanner(reddit_account, user_agent, cookie_header,
|
|
351
|
+
excluded_authors=excluded_authors,
|
|
352
|
+
own_replies_map=own_replies_map)
|
|
353
|
+
try:
|
|
354
|
+
scanner.scan()
|
|
355
|
+
except SessionInvalidError as e:
|
|
356
|
+
print(f"SESSION_INVALID: {e}")
|
|
357
|
+
scanner.finish()
|
|
358
|
+
sys.exit(0)
|
|
359
|
+
except urllib.error.HTTPError as e:
|
|
360
|
+
if e.code in (401, 403):
|
|
361
|
+
print(f"SESSION_INVALID: HTTP {e.code} on inbox endpoint. Refresh cookies via bootstrap_reddit_cookies.py.")
|
|
362
|
+
scanner.finish()
|
|
363
|
+
sys.exit(0)
|
|
364
|
+
print(f"ERROR: HTTP {e.code} {e.reason}")
|
|
365
|
+
scanner.finish()
|
|
366
|
+
sys.exit(1)
|
|
367
|
+
result = scanner.finish()
|
|
368
|
+
|
|
369
|
+
if args.no_engage or args.engage_limit <= 0:
|
|
370
|
+
print("Skipping engage step (per flags)")
|
|
371
|
+
sys.exit(0)
|
|
372
|
+
|
|
373
|
+
run_engage(args.engage_limit, args.engage_timeout)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
if __name__ == "__main__":
|
|
377
|
+
main()
|