@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.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. 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()