@m13v/s4l 1.7.6-rc.2 → 1.7.6-rc.21
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/mcp/dist/index.js +256 -92
- package/mcp/dist/telemetry.js +2 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +144 -18
- package/mcp/menubar/s4l_card_canvas.py +135 -14
- package/mcp/menubar/s4l_menubar.py +216 -73
- package/mcp/menubar/s4l_state.py +45 -130
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +35 -1
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/draft_prompt_core.py +100 -17
- package/scripts/engagement_styles.py +135 -98
- package/scripts/merge_review_queue.py +192 -129
- package/scripts/platform_strike_events.py +54 -5
- package/scripts/post_reddit.py +10 -0
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/reap_stale_claude_sessions.py +7 -6
- package/scripts/reddit_ban_check.py +220 -0
- package/scripts/reddit_browser.py +24 -0
- package/scripts/release-mcpb.sh +38 -7
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/store_patch.py +111 -0
- package/scripts/strike_alert.py +161 -12
- package/scripts/test_no_silent_fallbacks.py +11 -0
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/skill/archive-old-logs.sh +24 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-twitter-cycle.sh +146 -66
package/scripts/strike_alert.py
CHANGED
|
@@ -455,10 +455,93 @@ def _owner_strike_count(owner, days=90):
|
|
|
455
455
|
return (live_count, raw_count)
|
|
456
456
|
|
|
457
457
|
|
|
458
|
-
def
|
|
458
|
+
def _subreddit_from_url(url):
|
|
459
|
+
if not url:
|
|
460
|
+
return None
|
|
461
|
+
m = re.search(r"reddit\.com/r/([^/]+)", url)
|
|
462
|
+
return m.group(1).lower() if m else None
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _subreddit_strike_count(sub, days=90):
|
|
466
|
+
"""How many of our reddit posts in r/<sub> were moderated (removed/deleted)
|
|
467
|
+
in the last `days` days. The reddit analogue of _owner_strike_count: turns a
|
|
468
|
+
single strike email into 'this is the Nth removal in this community', which
|
|
469
|
+
is the clustering signal that separates a chronic bad-fit venue from a
|
|
470
|
+
one-off removal. Returns 0 on any lookup failure (never blocks the email)."""
|
|
471
|
+
if not sub:
|
|
472
|
+
return 0
|
|
473
|
+
try:
|
|
474
|
+
resp = api_get("/api/v1/posts/thread-urls", query={
|
|
475
|
+
"platform": "reddit", "moderated_within_days": int(days),
|
|
476
|
+
})
|
|
477
|
+
except Exception:
|
|
478
|
+
return 0
|
|
479
|
+
urls = (resp.get("data") or {}).get("thread_urls") or []
|
|
480
|
+
return sum(1 for u in urls if _subreddit_from_url(u) == sub)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _recorded_ban(sub, config, account):
|
|
484
|
+
"""True if r/<sub> is already on the config.json comment_blocked denylist
|
|
485
|
+
with reason 'account_blocked_in_sub' for this account (the recorded output
|
|
486
|
+
of the deterministic ban check). Account-scoped, matching
|
|
487
|
+
reddit_tools._load_comment_blocked_subs semantics."""
|
|
488
|
+
if not sub:
|
|
489
|
+
return False
|
|
490
|
+
acct = (account or "").lower()
|
|
491
|
+
for entry in ((config.get("subreddit_bans") or {}).get("comment_blocked") or []):
|
|
492
|
+
if not isinstance(entry, dict):
|
|
493
|
+
continue
|
|
494
|
+
if (entry.get("sub") or "").strip().lower() != sub:
|
|
495
|
+
continue
|
|
496
|
+
if entry.get("reason") != "account_blocked_in_sub":
|
|
497
|
+
continue
|
|
498
|
+
ent_acct = (entry.get("account") or "").lower()
|
|
499
|
+
if ent_acct and acct and ent_acct != acct:
|
|
500
|
+
continue
|
|
501
|
+
return True
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _reddit_ban_verdict(subs, config, account):
|
|
506
|
+
"""Return {sub: 'banned'|'not_banned'|'unknown'} for reddit strike subs.
|
|
507
|
+
|
|
508
|
+
Prefers a single live deterministic check (reddit_ban_check.banned_state,
|
|
509
|
+
one browser attach for the whole sweep, read-only) and falls back to the
|
|
510
|
+
recorded config denylist when no session is reachable or the check is
|
|
511
|
+
disabled via S4L_STRIKE_BAN_CHECK=0. A recorded ban is authoritative even
|
|
512
|
+
when the live check says unknown (session down) so we never downgrade a
|
|
513
|
+
known ban to 'single-post removal'."""
|
|
514
|
+
verdict = {}
|
|
515
|
+
live = {}
|
|
516
|
+
if subs and os.environ.get("S4L_STRIKE_BAN_CHECK", "1") not in ("0", "false", "no"):
|
|
517
|
+
try:
|
|
518
|
+
import reddit_ban_check
|
|
519
|
+
live = reddit_ban_check.banned_state(list(subs)) or {}
|
|
520
|
+
except Exception as e:
|
|
521
|
+
print(f"[strike_alert] reddit_ban_check unavailable (non-fatal): {e}",
|
|
522
|
+
file=sys.stderr)
|
|
523
|
+
for sub in subs:
|
|
524
|
+
lv = live.get(sub)
|
|
525
|
+
if lv is True:
|
|
526
|
+
verdict[sub] = "banned"
|
|
527
|
+
elif _recorded_ban(sub, config, account):
|
|
528
|
+
verdict[sub] = "banned"
|
|
529
|
+
elif lv is False:
|
|
530
|
+
verdict[sub] = "not_banned"
|
|
531
|
+
else:
|
|
532
|
+
verdict[sub] = "unknown"
|
|
533
|
+
return verdict
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _format_subject(post, repo_state=None, sub_ban=None):
|
|
459
537
|
platform = post["platform"] or "?"
|
|
460
538
|
status = post["status"] or "?"
|
|
461
539
|
tag = "STRIKE"
|
|
540
|
+
if platform == "reddit" and sub_ban == "banned":
|
|
541
|
+
# Account is banned from the whole community, not just this post
|
|
542
|
+
# removed. Loud subject tag so a ban is visually distinct from the
|
|
543
|
+
# far more common single-post removal.
|
|
544
|
+
tag = "STRIKE-BANNED"
|
|
462
545
|
if platform == "github" and repo_state == "repo_gone":
|
|
463
546
|
# Owner nuked the whole repo. Not a moderation strike against us.
|
|
464
547
|
status = "repo-deleted"
|
|
@@ -488,7 +571,7 @@ def _ts(v):
|
|
|
488
571
|
return v.isoformat() if hasattr(v, "isoformat") else str(v)
|
|
489
572
|
|
|
490
573
|
|
|
491
|
-
def _format_body(post, repo_state=None):
|
|
574
|
+
def _format_body(post, repo_state=None, sub_ban=None, sub_count=None):
|
|
492
575
|
platform = post["platform"] or "?"
|
|
493
576
|
status = post["status"] or "?"
|
|
494
577
|
project = post["project_name"] or "(no project)"
|
|
@@ -505,6 +588,35 @@ def _format_body(post, repo_state=None):
|
|
|
505
588
|
|
|
506
589
|
owner_block = ""
|
|
507
590
|
repo_block = ""
|
|
591
|
+
reddit_block = ""
|
|
592
|
+
if platform == "reddit":
|
|
593
|
+
sub = _subreddit_from_url(thread_url) or _subreddit_from_url(our_url)
|
|
594
|
+
if sub:
|
|
595
|
+
n = sub_count if sub_count is not None else _subreddit_strike_count(sub)
|
|
596
|
+
count_line = (
|
|
597
|
+
f"Subreddit: r/{sub} ({n} of our post(s) moderated here in the "
|
|
598
|
+
f"last 90 days)\n"
|
|
599
|
+
)
|
|
600
|
+
if sub_ban == "banned":
|
|
601
|
+
ban_line = (
|
|
602
|
+
f"Ban state: ACCOUNT BANNED from r/{sub} (deterministic "
|
|
603
|
+
f"user_is_banned check). This is not a single-post removal: "
|
|
604
|
+
f"every future post here will be removed. The sub is on the "
|
|
605
|
+
f"comment_blocked denylist so the drafter will stop targeting "
|
|
606
|
+
f"it, and a platform_banned learning signal feeds the digest.\n"
|
|
607
|
+
)
|
|
608
|
+
elif sub_ban == "not_banned":
|
|
609
|
+
ban_line = (
|
|
610
|
+
f"Ban state: not banned (user_is_banned=false). This is a "
|
|
611
|
+
f"single-post removal; the account can still post in r/{sub}.\n"
|
|
612
|
+
)
|
|
613
|
+
else:
|
|
614
|
+
ban_line = (
|
|
615
|
+
f"Ban state: unknown (no live Reddit session for the check and "
|
|
616
|
+
f"not on the recorded denylist). Treated as a single-post "
|
|
617
|
+
f"removal until confirmed.\n"
|
|
618
|
+
)
|
|
619
|
+
reddit_block = count_line + ban_line
|
|
508
620
|
if platform == "github" and thread_url:
|
|
509
621
|
if repo_state == "repo_gone":
|
|
510
622
|
repo_block = (
|
|
@@ -559,6 +671,7 @@ def _format_body(post, repo_state=None):
|
|
|
559
671
|
f"Detected: {checked_at}\n"
|
|
560
672
|
f"{repo_block}"
|
|
561
673
|
f"{owner_block}"
|
|
674
|
+
f"{reddit_block}"
|
|
562
675
|
f"\n"
|
|
563
676
|
f"Thread: {thread_url}\n"
|
|
564
677
|
f"Title: {title}\n"
|
|
@@ -567,20 +680,38 @@ def _format_body(post, repo_state=None):
|
|
|
567
680
|
f"--- Our content ---\n"
|
|
568
681
|
f"{content_preview}\n"
|
|
569
682
|
f"\n"
|
|
570
|
-
f"
|
|
571
|
-
f"1. Inspect the thread to see if the comment was deleted, hidden,\n"
|
|
572
|
-
f" or if the whole account was blocked.\n"
|
|
573
|
-
f"2. If the owner should be hard-blocked, add it to\n"
|
|
574
|
-
f" config.json -> exclusions.github_repos. Owner-level entries\n"
|
|
575
|
-
f" match all repos under that owner.\n"
|
|
576
|
-
f"3. The auto-blocklist (github_tools._dynamic_owner_blocklist)\n"
|
|
577
|
-
f" already covers any owner with >=2 strikes in 90 days.\n"
|
|
683
|
+
f"{_next_steps(platform)}"
|
|
578
684
|
f"\n"
|
|
579
685
|
f"To re-fire this alert: python3 scripts/strike_alert.py --post-id {post['id']}\n"
|
|
580
686
|
)
|
|
581
687
|
return _scrub_dashes(body)
|
|
582
688
|
|
|
583
689
|
|
|
690
|
+
def _next_steps(platform):
|
|
691
|
+
if platform == "reddit":
|
|
692
|
+
return (
|
|
693
|
+
"--- Next steps ---\n"
|
|
694
|
+
"1. Ban state above is deterministic (logged-in user_is_banned).\n"
|
|
695
|
+
" BANNED subs are auto-added to config.json subreddit_bans\n"
|
|
696
|
+
" .comment_blocked and feed a platform_banned learning event;\n"
|
|
697
|
+
" no manual action needed to stop targeting them.\n"
|
|
698
|
+
"2. A single-post removal is one community's judgment on the content;\n"
|
|
699
|
+
" the platform_removed event already teaches the digest that\n"
|
|
700
|
+
" pattern. Watch the 'moderated here in last 90 days' count: a\n"
|
|
701
|
+
" rising count on a not-yet-banned sub is an early bad-fit signal.\n"
|
|
702
|
+
)
|
|
703
|
+
return (
|
|
704
|
+
"--- Next steps ---\n"
|
|
705
|
+
"1. Inspect the thread to see if the comment was deleted, hidden,\n"
|
|
706
|
+
" or if the whole account was blocked.\n"
|
|
707
|
+
"2. If the owner should be hard-blocked, add it to\n"
|
|
708
|
+
" config.json -> exclusions.github_repos. Owner-level entries\n"
|
|
709
|
+
" match all repos under that owner.\n"
|
|
710
|
+
"3. The auto-blocklist (github_tools._dynamic_owner_blocklist)\n"
|
|
711
|
+
" already covers any owner with >=2 strikes in 90 days.\n"
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
|
|
584
715
|
def _send_email(subject, body):
|
|
585
716
|
msg = MIMEText(body, "plain", "utf-8")
|
|
586
717
|
msg["to"] = NOTIFICATION_EMAIL
|
|
@@ -651,6 +782,19 @@ def main():
|
|
|
651
782
|
print("[strike_alert] no pending strikes")
|
|
652
783
|
return
|
|
653
784
|
|
|
785
|
+
# Deterministic reddit ban verdict, computed ONCE for the whole sweep (a
|
|
786
|
+
# single read-only browser attach for all distinct subs, not one per email).
|
|
787
|
+
# Falls back to the recorded denylist when no session is reachable.
|
|
788
|
+
reddit_subs = {
|
|
789
|
+
s for s in (
|
|
790
|
+
_subreddit_from_url(r.get("thread_url")) or _subreddit_from_url(r.get("our_url"))
|
|
791
|
+
for r in rows if r.get("platform") == "reddit"
|
|
792
|
+
) if s
|
|
793
|
+
}
|
|
794
|
+
reddit_ban = _reddit_ban_verdict(reddit_subs, _cfg, _reddit_username)
|
|
795
|
+
if reddit_subs:
|
|
796
|
+
print(f"[strike_alert] reddit_ban_verdict {reddit_ban}", flush=True)
|
|
797
|
+
|
|
654
798
|
sent = 0
|
|
655
799
|
skipped = 0
|
|
656
800
|
filtered = 0
|
|
@@ -752,8 +896,13 @@ def main():
|
|
|
752
896
|
)
|
|
753
897
|
continue
|
|
754
898
|
|
|
755
|
-
|
|
756
|
-
|
|
899
|
+
_sub = None
|
|
900
|
+
_sub_ban = None
|
|
901
|
+
if r["platform"] == "reddit":
|
|
902
|
+
_sub = _subreddit_from_url(r.get("thread_url")) or _subreddit_from_url(r.get("our_url"))
|
|
903
|
+
_sub_ban = reddit_ban.get(_sub)
|
|
904
|
+
subject = _format_subject(r, repo_state=repo_state, sub_ban=_sub_ban)
|
|
905
|
+
body = _format_body(r, repo_state=repo_state, sub_ban=_sub_ban)
|
|
757
906
|
if args.dry_run:
|
|
758
907
|
print(f"[strike_alert] DRY RUN id={r['id']}")
|
|
759
908
|
print(f" subject: {subject}")
|
|
@@ -228,6 +228,7 @@ def check_account_resolver_hard_fails():
|
|
|
228
228
|
import account_resolver # noqa: E402
|
|
229
229
|
|
|
230
230
|
saved_load_config = account_resolver._load_config
|
|
231
|
+
saved_durable_handle = account_resolver._durable_handle
|
|
231
232
|
saved_env = dict(os.environ)
|
|
232
233
|
try:
|
|
233
234
|
# Strip every account-related env var + simulate an empty config.json
|
|
@@ -237,6 +238,15 @@ def check_account_resolver_hard_fails():
|
|
|
237
238
|
if k.startswith("AUTOPOSTER_"):
|
|
238
239
|
del os.environ[k]
|
|
239
240
|
account_resolver._load_config = lambda: {}
|
|
241
|
+
# Also stub the durable identity store (2026-07-17, added alongside
|
|
242
|
+
# account_resolver's _durable_handle): resolve() falls through to it
|
|
243
|
+
# as a THIRD source after env/config, so on any machine with a real
|
|
244
|
+
# twitter_cookie_mirror (this box included) "no config" alone no
|
|
245
|
+
# longer means "nothing resolvable" -- the real connected account's
|
|
246
|
+
# own handle would still come back and fail this "fresh install"
|
|
247
|
+
# simulation for the wrong reason. Stub it the same way _load_config
|
|
248
|
+
# is stubbed so this test isolates all three sources, not just one.
|
|
249
|
+
account_resolver._durable_handle = lambda key: None
|
|
240
250
|
|
|
241
251
|
for platform in ("twitter", "reddit", "linkedin", "github", "moltbook"):
|
|
242
252
|
result = account_resolver.resolve(platform)
|
|
@@ -258,6 +268,7 @@ def check_account_resolver_hard_fails():
|
|
|
258
268
|
result == "some_handle", f"got {result!r}")
|
|
259
269
|
finally:
|
|
260
270
|
account_resolver._load_config = saved_load_config
|
|
271
|
+
account_resolver._durable_handle = saved_durable_handle
|
|
261
272
|
os.environ.clear()
|
|
262
273
|
os.environ.update(saved_env)
|
|
263
274
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression test: the autopilot stall watchdog's batch-progression check must
|
|
3
|
+
read the last N twitter_batches from the API envelope's data.batches, NOT the
|
|
4
|
+
(nonexistent) top-level resp["batches"].
|
|
5
|
+
|
|
6
|
+
Background (Nhat ae18af69, 07-15): http_api.api_get returns the RAW envelope
|
|
7
|
+
{"ok":..,"data":{"batches":[..],"owner_host":..}} and does not unwrap "data".
|
|
8
|
+
_recent_batches_not_progressing() read resp.get("batches") -> always None ->
|
|
9
|
+
batches=[] -> len<min -> returned False on EVERY call. batches_stuck was thus
|
|
10
|
+
hardwired False and the "drafting is not actually happening" stall cause could
|
|
11
|
+
never be selected, so the watchdog mislabeled a real 30h phase2b-prep pileup as
|
|
12
|
+
"orphaned routines / account change?". This locks in the correct key + the
|
|
13
|
+
True/False logic so it cannot silently regress again.
|
|
14
|
+
|
|
15
|
+
Run: python3 scripts/test_stall_watch_batch_progression.py
|
|
16
|
+
Exit 0 = all pass; non-zero with FAIL lines otherwise.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import importlib
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import types
|
|
24
|
+
|
|
25
|
+
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
SCRIPTS = os.path.join(REPO, "scripts")
|
|
27
|
+
# Source-tree test: pin S4L_REPO_DIR to THIS checkout so the function resolves
|
|
28
|
+
# http_api from here, not an inherited installed-package path
|
|
29
|
+
# (see gotcha_s4l_repo_dir_env_inherited_installed_package).
|
|
30
|
+
os.environ["S4L_REPO_DIR"] = REPO
|
|
31
|
+
if SCRIPTS not in sys.path:
|
|
32
|
+
sys.path.insert(0, SCRIPTS)
|
|
33
|
+
|
|
34
|
+
asw = importlib.import_module("autopilot_stall_watch")
|
|
35
|
+
MIN = asw.BATCH_PROGRESSION_MIN_BATCHES
|
|
36
|
+
|
|
37
|
+
failures: list[str] = []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check(name: str, got, want) -> None:
|
|
41
|
+
ok = got == want
|
|
42
|
+
print(f"{'PASS' if ok else 'FAIL'} {name}: got {got!r}, want {want!r}")
|
|
43
|
+
if not ok:
|
|
44
|
+
failures.append(name)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fake_api(canned):
|
|
48
|
+
"""Swap http_api for one whose api_get returns `canned` (or raises it, if a
|
|
49
|
+
BaseException). The function under test does `import http_api` lazily, so a
|
|
50
|
+
module planted in sys.modules wins."""
|
|
51
|
+
fake = types.ModuleType("http_api")
|
|
52
|
+
|
|
53
|
+
def api_get(path, query=None, ok_on_404=False):
|
|
54
|
+
if isinstance(canned, BaseException):
|
|
55
|
+
raise canned
|
|
56
|
+
return canned
|
|
57
|
+
|
|
58
|
+
fake.api_get = api_get
|
|
59
|
+
sys.modules["http_api"] = fake
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def envelope(phases):
|
|
63
|
+
"""The real /api/v1/twitter-batches shape: batches nested under data."""
|
|
64
|
+
return {"ok": True, "data": {"owner_host": "test-host",
|
|
65
|
+
"batches": [{"current_phase": p} for p in phases]}}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# 1. Last N batches ALL stuck at prep -> stall detected (the case that was missed).
|
|
69
|
+
_fake_api(envelope(["phase2b-prep"] * MIN))
|
|
70
|
+
check("all phase2b-prep -> batches_not_progressing True",
|
|
71
|
+
asw._recent_batches_not_progressing(), True)
|
|
72
|
+
|
|
73
|
+
# 2. One of the last N reached real drafting -> not a stall.
|
|
74
|
+
_fake_api(envelope(["phase2b-prep"] * (MIN - 1) + ["phase2b-gen"]))
|
|
75
|
+
check("one phase2b-gen among last N -> False",
|
|
76
|
+
asw._recent_batches_not_progressing(), False)
|
|
77
|
+
|
|
78
|
+
# 3. Fewer than N batches -> inconclusive, never page on thin history.
|
|
79
|
+
_fake_api(envelope(["phase2b-prep"] * (MIN - 1)))
|
|
80
|
+
check("fewer than MIN batches -> False",
|
|
81
|
+
asw._recent_batches_not_progressing(), False)
|
|
82
|
+
|
|
83
|
+
# 4. THE REGRESSION GUARD: batches present ONLY at the wrong top-level key must be
|
|
84
|
+
# ignored. Correct code reads data.batches -> sees [] -> False. If someone
|
|
85
|
+
# "simplifies" back to resp.get("batches"), this flips to True and fails.
|
|
86
|
+
_fake_api({"ok": True, "batches": [{"current_phase": "phase2b-prep"}] * MIN})
|
|
87
|
+
check("top-level resp['batches'] ignored (must read data.batches)",
|
|
88
|
+
asw._recent_batches_not_progressing(), False)
|
|
89
|
+
|
|
90
|
+
# 5. A terminal API failure (http_api raises SystemExit, a BaseException) must be
|
|
91
|
+
# swallowed to False, never crash this best-effort watchdog.
|
|
92
|
+
_fake_api(SystemExit("terminal 5xx"))
|
|
93
|
+
check("api SystemExit swallowed -> False",
|
|
94
|
+
asw._recent_batches_not_progressing(), False)
|
|
95
|
+
|
|
96
|
+
if failures:
|
|
97
|
+
print(f"\n{len(failures)} FAILED: {failures}")
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
print("\nALL PASS")
|
|
@@ -22,6 +22,12 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
22
22
|
|
|
23
23
|
MIN_CONTENT_LEN = 30 # skip posts with empty/placeholder content
|
|
24
24
|
|
|
25
|
+
# Duplicated literal (not imported): must match draft_prompt_core.py's
|
|
26
|
+
# STYLE_SENTINEL_TREATMENT and engagement_styles.py's STYLE_SENTINEL, both
|
|
27
|
+
# of which independently define "voice_first" the same way ARM_TREATMENT's
|
|
28
|
+
# "treatment_v4" is already duplicated across those two modules.
|
|
29
|
+
STYLE_SENTINEL_TREATMENT = "voice_first"
|
|
30
|
+
|
|
25
31
|
# CTE that adds a bot-filtered `clicks` column to every row of `posts`.
|
|
26
32
|
# Sources from `post_link_clicks` (per-hit log, populated by the redirector
|
|
27
33
|
# after 2026-05-07) with `is_bot=false`. This is the same attribution path
|
|
@@ -522,6 +528,24 @@ def _fetch_report_via_api(*, platform, project, top, bottom):
|
|
|
522
528
|
raw_group = data.get("top_by_group") or {}
|
|
523
529
|
top_by_style = data.get("top_by_style") or []
|
|
524
530
|
|
|
531
|
+
# treatment_v4 Twitter posts carry no real engagement style; they're
|
|
532
|
+
# stamped with the sentinel "voice_first" (must match
|
|
533
|
+
# draft_prompt_core.STYLE_SENTINEL_TREATMENT / engagement_styles.py's
|
|
534
|
+
# duplicated literal) instead of NULL, to avoid SQL NULL-handling
|
|
535
|
+
# landmines. This SQL-side GROUP BY has no whitelist, so without this
|
|
536
|
+
# filter "voice_first" would show up as its own row here, at roughly
|
|
537
|
+
# half of all Twitter post volume, and its single highest-scoring post
|
|
538
|
+
# would get presented as a "style to imitate" in the Best Example
|
|
539
|
+
# section below -- exactly the fake-style pollution this sentinel was
|
|
540
|
+
# designed to avoid downstream, just relocated to this report instead.
|
|
541
|
+
# Filtered here (not at the API) so every caller of this module is
|
|
542
|
+
# covered without touching the sibling website repo's SQL.
|
|
543
|
+
style_perf = [r for r in style_perf if r and r[0] != STYLE_SENTINEL_TREATMENT]
|
|
544
|
+
top_by_style = [
|
|
545
|
+
r for r in top_by_style
|
|
546
|
+
if not (len(r) > 12 and r[12] == STYLE_SENTINEL_TREATMENT)
|
|
547
|
+
]
|
|
548
|
+
|
|
525
549
|
top_filtered = _apply_top_filter(raw_top, top) if raw_top else []
|
|
526
550
|
fallback_filtered = None
|
|
527
551
|
if project and not top_filtered and raw_fallback:
|
|
@@ -419,68 +419,38 @@ def _arm_self_liveness_watchdog():
|
|
|
419
419
|
# _get_browser_and_page_raw, and find_twitter_cdp_port discovery. The
|
|
420
420
|
# harness-profile discovery fallback stays as a belt for a closed tab.
|
|
421
421
|
_PARK_URL = "https://x.com/robots.txt"
|
|
422
|
-
_PARK_REGISTERED = False
|
|
423
422
|
|
|
424
423
|
|
|
425
424
|
def _park_twitter_tabs():
|
|
426
|
-
|
|
427
|
-
|
|
425
|
+
# Thin alias kept for callers/tests; ONE implementation lives in
|
|
426
|
+
# scripts/browser_lifecycle.py (shared with reddit_browser.py — user
|
|
427
|
+
# directive 2026-07-17: share the machinery, don't fork failure points).
|
|
428
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
429
|
+
from browser_lifecycle import park_tabs
|
|
428
430
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
targets = json.loads(resp.read())
|
|
435
|
-
for t in targets:
|
|
436
|
-
url = t.get("url", "")
|
|
437
|
-
if t.get("type") != "page":
|
|
438
|
-
continue
|
|
439
|
-
if "x.com" not in url and "twitter.com" not in url:
|
|
440
|
-
continue
|
|
441
|
-
if url.split("?", 1)[0].rstrip("/").endswith("/robots.txt"):
|
|
442
|
-
continue # already parked
|
|
443
|
-
ws_url = t.get("webSocketDebuggerUrl")
|
|
444
|
-
if not ws_url:
|
|
445
|
-
continue
|
|
446
|
-
try:
|
|
447
|
-
import websocket
|
|
448
|
-
|
|
449
|
-
# suppress_origin: Chrome 111+ rejects CDP websocket clients
|
|
450
|
-
# that send an Origin header not in --remote-allow-origins
|
|
451
|
-
# (same pattern as restore_twitter_session.py).
|
|
452
|
-
ws = websocket.create_connection(
|
|
453
|
-
ws_url, timeout=3, suppress_origin=True
|
|
454
|
-
)
|
|
455
|
-
try:
|
|
456
|
-
ws.send(json.dumps({
|
|
457
|
-
"id": 1,
|
|
458
|
-
"method": "Page.navigate",
|
|
459
|
-
"params": {"url": _PARK_URL},
|
|
460
|
-
}))
|
|
461
|
-
ws.recv()
|
|
462
|
-
finally:
|
|
463
|
-
ws.close()
|
|
464
|
-
print(
|
|
465
|
-
f"[twitter_browser] parked tab on {_PARK_URL} (was {url[:80]})",
|
|
466
|
-
file=sys.stderr,
|
|
467
|
-
)
|
|
468
|
-
except Exception:
|
|
469
|
-
continue
|
|
470
|
-
except Exception:
|
|
471
|
-
pass
|
|
431
|
+
base = (
|
|
432
|
+
os.environ.get("TWITTER_CDP_URL", "").strip()
|
|
433
|
+
or "http://127.0.0.1:9555"
|
|
434
|
+
).rstrip("/")
|
|
435
|
+
park_tabs(base, ("x.com", "twitter.com"), _PARK_URL, "twitter_browser")
|
|
472
436
|
|
|
473
437
|
|
|
474
438
|
def _register_park_on_exit():
|
|
475
439
|
"""Arm parking once per process, only for processes that actually used the
|
|
476
440
|
browser. Registered AFTER the module-level _release_browser_lock atexit,
|
|
477
441
|
so it runs BEFORE it (LIFO): park while still holding the python lock,
|
|
478
|
-
then release. S4L_NO_TAB_PARK=1 is the escape hatch
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
442
|
+
then release. S4L_NO_TAB_PARK=1 is the escape hatch (honored inside
|
|
443
|
+
browser_lifecycle)."""
|
|
444
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
445
|
+
from browser_lifecycle import register_park_on_exit
|
|
446
|
+
|
|
447
|
+
base = (
|
|
448
|
+
os.environ.get("TWITTER_CDP_URL", "").strip()
|
|
449
|
+
or "http://127.0.0.1:9555"
|
|
450
|
+
).rstrip("/")
|
|
451
|
+
register_park_on_exit(
|
|
452
|
+
base, ("x.com", "twitter.com"), _PARK_URL, "twitter_browser"
|
|
453
|
+
)
|
|
484
454
|
|
|
485
455
|
|
|
486
456
|
def get_browser_and_page(playwright):
|
|
@@ -33,6 +33,30 @@ log "=== archive-old-logs starting (DAYS=$DAYS) ==="
|
|
|
33
33
|
find "$LOG_DIR" -maxdepth 1 -type f -mtime +"$DAYS" ! -name "$(basename "$RUN_LOG")" -print0 \
|
|
34
34
|
| xargs -0 -I{} mv {} "$ARCHIVE_DIR/" 2>&1 | tee -a "$RUN_LOG" >/dev/null || true
|
|
35
35
|
|
|
36
|
+
# --- Size-based rotation for live launchd-*.log files (2026-07-17) -----------
|
|
37
|
+
# launchd holds these fds open and appends forever, so the mtime sweep above
|
|
38
|
+
# never touches them: their mtime is always fresh. Found in the wild at 1.9GB
|
|
39
|
+
# (launchd-linkedin-stdout.log) and 694MB (launchd-engage-twitter-stdout.log),
|
|
40
|
+
# which made "grep the logs" surface months-stale content as if it were
|
|
41
|
+
# current (the 2026-07-17 Neon-hostname false alarm).
|
|
42
|
+
# Copy-truncate: gzip the current content into logs-archive with a timestamp,
|
|
43
|
+
# then truncate IN PLACE (same inode) so the launchd fd keeps appending.
|
|
44
|
+
# Lines appended during the gzip window are lost on truncate; acceptable for
|
|
45
|
+
# logs, and the window is seconds. Threshold tunable via ROTATE_MAX_MB.
|
|
46
|
+
ROTATE_MAX_MB="${ROTATE_MAX_MB:-10}"
|
|
47
|
+
find "$LOG_DIR" -maxdepth 1 -type f -name "launchd-*.log" -size +"${ROTATE_MAX_MB}M" -print0 \
|
|
48
|
+
| while IFS= read -r -d '' f; do
|
|
49
|
+
base=$(basename "$f" .log)
|
|
50
|
+
dest="$ARCHIVE_DIR/${base}-$(date +%Y%m%d-%H%M%S).log.gz"
|
|
51
|
+
if gzip -c "$f" > "$dest" 2>>"$RUN_LOG"; then
|
|
52
|
+
: > "$f"
|
|
53
|
+
log "rotated $(basename "$f") -> $(basename "$dest") ($(du -h "$dest" | cut -f1) compressed)"
|
|
54
|
+
else
|
|
55
|
+
rm -f "$dest"
|
|
56
|
+
log "WARN: rotate failed for $f (left untouched)"
|
|
57
|
+
fi
|
|
58
|
+
done
|
|
59
|
+
|
|
36
60
|
remaining=$(find "$LOG_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
|
|
37
61
|
archived=$(find "$ARCHIVE_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ')
|
|
38
62
|
|
|
@@ -153,6 +153,7 @@ ensure_reddit_browser_for_backend() {
|
|
|
153
153
|
HC_WINDOW_POS="${BH_REDDIT_WINDOW_POS:-2131,-1032}" \
|
|
154
154
|
HC_WINDOW_SIZE="${BH_REDDIT_WINDOW_SIZE:-911,1016}" \
|
|
155
155
|
HC_PRE_LAUNCH_HOOK=_reddit_defer_if_posting \
|
|
156
|
+
HC_EXTRA_FLAGS="${BH_REDDIT_PROXY:+--proxy-server=$BH_REDDIT_PROXY}" \
|
|
156
157
|
hc_ensure_browser
|
|
157
158
|
}
|
|
158
159
|
|