@m13v/s4l 1.7.5-rc.26 → 1.7.5-rc.27
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/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_menubar.py +79 -16
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/reddit_browser_fetch.py +119 -64
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.5-rc.
|
|
5
|
+
"version": "1.7.5-rc.27",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
|
@@ -1070,6 +1070,21 @@ class S4LMenuBar(rumps.App):
|
|
|
1070
1070
|
def _toggle_promotion(self, _=None):
|
|
1071
1071
|
self._toggle_lane(st.MODE_PROMOTION)
|
|
1072
1072
|
|
|
1073
|
+
def _toggle_canvas_review(self, _=None):
|
|
1074
|
+
"""Menu: flip which review surface presents pending drafts. Persisted
|
|
1075
|
+
(st.write_review_layout), so it survives a restart; an already-open
|
|
1076
|
+
surface keeps driving through whichever module it started with
|
|
1077
|
+
(_review_mod_name) -- this only changes what the NEXT presentation
|
|
1078
|
+
uses. See _review_mod()."""
|
|
1079
|
+
current = st.read_review_layout()
|
|
1080
|
+
st.write_review_layout("cards" if current == "canvas" else "canvas")
|
|
1081
|
+
self._sig = None
|
|
1082
|
+
try:
|
|
1083
|
+
self._tick(None)
|
|
1084
|
+
except Exception as e:
|
|
1085
|
+
sys.stderr.write(f"[s4l-menubar] canvas toggle rebuild failed: {e}\n")
|
|
1086
|
+
sys.stderr.flush()
|
|
1087
|
+
|
|
1073
1088
|
# Personal-brand share presets for the both-lanes-on state. rumps has no
|
|
1074
1089
|
# slider, so the "Lane split" submenu offers these fixed points; the
|
|
1075
1090
|
# dashboard's slider can set anything in between and the checkmark simply
|
|
@@ -2788,19 +2803,38 @@ class S4LMenuBar(rumps.App):
|
|
|
2788
2803
|
until = self._last_presented_at + cadence
|
|
2789
2804
|
return until if (cadence > 0 and now < until) else 0.0
|
|
2790
2805
|
|
|
2806
|
+
def _review_mod(self):
|
|
2807
|
+
"""Which review-surface module drives the review-drafts flow: the
|
|
2808
|
+
module that owns the currently open panel/window when one is open
|
|
2809
|
+
(so a mid-session flip of the "Canvas review" checkbox never talks to
|
|
2810
|
+
the wrong surface for an already-open one), else the persisted
|
|
2811
|
+
st.read_review_layout() preference for a FRESH presentation. Both
|
|
2812
|
+
modules expose the exact same present_review*/extend_active/
|
|
2813
|
+
prune_active/active_status/heal_active/focus_active/dismiss_active
|
|
2814
|
+
surface (see s4l_card_canvas.py's module docstring), so every call
|
|
2815
|
+
site below is agnostic to which one this returns."""
|
|
2816
|
+
name = self._review_mod_name or st.read_review_layout()
|
|
2817
|
+
if name == "canvas":
|
|
2818
|
+
import s4l_card_canvas
|
|
2819
|
+
|
|
2820
|
+
return "canvas", s4l_card_canvas
|
|
2821
|
+
import s4l_card
|
|
2822
|
+
|
|
2823
|
+
return "cards", s4l_card
|
|
2824
|
+
|
|
2791
2825
|
def _review_now(self, _=None):
|
|
2792
2826
|
"""Menu: bring the pending draft cards up right now. Clears any snooze;
|
|
2793
2827
|
this is the one presentation path that may take keyboard focus (the
|
|
2794
2828
|
user explicitly asked for the cards)."""
|
|
2795
2829
|
self._set_snooze(0.0)
|
|
2796
2830
|
try:
|
|
2797
|
-
|
|
2831
|
+
_, mod = self._review_mod()
|
|
2798
2832
|
|
|
2799
|
-
if
|
|
2833
|
+
if mod.active_status():
|
|
2800
2834
|
# A card is already on screen (user clicked while one is up,
|
|
2801
2835
|
# perhaps parked on another display): bring it to them instead.
|
|
2802
|
-
|
|
2803
|
-
|
|
2836
|
+
mod.heal_active()
|
|
2837
|
+
mod.focus_active()
|
|
2804
2838
|
return
|
|
2805
2839
|
except Exception:
|
|
2806
2840
|
pass
|
|
@@ -2856,14 +2890,17 @@ class S4LMenuBar(rumps.App):
|
|
|
2856
2890
|
if self._review_active:
|
|
2857
2891
|
if self._panel_open:
|
|
2858
2892
|
try:
|
|
2859
|
-
|
|
2893
|
+
# _review_mod_name is already pinned to whichever module
|
|
2894
|
+
# actually opened this panel (set below); _review_mod()
|
|
2895
|
+
# returns that same module, not a fresh preference read.
|
|
2896
|
+
_, mod = self._review_mod()
|
|
2860
2897
|
|
|
2861
2898
|
prev_ns = {n for n, _ in (self._last_review_sig or ())}
|
|
2862
2899
|
cur_ns = {d.get("n") for d in drafts}
|
|
2863
2900
|
vanished = prev_ns - cur_ns
|
|
2864
2901
|
if vanished:
|
|
2865
|
-
|
|
2866
|
-
|
|
2902
|
+
mod.prune_active(vanished)
|
|
2903
|
+
mod.extend_active(drafts)
|
|
2867
2904
|
except Exception as e:
|
|
2868
2905
|
sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
|
|
2869
2906
|
sys.stderr.flush()
|
|
@@ -2889,6 +2926,9 @@ class S4LMenuBar(rumps.App):
|
|
|
2889
2926
|
self._review_active = True
|
|
2890
2927
|
self._panel_open = True
|
|
2891
2928
|
try:
|
|
2929
|
+
layout_name, mod = self._review_mod()
|
|
2930
|
+
self._review_mod_name = layout_name
|
|
2931
|
+
|
|
2892
2932
|
import s4l_card
|
|
2893
2933
|
|
|
2894
2934
|
# present_feedback (the menu bar's feedback item) falls back to
|
|
@@ -2896,14 +2936,23 @@ class S4LMenuBar(rumps.App):
|
|
|
2896
2936
|
# card shows. Same pattern for the title bar's "Discard all…"
|
|
2897
2937
|
# button (moved out of the dropdown 2026-07-10), which reuses the
|
|
2898
2938
|
# bulk-discard handler wholesale, confirmation alert included.
|
|
2939
|
+
# Both are s4l_card-only surfaces (the standalone feedback panel
|
|
2940
|
+
# and the corner card's title-bar accessory; the canvas has no
|
|
2941
|
+
# equivalent title-bar button — "Select all" + "Discard
|
|
2942
|
+
# selected" already cover that case natively), so this stays
|
|
2943
|
+
# unconditional on s4l_card regardless of which layout is about
|
|
2944
|
+
# to present, and never goes stale if the layout is switched.
|
|
2899
2945
|
s4l_card.set_feedback_handler(self._on_feedback_text)
|
|
2900
2946
|
s4l_card.set_discard_all_handler(self._discard_all_pending)
|
|
2901
|
-
|
|
2902
|
-
drafts,
|
|
2947
|
+
present_kwargs = dict(
|
|
2903
2948
|
on_decision=lambda d: self._on_card_decision(batch, d),
|
|
2904
2949
|
on_complete=lambda decisions: self._on_review_closed(batch, decisions),
|
|
2905
2950
|
focus=focus,
|
|
2906
2951
|
)
|
|
2952
|
+
if layout_name == "canvas":
|
|
2953
|
+
mod.present_review_canvas(drafts, **present_kwargs)
|
|
2954
|
+
else:
|
|
2955
|
+
mod.present_review(drafts, **present_kwargs)
|
|
2907
2956
|
# Record as shown only AFTER the cards are actually up, so a transient
|
|
2908
2957
|
# card-UI failure never permanently suppresses this pending set.
|
|
2909
2958
|
self._last_review_sig = sig
|
|
@@ -2914,11 +2963,12 @@ class S4LMenuBar(rumps.App):
|
|
|
2914
2963
|
# notifies once per episode); a stderr line keeps fresh stacks
|
|
2915
2964
|
# greppable.
|
|
2916
2965
|
n = len(drafts)
|
|
2917
|
-
sys.stderr.write(f"[s4l-menubar] presented {n} draft
|
|
2966
|
+
sys.stderr.write(f"[s4l-menubar] presented {n} draft{'s' if n != 1 else ''} ({layout_name})\n")
|
|
2918
2967
|
except Exception as e:
|
|
2919
|
-
#
|
|
2968
|
+
# Review UI unavailable — don't strand the batch; chat review still works.
|
|
2920
2969
|
self._review_active = False
|
|
2921
2970
|
self._panel_open = False
|
|
2971
|
+
self._review_mod_name = None
|
|
2922
2972
|
sys.stderr.write(f"[s4l-menubar] review cards failed: {e}\n")
|
|
2923
2973
|
sys.stderr.flush()
|
|
2924
2974
|
_capture(e, phase="review_cards")
|
|
@@ -2933,9 +2983,9 @@ class S4LMenuBar(rumps.App):
|
|
|
2933
2983
|
once per episode; after REVIEW_UNATTENDED_SENTRY_SECONDS emit one
|
|
2934
2984
|
Sentry event so ignored review surfaces are visible fleet-wide."""
|
|
2935
2985
|
try:
|
|
2936
|
-
|
|
2986
|
+
_, mod = self._review_mod()
|
|
2937
2987
|
|
|
2938
|
-
status =
|
|
2988
|
+
status = mod.active_status()
|
|
2939
2989
|
except Exception:
|
|
2940
2990
|
return
|
|
2941
2991
|
if not status or not status.get("pending"):
|
|
@@ -2959,7 +3009,7 @@ class S4LMenuBar(rumps.App):
|
|
|
2959
3009
|
self._review_heal_at = now
|
|
2960
3010
|
healed = False
|
|
2961
3011
|
try:
|
|
2962
|
-
healed =
|
|
3012
|
+
healed = mod.heal_active()
|
|
2963
3013
|
except Exception as e:
|
|
2964
3014
|
sys.stderr.write(f"[s4l-menubar] review heal failed: {e}\n")
|
|
2965
3015
|
sys.stderr.flush()
|
|
@@ -3132,6 +3182,10 @@ class S4LMenuBar(rumps.App):
|
|
|
3132
3182
|
# isn't re-presented as a fresh batch.
|
|
3133
3183
|
with self._review_lock:
|
|
3134
3184
|
self._panel_open = False
|
|
3185
|
+
# The surface is confirmed gone: release the pin so the NEXT
|
|
3186
|
+
# presentation re-resolves from st.read_review_layout() (picks up
|
|
3187
|
+
# a "Canvas review" toggle flipped while this one was open).
|
|
3188
|
+
self._review_mod_name = None
|
|
3135
3189
|
if self._posts_outstanding <= 0:
|
|
3136
3190
|
self._review_active = False
|
|
3137
3191
|
self._reset_posting_progress_locked()
|
|
@@ -3229,13 +3283,14 @@ class S4LMenuBar(rumps.App):
|
|
|
3229
3283
|
|
|
3230
3284
|
threading.Thread(target=_persist_discard, daemon=True).start()
|
|
3231
3285
|
try:
|
|
3232
|
-
|
|
3286
|
+
_, mod = self._review_mod()
|
|
3233
3287
|
|
|
3234
|
-
|
|
3288
|
+
mod.dismiss_active()
|
|
3235
3289
|
except Exception:
|
|
3236
3290
|
pass
|
|
3237
3291
|
with self._review_lock:
|
|
3238
3292
|
self._panel_open = False
|
|
3293
|
+
self._review_mod_name = None
|
|
3239
3294
|
if self._posts_outstanding <= 0:
|
|
3240
3295
|
self._review_active = False
|
|
3241
3296
|
self._reset_posting_progress_locked()
|
|
@@ -3514,6 +3569,14 @@ class S4LMenuBar(rumps.App):
|
|
|
3514
3569
|
split_menu.add(it)
|
|
3515
3570
|
items.append(split_menu)
|
|
3516
3571
|
|
|
3572
|
+
# Review surface layout: the one-at-a-time corner card (default) vs.
|
|
3573
|
+
# the large centered multi-select canvas (2026-07-15). Always visible
|
|
3574
|
+
# for the same reason as the lane checkmarks above — a standing
|
|
3575
|
+
# preference, not tied to whether anything is pending right now.
|
|
3576
|
+
canvas_item = rumps.MenuItem("Canvas review", callback=self._toggle_canvas_review)
|
|
3577
|
+
canvas_item.state = 1 if st.read_review_layout() == "canvas" else 0
|
|
3578
|
+
items.append(canvas_item)
|
|
3579
|
+
|
|
3517
3580
|
# Reveal cadence: how often fresh draft cards may pop (the drafting
|
|
3518
3581
|
# pipeline is unchanged; this only paces the pop-up). Always offered:
|
|
3519
3582
|
# it is the "don't interrupt me every few minutes" control.
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.5-rc.
|
|
3
|
+
"version": "1.7.5-rc.27",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -33,6 +33,9 @@ import sys
|
|
|
33
33
|
import time
|
|
34
34
|
from urllib.parse import urlparse
|
|
35
35
|
|
|
36
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
37
|
+
from browser_mutex import BrowserMutex
|
|
38
|
+
|
|
36
39
|
|
|
37
40
|
def _default_cdp_url():
|
|
38
41
|
return os.environ.get("REDDIT_CDP_URL", "http://127.0.0.1:9557").strip() \
|
|
@@ -70,6 +73,50 @@ def _yield_to_poster():
|
|
|
70
73
|
sys.stderr.write("[reddit_browser_fetch] yielded to active poster\n")
|
|
71
74
|
|
|
72
75
|
|
|
76
|
+
# ---- browser-session mutex (2026-07-15) -------------------------------------
|
|
77
|
+
# Overlapping run-reddit-search.sh cycles are BY DESIGN (the "double-fork
|
|
78
|
+
# wrapper" in that script deliberately lets cycles stack when one runs longer
|
|
79
|
+
# than the 15-min launchd interval, which is routine — cycles regularly take
|
|
80
|
+
# 20-40 min). Every one of those cycles' discover-phase fetches calls
|
|
81
|
+
# browser_get_json, and until now nothing serialized them: two concurrent
|
|
82
|
+
# calls both reuse the SAME harness tab (see the tab-reuse comment below), so
|
|
83
|
+
# one call's page.goto() destroys the other's in-flight page.evaluate()
|
|
84
|
+
# execution context ("Execution context was destroyed, most likely because
|
|
85
|
+
# of a navigation" / "TypeError: Failed to fetch"). That failure returns
|
|
86
|
+
# (None, 0), the caller falls back to urllib, and urllib has been 403'd by
|
|
87
|
+
# Reddit's bot wall since 2026-05-28 — so what looks like "Reddit blocked us"
|
|
88
|
+
# is actually two of our own readers racing each other.
|
|
89
|
+
#
|
|
90
|
+
# reddit_browser.py (posting) already fixed this class of bug for itself via
|
|
91
|
+
# scripts/browser_mutex.py (twitter's proven mutex, parameterized) — same
|
|
92
|
+
# lock_file, same JSON shape, so this shares that lock domain rather than
|
|
93
|
+
# adding a fourth divergent one. Role defaults to "scan" (a read), matching
|
|
94
|
+
# every other reader; BrowserMutex's own env read means an actual role="post"
|
|
95
|
+
# caller would still inherit posting priority automatically.
|
|
96
|
+
_LOCK_FILE = os.path.expanduser("~/.claude/reddit-agent-lock.json")
|
|
97
|
+
_MUTEX = BrowserMutex(
|
|
98
|
+
lock_file=_LOCK_FILE,
|
|
99
|
+
label="Reddit browser",
|
|
100
|
+
lock_expiry=300,
|
|
101
|
+
wait_max=45,
|
|
102
|
+
poll_interval=2,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _acquire_fetch_lock() -> bool:
|
|
107
|
+
"""True if the tab is ours. BrowserMutex.acquire() prints a JSON error and
|
|
108
|
+
sys.exit(1)s on contention timeout — correct for a CLI entrypoint like
|
|
109
|
+
reddit_browser.py's own callers, but browser_get_json is a library call
|
|
110
|
+
nested inside reddit_tools.py/post_reddit.py and must degrade to its
|
|
111
|
+
documented (None, 0) contract instead of killing the caller process."""
|
|
112
|
+
try:
|
|
113
|
+
_MUTEX.acquire()
|
|
114
|
+
return True
|
|
115
|
+
except SystemExit:
|
|
116
|
+
sys.stderr.write("[reddit_browser_fetch] tab lock contended; skipping this fetch\n")
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
73
120
|
def browser_get_json(url, cdp_url=None, timeout_ms=25000):
|
|
74
121
|
"""Fetch a Reddit JSON URL through the logged-in harness Chrome.
|
|
75
122
|
|
|
@@ -88,73 +135,81 @@ def browser_get_json(url, cdp_url=None, timeout_ms=25000):
|
|
|
88
135
|
host = parsed.netloc or "www.reddit.com"
|
|
89
136
|
host_root = f"{parsed.scheme or 'https'}://{host}/"
|
|
90
137
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
ctx = browser.contexts[0]
|
|
100
|
-
# Reuse an existing tab instead of new_page() on every fetch. new_page()
|
|
101
|
-
# steals OS focus each call (there are many discovery fetches per cycle,
|
|
102
|
-
# so this churned the user's focus constantly); navigating a background
|
|
103
|
-
# tab does not. Prefer a tab already on reddit.com; else pages[0]; else
|
|
104
|
-
# create one. Mirrors reddit_browser / twitter_browser tab reuse. The
|
|
105
|
-
# page is left OPEN for the next fetch (cleanup_harness_tabs trims to one
|
|
106
|
-
# at cycle start).
|
|
138
|
+
# Tab access is mutex-protected (2026-07-15, see _MUTEX above): everything
|
|
139
|
+
# from tab selection through the evaluate retries must be atomic relative
|
|
140
|
+
# to every other concurrent fetch, since they all share the ONE reused tab.
|
|
141
|
+
if not _acquire_fetch_lock():
|
|
142
|
+
return None, 0
|
|
143
|
+
try:
|
|
144
|
+
with sync_playwright() as p:
|
|
145
|
+
browser = None
|
|
107
146
|
page = None
|
|
108
|
-
for pg in ctx.pages:
|
|
109
|
-
if "reddit.com" in (pg.url or "") and "login" not in (pg.url or ""):
|
|
110
|
-
page = pg
|
|
111
|
-
break
|
|
112
|
-
if page is None and ctx.pages:
|
|
113
|
-
page = ctx.pages[0]
|
|
114
|
-
if page is None:
|
|
115
|
-
page = ctx.new_page()
|
|
116
|
-
# Load the matching host root so the subsequent fetch() is same-origin
|
|
117
|
-
# (no CORS between www/old) and carries the logged-in session.
|
|
118
147
|
try:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
148
|
+
browser = p.chromium.connect_over_cdp(cdp_url)
|
|
149
|
+
if not browser.contexts:
|
|
150
|
+
sys.stderr.write("[reddit_browser_fetch] no CDP contexts on harness\n")
|
|
151
|
+
return None, 0
|
|
152
|
+
ctx = browser.contexts[0]
|
|
153
|
+
# Reuse an existing tab instead of new_page() on every fetch. new_page()
|
|
154
|
+
# steals OS focus each call (there are many discovery fetches per cycle,
|
|
155
|
+
# so this churned the user's focus constantly); navigating a background
|
|
156
|
+
# tab does not. Prefer a tab already on reddit.com; else pages[0]; else
|
|
157
|
+
# create one. Mirrors reddit_browser / twitter_browser tab reuse. The
|
|
158
|
+
# page is left OPEN for the next fetch (cleanup_harness_tabs trims to one
|
|
159
|
+
# at cycle start).
|
|
160
|
+
page = None
|
|
161
|
+
for pg in ctx.pages:
|
|
162
|
+
if "reddit.com" in (pg.url or "") and "login" not in (pg.url or ""):
|
|
163
|
+
page = pg
|
|
164
|
+
break
|
|
165
|
+
if page is None and ctx.pages:
|
|
166
|
+
page = ctx.pages[0]
|
|
167
|
+
if page is None:
|
|
168
|
+
page = ctx.new_page()
|
|
169
|
+
# Load the matching host root so the subsequent fetch() is same-origin
|
|
170
|
+
# (no CORS between www/old) and carries the logged-in session.
|
|
134
171
|
try:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
172
|
+
page.goto(host_root, wait_until="load", timeout=timeout_ms)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass # partial load is fine; we just need an active reddit origin
|
|
175
|
+
# Same-origin fetch with a couple retries — reddit.com sometimes does a
|
|
176
|
+
# client redirect on first load that destroys the execution context.
|
|
177
|
+
js = (
|
|
178
|
+
"async (u) => {"
|
|
179
|
+
" const r = await fetch(u, {credentials:'include',"
|
|
180
|
+
" headers:{'Accept':'application/json'}});"
|
|
181
|
+
" const t = await r.text();"
|
|
182
|
+
" return {status: r.status, body: t};"
|
|
183
|
+
"}"
|
|
184
|
+
)
|
|
185
|
+
last_err = None
|
|
186
|
+
for attempt in range(3):
|
|
187
|
+
try:
|
|
188
|
+
res = page.evaluate(js, url)
|
|
189
|
+
status = int(res.get("status", 0))
|
|
190
|
+
body = res.get("body") or ""
|
|
191
|
+
if status != 200:
|
|
192
|
+
return None, status
|
|
193
|
+
return body, status
|
|
194
|
+
except Exception as e:
|
|
195
|
+
last_err = e
|
|
196
|
+
time.sleep(2.0) # let a redirect settle, then retry
|
|
197
|
+
sys.stderr.write(f"[reddit_browser_fetch] evaluate failed after retries: {last_err}\n")
|
|
198
|
+
return None, 0
|
|
199
|
+
except Exception as e:
|
|
200
|
+
sys.stderr.write(f"[reddit_browser_fetch] error: {e}\n")
|
|
201
|
+
return None, 0
|
|
202
|
+
finally:
|
|
203
|
+
# Do NOT close the page: it is a REUSED tab, and closing it forces the
|
|
204
|
+
# next fetch to new_page() which steals OS focus. Leaving it open lets
|
|
205
|
+
# the next fetch reuse it (cleanup_harness_tabs trims to one at cycle
|
|
206
|
+
# start). Also never close the connect_over_cdp browser/context: that can
|
|
207
|
+
# terminate the real harness Chrome (see reddit_browser.py warning). The
|
|
208
|
+
# sync_playwright() context exit disconnects the CDP client cleanly
|
|
209
|
+
# without killing the remote browser.
|
|
210
|
+
pass
|
|
211
|
+
finally:
|
|
212
|
+
_MUTEX.release()
|
|
158
213
|
|
|
159
214
|
|
|
160
215
|
def main(argv):
|