@m13v/s4l 1.6.197-rc.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1314 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +497 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Classify whether our logged-in X account can see a tweet.
|
|
3
|
+
|
|
4
|
+
This is deliberately separate from twitter_browser.py because that file is
|
|
5
|
+
locked in this repo. It reuses the same harness Chrome/CDP session and lock via
|
|
6
|
+
twitter_browser.get_browser_and_page(), but returns a small access diagnosis:
|
|
7
|
+
|
|
8
|
+
visible - the target tweet article rendered for our account
|
|
9
|
+
visible_no_anchor - tweet articles rendered, but the exact status id was not
|
|
10
|
+
found in article links (usable, but less certain)
|
|
11
|
+
blocked - X rendered a block-specific message
|
|
12
|
+
protected - X rendered protected-account copy
|
|
13
|
+
unavailable - X rendered deleted/suspended/not-found/unavailable copy
|
|
14
|
+
access_gated - X redirected to /account/access ("verify it's you") or a
|
|
15
|
+
Cloudflare "security verification" interstitial gated the
|
|
16
|
+
page. The session cookie is valid but X is limiting it
|
|
17
|
+
(commonly datacenter-IP trust degradation). Acting on this
|
|
18
|
+
session yields phantom "doesn't exist" results, so callers
|
|
19
|
+
should STOP rather than treat the empty render as truth.
|
|
20
|
+
app_error - X rendered a generic retry/error state
|
|
21
|
+
logged_out - the harness session is no longer logged in
|
|
22
|
+
app_not_hydrated - X served the app shell but no DOM content rendered
|
|
23
|
+
unknown - no reliable signal
|
|
24
|
+
|
|
25
|
+
The optional fxtwitter public control proves public existence only. It cannot
|
|
26
|
+
prove whether our logged-in account is blocked.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
import time
|
|
37
|
+
|
|
38
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
39
|
+
import twitter_browser as tb # noqa: E402
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_tweet_url(tweet_url: str) -> tuple[str, str]:
|
|
43
|
+
m = re.search(r"(?:twitter|x)\.com/([^/?#]+)/status/(\d+)", tweet_url or "")
|
|
44
|
+
if not m:
|
|
45
|
+
return "", ""
|
|
46
|
+
return m.group(1).lstrip("@"), m.group(2)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def public_control(tweet_url: str) -> dict:
|
|
50
|
+
handle, tweet_id = parse_tweet_url(tweet_url)
|
|
51
|
+
if not handle or not tweet_id:
|
|
52
|
+
return {"checked": False, "error": "bad_tweet_url"}
|
|
53
|
+
try:
|
|
54
|
+
import urllib.error
|
|
55
|
+
import urllib.request
|
|
56
|
+
|
|
57
|
+
req = urllib.request.Request(
|
|
58
|
+
f"https://api.fxtwitter.com/{handle}/status/{tweet_id}",
|
|
59
|
+
headers={"User-Agent": "social-autoposter/1.0"},
|
|
60
|
+
)
|
|
61
|
+
try:
|
|
62
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
63
|
+
data = json.loads(resp.read() or b"{}")
|
|
64
|
+
except urllib.error.HTTPError as e:
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(e.read() or b"{}")
|
|
67
|
+
except Exception:
|
|
68
|
+
return {"checked": True, "exists": False, "code": e.code}
|
|
69
|
+
|
|
70
|
+
tweet = data.get("tweet")
|
|
71
|
+
if isinstance(tweet, dict) and tweet.get("type") == "tombstone":
|
|
72
|
+
return {
|
|
73
|
+
"checked": True,
|
|
74
|
+
"exists": True,
|
|
75
|
+
"code": data.get("code"),
|
|
76
|
+
"tweet_type": "tombstone",
|
|
77
|
+
"reason": tweet.get("reason") or "tombstone",
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
"checked": True,
|
|
81
|
+
"exists": bool(tweet),
|
|
82
|
+
"code": data.get("code"),
|
|
83
|
+
"author": ((tweet or {}).get("author") or {}).get("screen_name"),
|
|
84
|
+
"text_prefix": ((tweet or {}).get("text") or "")[:220],
|
|
85
|
+
}
|
|
86
|
+
except Exception as e:
|
|
87
|
+
return {"checked": True, "exists": None, "error": str(e)}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def page_state(page) -> dict:
|
|
91
|
+
try:
|
|
92
|
+
return page.evaluate(
|
|
93
|
+
r"""() => {
|
|
94
|
+
const bodyText = document.body ? (document.body.innerText || '') : '';
|
|
95
|
+
const main = document.querySelector('main');
|
|
96
|
+
const mainText = main ? (main.innerText || '') : '';
|
|
97
|
+
const articles = Array.from(document.querySelectorAll('article[data-testid="tweet"]'));
|
|
98
|
+
return {
|
|
99
|
+
href: location.href,
|
|
100
|
+
title: document.title || '',
|
|
101
|
+
ready_state: document.readyState,
|
|
102
|
+
html_len: document.documentElement ? document.documentElement.outerHTML.length : 0,
|
|
103
|
+
body_len: bodyText.length,
|
|
104
|
+
main_len: mainText.length,
|
|
105
|
+
text_prefix: (mainText || bodyText).slice(0, 1800),
|
|
106
|
+
article_count: articles.length,
|
|
107
|
+
article_texts: articles.slice(0, 5).map(a => (a.innerText || '').slice(0, 700))
|
|
108
|
+
};
|
|
109
|
+
}"""
|
|
110
|
+
)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
return {"error": str(e)}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _norm(text: str) -> str:
|
|
116
|
+
return (
|
|
117
|
+
(text or "")
|
|
118
|
+
.replace("\u2019", "'")
|
|
119
|
+
.replace("\u2018", "'")
|
|
120
|
+
.replace("\u201c", '"')
|
|
121
|
+
.replace("\u201d", '"')
|
|
122
|
+
.lower()
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def classify_current_page(page, tweet_url: str, tweets: list[dict] | None = None) -> dict:
|
|
127
|
+
handle, tweet_id = parse_tweet_url(tweet_url)
|
|
128
|
+
if tweets is None:
|
|
129
|
+
try:
|
|
130
|
+
tweets = page.evaluate(tb.THREAD_EXTRACTOR_JS)
|
|
131
|
+
except Exception:
|
|
132
|
+
tweets = []
|
|
133
|
+
|
|
134
|
+
state = page_state(page)
|
|
135
|
+
text = (state.get("text_prefix") or "") + "\n" + "\n".join(state.get("article_texts") or [])
|
|
136
|
+
text_norm = _norm(text)
|
|
137
|
+
href = (state.get("href") or "").lower()
|
|
138
|
+
rendered_ids = [str(t.get("tweet_id") or "") for t in tweets if t.get("tweet_id")]
|
|
139
|
+
rendered_handles = sorted({
|
|
140
|
+
(t.get("handle") or "").lstrip("@").lower()
|
|
141
|
+
for t in tweets
|
|
142
|
+
if t.get("handle")
|
|
143
|
+
})
|
|
144
|
+
matched = bool(tweet_id and tweet_id in rendered_ids)
|
|
145
|
+
phrases: list[str] = []
|
|
146
|
+
|
|
147
|
+
def has_any(candidates: list[str]) -> bool:
|
|
148
|
+
for phrase in candidates:
|
|
149
|
+
if phrase.lower() in text_norm:
|
|
150
|
+
phrases.append(phrase)
|
|
151
|
+
return True
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
status = "unknown"
|
|
155
|
+
reason = "no_access_signal"
|
|
156
|
+
if matched:
|
|
157
|
+
status, reason = "visible", "anchor_tweet_rendered"
|
|
158
|
+
elif "/account/access" in href:
|
|
159
|
+
# X 302'd the session to its "verify it's you" gate. Valid cookie, but
|
|
160
|
+
# X is limiting this session — treat as gated, not deleted/blocked.
|
|
161
|
+
status, reason = "access_gated", "account_access_redirect"
|
|
162
|
+
elif has_any([
|
|
163
|
+
"performing security verification",
|
|
164
|
+
"verify you are human",
|
|
165
|
+
"checking if the site connection is secure",
|
|
166
|
+
"security service to protect",
|
|
167
|
+
"needs to review the security of your connection",
|
|
168
|
+
]):
|
|
169
|
+
# Cloudflare interstitial in front of x.com (datacenter-IP trust gate).
|
|
170
|
+
status, reason = "access_gated", "cloudflare_challenge"
|
|
171
|
+
elif has_any([
|
|
172
|
+
"you're blocked",
|
|
173
|
+
"you are blocked",
|
|
174
|
+
"blocked you",
|
|
175
|
+
"has blocked you",
|
|
176
|
+
"you can't follow or see",
|
|
177
|
+
]):
|
|
178
|
+
status, reason = "blocked", "block_phrase_rendered"
|
|
179
|
+
elif has_any([
|
|
180
|
+
"these posts are protected",
|
|
181
|
+
"only approved followers",
|
|
182
|
+
"follow to see their posts",
|
|
183
|
+
]):
|
|
184
|
+
status, reason = "protected", "protected_phrase_rendered"
|
|
185
|
+
elif has_any([
|
|
186
|
+
"this post is unavailable",
|
|
187
|
+
"this page doesn't exist",
|
|
188
|
+
"account suspended",
|
|
189
|
+
"this account doesn't exist",
|
|
190
|
+
]):
|
|
191
|
+
status, reason = "unavailable", "unavailable_phrase_rendered"
|
|
192
|
+
elif has_any([
|
|
193
|
+
"something went wrong",
|
|
194
|
+
"try reloading",
|
|
195
|
+
"retry",
|
|
196
|
+
]):
|
|
197
|
+
status, reason = "app_error", "generic_x_error_rendered"
|
|
198
|
+
elif "/login" in href or "/i/flow/login" in href:
|
|
199
|
+
status, reason = "logged_out", "login_url"
|
|
200
|
+
elif (state.get("article_count") or 0) > 0:
|
|
201
|
+
status, reason = "visible_no_anchor", "tweet_articles_rendered_but_anchor_not_found"
|
|
202
|
+
elif (state.get("body_len") or 0) == 0 and (state.get("article_count") or 0) == 0:
|
|
203
|
+
status, reason = "app_not_hydrated", "empty_x_app_shell"
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
"status": status,
|
|
207
|
+
"reason": reason,
|
|
208
|
+
"tweet_url": tweet_url,
|
|
209
|
+
"handle": handle,
|
|
210
|
+
"tweet_id": tweet_id,
|
|
211
|
+
"matched_tweet": matched,
|
|
212
|
+
"rendered_tweet_ids": rendered_ids[:12],
|
|
213
|
+
"rendered_handles": rendered_handles[:12],
|
|
214
|
+
"current_url": state.get("href"),
|
|
215
|
+
"title": state.get("title"),
|
|
216
|
+
"body_len": state.get("body_len"),
|
|
217
|
+
"main_len": state.get("main_len"),
|
|
218
|
+
"article_count": state.get("article_count"),
|
|
219
|
+
"phrases": phrases,
|
|
220
|
+
**({"state_error": state.get("error")} if state.get("error") else {}),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def diagnose_tweet_access(
|
|
225
|
+
tweet_url: str,
|
|
226
|
+
wait_ms: int = 12000,
|
|
227
|
+
include_public: bool = True,
|
|
228
|
+
) -> dict:
|
|
229
|
+
handle, tweet_id = parse_tweet_url(tweet_url)
|
|
230
|
+
if not handle or not tweet_id:
|
|
231
|
+
return {
|
|
232
|
+
"ok": False,
|
|
233
|
+
"status": "bad_tweet_url",
|
|
234
|
+
"tweet_url": tweet_url,
|
|
235
|
+
"public_control": public_control(tweet_url) if include_public else None,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
from playwright.sync_api import sync_playwright
|
|
239
|
+
|
|
240
|
+
retryable = {"app_not_hydrated", "unknown"}
|
|
241
|
+
final: dict | None = None
|
|
242
|
+
with sync_playwright() as p:
|
|
243
|
+
browser, page, is_cdp = tb.get_browser_and_page(p)
|
|
244
|
+
try:
|
|
245
|
+
for attempt in (1, 2):
|
|
246
|
+
try:
|
|
247
|
+
page.goto(tweet_url, wait_until="domcontentloaded", timeout=45000)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
print(f"[twitter_access] navigate attempt={attempt} failed: {e}", file=sys.stderr)
|
|
250
|
+
|
|
251
|
+
deadline = time.time() + max(2.0, wait_ms / 1000.0)
|
|
252
|
+
while True:
|
|
253
|
+
page.wait_for_timeout(1000)
|
|
254
|
+
try:
|
|
255
|
+
tweets = page.evaluate(tb.THREAD_EXTRACTOR_JS)
|
|
256
|
+
except Exception:
|
|
257
|
+
tweets = []
|
|
258
|
+
final = classify_current_page(page, tweet_url, tweets=tweets)
|
|
259
|
+
if final["status"] not in retryable or time.time() >= deadline:
|
|
260
|
+
break
|
|
261
|
+
if final["status"] not in retryable:
|
|
262
|
+
break
|
|
263
|
+
try:
|
|
264
|
+
page.evaluate("window.stop()")
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
if final is None:
|
|
268
|
+
final = classify_current_page(page, tweet_url)
|
|
269
|
+
finally:
|
|
270
|
+
if not is_cdp:
|
|
271
|
+
page.close()
|
|
272
|
+
browser.close()
|
|
273
|
+
|
|
274
|
+
if include_public:
|
|
275
|
+
final["public_control"] = public_control(tweet_url)
|
|
276
|
+
final["ok"] = final.get("status") == "visible"
|
|
277
|
+
return final
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def diagnose_session_access(
|
|
281
|
+
probe_url: str = "https://x.com/home",
|
|
282
|
+
wait_ms: int = 9000,
|
|
283
|
+
) -> dict:
|
|
284
|
+
"""Navigate one authenticated route and report whether X is gating us.
|
|
285
|
+
|
|
286
|
+
Unlike a cookie probe (which only proves an auth_token exists), this loads a
|
|
287
|
+
real authenticated page and classifies the rendered result. It returns a
|
|
288
|
+
`gated` boolean that callers (e.g. the cycle preflight) use to STOP before
|
|
289
|
+
scanning/posting against a session X is limiting, instead of mistaking the
|
|
290
|
+
resulting phantom "doesn't exist" renders for real, empty results.
|
|
291
|
+
|
|
292
|
+
status: access_gated | logged_out | ok | unknown
|
|
293
|
+
"""
|
|
294
|
+
from playwright.sync_api import sync_playwright
|
|
295
|
+
|
|
296
|
+
cf_phrases = (
|
|
297
|
+
"performing security verification",
|
|
298
|
+
"verify you are human",
|
|
299
|
+
"checking if the site connection is secure",
|
|
300
|
+
"security service to protect",
|
|
301
|
+
"needs to review the security of your connection",
|
|
302
|
+
)
|
|
303
|
+
final: dict = {"status": "unknown", "reason": "no_signal"}
|
|
304
|
+
with sync_playwright() as p:
|
|
305
|
+
browser, page, is_cdp = tb.get_browser_and_page(p)
|
|
306
|
+
try:
|
|
307
|
+
try:
|
|
308
|
+
page.goto(probe_url, wait_until="domcontentloaded", timeout=45000)
|
|
309
|
+
except Exception as e:
|
|
310
|
+
print(f"[twitter_access] session navigate failed: {e}", file=sys.stderr)
|
|
311
|
+
|
|
312
|
+
deadline = time.time() + max(2.0, wait_ms / 1000.0)
|
|
313
|
+
while True:
|
|
314
|
+
page.wait_for_timeout(1000)
|
|
315
|
+
state = page_state(page)
|
|
316
|
+
href = (state.get("href") or "").lower()
|
|
317
|
+
text_norm = _norm(state.get("text_prefix") or "")
|
|
318
|
+
articles = state.get("article_count") or 0
|
|
319
|
+
if "/account/access" in href:
|
|
320
|
+
final = {"status": "access_gated", "reason": "account_access_redirect"}
|
|
321
|
+
elif "/login" in href or "/i/flow/login" in href or "/logout" in href:
|
|
322
|
+
final = {"status": "logged_out", "reason": "login_url"}
|
|
323
|
+
elif any(s in text_norm for s in cf_phrases):
|
|
324
|
+
final = {"status": "access_gated", "reason": "cloudflare_challenge"}
|
|
325
|
+
elif articles > 0:
|
|
326
|
+
final = {"status": "ok", "reason": "timeline_rendered"}
|
|
327
|
+
else:
|
|
328
|
+
final = {"status": "unknown", "reason": "no_signal_yet"}
|
|
329
|
+
if final["status"] in ("access_gated", "logged_out", "ok") or time.time() >= deadline:
|
|
330
|
+
final["current_url"] = state.get("href")
|
|
331
|
+
final["title"] = state.get("title")
|
|
332
|
+
final["body_len"] = state.get("body_len")
|
|
333
|
+
final["article_count"] = articles
|
|
334
|
+
break
|
|
335
|
+
finally:
|
|
336
|
+
if not is_cdp:
|
|
337
|
+
page.close()
|
|
338
|
+
browser.close()
|
|
339
|
+
|
|
340
|
+
final["probe_url"] = probe_url
|
|
341
|
+
# Only the two positively-detected gate states halt the caller. ok/unknown
|
|
342
|
+
# never block, so a transient hydration miss can't silently stop posting.
|
|
343
|
+
final["gated"] = final.get("status") in ("access_gated", "logged_out")
|
|
344
|
+
return final
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def main() -> int:
|
|
348
|
+
parser = argparse.ArgumentParser()
|
|
349
|
+
parser.add_argument("tweet_url", nargs="?")
|
|
350
|
+
parser.add_argument("--wait-ms", type=int, default=12000)
|
|
351
|
+
parser.add_argument("--no-public-control", action="store_true")
|
|
352
|
+
parser.add_argument(
|
|
353
|
+
"--session-probe",
|
|
354
|
+
action="store_true",
|
|
355
|
+
help="Navigate an authenticated route and report whether X is gating "
|
|
356
|
+
"this session (access_gated/logged_out/ok). No tweet_url needed.",
|
|
357
|
+
)
|
|
358
|
+
parser.add_argument("--probe-url", default="https://x.com/home")
|
|
359
|
+
args = parser.parse_args()
|
|
360
|
+
|
|
361
|
+
if args.session_probe:
|
|
362
|
+
result = diagnose_session_access(
|
|
363
|
+
probe_url=args.probe_url,
|
|
364
|
+
wait_ms=min(args.wait_ms, 12000),
|
|
365
|
+
)
|
|
366
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
if not args.tweet_url:
|
|
370
|
+
parser.error("tweet_url is required unless --session-probe is given")
|
|
371
|
+
|
|
372
|
+
result = diagnose_tweet_access(
|
|
373
|
+
args.tweet_url,
|
|
374
|
+
wait_ms=args.wait_ms,
|
|
375
|
+
include_public=not args.no_public_control,
|
|
376
|
+
)
|
|
377
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
378
|
+
return 0
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Twitter-handle helper. Thin shim over `account_resolver` so the dozens of
|
|
2
|
+
existing `from twitter_account import resolve_handle` callers keep working.
|
|
3
|
+
|
|
4
|
+
New code should call `account_resolver.resolve('twitter')` directly. See
|
|
5
|
+
`account_resolver.py` for the canonical resolution order and normalization
|
|
6
|
+
rules.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from account_resolver import (
|
|
13
|
+
resolve as _resolve,
|
|
14
|
+
require as _require,
|
|
15
|
+
normalize as _normalize,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_handle() -> Optional[str]:
|
|
20
|
+
"""Return the normalized Twitter handle for this machine, or None."""
|
|
21
|
+
return _resolve("twitter")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def require_handle() -> str:
|
|
25
|
+
"""Raise if no Twitter handle is configured."""
|
|
26
|
+
return _require("twitter")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Some callers import the raw normalizer; keep the symbol stable.
|
|
30
|
+
def _normalize_legacy(handle: Optional[str]) -> Optional[str]: # pragma: no cover
|
|
31
|
+
return _normalize(handle)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
import sys
|
|
36
|
+
h = resolve_handle()
|
|
37
|
+
if h:
|
|
38
|
+
sys.stdout.write(h + "\n")
|
|
39
|
+
sys.exit(0)
|
|
40
|
+
sys.stderr.write("no twitter handle configured\n")
|
|
41
|
+
sys.exit(1)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Track per-cycle phase in twitter_batches so salvage can be phase-aware.
|
|
3
|
+
|
|
4
|
+
Owning run-twitter-cycle.sh stamps phase transitions; the NEXT cycle's
|
|
5
|
+
Phase 0 reads twitter_batches.current_phase + phase_started_at to decide
|
|
6
|
+
salvage timing per-phase instead of a flat 20-min wall-clock budget.
|
|
7
|
+
|
|
8
|
+
The flat cutoff salvaged live cycles mid Phase 2b-gen (SEO landing-page
|
|
9
|
+
build, 10-40 min), creating phantom failures and double-prep cost. See
|
|
10
|
+
the migration file 2026-05-01_twitter_batches.sql for context.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
twitter_batch_phase.py start <batch_id> --phase <name>
|
|
14
|
+
twitter_batch_phase.py advance <batch_id> --phase <name>
|
|
15
|
+
twitter_batch_phase.py end <batch_id>
|
|
16
|
+
|
|
17
|
+
start upserts the row (used at cycle init even if a stale row remains
|
|
18
|
+
from a SIGKILLed prior run with the same batch_id, which is
|
|
19
|
+
unlikely but harmless).
|
|
20
|
+
advance updates current_phase + phase_started_at; auto-creates the row
|
|
21
|
+
if start was missed for any reason.
|
|
22
|
+
end deletes the row on clean cycle exit. SIGKILL/OOM intentionally
|
|
23
|
+
leaves the row stale so the next cycle's Phase 0 can salvage
|
|
24
|
+
our pending candidates after the per-phase budget elapses.
|
|
25
|
+
|
|
26
|
+
The owning shell wraps lock.sh's EXIT trap to call `end` on clean exit;
|
|
27
|
+
see run-twitter-cycle.sh _sa_combined_exit.
|
|
28
|
+
|
|
29
|
+
Migrated 2026-05-18: DB writes now go through the s4l.ai HTTP API
|
|
30
|
+
(scripts/http_api.py -> /api/v1/twitter-batches) instead of psycopg2.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import os
|
|
37
|
+
import socket
|
|
38
|
+
import sys
|
|
39
|
+
|
|
40
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
41
|
+
from http_api import api_post # noqa: E402
|
|
42
|
+
|
|
43
|
+
VALID_PHASES = {
|
|
44
|
+
"phase0",
|
|
45
|
+
"phase1",
|
|
46
|
+
"phase2a",
|
|
47
|
+
"phase2b-prep",
|
|
48
|
+
"phase2b-gen",
|
|
49
|
+
"phase2b-post",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _validate_phase(phase: str) -> None:
|
|
54
|
+
if phase not in VALID_PHASES:
|
|
55
|
+
print(
|
|
56
|
+
f"twitter_batch_phase: invalid phase {phase!r}; expected one of {sorted(VALID_PHASES)}",
|
|
57
|
+
file=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_start(batch_id: str, phase: str) -> None:
|
|
63
|
+
_validate_phase(phase)
|
|
64
|
+
api_post(
|
|
65
|
+
"/api/v1/twitter-batches",
|
|
66
|
+
{
|
|
67
|
+
"action": "start",
|
|
68
|
+
"batch_id": batch_id,
|
|
69
|
+
"phase": phase,
|
|
70
|
+
"owner_pid": os.getppid(),
|
|
71
|
+
"owner_host": socket.gethostname(),
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
print(f"twitter_batches: started {batch_id} phase={phase}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_advance(batch_id: str, phase: str) -> None:
|
|
78
|
+
_validate_phase(phase)
|
|
79
|
+
api_post(
|
|
80
|
+
"/api/v1/twitter-batches",
|
|
81
|
+
{
|
|
82
|
+
"action": "advance",
|
|
83
|
+
"batch_id": batch_id,
|
|
84
|
+
"phase": phase,
|
|
85
|
+
"owner_pid": os.getppid(),
|
|
86
|
+
"owner_host": socket.gethostname(),
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
print(f"twitter_batches: advanced {batch_id} phase={phase}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_end(batch_id: str) -> None:
|
|
93
|
+
api_post(
|
|
94
|
+
"/api/v1/twitter-batches",
|
|
95
|
+
{"action": "end", "batch_id": batch_id},
|
|
96
|
+
)
|
|
97
|
+
print(f"twitter_batches: ended {batch_id}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> None:
|
|
101
|
+
ap = argparse.ArgumentParser(description="Track per-cycle phase in twitter_batches.")
|
|
102
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
103
|
+
|
|
104
|
+
p_start = sub.add_parser("start")
|
|
105
|
+
p_start.add_argument("batch_id")
|
|
106
|
+
p_start.add_argument("--phase", required=True)
|
|
107
|
+
|
|
108
|
+
p_adv = sub.add_parser("advance")
|
|
109
|
+
p_adv.add_argument("batch_id")
|
|
110
|
+
p_adv.add_argument("--phase", required=True)
|
|
111
|
+
|
|
112
|
+
p_end = sub.add_parser("end")
|
|
113
|
+
p_end.add_argument("batch_id")
|
|
114
|
+
|
|
115
|
+
args = ap.parse_args()
|
|
116
|
+
|
|
117
|
+
if args.cmd == "start":
|
|
118
|
+
cmd_start(args.batch_id, args.phase)
|
|
119
|
+
elif args.cmd == "advance":
|
|
120
|
+
cmd_advance(args.batch_id, args.phase)
|
|
121
|
+
elif args.cmd == "end":
|
|
122
|
+
cmd_end(args.batch_id)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|