@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,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""DEPRECATED 2026-05-05.
|
|
3
|
+
|
|
4
|
+
This script implemented the per-permalink scrape loop pattern that LinkedIn's
|
|
5
|
+
anti-bot system flagged on 2026-05-05 (incident #2 after 2026-04-17). Even
|
|
6
|
+
when CDP-attached to the linkedin-agent MCP, looping `page.goto` over 30
|
|
7
|
+
`/feed/update/<urn>/` permalinks per fire is itself the banned pattern,
|
|
8
|
+
regardless of which Chrome process drives it.
|
|
9
|
+
|
|
10
|
+
Replaced by:
|
|
11
|
+
- skill/stats-linkedin.sh (Claude-driven, MCP linkedin-agent only)
|
|
12
|
+
- scripts/update_linkedin_stats_from_feed.py (DB writer with scan_no_change_count)
|
|
13
|
+
|
|
14
|
+
The new pipeline does ONE navigation per fire to /in/me/recent-activity/all/,
|
|
15
|
+
scroll-loads in-page (native LinkedIn UX), and extracts engagement counts
|
|
16
|
+
for every visible post in a single DOM read. No permalink hops.
|
|
17
|
+
|
|
18
|
+
The locked skill/stats.sh (Step 4 LinkedIn leg) still references this file
|
|
19
|
+
path. Until stats.sh is unlocked and updated to call stats-linkedin.sh
|
|
20
|
+
directly, this stub stays in place to fail fast and keep the rest of
|
|
21
|
+
stats.sh's per-platform fan-out unaffected.
|
|
22
|
+
|
|
23
|
+
Do NOT restore the old body. The git history preserves it if archaeology
|
|
24
|
+
is needed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main() -> None:
|
|
32
|
+
print(
|
|
33
|
+
json.dumps({
|
|
34
|
+
"ok": False,
|
|
35
|
+
"error": "deprecated",
|
|
36
|
+
"detail": (
|
|
37
|
+
"scrape_linkedin_stats_browser.py was retired 2026-05-05 "
|
|
38
|
+
"after triggering LinkedIn anti-bot fingerprinting (incident "
|
|
39
|
+
"#2 in 3 weeks). Use skill/stats-linkedin.sh instead, which "
|
|
40
|
+
"runs MCP-only with a single activity-feed navigation. See "
|
|
41
|
+
"the file header for full context."
|
|
42
|
+
),
|
|
43
|
+
}),
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
# Exit 2 (not 1) so stats.sh logs it as a hard failure distinct from
|
|
47
|
+
# 'no eligible posts' (exit 0 with note) or runtime error (exit 1).
|
|
48
|
+
sys.exit(2)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
main()
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Update Reddit view counts in the database.
|
|
3
|
+
|
|
4
|
+
Reddit doesn't expose view counts via API. Views are scraped from the
|
|
5
|
+
profile page by Claude using MCP Playwright, then saved to a JSON file.
|
|
6
|
+
This script reads that JSON and updates the `views` column in the DB.
|
|
7
|
+
|
|
8
|
+
IMPORTANT — Browser scraping notes for Claude:
|
|
9
|
+
Reddit virtualizes the DOM: items scrolled off-screen get removed.
|
|
10
|
+
You MUST collect view data incrementally as you scroll — NOT after
|
|
11
|
+
scrolling to the bottom. Use this pattern:
|
|
12
|
+
1. Collect visible articles + view counts
|
|
13
|
+
2. Scroll down ~600px
|
|
14
|
+
3. Wait 800-1500ms for new content
|
|
15
|
+
4. Collect again (dedup by URL in a Map/dict)
|
|
16
|
+
5. Repeat until no new articles load (check article count, not scroll height)
|
|
17
|
+
View counts appear as text nodes matching /^\d[\d,.]*[KkMm]?\s*views?$/
|
|
18
|
+
inside <article> elements. Parse "1.3K views" -> 1300, "2 views" -> 2.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
python3 scripts/scrape_reddit_views.py --from-json /tmp/reddit_views.json
|
|
22
|
+
python3 scripts/scrape_reddit_views.py --from-json /tmp/reddit_views.json --json
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
32
|
+
import http_api
|
|
33
|
+
from http_api import api_get
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_ids(url):
|
|
37
|
+
"""Extract (post_id, comment_id) from any reddit URL format."""
|
|
38
|
+
url = re.sub(r"https?://(old|www|new)\.reddit\.com", "", url)
|
|
39
|
+
url = re.sub(r"\?.*$", "", url).rstrip("/")
|
|
40
|
+
|
|
41
|
+
# New format: /r/sub/comments/POST_ID/comment/COMMENT_ID
|
|
42
|
+
m = re.search(r"/comments/([a-z0-9]+)/comment/([a-z0-9]+)", url)
|
|
43
|
+
if m:
|
|
44
|
+
return (m.group(1), m.group(2))
|
|
45
|
+
|
|
46
|
+
# Old format: /r/sub/comments/POST_ID/slug/COMMENT_ID
|
|
47
|
+
m = re.search(r"/comments/([a-z0-9]+)/[^/]+/([a-z0-9]+)", url)
|
|
48
|
+
if m:
|
|
49
|
+
return (m.group(1), m.group(2))
|
|
50
|
+
|
|
51
|
+
# Post only: /r/sub/comments/POST_ID/...
|
|
52
|
+
m = re.search(r"/comments/([a-z0-9]+)", url)
|
|
53
|
+
if m:
|
|
54
|
+
return (m.group(1), None)
|
|
55
|
+
|
|
56
|
+
return (None, None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _list_active_reddit_posts():
|
|
60
|
+
"""Paginated GET against /api/v1/posts?platform=reddit&status=active.
|
|
61
|
+
|
|
62
|
+
Returns a list of {id, our_url} dicts so callers can keep the prior
|
|
63
|
+
contract. The API's `limit` is capped at 500 server-side; we page by
|
|
64
|
+
walking `posted_at` cursors until we get a short page back.
|
|
65
|
+
"""
|
|
66
|
+
out = []
|
|
67
|
+
since = None
|
|
68
|
+
seen_ids = set()
|
|
69
|
+
while True:
|
|
70
|
+
query = {
|
|
71
|
+
"platform": "reddit",
|
|
72
|
+
"status": "active",
|
|
73
|
+
"limit": 500,
|
|
74
|
+
}
|
|
75
|
+
if since:
|
|
76
|
+
query["since"] = since
|
|
77
|
+
resp = api_get("/api/v1/posts", query=query)
|
|
78
|
+
rows = ((resp or {}).get("data") or {}).get("posts") or []
|
|
79
|
+
new = 0
|
|
80
|
+
oldest = None
|
|
81
|
+
for r in rows:
|
|
82
|
+
pid = r.get("id")
|
|
83
|
+
if pid is None or pid in seen_ids:
|
|
84
|
+
continue
|
|
85
|
+
seen_ids.add(pid)
|
|
86
|
+
if not r.get("our_url"):
|
|
87
|
+
continue
|
|
88
|
+
out.append({"id": int(pid), "our_url": r.get("our_url")})
|
|
89
|
+
new += 1
|
|
90
|
+
ts = r.get("posted_at")
|
|
91
|
+
if ts and (oldest is None or ts < oldest):
|
|
92
|
+
oldest = ts
|
|
93
|
+
# Stop when the server returned fewer than the page size (no more
|
|
94
|
+
# posts behind the cursor) OR no rows were new this iteration.
|
|
95
|
+
if not rows or new == 0 or len(rows) < 500:
|
|
96
|
+
break
|
|
97
|
+
# The /api/v1/posts GET orders by posted_at DESC and filters
|
|
98
|
+
# since >= ${since}. Walking older requires the inverse (a
|
|
99
|
+
# `posted_at <` cursor), which the route doesn't yet expose; one
|
|
100
|
+
# page of 500 covers most refresh cycles. If we ever outgrow that,
|
|
101
|
+
# add `before` / `cursor` to the GET and resume here.
|
|
102
|
+
break
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def update_views(db, scraped_data, quiet=False):
|
|
107
|
+
"""Match scraped view data to DB posts and update.
|
|
108
|
+
|
|
109
|
+
scraped_data accepts:
|
|
110
|
+
- list of dicts {url, views, score?, comments_count?}
|
|
111
|
+
- legacy list of {url, views}
|
|
112
|
+
- legacy dict {url: views}
|
|
113
|
+
|
|
114
|
+
Score sources on the profile page:
|
|
115
|
+
- Thread rows: <shreddit-post score="N" comment-count="N">
|
|
116
|
+
- Comment rows: <shreddit-comment-action-row score="N"> (no reply count)
|
|
117
|
+
Views are visible text on both row types.
|
|
118
|
+
"""
|
|
119
|
+
# Normalise to list of dicts
|
|
120
|
+
if isinstance(scraped_data, dict):
|
|
121
|
+
normalised = [{"url": u, "views": v} for u, v in scraped_data.items()]
|
|
122
|
+
else:
|
|
123
|
+
normalised = []
|
|
124
|
+
for item in scraped_data:
|
|
125
|
+
if isinstance(item, dict):
|
|
126
|
+
normalised.append(item)
|
|
127
|
+
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
128
|
+
normalised.append({"url": item[0], "views": item[1]})
|
|
129
|
+
|
|
130
|
+
views_by_comment = {}
|
|
131
|
+
views_by_post = {} # post_id -> max views (threads)
|
|
132
|
+
score_by_comment = {} # comment_id -> score (comment rows)
|
|
133
|
+
score_by_post = {} # post_id -> score (thread rows)
|
|
134
|
+
cc_by_post = {} # post_id -> comment-count attr (thread rows)
|
|
135
|
+
|
|
136
|
+
for item in normalised:
|
|
137
|
+
url = item.get("url")
|
|
138
|
+
if not url:
|
|
139
|
+
continue
|
|
140
|
+
views = item.get("views")
|
|
141
|
+
score = item.get("score")
|
|
142
|
+
cc = item.get("comments_count")
|
|
143
|
+
post_id, comment_id = extract_ids(url)
|
|
144
|
+
|
|
145
|
+
if views is not None:
|
|
146
|
+
if comment_id:
|
|
147
|
+
views_by_comment[comment_id] = views
|
|
148
|
+
if post_id:
|
|
149
|
+
if post_id not in views_by_post or views > views_by_post[post_id]:
|
|
150
|
+
views_by_post[post_id] = views
|
|
151
|
+
if score is not None:
|
|
152
|
+
if comment_id:
|
|
153
|
+
score_by_comment[comment_id] = score
|
|
154
|
+
elif post_id:
|
|
155
|
+
score_by_post[post_id] = score
|
|
156
|
+
if cc is not None and post_id and not comment_id:
|
|
157
|
+
cc_by_post[post_id] = cc
|
|
158
|
+
|
|
159
|
+
posts = _list_active_reddit_posts()
|
|
160
|
+
|
|
161
|
+
matched = 0
|
|
162
|
+
matched_comment_score = 0
|
|
163
|
+
matched_thread_stats = 0
|
|
164
|
+
unmatched = 0
|
|
165
|
+
|
|
166
|
+
for post in posts:
|
|
167
|
+
db_id, our_url = post["id"], post["our_url"]
|
|
168
|
+
post_id, comment_id = extract_ids(our_url)
|
|
169
|
+
|
|
170
|
+
views = None
|
|
171
|
+
if comment_id and comment_id in views_by_comment:
|
|
172
|
+
views = views_by_comment[comment_id]
|
|
173
|
+
elif post_id and post_id in views_by_post:
|
|
174
|
+
views = views_by_post[post_id]
|
|
175
|
+
|
|
176
|
+
score_val = None
|
|
177
|
+
cc_val = None
|
|
178
|
+
if comment_id:
|
|
179
|
+
score_val = score_by_comment.get(comment_id)
|
|
180
|
+
elif post_id:
|
|
181
|
+
score_val = score_by_post.get(post_id)
|
|
182
|
+
cc_val = cc_by_post.get(post_id)
|
|
183
|
+
|
|
184
|
+
has_update = views is not None or score_val is not None or cc_val is not None
|
|
185
|
+
if has_update:
|
|
186
|
+
patch_body = {"stamp_engagement_now": True}
|
|
187
|
+
if views is not None:
|
|
188
|
+
patch_body["views"] = views
|
|
189
|
+
if score_val is not None:
|
|
190
|
+
patch_body["upvotes"] = score_val
|
|
191
|
+
if cc_val is not None:
|
|
192
|
+
patch_body["comments_count"] = cc_val
|
|
193
|
+
http_api.api_patch(f"/api/v1/posts/{db_id}", patch_body)
|
|
194
|
+
if views is not None:
|
|
195
|
+
http_api.api_post(f"/api/v1/posts/{db_id}/views", {"views": views})
|
|
196
|
+
matched += 1
|
|
197
|
+
if comment_id and score_val is not None:
|
|
198
|
+
matched_comment_score += 1
|
|
199
|
+
if comment_id is None and (score_val is not None or cc_val is not None):
|
|
200
|
+
matched_thread_stats += 1
|
|
201
|
+
else:
|
|
202
|
+
unmatched += 1
|
|
203
|
+
|
|
204
|
+
# ---- Second pass: walk the `replies` table (DM-rail follow-ups) ----
|
|
205
|
+
# 2026-05-18: the Reddit profile-page scrape already captures view + score
|
|
206
|
+
# for every comment we've made, including reply-to-replies that live in
|
|
207
|
+
# the `replies` table (not `posts`). Before this pass those rows defaulted
|
|
208
|
+
# to views=0 because update_reddit_replies() uses Reddit's JSON API, which
|
|
209
|
+
# doesn't expose per-comment views. The scrape data is already on disk;
|
|
210
|
+
# all we have to do is also match `replies.our_reply_id` against the
|
|
211
|
+
# scraped (post_id, comment_id) keys and PATCH the row.
|
|
212
|
+
replies_matched = 0
|
|
213
|
+
replies_unmatched = 0
|
|
214
|
+
try:
|
|
215
|
+
resp = api_get(
|
|
216
|
+
"/api/v1/replies",
|
|
217
|
+
query={
|
|
218
|
+
"platform": "reddit",
|
|
219
|
+
"status": "replied",
|
|
220
|
+
"has_our_reply_id": "true",
|
|
221
|
+
"order_by": "id",
|
|
222
|
+
"limit": 500,
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
reply_rows = ((resp or {}).get("data") or {}).get("replies") or []
|
|
226
|
+
except Exception:
|
|
227
|
+
reply_rows = []
|
|
228
|
+
|
|
229
|
+
for r in reply_rows:
|
|
230
|
+
rid = r.get("id")
|
|
231
|
+
our_reply_id = r.get("our_reply_id")
|
|
232
|
+
if not rid or not our_reply_id:
|
|
233
|
+
continue
|
|
234
|
+
# our_reply_id is the bare base-36 comment ID (no `t1_` prefix).
|
|
235
|
+
cid = our_reply_id.replace("t1_", "")
|
|
236
|
+
views = views_by_comment.get(cid)
|
|
237
|
+
score = score_by_comment.get(cid)
|
|
238
|
+
if views is None and score is None:
|
|
239
|
+
replies_unmatched += 1
|
|
240
|
+
continue
|
|
241
|
+
patch_body = {"stamp_engagement_now": True}
|
|
242
|
+
if views is not None:
|
|
243
|
+
patch_body["views"] = int(views)
|
|
244
|
+
if score is not None:
|
|
245
|
+
patch_body["upvotes"] = int(score)
|
|
246
|
+
try:
|
|
247
|
+
http_api.api_patch(f"/api/v1/replies/{int(rid)}", patch_body)
|
|
248
|
+
replies_matched += 1
|
|
249
|
+
except Exception:
|
|
250
|
+
replies_unmatched += 1
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"matched": matched,
|
|
254
|
+
"matched_comment_score": matched_comment_score,
|
|
255
|
+
"matched_thread_stats": matched_thread_stats,
|
|
256
|
+
"unmatched": unmatched,
|
|
257
|
+
"replies_matched": replies_matched,
|
|
258
|
+
"replies_unmatched": replies_unmatched,
|
|
259
|
+
"scraped_total": len(normalised),
|
|
260
|
+
"with_views": len(views_by_comment) + len(views_by_post),
|
|
261
|
+
"with_score_comment": len(score_by_comment),
|
|
262
|
+
"with_score_thread": len(score_by_post),
|
|
263
|
+
"with_comments_count": len(cc_by_post),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def main():
|
|
268
|
+
parser = argparse.ArgumentParser(description="Update Reddit view counts from scraped JSON")
|
|
269
|
+
parser.add_argument("--from-json", required=True, help="Path to JSON file with scraped views")
|
|
270
|
+
parser.add_argument("--quiet", action="store_true", help="Minimal output")
|
|
271
|
+
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
272
|
+
parser.add_argument("--summary", default=None,
|
|
273
|
+
help="Write a small JSON file ({refreshed: N, unmatched: N}) so "
|
|
274
|
+
"stats.sh can aggregate the dashboard refreshed pill.")
|
|
275
|
+
args = parser.parse_args()
|
|
276
|
+
|
|
277
|
+
if not os.path.exists(args.from_json):
|
|
278
|
+
print(f"ERROR: File not found: {args.from_json}", file=sys.stderr)
|
|
279
|
+
sys.exit(1)
|
|
280
|
+
|
|
281
|
+
with open(args.from_json) as f:
|
|
282
|
+
scraped_data = json.load(f)
|
|
283
|
+
|
|
284
|
+
if not args.quiet:
|
|
285
|
+
print(f"Loaded {len(scraped_data)} items from {args.from_json}")
|
|
286
|
+
|
|
287
|
+
result = update_views(None, scraped_data, quiet=args.quiet)
|
|
288
|
+
|
|
289
|
+
# Aggregate totals via /api/v1/posts/totals. Excludes platforms we don't
|
|
290
|
+
# want in the headline (github_issues, moltbook) and only counts active
|
|
291
|
+
# rows. Net upvotes strip the self-upvote +1 server-side.
|
|
292
|
+
from datetime import datetime, timezone as _tz
|
|
293
|
+
totals_resp = api_get(
|
|
294
|
+
"/api/v1/posts/totals",
|
|
295
|
+
query={
|
|
296
|
+
"status": "active",
|
|
297
|
+
"exclude_platforms": "github_issues,moltbook",
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
t = (totals_resp or {}).get("data") or {}
|
|
301
|
+
total_views = int(t.get("total_views") or 0)
|
|
302
|
+
total_upvotes = int(t.get("total_upvotes") or 0)
|
|
303
|
+
total_comments = int(t.get("total_comments") or 0)
|
|
304
|
+
total_posts = int(t.get("total_posts") or 0)
|
|
305
|
+
first_post_iso = t.get("first_post_at")
|
|
306
|
+
first_post = None
|
|
307
|
+
if first_post_iso:
|
|
308
|
+
try:
|
|
309
|
+
first_post = datetime.fromisoformat(first_post_iso.replace("Z", "+00:00"))
|
|
310
|
+
except Exception:
|
|
311
|
+
first_post = None
|
|
312
|
+
if first_post:
|
|
313
|
+
now = datetime.now(first_post.tzinfo) if first_post.tzinfo else datetime.now()
|
|
314
|
+
days = max((now - first_post).days, 1)
|
|
315
|
+
else:
|
|
316
|
+
days = 1
|
|
317
|
+
|
|
318
|
+
result["totals"] = {
|
|
319
|
+
"total_views": total_views, "total_upvotes": total_upvotes,
|
|
320
|
+
"total_comments": total_comments, "total_posts": total_posts,
|
|
321
|
+
"days_active": days, "views_per_day": round(total_views / days) if days else 0,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if args.summary:
|
|
325
|
+
try:
|
|
326
|
+
# `refreshed` is the count stats.sh consumes for the "views-refreshed"
|
|
327
|
+
# pill. Sum both legs: posts table + replies table (DM-rail follow-ups,
|
|
328
|
+
# added 2026-05-18). Pre-2026-05-18 logs only had the posts leg.
|
|
329
|
+
refreshed_total = int(result.get("matched", 0) or 0) + \
|
|
330
|
+
int(result.get("replies_matched", 0) or 0)
|
|
331
|
+
with open(args.summary, "w") as f:
|
|
332
|
+
json.dump({
|
|
333
|
+
"refreshed": refreshed_total,
|
|
334
|
+
"refreshed_posts": int(result.get("matched", 0) or 0),
|
|
335
|
+
"refreshed_replies": int(result.get("replies_matched", 0) or 0),
|
|
336
|
+
"unmatched": int(result.get("unmatched", 0) or 0),
|
|
337
|
+
}, f)
|
|
338
|
+
except Exception as e:
|
|
339
|
+
print(f"WARN: failed to write summary {args.summary}: {e}", file=sys.stderr)
|
|
340
|
+
|
|
341
|
+
if args.json:
|
|
342
|
+
print(json.dumps(result, indent=2))
|
|
343
|
+
else:
|
|
344
|
+
# Stats.sh greps for "^Reddit Views:" and extracts the "<N> DB posts
|
|
345
|
+
# updated" number for the views-refreshed pill. Include the replies
|
|
346
|
+
# leg in the same number so the pill reflects ALL rows whose view
|
|
347
|
+
# counts got written this run, not just the posts table.
|
|
348
|
+
total_refreshed = result.get("matched", 0) + result.get("replies_matched", 0)
|
|
349
|
+
print(
|
|
350
|
+
f"Reddit Views: {result['with_views']} had views, "
|
|
351
|
+
f"{total_refreshed} DB posts updated "
|
|
352
|
+
f"(posts={result.get('matched', 0)} replies={result.get('replies_matched', 0)}), "
|
|
353
|
+
f"{result['unmatched']} unmatched"
|
|
354
|
+
)
|
|
355
|
+
t = result["totals"]
|
|
356
|
+
print(f"\n--- Totals ({t['days_active']} days) ---")
|
|
357
|
+
print(f"Posts: {t['total_posts']} | "
|
|
358
|
+
f"Views: {t['total_views']:,} | "
|
|
359
|
+
f"Upvotes: {t['total_upvotes']:,} | "
|
|
360
|
+
f"Comments: {t['total_comments']:,} | "
|
|
361
|
+
f"Views/day: {t['views_per_day']:,}")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
main()
|