@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,324 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
twitter_scan.py — deterministic X/Twitter search scrape.
|
|
4
|
+
|
|
5
|
+
Runs inside the browser-harness CLI process (BU_NAME=twitter-harness,
|
|
6
|
+
BU_CDP_URL=http://127.0.0.1:9555) and drives the live managed Chrome on 9555.
|
|
7
|
+
Called once per drafted query by run-twitter-cycle.sh's Phase 1 lean loop:
|
|
8
|
+
|
|
9
|
+
BU_NAME=twitter-harness BU_CDP_URL=http://127.0.0.1:9555 \
|
|
10
|
+
browser-harness -c "
|
|
11
|
+
import sys; sys.path.insert(0, '/Users/matthewdi/social-autoposter/scripts')
|
|
12
|
+
from twitter_scan import scan
|
|
13
|
+
for q in <queries list>:
|
|
14
|
+
scan(query=q['query'], project=q['project'],
|
|
15
|
+
search_topic=q['search_topic'],
|
|
16
|
+
freshness_hours=<env FRESHNESS_HOURS_DISCOVER>,
|
|
17
|
+
skip_ids=<env ENGAGED_TWEET_IDS>)
|
|
18
|
+
"
|
|
19
|
+
|
|
20
|
+
The cycle shell drafts queries via a small Claude call (no tools), then loops
|
|
21
|
+
this function per query. Result tweets go to SCAN_TWEETS_FILE (env-set), which
|
|
22
|
+
the cycle reads directly into $RAW_FILE + $QUERIES_FILE for the scorer.
|
|
23
|
+
|
|
24
|
+
What scan() does:
|
|
25
|
+
- Strips any since/until/since_time/until_time from the query so the
|
|
26
|
+
freshness window is operator-controlled, not caller-controlled.
|
|
27
|
+
- Builds an x.com/search URL with `&f=live` (Latest tab forced) and appends
|
|
28
|
+
`since_time:<now - freshness_hours*3600>` to the query.
|
|
29
|
+
- Reuses an existing real tab or opens one on the first call.
|
|
30
|
+
- Scrapes the first ~8 article cards.
|
|
31
|
+
- Applies a deterministic Python age gate behind the URL since_time
|
|
32
|
+
(belt-and-suspenders against cached / lazy-loaded stale viewports).
|
|
33
|
+
- Drops skip_ids (recently-engaged tweets).
|
|
34
|
+
- Stamps search_topic / matched_project / query on every kept tweet.
|
|
35
|
+
- Appends a sidecar JSONL record to
|
|
36
|
+
~/social-autoposter/skill/logs/twitter-scan-attempts.jsonl for operator
|
|
37
|
+
visibility, and a per-attempt record to SCAN_TWEETS_FILE for the shell.
|
|
38
|
+
- Returns the kept tweet list.
|
|
39
|
+
|
|
40
|
+
Standalone test (no cycle shell):
|
|
41
|
+
|
|
42
|
+
~/.local/bin/browser-harness -c '
|
|
43
|
+
import sys; sys.path.insert(0, "/Users/matthewdi/social-autoposter/scripts")
|
|
44
|
+
from twitter_scan import scan
|
|
45
|
+
scan(query="AI agent min_faves:10",
|
|
46
|
+
project="WhatsApp MCP",
|
|
47
|
+
search_topic="AI agent",
|
|
48
|
+
freshness_hours=6)
|
|
49
|
+
'
|
|
50
|
+
"""
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import datetime
|
|
54
|
+
import json
|
|
55
|
+
import os
|
|
56
|
+
import pathlib
|
|
57
|
+
import re
|
|
58
|
+
import time
|
|
59
|
+
import urllib.parse
|
|
60
|
+
|
|
61
|
+
# Pin the daemon socket BEFORE importing helpers — helpers.py reads BU_NAME
|
|
62
|
+
# at module-load time (helpers.py:37). setdefault is a no-op when the bh_run
|
|
63
|
+
# wrapper or the cycle shell already set these; required when invoked from a
|
|
64
|
+
# bare `browser-harness -c` test invocation where the env happens to be empty.
|
|
65
|
+
os.environ.setdefault("BU_NAME", "twitter-harness")
|
|
66
|
+
os.environ.setdefault("BU_CDP_URL", "http://127.0.0.1:9555")
|
|
67
|
+
|
|
68
|
+
from browser_harness.helpers import ( # noqa: E402 (env must be set first)
|
|
69
|
+
goto_url,
|
|
70
|
+
js,
|
|
71
|
+
list_tabs,
|
|
72
|
+
new_tab,
|
|
73
|
+
wait_for_load,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
_SIDECAR = (
|
|
77
|
+
pathlib.Path.home()
|
|
78
|
+
/ "social-autoposter"
|
|
79
|
+
/ "skill"
|
|
80
|
+
/ "logs"
|
|
81
|
+
/ "twitter-scan-attempts.jsonl"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Derived from skill/run-twitter-cycle.sh:666-708 (the legacy inline scan JS).
|
|
85
|
+
# twitter_candidates fields (handle, text, tweetUrl, datetime, engagement
|
|
86
|
+
# counters) land in the same shape the scorer and dashboard already consume.
|
|
87
|
+
# 2026-06-04 DIVERGENCE: this copy adds repost awareness on top of the legacy
|
|
88
|
+
# JS — `handle` is now taken from the status URL (authoritative original author,
|
|
89
|
+
# since on a repost the first profile link is the REPOSTER), plus `is_repost`
|
|
90
|
+
# and `reposted_by` from the "<X> reposted" socialContext banner. This is the
|
|
91
|
+
# live data path (the cycle reads SCAN_TWEETS_FILE written here); the locked
|
|
92
|
+
# shell's inline JS is the inert fallback and simply omits the two new fields.
|
|
93
|
+
_SCRAPE_JS = r"""
|
|
94
|
+
(() => {
|
|
95
|
+
const SNOWFLAKE = /\/status\/(\d{15,19})(?:[\/?#]|$)/;
|
|
96
|
+
const FAKE_TAIL = /0{6,}$/;
|
|
97
|
+
const results = [];
|
|
98
|
+
for (const article of [...document.querySelectorAll('article[data-testid="tweet"]')].slice(0, 8)) {
|
|
99
|
+
try {
|
|
100
|
+
let handle = '';
|
|
101
|
+
for (const link of article.querySelectorAll('a[role="link"]')) {
|
|
102
|
+
const href = link.getAttribute('href');
|
|
103
|
+
if (href && href.startsWith('/') && !href.includes('/status/') && !href.includes('/search') && href.length > 1 && href.split('/').length === 2) {
|
|
104
|
+
handle = href.replace('/', ''); break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const tweetText = article.querySelector('[data-testid="tweetText"]');
|
|
108
|
+
const text = tweetText ? tweetText.textContent : '';
|
|
109
|
+
const timeEl = article.querySelector('time');
|
|
110
|
+
const timeParent = timeEl ? timeEl.closest('a') : null;
|
|
111
|
+
const tweetUrl = timeParent ? 'https://x.com' + timeParent.getAttribute('href') : '';
|
|
112
|
+
const datetime = timeEl ? timeEl.getAttribute('datetime') : '';
|
|
113
|
+
const sm = tweetUrl.match(SNOWFLAKE);
|
|
114
|
+
if (!sm || FAKE_TAIL.test(sm[1])) continue;
|
|
115
|
+
if (!datetime || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(datetime)) continue;
|
|
116
|
+
// Author = first path segment of the status URL. This is authoritative:
|
|
117
|
+
// on a repost the displayed/first-link handle above is the REPOSTER, not
|
|
118
|
+
// the original author, so prefer the URL author and keep the link-scan
|
|
119
|
+
// handle only as a fallback when the URL can't be parsed.
|
|
120
|
+
const authorM = tweetUrl.match(/x\.com\/([^\/]+)\/status\//);
|
|
121
|
+
if (authorM && authorM[1]) handle = authorM[1];
|
|
122
|
+
// Repost detection: a "<X> reposted" banner lives in socialContext. The
|
|
123
|
+
// SAME testid is reused for "Pinned", so match the text, not presence.
|
|
124
|
+
// reposted_by = the account whose profile link wraps the banner.
|
|
125
|
+
let is_repost = false, reposted_by = '';
|
|
126
|
+
const sc = article.querySelector('[data-testid="socialContext"]');
|
|
127
|
+
if (sc && /\breposted\b/i.test(sc.textContent || '')) {
|
|
128
|
+
is_repost = true;
|
|
129
|
+
const a = sc.closest('a');
|
|
130
|
+
const rh = a ? (a.getAttribute('href') || '') : '';
|
|
131
|
+
if (rh.startsWith('/') && rh.split('/').length === 2) reposted_by = rh.replace('/', '');
|
|
132
|
+
}
|
|
133
|
+
let replies=0, retweets=0, likes=0, views=0, bookmarks=0;
|
|
134
|
+
for (const btn of article.querySelectorAll('[role="group"] button')) {
|
|
135
|
+
const al = btn.getAttribute('aria-label') || '';
|
|
136
|
+
let m;
|
|
137
|
+
if (m=al.match(/([\d,]+)\s*repl/i)) replies=parseInt(m[1].replace(/,/g,''));
|
|
138
|
+
if (m=al.match(/([\d,]+)\s*repost/i)) retweets=parseInt(m[1].replace(/,/g,''));
|
|
139
|
+
if (m=al.match(/([\d,]+)\s*like/i)) likes=parseInt(m[1].replace(/,/g,''));
|
|
140
|
+
if (m=al.match(/([\d,]+)\s*view/i)) views=parseInt(m[1].replace(/,/g,''));
|
|
141
|
+
if (m=al.match(/([\d,]+)\s*bookmark/i)) bookmarks=parseInt(m[1].replace(/,/g,''));
|
|
142
|
+
}
|
|
143
|
+
results.push({handle, text, tweetUrl, datetime, replies, retweets, likes, views, bookmarks, is_repost, reposted_by});
|
|
144
|
+
} catch(e) {}
|
|
145
|
+
}
|
|
146
|
+
return results;
|
|
147
|
+
})()
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
# 2026-05-28: also matches the bash-arithmetic form
|
|
151
|
+
# `since_time:$(( $(date +%s) - FRESHNESS_HOURS_DISCOVER * 3600 ))` that was
|
|
152
|
+
# accidentally taught to the model when an escaping bug in the prompt sent
|
|
153
|
+
# the literal template text instead of an evaluated epoch. \S+ alone stops
|
|
154
|
+
# at the first space and leaves the tail (`$(date +%s) - ... ))`) behind as
|
|
155
|
+
# keyword garbage that X searches for literally. The non-greedy `.*?` inside
|
|
156
|
+
# `$((...))` matches up to the first `))` which is the template's own close.
|
|
157
|
+
_DATE_OPS_RE = re.compile(
|
|
158
|
+
r"\b(since|until|since_time|until_time):(?:\$\(\(.*?\)\)|\S+)",
|
|
159
|
+
re.IGNORECASE,
|
|
160
|
+
)
|
|
161
|
+
# Belt + suspenders: even after _DATE_OPS_RE, residual orphan fragments could
|
|
162
|
+
# remain if the model invents some other broken template. Strip common ones.
|
|
163
|
+
_BASH_GARBAGE_RE = re.compile(
|
|
164
|
+
r"\$\(\(|\$\([^)]*\)|\bFRESHNESS_HOURS_DISCOVER\s*\*\s*\d+\b|\)\)"
|
|
165
|
+
)
|
|
166
|
+
_STATUS_ID_RE = re.compile(r"/status/(\d+)")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _build_url(query: str, freshness_hours: int) -> str:
|
|
170
|
+
"""Force-build the Latest-tab URL with since_time pinned `freshness_hours` ago.
|
|
171
|
+
|
|
172
|
+
Stripping the model's date operators first is what closes the dodge: a
|
|
173
|
+
rogue `since:2020-01-01` in the model's query string can no longer widen
|
|
174
|
+
the window. `f=live` is what closes the Top-tab dodge: without it X may
|
|
175
|
+
serve the Top tab where the time operator is advisory."""
|
|
176
|
+
cleaned = _DATE_OPS_RE.sub("", query)
|
|
177
|
+
cleaned = _BASH_GARBAGE_RE.sub("", cleaned)
|
|
178
|
+
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
|
179
|
+
cap_epoch = int(time.time()) - int(freshness_hours) * 3600
|
|
180
|
+
full = f"{cleaned} since_time:{cap_epoch}".strip()
|
|
181
|
+
return "https://x.com/search?q=" + urllib.parse.quote(full) + "&src=typed_query&f=live"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parse_dt_epoch(ds: str):
|
|
185
|
+
if not ds:
|
|
186
|
+
return None
|
|
187
|
+
try:
|
|
188
|
+
return int(
|
|
189
|
+
datetime.datetime.fromisoformat(ds.replace("Z", "+00:00")).timestamp()
|
|
190
|
+
)
|
|
191
|
+
except (ValueError, TypeError):
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _status_id(url: str):
|
|
196
|
+
m = _STATUS_ID_RE.search(url or "")
|
|
197
|
+
return m.group(1) if m else None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _write_sidecar(rec: dict) -> None:
|
|
201
|
+
try:
|
|
202
|
+
_SIDECAR.parent.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
with _SIDECAR.open("a") as f:
|
|
204
|
+
f.write(json.dumps(rec) + "\n")
|
|
205
|
+
except OSError:
|
|
206
|
+
pass # fail-open; sidecar is operator visibility only, not on the data path
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _write_scan_tweets_record(rec: dict) -> None:
|
|
210
|
+
"""Append one JSONL record per scan() call to the path in SCAN_TWEETS_FILE.
|
|
211
|
+
|
|
212
|
+
2026-05-28: shell-side data path. When the cycle exports SCAN_TWEETS_FILE,
|
|
213
|
+
run-twitter-cycle.sh reads this file after the scan claude session ends
|
|
214
|
+
and uses it as the source of truth for both $RAW_FILE (tweets fed to the
|
|
215
|
+
scorer) and $QUERIES_FILE (attempts fed to log_twitter_search_attempts.py),
|
|
216
|
+
skipping the model's structured_output relay entirely. This cuts the
|
|
217
|
+
relay-tokens bill (model no longer has to copy the tweets/queries_used
|
|
218
|
+
arrays from bh_run stdout into structured_output).
|
|
219
|
+
|
|
220
|
+
Inert when SCAN_TWEETS_FILE is unset; the model's structured_output path
|
|
221
|
+
remains the fallback so existing standalone test invocations (no cycle
|
|
222
|
+
env) and any session where the file write fails still produce candidates."""
|
|
223
|
+
path = os.environ.get("SCAN_TWEETS_FILE")
|
|
224
|
+
if not path:
|
|
225
|
+
return
|
|
226
|
+
try:
|
|
227
|
+
with open(path, "a") as f:
|
|
228
|
+
f.write(json.dumps(rec) + "\n")
|
|
229
|
+
except OSError:
|
|
230
|
+
pass # fail-open; shell falls back to structured_output if file is missing
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _navigate(url: str) -> None:
|
|
234
|
+
"""Reuse the existing real tab if there is one (typical cycle behavior),
|
|
235
|
+
otherwise open one. The MCP-managed Chrome always has at least an
|
|
236
|
+
about:blank tab from launch, but be defensive: a hung tab close between
|
|
237
|
+
cycles can leave us with only chrome:// tabs."""
|
|
238
|
+
real = [
|
|
239
|
+
t for t in list_tabs(include_chrome=False)
|
|
240
|
+
if (t.get("url") or "").startswith(("http", "about:"))
|
|
241
|
+
]
|
|
242
|
+
if real:
|
|
243
|
+
goto_url(url)
|
|
244
|
+
else:
|
|
245
|
+
new_tab(url)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def scan(
|
|
249
|
+
query: str,
|
|
250
|
+
project: str,
|
|
251
|
+
search_topic: str,
|
|
252
|
+
freshness_hours: int = 6,
|
|
253
|
+
skip_ids=None,
|
|
254
|
+
settle_seconds: float = 4.0,
|
|
255
|
+
) -> list:
|
|
256
|
+
"""Deterministic scrape + age gate. Prints JSON between
|
|
257
|
+
###TWEETS_BEGIN###/###TWEETS_END### sentinels for the scan model to relay
|
|
258
|
+
into StructuredOutput; also returns the kept list so direct callers (tests,
|
|
259
|
+
future shell-driven invocations) can consume it without parsing stdout."""
|
|
260
|
+
skip = {str(s) for s in (skip_ids or [])}
|
|
261
|
+
url = _build_url(query, int(freshness_hours))
|
|
262
|
+
_navigate(url)
|
|
263
|
+
wait_for_load(timeout=15.0)
|
|
264
|
+
# X lazy-loads the result list; settle briefly before scraping. Matches
|
|
265
|
+
# the legacy template's `time.sleep(4)`.
|
|
266
|
+
time.sleep(float(settle_seconds))
|
|
267
|
+
|
|
268
|
+
raw = js(_SCRAPE_JS)
|
|
269
|
+
tweets = raw if isinstance(raw, list) else []
|
|
270
|
+
pre_count = len(tweets)
|
|
271
|
+
|
|
272
|
+
cap_epoch = int(time.time()) - int(freshness_hours) * 3600
|
|
273
|
+
fresh = []
|
|
274
|
+
for t in tweets:
|
|
275
|
+
ep = _parse_dt_epoch(t.get("datetime", ""))
|
|
276
|
+
if ep is not None and ep >= cap_epoch:
|
|
277
|
+
fresh.append(t)
|
|
278
|
+
dropped_age = pre_count - len(fresh)
|
|
279
|
+
|
|
280
|
+
kept = [t for t in fresh if _status_id(t.get("tweetUrl", "")) not in skip]
|
|
281
|
+
dropped_skip = len(fresh) - len(kept)
|
|
282
|
+
|
|
283
|
+
for t in kept:
|
|
284
|
+
t["search_topic"] = search_topic
|
|
285
|
+
t["matched_project"] = project
|
|
286
|
+
t["query"] = query
|
|
287
|
+
|
|
288
|
+
_write_sidecar(
|
|
289
|
+
{
|
|
290
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
291
|
+
"ts_epoch": int(time.time()),
|
|
292
|
+
"query": query,
|
|
293
|
+
"project": project,
|
|
294
|
+
"search_topic": search_topic,
|
|
295
|
+
"freshness_hours": int(freshness_hours),
|
|
296
|
+
"url": url,
|
|
297
|
+
"pre_count": pre_count,
|
|
298
|
+
"kept_after_age": len(fresh),
|
|
299
|
+
"dropped_age": dropped_age,
|
|
300
|
+
"kept_after_skip": len(kept),
|
|
301
|
+
"dropped_skip": dropped_skip,
|
|
302
|
+
"batch_id": os.environ.get("BATCH_ID"),
|
|
303
|
+
"cycle_variant": os.environ.get("TWITTER_CYCLE_VARIANT"),
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Shell-side data path. The cycle (when it exports SCAN_TWEETS_FILE) reads
|
|
308
|
+
# this file directly instead of asking the scan model to relay tweets via
|
|
309
|
+
# structured_output, saving relay tokens. One JSONL record per scan() call;
|
|
310
|
+
# the cycle aggregates across all calls in one Phase 1 attempt.
|
|
311
|
+
_write_scan_tweets_record(
|
|
312
|
+
{
|
|
313
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
314
|
+
"query": query,
|
|
315
|
+
"project": project,
|
|
316
|
+
"search_topic": search_topic,
|
|
317
|
+
"tweets": kept,
|
|
318
|
+
}
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# 2026-05-28 cleanup: sentinel-print removed. The cycle reads SCAN_TWEETS_FILE
|
|
322
|
+
# directly via _write_scan_tweets_record() above; the bh_run stdout relay path
|
|
323
|
+
# is no longer wired. scan() still returns `kept` so direct callers can use it.
|
|
324
|
+
return kept
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
twitter_supply_signal.py
|
|
4
|
+
|
|
5
|
+
Per-project supply table: at each `min_faves:N` tier, what's the median
|
|
6
|
+
number of tweets X actually returned for queries we ran for that project?
|
|
7
|
+
|
|
8
|
+
This is the answer to the question the Phase 1 scanner has been guessing
|
|
9
|
+
at since the cycle was written: "what min_faves should I use for this
|
|
10
|
+
project?". Today the prompt says a flat "broad=50, narrow=20" rule, which
|
|
11
|
+
works for tech-Twitter (mk0r, claude-meter, fazm) but starves student-
|
|
12
|
+
Twitter (studyly), where even niche audience tweets rarely clear 20 likes.
|
|
13
|
+
|
|
14
|
+
Output: JSON list of
|
|
15
|
+
{"project": "<name>", "tiers": [{"min_faves": N, "attempts": N,
|
|
16
|
+
"median_tweets_found": N,
|
|
17
|
+
"zero_result_pct": 0-100}, ...]}
|
|
18
|
+
sorted by project. Within each project, tiers ordered ascending min_faves
|
|
19
|
+
so the model can read "as I raise the floor, supply collapses; pick the
|
|
20
|
+
lowest min_faves where supply is still ≥3".
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
|
|
24
|
+
python3 scripts/twitter_supply_signal.py [--window-days 14] [--project NAME]
|
|
25
|
+
|
|
26
|
+
Migrated 2026-05-18: reads now go through /api/v1/twitter-search-attempts/
|
|
27
|
+
supply-signal via scripts/http_api.py instead of psycopg2.
|
|
28
|
+
"""
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
35
|
+
from http_api import api_get # noqa: E402
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main():
|
|
39
|
+
p = argparse.ArgumentParser()
|
|
40
|
+
p.add_argument("--window-days", type=int, default=14)
|
|
41
|
+
p.add_argument("--project", default=None,
|
|
42
|
+
help="If set, only return supply table for this project.")
|
|
43
|
+
args = p.parse_args()
|
|
44
|
+
|
|
45
|
+
query = {"window_days": args.window_days}
|
|
46
|
+
if args.project:
|
|
47
|
+
query["project"] = args.project
|
|
48
|
+
|
|
49
|
+
resp = api_get("/api/v1/twitter-search-attempts/supply-signal", query=query)
|
|
50
|
+
out = (resp.get("data") or {}).get("rows") or []
|
|
51
|
+
|
|
52
|
+
json.dump(out, sys.stdout)
|
|
53
|
+
print("", file=sys.stdout)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""twitter_threads_helper.py — small CLI wrapper used by
|
|
3
|
+
skill/run-twitter-threads.sh to replace the three `psql` one-liners that
|
|
4
|
+
loaded recent posts / styles / top performers for the original-thread
|
|
5
|
+
prompt context. Each subcommand prints exactly one value to stdout (raw
|
|
6
|
+
newline-separated content or pipe-separated tuples) so the surrounding
|
|
7
|
+
bash code can keep using $(...) capture unchanged.
|
|
8
|
+
|
|
9
|
+
Subcommands:
|
|
10
|
+
recent-posts --project P [--days 14] [--limit 10]
|
|
11
|
+
-> GET /api/v1/posts?platform=twitter&project=P
|
|
12
|
+
&since=<now-14d>&limit=...
|
|
13
|
+
-> filter rows to platform='twitter' AND thread_url = our_url
|
|
14
|
+
(= our original posts, not mention placeholders) and print one
|
|
15
|
+
line per row containing the post's our_content (newlines and
|
|
16
|
+
pipes inside content survive because the legacy psql -t -A
|
|
17
|
+
output did the same).
|
|
18
|
+
|
|
19
|
+
recent-styles --project P [--limit 5]
|
|
20
|
+
-> Same source; print one engagement_style per line for the most
|
|
21
|
+
recent N our-original posts where engagement_style is set.
|
|
22
|
+
|
|
23
|
+
top-posts --project P [--limit 8]
|
|
24
|
+
-> Same source; print pipe-separated tuples:
|
|
25
|
+
our_content|upvotes|comments_count|views
|
|
26
|
+
filtered to rows with composite (upvotes + 3*comments + views/100)
|
|
27
|
+
> 5 and sorted by the same composite DESC.
|
|
28
|
+
|
|
29
|
+
Migrated 2026-05-18: removes 3 direct psql calls from
|
|
30
|
+
skill/run-twitter-threads.sh. The route at /api/v1/posts already supports
|
|
31
|
+
platform + project + since + status filters server-side; this helper just
|
|
32
|
+
shapes the response into the legacy line/pipe format the bash prompt
|
|
33
|
+
consumes verbatim.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import os
|
|
39
|
+
import sys
|
|
40
|
+
from datetime import datetime, timedelta, timezone
|
|
41
|
+
|
|
42
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
43
|
+
from http_api import api_get # noqa: E402
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fetch_posts(project: str, days: int | None = None, limit: int = 500):
|
|
47
|
+
"""Pull recent twitter posts for a project via /api/v1/posts. The
|
|
48
|
+
server-side WHERE handles platform/project/status/since; the
|
|
49
|
+
thread_url = our_url filter (= our original posts, not replies)
|
|
50
|
+
is applied client-side so the route stays general-purpose.
|
|
51
|
+
|
|
52
|
+
Post 2026-05-23, mentions live in the dedicated `mentions` table,
|
|
53
|
+
so we no longer need to filter '(mention%' placeholders client-side.
|
|
54
|
+
"""
|
|
55
|
+
query: dict = {
|
|
56
|
+
"platform": "twitter",
|
|
57
|
+
"project": project,
|
|
58
|
+
"limit": limit,
|
|
59
|
+
"status": "active",
|
|
60
|
+
"has_our_url": "true",
|
|
61
|
+
}
|
|
62
|
+
if days is not None:
|
|
63
|
+
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
64
|
+
query["since"] = since
|
|
65
|
+
resp = api_get("/api/v1/posts", query=query)
|
|
66
|
+
rows = (resp.get("data") or {}).get("posts") or []
|
|
67
|
+
out = []
|
|
68
|
+
for r in rows:
|
|
69
|
+
if r.get("thread_url") != r.get("our_url"):
|
|
70
|
+
continue
|
|
71
|
+
out.append(r)
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_recent_posts(project: str, days: int, limit: int) -> int:
|
|
76
|
+
rows = _fetch_posts(project, days=days, limit=max(limit * 5, 50))
|
|
77
|
+
# Posts come back posted_at DESC already; just take the first N.
|
|
78
|
+
for r in rows[:limit]:
|
|
79
|
+
content = (r.get("our_content") or "").replace("\n", " ").replace("\r", " ")
|
|
80
|
+
sys.stdout.write(content + "\n")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_recent_styles(project: str, limit: int) -> int:
|
|
85
|
+
# No --days bound originally; pull a generous window so we don't miss
|
|
86
|
+
# styles when the account has been quiet recently.
|
|
87
|
+
rows = _fetch_posts(project, days=90, limit=max(limit * 10, 50))
|
|
88
|
+
n = 0
|
|
89
|
+
for r in rows:
|
|
90
|
+
style = (r.get("engagement_style") or "").strip()
|
|
91
|
+
if not style:
|
|
92
|
+
continue
|
|
93
|
+
sys.stdout.write(style + "\n")
|
|
94
|
+
n += 1
|
|
95
|
+
if n >= limit:
|
|
96
|
+
break
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_top_posts(project: str, limit: int) -> int:
|
|
101
|
+
rows = _fetch_posts(project, days=None, limit=500)
|
|
102
|
+
|
|
103
|
+
def composite(r):
|
|
104
|
+
return (
|
|
105
|
+
int(r.get("upvotes") or 0)
|
|
106
|
+
+ int(r.get("comments_count") or 0) * 3
|
|
107
|
+
+ int(r.get("views") or 0) // 100
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Same composite + threshold + sort as the legacy SQL: floor=5,
|
|
111
|
+
# ORDER BY composite DESC, LIMIT.
|
|
112
|
+
filtered = [r for r in rows if composite(r) > 5]
|
|
113
|
+
filtered.sort(key=composite, reverse=True)
|
|
114
|
+
for r in filtered[:limit]:
|
|
115
|
+
content = (r.get("our_content") or "").replace("\n", " ").replace("\r", " ")
|
|
116
|
+
upvotes = int(r.get("upvotes") or 0)
|
|
117
|
+
comments = int(r.get("comments_count") or 0)
|
|
118
|
+
views = int(r.get("views") or 0)
|
|
119
|
+
sys.stdout.write(f"{content}|{upvotes}|{comments}|{views}\n")
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main() -> int:
|
|
124
|
+
ap = argparse.ArgumentParser(description="Helper for run-twitter-threads.sh")
|
|
125
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
126
|
+
|
|
127
|
+
p_rp = sub.add_parser("recent-posts")
|
|
128
|
+
p_rp.add_argument("--project", required=True)
|
|
129
|
+
p_rp.add_argument("--days", type=int, default=14)
|
|
130
|
+
p_rp.add_argument("--limit", type=int, default=10)
|
|
131
|
+
|
|
132
|
+
p_rs = sub.add_parser("recent-styles")
|
|
133
|
+
p_rs.add_argument("--project", required=True)
|
|
134
|
+
p_rs.add_argument("--limit", type=int, default=5)
|
|
135
|
+
|
|
136
|
+
p_tp = sub.add_parser("top-posts")
|
|
137
|
+
p_tp.add_argument("--project", required=True)
|
|
138
|
+
p_tp.add_argument("--limit", type=int, default=8)
|
|
139
|
+
|
|
140
|
+
args = ap.parse_args()
|
|
141
|
+
|
|
142
|
+
if args.cmd == "recent-posts":
|
|
143
|
+
return cmd_recent_posts(args.project, args.days, args.limit)
|
|
144
|
+
if args.cmd == "recent-styles":
|
|
145
|
+
return cmd_recent_styles(args.project, args.limit)
|
|
146
|
+
if args.cmd == "top-posts":
|
|
147
|
+
return cmd_top_posts(args.project, args.limit)
|
|
148
|
+
return 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
sys.exit(main())
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unclaim a web-chat thread (re-set unread_by_founder=1, clear cooldown).
|
|
3
|
+
|
|
4
|
+
HTTP-only: POST /api/v1/web-chat/threads/<thread_id>/unclaim. Used when a Claude
|
|
5
|
+
session fails so the next pipeline tick will retry.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 unclaim_web_chat.py <thread_id>
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
from http_api import api_post
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
parser = argparse.ArgumentParser()
|
|
21
|
+
parser.add_argument("thread_id")
|
|
22
|
+
args = parser.parse_args()
|
|
23
|
+
|
|
24
|
+
api_post(f"/api/v1/web-chat/threads/{args.thread_id}/unclaim", {})
|
|
25
|
+
print(f"unclaimed thread {args.thread_id}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|