@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,530 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Find candidate threads to comment on via Reddit JSON API + Moltbook API.
|
|
3
|
+
|
|
4
|
+
Also generates Twitter/LinkedIn search URLs for browser-based discovery.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
python3 scripts/find_threads.py [--subreddits r/ClaudeAI,r/programming]
|
|
8
|
+
python3 scripts/find_threads.py --topic "macOS automation"
|
|
9
|
+
python3 scripts/find_threads.py --include-twitter --include-linkedin
|
|
10
|
+
python3 scripts/find_threads.py --include-moltbook --include-twitter --include-linkedin
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import urllib.request
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
from http_api import api_get
|
|
24
|
+
from moltbook_tools import fetch_moltbook_json, MoltbookRateLimitedError
|
|
25
|
+
from project_topics import topics_for_project
|
|
26
|
+
try:
|
|
27
|
+
from account_resolver import resolve as _resolve_account
|
|
28
|
+
except Exception:
|
|
29
|
+
def _resolve_account(_platform): # type: ignore[unused-arg]
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_config():
|
|
36
|
+
if os.path.exists(CONFIG_PATH):
|
|
37
|
+
with open(CONFIG_PATH) as f:
|
|
38
|
+
return json.load(f)
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def fetch_json(url, headers=None, user_agent="social-autoposter/1.0"):
|
|
43
|
+
hdrs = {"User-Agent": user_agent}
|
|
44
|
+
if headers:
|
|
45
|
+
hdrs.update(headers)
|
|
46
|
+
req = urllib.request.Request(url, headers=hdrs)
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
49
|
+
return json.loads(resp.read())
|
|
50
|
+
except Exception as e:
|
|
51
|
+
print(f" ERROR fetching {url}: {e}", file=sys.stderr)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_already_posted():
|
|
56
|
+
"""Return set of thread URLs we've already posted in.
|
|
57
|
+
|
|
58
|
+
Scoped per Reddit account when one is configured (this helper feeds the
|
|
59
|
+
Reddit branch of find_threads.py). Falls back to all-platform unscoped
|
|
60
|
+
on the legacy path so existing callers that haven't wired an account
|
|
61
|
+
keep the old behavior. Other platforms have their own scoped readers
|
|
62
|
+
(score_twitter_candidates.py, github_tools.py, score_linkedin_candidates.py).
|
|
63
|
+
"""
|
|
64
|
+
acct = _resolve_account("reddit")
|
|
65
|
+
if acct:
|
|
66
|
+
resp = api_get(
|
|
67
|
+
"/api/v1/posts/thread-urls",
|
|
68
|
+
query={"platform": "reddit", "our_account": acct},
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
resp = api_get("/api/v1/posts/thread-urls", query={"all_platforms": 1})
|
|
72
|
+
urls = (resp.get("data") or {}).get("thread_urls") or []
|
|
73
|
+
return {u for u in urls if u}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_engaged_linkedin_authors():
|
|
77
|
+
"""Return set of LinkedIn authors we've already commented on.
|
|
78
|
+
|
|
79
|
+
LinkedIn batch commenting uses search result pages (not unique post URLs),
|
|
80
|
+
so URL-based dedup doesn't work. This provides author-level dedup instead.
|
|
81
|
+
"""
|
|
82
|
+
resp = api_get("/api/v1/linkedin-engaged", query={"list_authors": 1})
|
|
83
|
+
authors = (resp.get("data") or {}).get("authors") or []
|
|
84
|
+
return {a for a in authors if a}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_engaged_linkedin_post_ids():
|
|
88
|
+
"""Return sorted list of every LinkedIn URN ID we've engaged with —
|
|
89
|
+
16-19 digit numbers found in thread_url or our_url for platform='linkedin'.
|
|
90
|
+
LinkedIn surfaces the same post under /feed/update/urn:li:activity:<X>/,
|
|
91
|
+
/posts/...-share-<Y>-<suffix>, and /posts/...-ugcPost-<Z>-<suffix>;
|
|
92
|
+
the X/Y/Z are different numbers but the SET of IDs across all rows
|
|
93
|
+
that touch the same post overlaps. Used by run-linkedin.sh to brief
|
|
94
|
+
the LLM so it skips a candidate whose URL contains any engaged ID."""
|
|
95
|
+
import linkedin_url as li_url
|
|
96
|
+
return li_url.get_engaged_ids()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_recent_posts(limit=5):
|
|
100
|
+
"""Return our last N post contents for repetition checking."""
|
|
101
|
+
resp = api_get(
|
|
102
|
+
"/api/v1/posts",
|
|
103
|
+
query={"order_by": "id", "order_dir": "desc", "limit": int(limit)},
|
|
104
|
+
)
|
|
105
|
+
posts = (resp.get("data") or {}).get("posts") or []
|
|
106
|
+
return [p.get("our_content") for p in posts]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_rate_limit(max_per_day=4000):
|
|
110
|
+
"""Return (posts_today, can_post). Default limit: 4000/day.
|
|
111
|
+
|
|
112
|
+
The count endpoint filters by platform equality (no negation), so we
|
|
113
|
+
take the all-platform 24h count and subtract the github_issues 24h
|
|
114
|
+
count to reproduce the original `platform != 'github_issues'` gate.
|
|
115
|
+
"""
|
|
116
|
+
total_resp = api_get("/api/v1/posts/count", query={"within_seconds": 86400})
|
|
117
|
+
gh_resp = api_get(
|
|
118
|
+
"/api/v1/posts/count",
|
|
119
|
+
query={"within_seconds": 86400, "platform": "github_issues"},
|
|
120
|
+
)
|
|
121
|
+
total = int((total_resp.get("data") or {}).get("count") or 0)
|
|
122
|
+
gh = int((gh_resp.get("data") or {}).get("count") or 0)
|
|
123
|
+
count = max(0, total - gh)
|
|
124
|
+
can_post = count < max_per_day if max_per_day else True
|
|
125
|
+
return count, can_post
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fetch_reddit_threads(subreddits, sort="new", limit=10, user_agent="social-autoposter/1.0"):
|
|
129
|
+
"""Fetch threads from subreddits via Reddit JSON API.
|
|
130
|
+
|
|
131
|
+
Uses multi-subreddit requests (r/sub1+sub2+sub3) to reduce API calls.
|
|
132
|
+
Randomizes subreddit order so different subs get coverage across runs.
|
|
133
|
+
Backs off on 429 rate limits instead of silently skipping.
|
|
134
|
+
"""
|
|
135
|
+
import random
|
|
136
|
+
|
|
137
|
+
clean_subs = [s.lstrip("r/") for s in subreddits]
|
|
138
|
+
random.shuffle(clean_subs)
|
|
139
|
+
|
|
140
|
+
# Batch into groups of 5 (Reddit supports multi-sub via r/a+b+c)
|
|
141
|
+
batches = []
|
|
142
|
+
for i in range(0, len(clean_subs), 5):
|
|
143
|
+
batches.append(clean_subs[i:i + 5])
|
|
144
|
+
|
|
145
|
+
# Cap at 10 batches (50 subs) per run to stay within rate limits
|
|
146
|
+
batches = batches[:10]
|
|
147
|
+
|
|
148
|
+
threads = []
|
|
149
|
+
consecutive_429s = 0
|
|
150
|
+
delay = 4
|
|
151
|
+
|
|
152
|
+
for batch in batches:
|
|
153
|
+
multi_sub = "+".join(batch)
|
|
154
|
+
url = f"https://old.reddit.com/r/{multi_sub}/{sort}.json?limit={limit}"
|
|
155
|
+
data = fetch_json(url, user_agent=user_agent)
|
|
156
|
+
|
|
157
|
+
if data is None:
|
|
158
|
+
consecutive_429s += 1
|
|
159
|
+
if consecutive_429s >= 3:
|
|
160
|
+
print(f" Rate limited after {consecutive_429s} failures, stopping with "
|
|
161
|
+
f"{len(threads)} threads", file=sys.stderr)
|
|
162
|
+
break
|
|
163
|
+
delay = min(delay * 2, 30)
|
|
164
|
+
time.sleep(delay)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
consecutive_429s = 0
|
|
168
|
+
|
|
169
|
+
for child in data.get("data", {}).get("children", []):
|
|
170
|
+
post = child.get("data", {})
|
|
171
|
+
created = post.get("created_utc", 0)
|
|
172
|
+
age_hours = (datetime.now(timezone.utc).timestamp() - created) / 3600 if created else 999
|
|
173
|
+
subreddit = post.get("subreddit", "")
|
|
174
|
+
|
|
175
|
+
threads.append({
|
|
176
|
+
"platform": "reddit",
|
|
177
|
+
"subreddit": f"r/{subreddit}",
|
|
178
|
+
"url": f"https://old.reddit.com{post.get('permalink', '')}",
|
|
179
|
+
"title": post.get("title", ""),
|
|
180
|
+
"author": post.get("author", ""),
|
|
181
|
+
"score": post.get("score", 0),
|
|
182
|
+
"num_comments": post.get("num_comments", 0),
|
|
183
|
+
"age_hours": round(age_hours, 1),
|
|
184
|
+
"selftext": post.get("selftext", ""),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
time.sleep(delay)
|
|
188
|
+
|
|
189
|
+
return threads
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def fetch_moltbook_threads(api_key, limit=50):
|
|
193
|
+
"""Fetch threads from Moltbook REST API.
|
|
194
|
+
|
|
195
|
+
Fetches multiple pages and filters out spam (mint/token posts).
|
|
196
|
+
"""
|
|
197
|
+
if not api_key:
|
|
198
|
+
return []
|
|
199
|
+
|
|
200
|
+
threads = []
|
|
201
|
+
spam_patterns = ['mbc-20', 'mbc20', '"op":"mint"', '"tick"', 'pump.fun']
|
|
202
|
+
spam_title_patterns = ['mint', 'mbc20', 'token launch', 'inscription', 'redx',
|
|
203
|
+
'wang ', 'bot claim', 'hackai']
|
|
204
|
+
|
|
205
|
+
for offset in [0, 50]:
|
|
206
|
+
try:
|
|
207
|
+
data = fetch_moltbook_json(
|
|
208
|
+
f"https://www.moltbook.com/api/v1/posts?sort=new&limit={limit}&offset={offset}",
|
|
209
|
+
api_key=api_key,
|
|
210
|
+
)
|
|
211
|
+
except MoltbookRateLimitedError as e:
|
|
212
|
+
print(f" Moltbook rate-limited for {int(e.reset_seconds)}s, skipping thread discovery",
|
|
213
|
+
file=sys.stderr)
|
|
214
|
+
break
|
|
215
|
+
if not data or "posts" not in data:
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
for post in data["posts"]:
|
|
219
|
+
content = post.get("content", "")
|
|
220
|
+
title = post.get("title", "")
|
|
221
|
+
|
|
222
|
+
# Filter spam
|
|
223
|
+
if any(p in content.lower() for p in spam_patterns):
|
|
224
|
+
continue
|
|
225
|
+
if any(p in title.lower() for p in spam_title_patterns):
|
|
226
|
+
continue
|
|
227
|
+
if len(content) < 40:
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
threads.append({
|
|
231
|
+
"platform": "moltbook",
|
|
232
|
+
"url": f"https://www.moltbook.com/post/{post.get('uuid', post.get('id', ''))}",
|
|
233
|
+
"title": title,
|
|
234
|
+
"author": post.get("author", {}).get("name", ""),
|
|
235
|
+
"score": post.get("upvotes", 0),
|
|
236
|
+
"num_comments": post.get("comment_count", 0),
|
|
237
|
+
"content": content,
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
return threads
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def generate_twitter_search_urls(topics, exclusions=None):
|
|
244
|
+
"""Generate X/Twitter search URLs for browser-based discovery.
|
|
245
|
+
|
|
246
|
+
Twitter has no free public search API, so we generate search URLs
|
|
247
|
+
that the agent browses via Playwright to find threads.
|
|
248
|
+
"""
|
|
249
|
+
import urllib.parse
|
|
250
|
+
|
|
251
|
+
excluded_accounts = set()
|
|
252
|
+
if exclusions:
|
|
253
|
+
excluded_accounts = {a.lower() for a in exclusions.get("twitter_accounts", [])}
|
|
254
|
+
|
|
255
|
+
threads = []
|
|
256
|
+
for topic in topics:
|
|
257
|
+
# Build exclusion string for the query
|
|
258
|
+
exclude_str = " ".join(f"-from:{acct}" for acct in excluded_accounts)
|
|
259
|
+
query = f"{topic} {exclude_str}".strip()
|
|
260
|
+
# min_faves:5 filters to tweets with some engagement
|
|
261
|
+
search_url = f"https://x.com/search?q={urllib.parse.quote(query + ' min_faves:5')}&f=live"
|
|
262
|
+
|
|
263
|
+
threads.append({
|
|
264
|
+
"platform": "twitter",
|
|
265
|
+
"url": search_url,
|
|
266
|
+
"title": f"Search: {topic}",
|
|
267
|
+
"author": "",
|
|
268
|
+
"score": 0,
|
|
269
|
+
"num_comments": 0,
|
|
270
|
+
"discovery_method": "search_url",
|
|
271
|
+
"search_topic": topic,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
return threads
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def generate_linkedin_search_urls(topics, exclusions=None):
|
|
278
|
+
"""Generate LinkedIn search URLs for browser-based discovery.
|
|
279
|
+
|
|
280
|
+
LinkedIn has no public search API, so we generate content search URLs
|
|
281
|
+
that the agent browses via Playwright to find posts.
|
|
282
|
+
"""
|
|
283
|
+
import urllib.parse
|
|
284
|
+
|
|
285
|
+
threads = []
|
|
286
|
+
for topic in topics:
|
|
287
|
+
search_url = f"https://www.linkedin.com/search/results/content/?keywords={urllib.parse.quote(topic)}&sortBy=%22date_posted%22"
|
|
288
|
+
|
|
289
|
+
threads.append({
|
|
290
|
+
"platform": "linkedin",
|
|
291
|
+
"url": search_url,
|
|
292
|
+
"title": f"Search: {topic}",
|
|
293
|
+
"author": "",
|
|
294
|
+
"score": 0,
|
|
295
|
+
"num_comments": 0,
|
|
296
|
+
"discovery_method": "search_url",
|
|
297
|
+
"search_topic": topic,
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
return threads
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def fetch_github_issues(search_topics, exclusions=None, limit=10):
|
|
304
|
+
"""Search GitHub issues using gh CLI and return candidate threads.
|
|
305
|
+
|
|
306
|
+
Rotates through search_topics, picking a random subset each run.
|
|
307
|
+
"""
|
|
308
|
+
import random
|
|
309
|
+
import subprocess
|
|
310
|
+
|
|
311
|
+
excluded_repos = set()
|
|
312
|
+
excluded_authors = set()
|
|
313
|
+
if exclusions:
|
|
314
|
+
excluded_repos = {r.lower() for r in exclusions.get("github_repos", [])}
|
|
315
|
+
excluded_authors = {a.lower() for a in exclusions.get("authors", [])}
|
|
316
|
+
|
|
317
|
+
# Pick 5 random topics to rotate
|
|
318
|
+
topics = random.sample(search_topics, min(5, len(search_topics)))
|
|
319
|
+
threads = []
|
|
320
|
+
|
|
321
|
+
for topic in topics:
|
|
322
|
+
try:
|
|
323
|
+
result = subprocess.run(
|
|
324
|
+
["gh", "search", "issues", topic, "--limit", "10",
|
|
325
|
+
"--state", "open", "--sort", "updated",
|
|
326
|
+
"--json", "url,title,author,repository"],
|
|
327
|
+
capture_output=True, text=True, timeout=15
|
|
328
|
+
)
|
|
329
|
+
if result.returncode != 0:
|
|
330
|
+
continue
|
|
331
|
+
issues = json.loads(result.stdout) if result.stdout.strip() else []
|
|
332
|
+
except Exception as e:
|
|
333
|
+
print(f" ERROR searching GitHub for '{topic}': {e}", file=sys.stderr)
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
for issue in issues:
|
|
337
|
+
repo_name = issue.get("repository", {}).get("nameWithOwner", "")
|
|
338
|
+
author = issue.get("author", {}).get("login", "")
|
|
339
|
+
|
|
340
|
+
# Apply exclusions
|
|
341
|
+
if any(excl in repo_name.lower() for excl in excluded_repos):
|
|
342
|
+
continue
|
|
343
|
+
if author.lower() in excluded_authors:
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
threads.append({
|
|
347
|
+
"platform": "github_issues",
|
|
348
|
+
"url": issue.get("url", ""),
|
|
349
|
+
"title": issue.get("title", ""),
|
|
350
|
+
"author": author,
|
|
351
|
+
"score": 0,
|
|
352
|
+
"num_comments": 0,
|
|
353
|
+
"search_topic": topic,
|
|
354
|
+
"repository": repo_name,
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
if len(threads) >= limit:
|
|
358
|
+
break
|
|
359
|
+
|
|
360
|
+
return threads[:limit]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def load_exclusions(config):
|
|
364
|
+
"""Load exclusion lists from config."""
|
|
365
|
+
excl = config.get("exclusions", {})
|
|
366
|
+
return {
|
|
367
|
+
"authors": {a.lower() for a in excl.get("authors", [])},
|
|
368
|
+
"subreddits": {s.lower().lstrip("r/") for s in excl.get("subreddits", [])},
|
|
369
|
+
"urls": excl.get("urls", []),
|
|
370
|
+
"keywords": [k.lower() for k in excl.get("keywords", [])],
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def is_excluded(thread, exclusions):
|
|
375
|
+
"""Check if a thread matches any exclusion rule."""
|
|
376
|
+
# Author exclusion
|
|
377
|
+
author = thread.get("author", "").lower()
|
|
378
|
+
if author and author in exclusions["authors"]:
|
|
379
|
+
return "excluded_author"
|
|
380
|
+
|
|
381
|
+
# Subreddit exclusion
|
|
382
|
+
sub = thread.get("subreddit", "").lower().lstrip("r/")
|
|
383
|
+
if sub and sub in exclusions["subreddits"]:
|
|
384
|
+
return "excluded_subreddit"
|
|
385
|
+
|
|
386
|
+
# URL pattern exclusion
|
|
387
|
+
url = thread.get("url", "")
|
|
388
|
+
for pattern in exclusions["urls"]:
|
|
389
|
+
if pattern in url:
|
|
390
|
+
return "excluded_url"
|
|
391
|
+
|
|
392
|
+
# Keyword exclusion (skip threads containing these keywords)
|
|
393
|
+
if exclusions["keywords"]:
|
|
394
|
+
text = f"{thread.get('title', '')} {thread.get('selftext', '')} {thread.get('content', '')}".lower()
|
|
395
|
+
for kw in exclusions["keywords"]:
|
|
396
|
+
if kw in text:
|
|
397
|
+
return "excluded_keyword"
|
|
398
|
+
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def filter_threads(threads, already_posted, topic=None, exclusions=None):
|
|
403
|
+
"""Filter out already-posted threads and optionally filter by topic."""
|
|
404
|
+
if exclusions is None:
|
|
405
|
+
exclusions = {"authors": set(), "subreddits": set(), "urls": [], "keywords": []}
|
|
406
|
+
filtered = []
|
|
407
|
+
for t in threads:
|
|
408
|
+
if t["url"] in already_posted:
|
|
409
|
+
t["skip_reason"] = "already_posted"
|
|
410
|
+
continue
|
|
411
|
+
excl_reason = is_excluded(t, exclusions)
|
|
412
|
+
if excl_reason:
|
|
413
|
+
t["skip_reason"] = excl_reason
|
|
414
|
+
continue
|
|
415
|
+
if topic and t.get("discovery_method") != "search_url":
|
|
416
|
+
text = f"{t.get('title', '')} {t.get('selftext', '')} {t.get('content', '')}".lower()
|
|
417
|
+
if topic.lower() not in text:
|
|
418
|
+
continue
|
|
419
|
+
filtered.append(t)
|
|
420
|
+
return filtered
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def main():
|
|
424
|
+
parser = argparse.ArgumentParser(description="Find candidate threads to comment on")
|
|
425
|
+
parser.add_argument("--subreddits", default=None, help="Comma-separated subreddits (e.g. ClaudeAI,programming)")
|
|
426
|
+
parser.add_argument("--topic", default=None, help="Filter threads by topic keyword")
|
|
427
|
+
parser.add_argument("--sort", default="new", choices=["new", "hot", "top"], help="Reddit sort order")
|
|
428
|
+
parser.add_argument("--limit", type=int, default=10, help="Threads per subreddit")
|
|
429
|
+
parser.add_argument("--include-moltbook", action="store_true", help="Also search Moltbook")
|
|
430
|
+
parser.add_argument("--include-twitter", action="store_true", help="Generate X/Twitter search URLs")
|
|
431
|
+
parser.add_argument("--include-linkedin", action="store_true", help="Generate LinkedIn search URLs")
|
|
432
|
+
parser.add_argument("--include-github", action="store_true", help="Search GitHub issues via gh CLI")
|
|
433
|
+
parser.add_argument("--project", default=None, help="Use topics/subreddits from a specific project in config.json")
|
|
434
|
+
parser.add_argument("--force", action="store_true", help="Skip rate limit check")
|
|
435
|
+
args = parser.parse_args()
|
|
436
|
+
|
|
437
|
+
config = load_config()
|
|
438
|
+
|
|
439
|
+
# If --project is specified, use that project's config for topics/subreddits
|
|
440
|
+
project_config = None
|
|
441
|
+
if args.project:
|
|
442
|
+
for p in config.get("projects", []):
|
|
443
|
+
if p["name"].lower() == args.project.lower():
|
|
444
|
+
project_config = p
|
|
445
|
+
break
|
|
446
|
+
if not project_config:
|
|
447
|
+
print(json.dumps({"error": f"project '{args.project}' not found", "threads": []}))
|
|
448
|
+
sys.exit(1)
|
|
449
|
+
|
|
450
|
+
subreddits = args.subreddits.split(",") if args.subreddits else (
|
|
451
|
+
project_config.get("subreddits", config.get("subreddits", []))
|
|
452
|
+
if project_config else config.get("subreddits", [])
|
|
453
|
+
)
|
|
454
|
+
reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
|
|
455
|
+
user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
|
|
456
|
+
|
|
457
|
+
# Rate limit check
|
|
458
|
+
posts_today, can_post = check_rate_limit()
|
|
459
|
+
if not can_post and not args.force:
|
|
460
|
+
print(json.dumps({"error": "rate_limit", "posts_today": posts_today, "threads": []}))
|
|
461
|
+
sys.exit(1)
|
|
462
|
+
|
|
463
|
+
already_posted = get_already_posted()
|
|
464
|
+
recent_posts = get_recent_posts()
|
|
465
|
+
|
|
466
|
+
# Pre-filter excluded subreddits before fetching (saves API calls)
|
|
467
|
+
exclusions = load_exclusions(config)
|
|
468
|
+
if exclusions["subreddits"]:
|
|
469
|
+
subreddits = [s for s in subreddits if s.lower().lstrip("r/") not in exclusions["subreddits"]]
|
|
470
|
+
|
|
471
|
+
# Fetch threads
|
|
472
|
+
threads = fetch_reddit_threads(subreddits, sort=args.sort, limit=args.limit, user_agent=user_agent)
|
|
473
|
+
|
|
474
|
+
if args.include_moltbook:
|
|
475
|
+
moltbook_key = os.environ.get("MOLTBOOK_API_KEY", "")
|
|
476
|
+
threads.extend(fetch_moltbook_threads(moltbook_key))
|
|
477
|
+
|
|
478
|
+
# DB-backed search_topics is the single source of truth across platforms
|
|
479
|
+
# (post 2026-05-27 config.json removal; legacy *_topics fields removed 2026-04-30).
|
|
480
|
+
project_search_topics = list(topics_for_project((project_config or {}).get("name") or ""))
|
|
481
|
+
|
|
482
|
+
if args.include_twitter:
|
|
483
|
+
twitter_topics = list(project_search_topics)
|
|
484
|
+
if args.topic:
|
|
485
|
+
twitter_topics = [t for t in twitter_topics if args.topic.lower() in t.lower()]
|
|
486
|
+
raw_excl = config.get("exclusions", {})
|
|
487
|
+
threads.extend(generate_twitter_search_urls(twitter_topics, exclusions=raw_excl))
|
|
488
|
+
|
|
489
|
+
if args.include_linkedin:
|
|
490
|
+
linkedin_topics = list(project_search_topics)
|
|
491
|
+
if args.topic:
|
|
492
|
+
linkedin_topics = [t for t in linkedin_topics if args.topic.lower() in t.lower()]
|
|
493
|
+
raw_excl = config.get("exclusions", {})
|
|
494
|
+
threads.extend(generate_linkedin_search_urls(linkedin_topics, exclusions=raw_excl))
|
|
495
|
+
|
|
496
|
+
if args.include_github:
|
|
497
|
+
github_topics = list(project_search_topics)
|
|
498
|
+
if args.topic:
|
|
499
|
+
github_topics = [t for t in github_topics if args.topic.lower() in t.lower()]
|
|
500
|
+
raw_excl = config.get("exclusions", {})
|
|
501
|
+
threads.extend(fetch_github_issues(github_topics, exclusions=raw_excl))
|
|
502
|
+
|
|
503
|
+
# Filter
|
|
504
|
+
candidates = filter_threads(threads, already_posted, topic=args.topic, exclusions=exclusions)
|
|
505
|
+
|
|
506
|
+
output = {
|
|
507
|
+
"posts_today": posts_today,
|
|
508
|
+
"can_post": can_post,
|
|
509
|
+
"project": project_config["name"] if project_config else None,
|
|
510
|
+
"total_found": len(threads),
|
|
511
|
+
"candidates": len(candidates),
|
|
512
|
+
"recent_post_snippets": [p if p else "" for p in recent_posts],
|
|
513
|
+
"threads": candidates,
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
# Include engaged LinkedIn authors for dedup (author-level, not URL-level)
|
|
517
|
+
if args.include_linkedin:
|
|
518
|
+
output["engaged_linkedin_authors"] = sorted(get_engaged_linkedin_authors())
|
|
519
|
+
output["engaged_linkedin_count"] = len(output["engaged_linkedin_authors"])
|
|
520
|
+
# ID-set dedup: every URN we've engaged with on LinkedIn. LLM must
|
|
521
|
+
# extract the activity/share/ugcPost ID from a candidate post URL
|
|
522
|
+
# and skip if it appears here. Catches URL-shape drift across runs
|
|
523
|
+
# (/feed/update/ vs /posts/...-share-...).
|
|
524
|
+
output["engaged_linkedin_post_ids"] = get_engaged_linkedin_post_ids()
|
|
525
|
+
|
|
526
|
+
print(json.dumps(output, indent=2))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
if __name__ == "__main__":
|
|
530
|
+
main()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dedicated, isolated logging for the Twitter follow-gate.
|
|
3
|
+
|
|
4
|
+
The follow-gate in score_twitter_candidates.py drops candidate threads whose
|
|
5
|
+
author we already follow. Its `[follow_gate]` stderr markers land in the giant
|
|
6
|
+
mixed twitter-cycle log; this helper ALSO writes a clean, timestamped, greppable
|
|
7
|
+
record to skill/logs/follow-gate.log so you can `tail -f` exactly what the filter
|
|
8
|
+
loads and catches each cycle, without digging through 20MB of cycle output.
|
|
9
|
+
|
|
10
|
+
All functions are best-effort: they NEVER raise, so logging can never break the
|
|
11
|
+
fail-open gate. If the log can't be written, the gate proceeds silently.
|
|
12
|
+
|
|
13
|
+
Line formats (one CYCLE line per scoring run, one SKIP line per dropped author):
|
|
14
|
+
<iso8601> <our_account> CYCLE loaded=<N> source=<ok|404|error|unresolved> checked=<M> skipped=<K> batch=<id>
|
|
15
|
+
<iso8601> <our_account> SKIP @<handle> url=<url> batch=<id>
|
|
16
|
+
|
|
17
|
+
Read it with: tail -f ~/social-autoposter/skill/logs/follow-gate.log
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
|
|
24
|
+
LOG_PATH = os.path.expanduser("~/social-autoposter/skill/logs/follow-gate.log")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _now() -> str:
|
|
28
|
+
try:
|
|
29
|
+
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
30
|
+
except Exception:
|
|
31
|
+
return "?"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _append(line: str) -> None:
|
|
35
|
+
try:
|
|
36
|
+
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
|
|
37
|
+
with open(LOG_PATH, "a") as fh:
|
|
38
|
+
fh.write(line.rstrip("\n") + "\n")
|
|
39
|
+
except Exception:
|
|
40
|
+
# Best-effort: never let logging break the fail-open gate.
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def record_cycle(our_account, loaded, source, checked, skipped, batch_id=None) -> None:
|
|
45
|
+
"""One line per scoring run: did the gate load the set (loaded>0, source=ok),
|
|
46
|
+
how many candidates it checked, and how many it skipped this run."""
|
|
47
|
+
_append(
|
|
48
|
+
f"{_now()} {our_account or '(unresolved)'} CYCLE "
|
|
49
|
+
f"loaded={loaded} source={source} checked={checked} "
|
|
50
|
+
f"skipped={skipped} batch={batch_id or '-'}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def record_skip(our_account, handle, url, batch_id=None) -> None:
|
|
55
|
+
"""One line per dropped candidate (author we already follow)."""
|
|
56
|
+
_append(
|
|
57
|
+
f"{_now()} {our_account or '(unresolved)'} SKIP "
|
|
58
|
+
f"@{handle} url={url} batch={batch_id or '-'}"
|
|
59
|
+
)
|