@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,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""backfill_twitter_attempts_topic.py
|
|
3
|
+
|
|
4
|
+
Periodic UPDATE that fills `twitter_search_attempts.search_topic` from the
|
|
5
|
+
adjacent `twitter_candidates` rows once a scoring cycle finishes writing them.
|
|
6
|
+
|
|
7
|
+
HTTP-only (no DATABASE_URL): the two backfill passes run server-side behind
|
|
8
|
+
`POST /api/v1/twitter-search-attempts/backfill-topic`. This script is now a
|
|
9
|
+
thin trigger that POSTs the window and prints the rows-updated counts. The
|
|
10
|
+
published package carries no direct-DB dependency.
|
|
11
|
+
|
|
12
|
+
Why this backfill exists at all: `score_twitter_candidates.py` and the parent
|
|
13
|
+
`skill/run-twitter-cycle.sh` are both `chflags uchg` locked, and the canonical
|
|
14
|
+
SCAN_SCHEMA in the shell does not yet carry `search_topic` on each entry of
|
|
15
|
+
`queries_used` (so `log_twitter_search_attempts.py` cannot stamp it at INSERT
|
|
16
|
+
time). Until those locked files are extended, we backfill from the candidate
|
|
17
|
+
side, which DOES know the topic (set by `pick_search_topic.py` -> stamped onto
|
|
18
|
+
twitter_candidates.search_topic + search_attempt_id).
|
|
19
|
+
|
|
20
|
+
The endpoint runs two passes, both safe to rerun:
|
|
21
|
+
|
|
22
|
+
A) Direct join via search_attempt_id (covers non-dud attempts that produced
|
|
23
|
+
at least one candidate).
|
|
24
|
+
|
|
25
|
+
B) Fanout via (batch_id, project_name) -> covers dud attempts whose siblings
|
|
26
|
+
in the same cycle DID return candidates and therefore know the topic.
|
|
27
|
+
Skips ambiguous batches (more than one distinct topic) to avoid noise.
|
|
28
|
+
|
|
29
|
+
Fully-dud cycles stay NULL until the locked shell is extended; rare, and they
|
|
30
|
+
surface in the dashboard as a single "(no topic)" bucket per project.
|
|
31
|
+
|
|
32
|
+
Run from cron (launchd `com.m13v.social-twitter-attempt-topic-backfill`,
|
|
33
|
+
every 5 min) or directly:
|
|
34
|
+
|
|
35
|
+
python3 scripts/backfill_twitter_attempts_topic.py # 7d window
|
|
36
|
+
python3 scripts/backfill_twitter_attempts_topic.py --days 30
|
|
37
|
+
python3 scripts/backfill_twitter_attempts_topic.py --all # entire table
|
|
38
|
+
"""
|
|
39
|
+
import argparse
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
|
|
44
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
45
|
+
from http_api import api_post, load_env # noqa: E402
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main():
|
|
49
|
+
p = argparse.ArgumentParser()
|
|
50
|
+
p.add_argument("--days", type=int, default=7,
|
|
51
|
+
help="Only backfill rows where ran_at >= NOW() - INTERVAL "
|
|
52
|
+
"'N days' (default 7). Cron path uses 14; ad-hoc "
|
|
53
|
+
"operators can widen.")
|
|
54
|
+
p.add_argument("--all", action="store_true",
|
|
55
|
+
help="Backfill the entire table; ignores --days.")
|
|
56
|
+
args = p.parse_args()
|
|
57
|
+
|
|
58
|
+
load_env()
|
|
59
|
+
t0 = time.time()
|
|
60
|
+
|
|
61
|
+
resp = api_post(
|
|
62
|
+
"/api/v1/twitter-search-attempts/backfill-topic",
|
|
63
|
+
{"days": int(args.days), "all": bool(args.all)},
|
|
64
|
+
)
|
|
65
|
+
data = resp.get("data") or {}
|
|
66
|
+
a_rows = data.get("pass_a", 0)
|
|
67
|
+
b_rows = data.get("pass_b", 0)
|
|
68
|
+
window = data.get("window", "all" if args.all else f"{args.days}d")
|
|
69
|
+
|
|
70
|
+
elapsed = time.time() - t0
|
|
71
|
+
print(
|
|
72
|
+
f"backfill_twitter_attempts_topic: "
|
|
73
|
+
f"pass_a={a_rows} pass_b={b_rows} window={window} "
|
|
74
|
+
f"elapsed={elapsed:.2f}s",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
sys.exit(main())
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Backfill `posts` rows for Twitter replies that landed on x.com but failed
|
|
2
|
+
to be logged due to the `generation_trace exceeds 64 KB` API rejection
|
|
3
|
+
(2026-05-12 → 2026-05-13).
|
|
4
|
+
|
|
5
|
+
Background: when the generation_trace JSONB column landed on 2026-05-12, the
|
|
6
|
+
server-side cap (64 KB) was tighter than the actual size of Twitter cycle
|
|
7
|
+
traces (~85 KB). Every POST /api/v1/posts came back HTTP 400 bad_request, so
|
|
8
|
+
log_post.py returned no post_id and twitter_post_plan.py marked the
|
|
9
|
+
candidate `skipped` (correctly, to avoid double-posting on x.com) while
|
|
10
|
+
reporting `log_post_no_id` to the run summary. Net effect: the replies WERE
|
|
11
|
+
posted, but the database forgot them, dashboards showed posted=0, and
|
|
12
|
+
~50 posts since 2026-05-12 have no row.
|
|
13
|
+
|
|
14
|
+
This script reconstructs the missing rows from `skill/logs/twitter-cycle-*.log`:
|
|
15
|
+
|
|
16
|
+
1. Walks each cycle log.
|
|
17
|
+
2. Finds each `[post] candidate N log_post.py did not return post_id` event.
|
|
18
|
+
3. Walks backward to extract the reply JSON ({reply_url, final_text,
|
|
19
|
+
tweet_url, applied_campaigns}), the [gen] line (link_source), and the
|
|
20
|
+
[post] candidate line (project name via the surrounding context).
|
|
21
|
+
4. Pulls the candidate's project_name / thread_author / thread_text /
|
|
22
|
+
engagement_style / language out of the structured_output.candidates
|
|
23
|
+
block emitted by the Phase 2b-prep Claude result (logged verbatim).
|
|
24
|
+
5. Calls log_post.py without --generation-trace (sidesteps the 64 KB cap
|
|
25
|
+
entirely until the website cap-bump finishes deploying).
|
|
26
|
+
|
|
27
|
+
Idempotent: the API dedups on (platform, thread_url) so re-runs no-op for
|
|
28
|
+
already-backfilled posts.
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
python3 scripts/backfill_twitter_log_post_no_id.py [--dry-run]
|
|
32
|
+
[--since 2026-05-12] [--logs-dir skill/logs]
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
import time
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Optional
|
|
46
|
+
|
|
47
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
48
|
+
LOG_POST = REPO / "scripts" / "log_post.py"
|
|
49
|
+
|
|
50
|
+
FAIL_LINE_RE = re.compile(
|
|
51
|
+
r"\[post\] candidate (\d+) log_post\.py did not return post_id"
|
|
52
|
+
)
|
|
53
|
+
GEN_LINE_RE = re.compile(
|
|
54
|
+
r"\[gen\] candidate_id=(\d+) link_url=\S+ source=(\S+)"
|
|
55
|
+
)
|
|
56
|
+
REPLY_STDOUT_START_RE = re.compile(r"\[post\]\[reply\.stdout\]")
|
|
57
|
+
POST_BLOCK_HDR_RE = re.compile(r"\[post\] candidate (\d+) -> posting")
|
|
58
|
+
|
|
59
|
+
# The Phase 2b-prep Claude response is logged verbatim as a single very long
|
|
60
|
+
# line containing a JSON envelope with the structured_output. We regex into
|
|
61
|
+
# it rather than json.loads-ing the whole thing because the line is wrapped
|
|
62
|
+
# inside the run log with other framing characters.
|
|
63
|
+
STRUCTURED_OUTPUT_RE = re.compile(
|
|
64
|
+
r'"structured_output":\s*({.*?"candidates":\s*\[.*?\])\s*(?:,"rejected"|,"queries_used"|})',
|
|
65
|
+
re.DOTALL,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_plan_candidates(log_text: str) -> dict[int, dict]:
|
|
70
|
+
"""Pull the {candidate_id -> plan_entry} map out of the Claude prep
|
|
71
|
+
structured_output block embedded in the cycle log. Returns empty dict
|
|
72
|
+
if the block is missing or unparseable (older logs, partial runs).
|
|
73
|
+
"""
|
|
74
|
+
out: dict[int, dict] = {}
|
|
75
|
+
# There can be more than one Claude turn in a cycle (scan + prep). The
|
|
76
|
+
# one we care about is the prep step whose candidates have reply_text.
|
|
77
|
+
# Walk every block; prefer the latest one with reply_text fields.
|
|
78
|
+
for m in re.finditer(
|
|
79
|
+
r'"structured_output":\s*\{(?P<body>.*?)\}\s*,\s*"terminal_reason"',
|
|
80
|
+
log_text, re.DOTALL,
|
|
81
|
+
):
|
|
82
|
+
body = "{" + m.group("body") + "}"
|
|
83
|
+
try:
|
|
84
|
+
obj = json.loads(body)
|
|
85
|
+
except Exception:
|
|
86
|
+
continue
|
|
87
|
+
cands = obj.get("candidates") or []
|
|
88
|
+
for c in cands:
|
|
89
|
+
cid = c.get("candidate_id")
|
|
90
|
+
if cid is None:
|
|
91
|
+
continue
|
|
92
|
+
# Prep-phase entries have reply_text + engagement_style. Scan-phase
|
|
93
|
+
# entries don't. Prefer prep when both exist.
|
|
94
|
+
existing = out.get(cid)
|
|
95
|
+
if c.get("reply_text") or c.get("engagement_style"):
|
|
96
|
+
out[cid] = c
|
|
97
|
+
elif existing is None:
|
|
98
|
+
out[cid] = c
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def extract_reply_payload(lines: list[str], fail_idx: int) -> Optional[dict]:
|
|
103
|
+
"""Walk back from the fail line to find the most recent reply.stdout
|
|
104
|
+
JSON block for this candidate. Returns parsed dict or None.
|
|
105
|
+
"""
|
|
106
|
+
# The `[post][reply.stdout]` marker is followed by a JSON object spanning
|
|
107
|
+
# several lines. Find the marker, then read until the matching closing
|
|
108
|
+
# brace.
|
|
109
|
+
marker_idx = None
|
|
110
|
+
for i in range(fail_idx, max(-1, fail_idx - 200), -1):
|
|
111
|
+
if REPLY_STDOUT_START_RE.search(lines[i]):
|
|
112
|
+
marker_idx = i
|
|
113
|
+
break
|
|
114
|
+
if marker_idx is None:
|
|
115
|
+
return None
|
|
116
|
+
# JSON starts on the next line that begins with '{'
|
|
117
|
+
json_start = None
|
|
118
|
+
for i in range(marker_idx + 1, min(len(lines), marker_idx + 5)):
|
|
119
|
+
if lines[i].lstrip().startswith("{"):
|
|
120
|
+
json_start = i
|
|
121
|
+
break
|
|
122
|
+
if json_start is None:
|
|
123
|
+
return None
|
|
124
|
+
# Walk forward until depth returns to 0.
|
|
125
|
+
depth = 0
|
|
126
|
+
buf = []
|
|
127
|
+
for i in range(json_start, min(len(lines), json_start + 60)):
|
|
128
|
+
buf.append(lines[i])
|
|
129
|
+
for ch in lines[i]:
|
|
130
|
+
if ch == "{":
|
|
131
|
+
depth += 1
|
|
132
|
+
elif ch == "}":
|
|
133
|
+
depth -= 1
|
|
134
|
+
if depth == 0:
|
|
135
|
+
break
|
|
136
|
+
try:
|
|
137
|
+
return json.loads("\n".join(buf))
|
|
138
|
+
except Exception:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def extract_link_source(lines: list[str], fail_idx: int, cid: int) -> Optional[str]:
|
|
143
|
+
"""Walk back to find the [gen] candidate_id=N ... source=X line."""
|
|
144
|
+
for i in range(fail_idx, max(-1, fail_idx - 200), -1):
|
|
145
|
+
m = GEN_LINE_RE.search(lines[i])
|
|
146
|
+
if m and int(m.group(1)) == cid:
|
|
147
|
+
return m.group(2)
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def reconstruct_events(log_path: Path) -> list[dict]:
|
|
152
|
+
"""Walk one cycle log and return a list of backfill records."""
|
|
153
|
+
try:
|
|
154
|
+
text = log_path.read_text(errors="replace")
|
|
155
|
+
except OSError:
|
|
156
|
+
return []
|
|
157
|
+
plan = load_plan_candidates(text)
|
|
158
|
+
lines = text.splitlines()
|
|
159
|
+
out = []
|
|
160
|
+
for idx, line in enumerate(lines):
|
|
161
|
+
m = FAIL_LINE_RE.search(line)
|
|
162
|
+
if not m:
|
|
163
|
+
continue
|
|
164
|
+
cid = int(m.group(1))
|
|
165
|
+
reply = extract_reply_payload(lines, idx) or {}
|
|
166
|
+
link_source = extract_link_source(lines, idx, cid)
|
|
167
|
+
entry = plan.get(cid) or {}
|
|
168
|
+
record = {
|
|
169
|
+
"log": str(log_path.name),
|
|
170
|
+
"candidate_id": cid,
|
|
171
|
+
"thread_url": reply.get("tweet_url") or entry.get("candidate_url") or "",
|
|
172
|
+
"our_url": reply.get("reply_url") or "",
|
|
173
|
+
"our_content": reply.get("final_text") or entry.get("reply_text") or "",
|
|
174
|
+
"project": entry.get("matched_project") or "",
|
|
175
|
+
"thread_author": entry.get("thread_author") or "",
|
|
176
|
+
"thread_title": entry.get("thread_text") or "",
|
|
177
|
+
"engagement_style": entry.get("engagement_style") or "",
|
|
178
|
+
"language": entry.get("language") or "",
|
|
179
|
+
"link_source": link_source or "",
|
|
180
|
+
"applied_campaigns": reply.get("applied_campaigns") or [],
|
|
181
|
+
}
|
|
182
|
+
out.append(record)
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def call_log_post(rec: dict) -> tuple[bool, str]:
|
|
187
|
+
"""Invoke log_post.py for one backfill record. Returns (ok, message)."""
|
|
188
|
+
if not rec["thread_url"] or not rec["our_url"] or not rec["our_content"]:
|
|
189
|
+
return False, "missing required field(s)"
|
|
190
|
+
if not rec["project"]:
|
|
191
|
+
return False, "missing project (no plan data)"
|
|
192
|
+
args = [
|
|
193
|
+
sys.executable, str(LOG_POST),
|
|
194
|
+
"--platform", "twitter",
|
|
195
|
+
"--thread-url", rec["thread_url"],
|
|
196
|
+
"--our-url", rec["our_url"],
|
|
197
|
+
"--our-content", rec["our_content"],
|
|
198
|
+
"--project", rec["project"],
|
|
199
|
+
"--thread-author", rec["thread_author"],
|
|
200
|
+
"--thread-title", rec["thread_title"],
|
|
201
|
+
]
|
|
202
|
+
if rec["engagement_style"]:
|
|
203
|
+
args += ["--engagement-style", rec["engagement_style"]]
|
|
204
|
+
if rec["language"]:
|
|
205
|
+
args += ["--language", rec["language"]]
|
|
206
|
+
if rec["link_source"]:
|
|
207
|
+
args += ["--link-source", rec["link_source"]]
|
|
208
|
+
# CRITICAL: do NOT pass --generation-trace. We are bypassing the cap
|
|
209
|
+
# entirely for backfill. The audit-trail loss for these ~50 rows is
|
|
210
|
+
# acceptable; new posts post-cap-bump will carry their trace normally.
|
|
211
|
+
try:
|
|
212
|
+
proc = subprocess.run(
|
|
213
|
+
args, capture_output=True, text=True, timeout=60,
|
|
214
|
+
env={**os.environ},
|
|
215
|
+
)
|
|
216
|
+
except subprocess.TimeoutExpired:
|
|
217
|
+
return False, "timeout"
|
|
218
|
+
stdout = (proc.stdout or "").strip()
|
|
219
|
+
stderr = (proc.stderr or "").strip()
|
|
220
|
+
# Parse last JSON line from stdout
|
|
221
|
+
last_json = None
|
|
222
|
+
for ln in stdout.splitlines()[::-1]:
|
|
223
|
+
ln = ln.strip()
|
|
224
|
+
if ln.startswith("{") and ln.endswith("}"):
|
|
225
|
+
try:
|
|
226
|
+
last_json = json.loads(ln)
|
|
227
|
+
break
|
|
228
|
+
except Exception:
|
|
229
|
+
continue
|
|
230
|
+
if last_json is None:
|
|
231
|
+
return False, f"no JSON in stdout. rc={proc.returncode} stderr={stderr[:200]!r}"
|
|
232
|
+
if last_json.get("error") == "DUPLICATE_THREAD":
|
|
233
|
+
return True, f"dup → existing post_id={last_json.get('existing_post_id')}"
|
|
234
|
+
if last_json.get("logged"):
|
|
235
|
+
return True, f"inserted post_id={last_json.get('post_id')}"
|
|
236
|
+
return False, f"unexpected log_post.py response: {last_json}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main() -> int:
|
|
240
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
241
|
+
parser.add_argument("--logs-dir", default=str(REPO / "skill" / "logs"))
|
|
242
|
+
parser.add_argument("--since", default="2026-05-12",
|
|
243
|
+
help="date prefix; logs whose filenames sort >= "
|
|
244
|
+
"this string are included (YYYY-MM-DD).")
|
|
245
|
+
parser.add_argument("--dry-run", action="store_true",
|
|
246
|
+
help="print plan, do not call log_post.py")
|
|
247
|
+
parser.add_argument("--limit", type=int, default=0,
|
|
248
|
+
help="cap on number of records to backfill (0 = no cap)")
|
|
249
|
+
args = parser.parse_args()
|
|
250
|
+
|
|
251
|
+
logs_dir = Path(args.logs_dir)
|
|
252
|
+
if not logs_dir.is_dir():
|
|
253
|
+
print(f"ERROR: logs_dir not found: {logs_dir}", file=sys.stderr)
|
|
254
|
+
return 2
|
|
255
|
+
|
|
256
|
+
# Cycle log filenames look like: twitter-cycle-2026-05-13_083005.log
|
|
257
|
+
pat = re.compile(r"twitter-cycle-(\d{4}-\d{2}-\d{2})_")
|
|
258
|
+
log_files = []
|
|
259
|
+
for p in sorted(logs_dir.glob("twitter-cycle-*.log")):
|
|
260
|
+
m = pat.search(p.name)
|
|
261
|
+
if not m:
|
|
262
|
+
continue
|
|
263
|
+
if m.group(1) >= args.since:
|
|
264
|
+
log_files.append(p)
|
|
265
|
+
print(f"Scanning {len(log_files)} cycle logs since {args.since}…")
|
|
266
|
+
|
|
267
|
+
all_records = []
|
|
268
|
+
for p in log_files:
|
|
269
|
+
recs = reconstruct_events(p)
|
|
270
|
+
if recs:
|
|
271
|
+
print(f" {p.name}: {len(recs)} log_post_no_id event(s)")
|
|
272
|
+
all_records.extend(recs)
|
|
273
|
+
|
|
274
|
+
print(f"\nTotal backfill candidates: {len(all_records)}")
|
|
275
|
+
|
|
276
|
+
if not all_records:
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
# Dedup by our_url so we never insert the same reply twice within this run.
|
|
280
|
+
seen = set()
|
|
281
|
+
unique = []
|
|
282
|
+
for r in all_records:
|
|
283
|
+
key = r["our_url"]
|
|
284
|
+
if key in seen:
|
|
285
|
+
continue
|
|
286
|
+
seen.add(key)
|
|
287
|
+
unique.append(r)
|
|
288
|
+
if len(unique) != len(all_records):
|
|
289
|
+
print(f"De-duped by our_url: {len(all_records)} → {len(unique)}")
|
|
290
|
+
|
|
291
|
+
if args.limit > 0:
|
|
292
|
+
unique = unique[: args.limit]
|
|
293
|
+
print(f"Capped at --limit={args.limit}")
|
|
294
|
+
|
|
295
|
+
if args.dry_run:
|
|
296
|
+
for r in unique:
|
|
297
|
+
print(json.dumps({k: r[k] for k in (
|
|
298
|
+
"log", "candidate_id", "project", "thread_url", "our_url",
|
|
299
|
+
"engagement_style", "language", "link_source",
|
|
300
|
+
)}, ensure_ascii=False))
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
n_ok = n_fail = n_dup = 0
|
|
304
|
+
for r in unique:
|
|
305
|
+
ok, msg = call_log_post(r)
|
|
306
|
+
tag = "OK " if ok else "ERR"
|
|
307
|
+
if ok and "dup" in msg:
|
|
308
|
+
n_dup += 1
|
|
309
|
+
elif ok:
|
|
310
|
+
n_ok += 1
|
|
311
|
+
else:
|
|
312
|
+
n_fail += 1
|
|
313
|
+
print(f"{tag} cid={r['candidate_id']} project={r['project']!r} "
|
|
314
|
+
f"thread={r['thread_url']!s} | {msg}")
|
|
315
|
+
time.sleep(0.2) # gentle on the API
|
|
316
|
+
|
|
317
|
+
print(f"\nDone. inserted={n_ok} dup={n_dup} failed={n_fail}")
|
|
318
|
+
return 0 if n_fail == 0 else 1
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
sys.exit(main())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# bench_dashboard.sh
|
|
3
|
+
#
|
|
4
|
+
# Benchmark Social Autoposter dashboard endpoint latencies using curl + awk.
|
|
5
|
+
# Prints a table with p50/p95/min/max per endpoint.
|
|
6
|
+
#
|
|
7
|
+
# Env vars:
|
|
8
|
+
# BASE_URL default http://localhost:3141
|
|
9
|
+
# RUNS default 10 (requests per endpoint)
|
|
10
|
+
# CONCURRENCY default 1 (serial). If >1, uses background curls + wait.
|
|
11
|
+
#
|
|
12
|
+
# Always exits 0.
|
|
13
|
+
|
|
14
|
+
set -u
|
|
15
|
+
|
|
16
|
+
BASE_URL="${BASE_URL:-http://localhost:3141}"
|
|
17
|
+
RUNS="${RUNS:-10}"
|
|
18
|
+
CONCURRENCY="${CONCURRENCY:-1}"
|
|
19
|
+
|
|
20
|
+
ENDPOINTS=(
|
|
21
|
+
"/"
|
|
22
|
+
"/api/pending"
|
|
23
|
+
"/api/activity/stats?hours=24"
|
|
24
|
+
"/api/style/stats?hours=24"
|
|
25
|
+
"/api/status"
|
|
26
|
+
"/api/jobs"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"
|
|
30
|
+
|
|
31
|
+
printf 'bench_dashboard.sh time=%s base=%s runs=%s concurrency=%s\n' \
|
|
32
|
+
"$TIMESTAMP" "$BASE_URL" "$RUNS" "$CONCURRENCY"
|
|
33
|
+
printf '\n'
|
|
34
|
+
|
|
35
|
+
# Header row
|
|
36
|
+
printf '%-40s %-3s %-7s %-7s %-7s %-7s %s\n' \
|
|
37
|
+
"endpoint" "n" "p50" "p95" "min" "max" "codes"
|
|
38
|
+
|
|
39
|
+
TMPDIR="$(mktemp -d -t bench_dashboard.XXXXXX)"
|
|
40
|
+
trap 'rm -rf "$TMPDIR"' EXIT
|
|
41
|
+
|
|
42
|
+
# Run one curl, append "http_code time_total" to the given outfile.
|
|
43
|
+
# Args: URL OUTFILE
|
|
44
|
+
run_one() {
|
|
45
|
+
local url="$1"
|
|
46
|
+
local out="$2"
|
|
47
|
+
# -s silent, -o /dev/null discard body, -w format.
|
|
48
|
+
# On connection failure curl prints "000 0.000".
|
|
49
|
+
local line
|
|
50
|
+
line="$(curl -s -o /dev/null -w '%{http_code} %{time_total}\n' \
|
|
51
|
+
--max-time 60 "$url" 2>/dev/null || true)"
|
|
52
|
+
if [ -z "$line" ]; then
|
|
53
|
+
line="000 0.000"
|
|
54
|
+
fi
|
|
55
|
+
printf '%s\n' "$line" >> "$out"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for ep in "${ENDPOINTS[@]}"; do
|
|
59
|
+
url="${BASE_URL}${ep}"
|
|
60
|
+
outfile="${TMPDIR}/out.$$.$(echo "$ep" | tr -c 'A-Za-z0-9' '_')"
|
|
61
|
+
: > "$outfile"
|
|
62
|
+
|
|
63
|
+
if [ "$CONCURRENCY" -le 1 ]; then
|
|
64
|
+
i=0
|
|
65
|
+
while [ "$i" -lt "$RUNS" ]; do
|
|
66
|
+
run_one "$url" "$outfile"
|
|
67
|
+
i=$((i + 1))
|
|
68
|
+
done
|
|
69
|
+
else
|
|
70
|
+
# Launch in waves of CONCURRENCY until RUNS total are done.
|
|
71
|
+
launched=0
|
|
72
|
+
while [ "$launched" -lt "$RUNS" ]; do
|
|
73
|
+
wave=0
|
|
74
|
+
pids=""
|
|
75
|
+
while [ "$wave" -lt "$CONCURRENCY" ] && [ "$launched" -lt "$RUNS" ]; do
|
|
76
|
+
run_one "$url" "$outfile" &
|
|
77
|
+
pids="$pids $!"
|
|
78
|
+
wave=$((wave + 1))
|
|
79
|
+
launched=$((launched + 1))
|
|
80
|
+
done
|
|
81
|
+
# wait for this wave
|
|
82
|
+
for p in $pids; do
|
|
83
|
+
wait "$p" 2>/dev/null || true
|
|
84
|
+
done
|
|
85
|
+
done
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# Compute stats with awk.
|
|
89
|
+
# Input: lines of "CODE TIME". Output one line:
|
|
90
|
+
# count p50 p95 min max codes
|
|
91
|
+
awk -v ep="$ep" '
|
|
92
|
+
{
|
|
93
|
+
code = $1
|
|
94
|
+
t = $2 + 0
|
|
95
|
+
times[NR] = t
|
|
96
|
+
codes[code]++
|
|
97
|
+
n++
|
|
98
|
+
if (n == 1 || t < mn) mn = t
|
|
99
|
+
if (n == 1 || t > mx) mx = t
|
|
100
|
+
}
|
|
101
|
+
END {
|
|
102
|
+
if (n == 0) {
|
|
103
|
+
printf "%-40s %-3d %-7s %-7s %-7s %-7s %s\n", ep, 0, "-", "-", "-", "-", "none"
|
|
104
|
+
exit
|
|
105
|
+
}
|
|
106
|
+
# sort times ascending (insertion sort, fine for small n)
|
|
107
|
+
for (i = 2; i <= n; i++) {
|
|
108
|
+
v = times[i]; j = i - 1
|
|
109
|
+
while (j >= 1 && times[j] > v) { times[j+1] = times[j]; j-- }
|
|
110
|
+
times[j+1] = v
|
|
111
|
+
}
|
|
112
|
+
# p50 and p95 using nearest-rank, 1-indexed
|
|
113
|
+
p50_idx = int((50/100) * n + 0.9999); if (p50_idx < 1) p50_idx = 1; if (p50_idx > n) p50_idx = n
|
|
114
|
+
p95_idx = int((95/100) * n + 0.9999); if (p95_idx < 1) p95_idx = 1; if (p95_idx > n) p95_idx = n
|
|
115
|
+
p50 = times[p50_idx]
|
|
116
|
+
p95 = times[p95_idx]
|
|
117
|
+
|
|
118
|
+
# Build codes string sorted by code key
|
|
119
|
+
ncodes = 0
|
|
120
|
+
for (c in codes) { ncodes++; ck[ncodes] = c }
|
|
121
|
+
for (i = 2; i <= ncodes; i++) {
|
|
122
|
+
v = ck[i]; j = i - 1
|
|
123
|
+
while (j >= 1 && ck[j] > v) { ck[j+1] = ck[j]; j-- }
|
|
124
|
+
ck[j+1] = v
|
|
125
|
+
}
|
|
126
|
+
codes_str = ""
|
|
127
|
+
for (i = 1; i <= ncodes; i++) {
|
|
128
|
+
sep = (i == 1) ? "" : " "
|
|
129
|
+
codes_str = codes_str sep ck[i] "x" codes[ck[i]]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
printf "%-40s %-3d %-7.3f %-7.3f %-7.3f %-7.3f %s\n", \
|
|
133
|
+
ep, n, p50, p95, mn, mx, codes_str
|
|
134
|
+
}
|
|
135
|
+
' "$outfile"
|
|
136
|
+
done
|
|
137
|
+
|
|
138
|
+
exit 0
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# exec'd inside browser-harness; helpers (goto_url, js, click_at_xy, type_text,
|
|
2
|
+
# press_key, page_info, wait_for_load) are available as globals.
|
|
3
|
+
import time as _t, json as _j
|
|
4
|
+
def send(A, MSG, UNIQ):
|
|
5
|
+
goto_url("https://x.com/"+A); wait_for_load(); _t.sleep(2.5)
|
|
6
|
+
prof = js(r"""
|
|
7
|
+
(() => {
|
|
8
|
+
const nameEl=document.querySelector('[data-testid="UserName"]');
|
|
9
|
+
const bioEl=document.querySelector('[data-testid="UserDescription"]');
|
|
10
|
+
let followers=null;
|
|
11
|
+
document.querySelectorAll('a[href$="/verified_followers"],a[href$="/followers"]').forEach(a=>{followers=a.innerText.replace(/\n/g,' ');});
|
|
12
|
+
const tweets=[...document.querySelectorAll('[data-testid="tweetText"]')].slice(0,3).map(t=>t.innerText.slice(0,120).replace(/\n/g,' '));
|
|
13
|
+
const b=document.querySelector('[data-testid="sendDMFromProfile"]');
|
|
14
|
+
let msgRect=null; if(b){const r=b.getBoundingClientRect(); msgRect={x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};}
|
|
15
|
+
const suspended=/This account doesn|Account suspended|Hmm.*went wrong|Caution/i.test(document.body.innerText.slice(0,300));
|
|
16
|
+
return {name:nameEl?nameEl.innerText.replace(/\n/g,' | '):null, bio:bioEl?bioEl.innerText.replace(/\n/g,' '):null, followers, tweets, hasMsg:!!b, msgRect, suspended};
|
|
17
|
+
})()
|
|
18
|
+
""")
|
|
19
|
+
prof['tweets']=(prof.get('tweets') or [])[:1]
|
|
20
|
+
prof['bio']=(prof.get('bio') or '')[:140]
|
|
21
|
+
out={"prof":prof}
|
|
22
|
+
if prof.get('suspended') or not prof.get('hasMsg'):
|
|
23
|
+
out["status"]="no_dm"; print(_j.dumps(out,ensure_ascii=True)[:2400]); return
|
|
24
|
+
click_at_xy(prof['msgRect']['x'],prof['msgRect']['y']); _t.sleep(3)
|
|
25
|
+
rect=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); if(!t)return null; const r=t.getBoundingClientRect(); return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)};})()""")
|
|
26
|
+
if not rect:
|
|
27
|
+
out["status"]="no_composer"; out["url"]=page_info().get('url'); print(_j.dumps(out,ensure_ascii=True)[:2400]); return
|
|
28
|
+
click_at_xy(rect['x'],rect['y']); _t.sleep(0.6)
|
|
29
|
+
type_text(MSG); _t.sleep(0.9)
|
|
30
|
+
val=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); return t?t.value:null;})()""")
|
|
31
|
+
if (val or "").strip()!=MSG:
|
|
32
|
+
out["status"]="type_mismatch"; out["len"]=len(val or ""); out["url"]=page_info().get('url'); print(_j.dumps(out,ensure_ascii=True)[:2400]); return
|
|
33
|
+
press_key("Enter"); _t.sleep(2.6)
|
|
34
|
+
chk=js(r"""(()=>{const t=document.querySelector('[data-testid="dm-composer-textarea"]'); const body=document.body.innerText; return {cleared:t?t.value.trim()==='':null, url:location.href, bodyHasHandle:body.includes('@__A__')};})()""".replace('__A__',A))
|
|
35
|
+
full=js(r"""(()=>document.body.innerText)()""") or ""
|
|
36
|
+
chk["hasPhrase"]= UNIQ in full
|
|
37
|
+
out["status"]="sent" if (chk.get('cleared') and chk.get('hasPhrase')) else "send_unverified"
|
|
38
|
+
out["url"]=chk.get('url'); out["verify"]=chk
|
|
39
|
+
print(_j.dumps(out,ensure_ascii=True)[:2400])
|