@m13v/s4l 1.6.197-rc.13 → 1.6.197-rc.15

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 CHANGED
@@ -4004,7 +4004,7 @@ tool("show_browser_to_user", {
4004
4004
  const message = ensured.error === "no_browser"
4005
4005
  ? "No managed Chrome is running right now. Start a draft cycle or autopilot so there's a live browser session to show."
4006
4006
  : ensured.error === "no_websocket"
4007
- ? "This Node runtime has no WebSocket support (needs Node 21+), so a screencast can't be opened."
4007
+ ? "This runtime has no WebSocket support, so a live screencast can't be opened. Use 'Bring to front' to see the browser window instead."
4008
4008
  : "Couldn't attach to the browser: " + String(ensured.error);
4009
4009
  return jsonContent({ ok: false, running: false, frame: null, message });
4010
4010
  }
@@ -17,10 +17,24 @@
17
17
  * this module is the robust baseline that works regardless.
18
18
  */
19
19
  import { execFile } from "node:child_process";
20
+ import { createRequire } from "node:module";
20
21
  // Untyped indirection: Node ships a global WebSocket at runtime (>=21) but
21
22
  // @types/node doesn't always declare it as a value, and MessageEvent isn't typed
22
23
  // without the DOM lib. Reach for it dynamically and keep the event handlers `any`.
23
- const WS = globalThis.WebSocket;
24
+ //
25
+ // Claude Desktop launches this server with whatever `node` is on the user's
26
+ // PATH, which can be 18/20 with no global WebSocket. Fall back to the bundled
27
+ // `ws` package, whose browser-style API (onopen/onmessage/send/close/readyState)
28
+ // matches how this module drives the socket.
29
+ const WS = globalThis.WebSocket ??
30
+ (() => {
31
+ try {
32
+ return createRequire(import.meta.url)("ws").WebSocket;
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ })();
24
38
  // Ports we manage a Chrome on, most-likely-active first. TWITTER_CDP_URL (the
25
39
  // twitter harness) wins if set; the rest cover linkedin (9556) / reddit (9557) /
26
40
  // browser-harness / assrt.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.197-rc.13",
3
- "installedAt": "2026-07-03T20:22:38.198Z"
2
+ "version": "1.6.197-rc.15",
3
+ "installedAt": "2026-07-03T20:53:31.521Z"
4
4
  }
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.6.197-rc.13",
5
+ "version": "1.6.197-rc.15",
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": {
@@ -22,7 +22,7 @@ entries).
22
22
  Overall feedback (guidance not tied to any single thread) has two entry
23
23
  points that share one submit handler, registered by the menu bar via
24
24
  `set_feedback_handler` (it ships decision='feedback' review events): the
25
- card's bubble button swaps the card body for an in-window composer (a
25
+ card's Feedback button swaps the card body for an in-window composer (a
26
26
  separate floating panel was tried and opened where the user never saw it on
27
27
  a multi-monitor setup), and the menu bar's "Send feedback…" item opens the
28
28
  standalone `present_feedback(on_submit)` panel (no card window need exist).
@@ -49,6 +49,7 @@ import objc
49
49
  from Foundation import (
50
50
  NSObject,
51
51
  NSMakeRect,
52
+ NSMakeSize,
52
53
  NSAttributedString,
53
54
  NSMutableAttributedString,
54
55
  NSURL,
@@ -92,6 +93,7 @@ from AppKit import (
92
93
  NSEventModifierFlagCommand,
93
94
  NSEventModifierFlagShift,
94
95
  NSEventModifierFlagDeviceIndependentFlagsMask,
96
+ NSViewWidthSizable,
95
97
  )
96
98
 
97
99
  # Strong reference to the live controller so pyobjc doesn't GC it mid-review
@@ -306,6 +308,35 @@ def _label(frame, text, *, size=12, bold=False, muted=False):
306
308
  return f
307
309
 
308
310
 
311
+ def _editable_scroll(frame, text=""):
312
+ """Bezel-bordered scrollable text editor. The document view must be sized
313
+ to the scroll view's contentSize (NOT the outer frame) and track its width;
314
+ sized to the outer frame the text runs underneath the scroller. The
315
+ scroller itself auto-hides so it only appears when the text overflows.
316
+ Returns (scroll, textview)."""
317
+ scroll = NSScrollView.alloc().initWithFrame_(frame)
318
+ scroll.setHasVerticalScroller_(True)
319
+ scroll.setAutohidesScrollers_(True)
320
+ scroll.setBorderType_(NS_BEZEL_BORDER)
321
+ cs = scroll.contentSize()
322
+ tv = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, cs.width, cs.height))
323
+ tv.setFont_(NSFont.systemFontOfSize_(12))
324
+ tv.setRichText_(False)
325
+ tv.setEditable_(True)
326
+ tv.setSelectable_(True)
327
+ tv.setVerticallyResizable_(True)
328
+ tv.setHorizontallyResizable_(False)
329
+ tv.setMinSize_(NSMakeSize(0, cs.height))
330
+ tv.setMaxSize_(NSMakeSize(1e7, 1e7))
331
+ tv.setAutoresizingMask_(NSViewWidthSizable)
332
+ tv.textContainer().setWidthTracksTextView_(True)
333
+ tv.textContainer().setContainerSize_(NSMakeSize(cs.width, 1e7))
334
+ if text:
335
+ tv.setString_(text)
336
+ scroll.setDocumentView_(tv)
337
+ return scroll, tv
338
+
339
+
309
340
  class _ReviewController(NSObject):
310
341
  def initWithDrafts_onDecision_onComplete_(self, drafts, on_decision, on_complete):
311
342
  self = objc.super(_ReviewController, self).init()
@@ -504,9 +535,10 @@ class _ReviewController(NSObject):
504
535
  content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
505
536
 
506
537
  # Buttons at the TOP, one line: Approve, Approve 😄 (approve + loved),
507
- # a small feedback-bubble icon (overall feedback, decides nothing),
508
- # Reject at the right.
509
- approve = NSButton.alloc().initWithFrame_(NSMakeRect(M, H - 42, 84, 30))
538
+ # Feedback (overall feedback, decides nothing), Reject at the right.
539
+ # Widths are hand-fit so all four bezeled buttons share the 348pt row
540
+ # without truncating their titles.
541
+ approve = NSButton.alloc().initWithFrame_(NSMakeRect(M, H - 42, 78, 30))
510
542
  approve.setTitle_("Approve")
511
543
  approve.setBezelStyle_(NSBezelStyleRounded)
512
544
  approve.setTarget_(self)
@@ -518,7 +550,7 @@ class _ReviewController(NSObject):
518
550
  # rail sees the difference. Labeled as an approve variant, not a bare
519
551
  # emoji: a lone 😄 read as decoration and users doubted the click
520
552
  # registered (2026-07-03 feedback).
521
- smile = NSButton.alloc().initWithFrame_(NSMakeRect(M + 88, H - 42, 116, 30))
553
+ smile = NSButton.alloc().initWithFrame_(NSMakeRect(M + 82, H - 42, 100, 30))
522
554
  smile.setTitle_("Approve 😄")
523
555
  smile.setBezelStyle_(NSBezelStyleRounded)
524
556
  smile.setTarget_(self)
@@ -529,22 +561,16 @@ class _ReviewController(NSObject):
529
561
  pass
530
562
  content.addSubview_(smile)
531
563
 
532
- # Feedback bubble = overall feedback (about the pipeline, not this
533
- # draft). Swaps the card body for the composer IN THIS WINDOW; a
534
- # separate floating panel was tried first and opened somewhere the
535
- # user never saw on a multi-monitor setup (2026-07-03: 7 clicks,
536
- # zero sightings). SF Symbol, like the stats eye; emoji 💬 rendered
537
- # as a grey three-dots glyph nobody could identify.
538
- fb = NSButton.alloc().initWithFrame_(NSMakeRect(M + 212, H - 40, 28, 26))
539
- fb.setBordered_(False)
540
- fb_img = NSImage.imageWithSystemSymbolName_accessibilityDescription_(
541
- "bubble.left", "overall feedback"
542
- )
543
- if fb_img is not None:
544
- fb.setImage_(fb_img)
545
- fb.setTitle_("")
546
- else: # pre-Big Sur fallback: no SF Symbols
547
- fb.setTitle_("💬")
564
+ # Feedback = overall feedback (about the pipeline, not this draft).
565
+ # Swaps the card body for the composer IN THIS WINDOW; a separate
566
+ # floating panel was tried first and opened somewhere the user never
567
+ # saw on a multi-monitor setup (2026-07-03: 7 clicks, zero sightings).
568
+ # A borderless bubble.left icon was tried next and read as decoration,
569
+ # not a button; a labeled bezel button matching its row mates won
570
+ # (2026-07-03 feedback).
571
+ fb = NSButton.alloc().initWithFrame_(NSMakeRect(M + 186, H - 42, 90, 30))
572
+ fb.setTitle_("Feedback")
573
+ fb.setBezelStyle_(NSBezelStyleRounded)
548
574
  fb.setTarget_(self)
549
575
  fb.setAction_("feedbackOpen:")
550
576
  try:
@@ -553,7 +579,7 @@ class _ReviewController(NSObject):
553
579
  pass
554
580
  content.addSubview_(fb)
555
581
 
556
- reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 84, H - 42, 84, 30))
582
+ reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
557
583
  reject.setTitle_("Reject")
558
584
  reject.setBezelStyle_(NSBezelStyleRounded)
559
585
  reject.setTarget_(self)
@@ -703,18 +729,9 @@ class _ReviewController(NSObject):
703
729
  reply = d.get("reply_text") or ""
704
730
  link = d.get("link_url")
705
731
  composed = f"{reply} {link}" if link else reply
706
- scroll = NSScrollView.alloc().initWithFrame_(
707
- NSMakeRect(M, M, W - 2 * M, H - 172 - M - 6)
732
+ scroll, tv = _editable_scroll(
733
+ NSMakeRect(M, M, W - 2 * M, H - 172 - M - 6), composed
708
734
  )
709
- scroll.setHasVerticalScroller_(True)
710
- scroll.setBorderType_(NS_BEZEL_BORDER)
711
- tv = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, W - 2 * M, 100))
712
- tv.setFont_(NSFont.systemFontOfSize_(12))
713
- tv.setRichText_(False)
714
- tv.setEditable_(True)
715
- tv.setSelectable_(True)
716
- tv.setString_(composed)
717
- scroll.setDocumentView_(tv)
718
735
  content.addSubview_(scroll)
719
736
  self._textview = tv
720
737
 
@@ -988,17 +1005,7 @@ class _ReviewController(NSObject):
988
1005
  muted=True,
989
1006
  )
990
1007
  )
991
- scroll = NSScrollView.alloc().initWithFrame_(
992
- NSMakeRect(M, 54, W - 2 * M, H - 86 - 54)
993
- )
994
- scroll.setHasVerticalScroller_(True)
995
- scroll.setBorderType_(NS_BEZEL_BORDER)
996
- tv = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, W - 2 * M, 100))
997
- tv.setFont_(NSFont.systemFontOfSize_(12))
998
- tv.setRichText_(False)
999
- tv.setEditable_(True)
1000
- tv.setSelectable_(True)
1001
- scroll.setDocumentView_(tv)
1008
+ scroll, tv = _editable_scroll(NSMakeRect(M, 54, W - 2 * M, H - 86 - 54))
1002
1009
  content.addSubview_(scroll)
1003
1010
  self._feedback_tv = tv
1004
1011
 
@@ -1256,7 +1263,7 @@ def heal_active():
1256
1263
  # ---- overall-feedback composer ----------------------------------------------
1257
1264
  # One small floating panel with a free-text field, for guidance that is about
1258
1265
  # the PIPELINE rather than any single draft ("less shilling", "more dev
1259
- # threads", ...). Reachable from the card's 💬 button and the menu bar's
1266
+ # threads", ...). Reachable from the card's Feedback button and the menu bar's
1260
1267
  # "Send feedback…" item; both call present_feedback(), which falls back to the
1261
1268
  # handler the menu bar registered at boot via set_feedback_handler() (that
1262
1269
  # handler ships a decision='feedback' review event down the same outbox rail
@@ -1265,7 +1272,7 @@ def heal_active():
1265
1272
  FB_W = 380
1266
1273
  FB_H = 200
1267
1274
 
1268
- # Default submit handler (menu bar's shipper). Module-level so the card's 💬
1275
+ # Default submit handler (menu bar's shipper). Module-level so the card's
1269
1276
  # button can open the composer without threading a callback through
1270
1277
  # present_review's signature.
1271
1278
  _feedback_handler = None
@@ -1341,19 +1348,9 @@ class _FeedbackController(NSObject):
1341
1348
  muted=True,
1342
1349
  )
1343
1350
  )
1344
- scroll = NSScrollView.alloc().initWithFrame_(
1351
+ scroll, tv = _editable_scroll(
1345
1352
  NSMakeRect(M, 54, FB_W - 2 * M, FB_H - 48 - 8 - 54)
1346
1353
  )
1347
- scroll.setHasVerticalScroller_(True)
1348
- scroll.setBorderType_(NS_BEZEL_BORDER)
1349
- tv = NSTextView.alloc().initWithFrame_(
1350
- NSMakeRect(0, 0, FB_W - 2 * M, 80)
1351
- )
1352
- tv.setFont_(NSFont.systemFontOfSize_(12))
1353
- tv.setRichText_(False)
1354
- tv.setEditable_(True)
1355
- tv.setSelectable_(True)
1356
- scroll.setDocumentView_(tv)
1357
1354
  content.addSubview_(scroll)
1358
1355
  self._tv = tv
1359
1356
 
@@ -1204,16 +1204,6 @@ class S4LMenuBar(rumps.App):
1204
1204
  continue
1205
1205
  return None, repo
1206
1206
 
1207
- @staticmethod
1208
- def _ver_tuple(v):
1209
- out = []
1210
- for part in str(v).split("-")[0].split("+")[0].split("."):
1211
- try:
1212
- out.append(int(part))
1213
- except ValueError:
1214
- out.append(0)
1215
- return tuple(out)
1216
-
1217
1207
  def _check_update_verdict(self):
1218
1208
  p = self._update_verify_path()
1219
1209
  if not os.path.exists(p):
@@ -1232,7 +1222,14 @@ class S4LMenuBar(rumps.App):
1232
1222
  self._drop_update_marker(p)
1233
1223
  return
1234
1224
  effective, repo = self._effective_version()
1235
- if effective and self._ver_tuple(effective) >= self._ver_tuple(target):
1225
+ # ONE comparator: st.ver_key delegates to scripts/snapshot.py::_ver_key
1226
+ # (rc-aware). If the snapshot module can't load this tick, skip the
1227
+ # verdict; the grace window below still resolves the marker either way.
1228
+ try:
1229
+ settled = bool(effective) and st.ver_key(effective) >= st.ver_key(target)
1230
+ except Exception:
1231
+ settled = False
1232
+ if settled:
1236
1233
  self._drop_update_marker(p)
1237
1234
  self._notify("S4L updated", f"Now on v{effective}.")
1238
1235
  return
@@ -2370,7 +2367,17 @@ class S4LMenuBar(rumps.App):
2370
2367
  items.append(rumps.separator)
2371
2368
  items.append(rumps.MenuItem("Open dashboard", callback=self._open_dashboard))
2372
2369
  items.append(rumps.MenuItem("Send feedback…", callback=self._menu_feedback))
2373
- if self._update_available and self._latest_version:
2370
+ # While the update-verify marker is pending, the pipeline copy still
2371
+ # resolves the OLD version (it only advances once the restarted server
2372
+ # re-provisions repo/package, ~2 min), so the snapshot honestly reports
2373
+ # update_available and the menu re-showed "update to vN" right after
2374
+ # the user clicked update — reading as a failed install (2026-07-03).
2375
+ # Show the in-progress state instead; _check_update_verdict drops the
2376
+ # marker on success or after UPDATE_VERIFY_GRACE_SEC either way.
2377
+ if os.path.exists(self._update_verify_path()):
2378
+ items.append(rumps.separator)
2379
+ items.append(self._label("⏳ Finishing update… verifying install"))
2380
+ elif self._update_available and self._latest_version:
2374
2381
  items.append(rumps.separator)
2375
2382
  items.append(self._label(f"⬆ Update available · v{self._latest_version}"))
2376
2383
  items.append(
@@ -222,7 +222,7 @@ _snap_lock = threading.Lock()
222
222
  _snap_refreshing = [False]
223
223
 
224
224
 
225
- def _compute_snapshot_full():
225
+ def _snapshot_module():
226
226
  repo = (
227
227
  os.environ.get("S4L_REPO_DIR")
228
228
  or os.environ.get("SAPS_REPO_DIR") # pre-rename plists (2026-07-03)
@@ -232,7 +232,19 @@ def _compute_snapshot_full():
232
232
  if scripts not in sys.path:
233
233
  sys.path.insert(0, scripts)
234
234
  import snapshot as _snapshot_mod # scripts/snapshot.py
235
- return _snapshot_mod.compute()
235
+ return _snapshot_mod
236
+
237
+
238
+ def _compute_snapshot_full():
239
+ return _snapshot_module().compute()
240
+
241
+
242
+ def ver_key(v):
243
+ """rc-aware version precedence key, delegated to scripts/snapshot.py::
244
+ _ver_key so there is exactly ONE Python implementation (kept in lockstep
245
+ with mcp/src/version.ts::verKey). The update verifier used to carry its
246
+ own third copy, which drifted rc-blind (2026-07-03); do not re-add one."""
247
+ return _snapshot_module()._ver_key(v)
236
248
 
237
249
 
238
250
  def _refresh_snapshot_bg():
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.6.197-rc.13",
3
+ "version": "1.6.197-rc.15",
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",
@@ -23,6 +23,7 @@
23
23
  "@modelcontextprotocol/ext-apps": "^1.7.3",
24
24
  "@modelcontextprotocol/sdk": "^1.29.0",
25
25
  "@sentry/node": "^10.58.0",
26
+ "ws": "^8.21.0",
26
27
  "zod": "^3.23.8"
27
28
  },
28
29
  "devDependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.6.197-rc.13",
3
+ "version": "1.6.197-rc.15",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -1,15 +1,22 @@
1
1
  #!/usr/bin/env python3
2
2
  """Read-only LinkedIn presence pass through the existing harness Chrome.
3
3
 
4
- This helper is deliberately small and deterministic:
4
+ Simulates a short human browsing session:
5
5
  - attach to the already-running linkedin-harness Chrome via CDP
6
- - reuse the existing tab/context
7
- - navigate to one first-party LinkedIn surface
8
- - perform a bounded number of mouse-wheel scrolls with short dwell periods
9
- - never click, type, post, react, message, open permalinks, or call Voyager
6
+ - start on one first-party LinkedIn surface
7
+ - random walk of scrolls, dwells, and read-only navigation clicks
8
+ (top nav tabs, profile links from the feed, company pages,
9
+ LinkedIn News stories), with occasional back-navigation
10
+
11
+ Hard safety rules:
12
+ - clicks ONLY <a href> elements whose URL matches a strict allowlist of
13
+ read-only linkedin.com surfaces; never clicks buttons or coordinates
14
+ - never likes, follows, connects, messages, comments, posts, or types
15
+ - never opens messaging conversations or external domains
16
+ - never calls Voyager
10
17
 
11
18
  The shell wrapper owns scheduling, locking, killswitch checks, and run_monitor
12
- logging. This Python file only does the browser action.
19
+ logging. This Python file only does the browser session.
13
20
  """
14
21
 
15
22
  from __future__ import annotations
@@ -17,6 +24,8 @@ from __future__ import annotations
17
24
  import argparse
18
25
  import json
19
26
  import os
27
+ import random
28
+ import re
20
29
  import sys
21
30
  import time
22
31
  from typing import Any
@@ -25,25 +34,79 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
25
34
  from linkedin_browser import _connect_to_running_or_launch, _is_login_or_checkpoint # noqa: E402
26
35
 
27
36
 
28
- MODE_URLS = {
37
+ START_URLS = {
29
38
  "feed": "https://www.linkedin.com/feed/",
30
39
  "notifications": "https://www.linkedin.com/notifications/",
31
- "messaging": "https://www.linkedin.com/messaging/",
40
+ "mynetwork": "https://www.linkedin.com/mynetwork/",
32
41
  "profile": "https://www.linkedin.com/in/me/",
33
42
  }
43
+ # Feed-heavy start distribution: (surface, weight)
44
+ START_WEIGHTS = [("feed", 55), ("notifications", 20), ("mynetwork", 15), ("profile", 10)]
45
+
46
+ # Click allowlist: read-only destinations only. Matched against href with the
47
+ # query string stripped. Anything not matching is never clicked.
48
+ CLICK_ALLOWLIST = {
49
+ "nav": re.compile(
50
+ r"^https://www\.linkedin\.com/(feed|mynetwork|notifications|jobs)/?$"
51
+ ),
52
+ "profile": re.compile(r"^https://www\.linkedin\.com/in/[^/?#]+/?$"),
53
+ "company": re.compile(
54
+ r"^https://www\.linkedin\.com/company/[^/?#]+/?(posts/?|about/?)?$"
55
+ ),
56
+ "news": re.compile(r"^https://www\.linkedin\.com/news/[^?#]+$"),
57
+ }
34
58
 
59
+ # Random-walk action distribution: (action, weight)
60
+ ACTION_WEIGHTS = [
61
+ ("scroll", 38),
62
+ ("read", 14),
63
+ ("scroll_up", 8),
64
+ ("click_nav", 12),
65
+ ("click_profile", 14),
66
+ ("click_company", 7),
67
+ ("click_news", 7),
68
+ ]
69
+
70
+ CLICK_CATEGORY = {
71
+ "click_nav": "nav",
72
+ "click_profile": "profile",
73
+ "click_company": "company",
74
+ "click_news": "news",
75
+ }
35
76
 
36
- def _csv_ints(raw: str, fallback: list[int]) -> list[int]:
37
- out: list[int] = []
38
- for part in (raw or "").split(","):
39
- part = part.strip()
40
- if not part:
41
- continue
42
- try:
43
- out.append(int(part))
44
- except ValueError:
45
- continue
46
- return out or list(fallback)
77
+ ANCHOR_SCAN_JS = """
78
+ () => {
79
+ const vh = window.innerHeight, vw = window.innerWidth;
80
+ const out = [];
81
+ const seen = new Set();
82
+ for (const a of document.querySelectorAll('a[href]')) {
83
+ const href = a.href || '';
84
+ const attr = a.getAttribute('href') || '';
85
+ if (!href.startsWith('https://www.linkedin.com/')) continue;
86
+ if (href.includes('"') || attr.includes('"')) continue;
87
+ const r = a.getBoundingClientRect();
88
+ if (r.width < 24 || r.height < 10) continue;
89
+ if (r.bottom < 0 || r.top > vh || r.right < 20 || r.left > vw - 20) continue;
90
+ const key = href.split('?')[0];
91
+ if (seen.has(key)) continue;
92
+ seen.add(key);
93
+ out.push({href: href, attr: attr, text: (a.innerText || '').trim().slice(0, 60)});
94
+ if (out.length >= 150) break;
95
+ }
96
+ return out;
97
+ }
98
+ """
99
+
100
+
101
+ def _weighted_choice(pairs: list[tuple[str, int]]) -> str:
102
+ total = sum(w for _, w in pairs)
103
+ roll = random.uniform(0, total)
104
+ acc = 0.0
105
+ for name, w in pairs:
106
+ acc += w
107
+ if roll <= acc:
108
+ return name
109
+ return pairs[-1][0]
47
110
 
48
111
 
49
112
  def _pick_page(context: Any) -> Any:
@@ -67,130 +130,243 @@ def _safe_eval(page: Any, expr: str, fallback: Any) -> Any:
67
130
  return fallback
68
131
 
69
132
 
70
- def _session_invalid(page: Any, mode: str) -> tuple[bool, str]:
133
+ def _session_invalid(page: Any) -> tuple[bool, str]:
71
134
  url = page.url or ""
72
135
  title = _safe_title(page)
73
- url_l = url.lower()
74
- title_l = title.lower()
75
- if _is_login_or_checkpoint(url_l):
136
+ if _is_login_or_checkpoint(url.lower()):
76
137
  return True, f"url:{url}"
77
- if any(s in title_l for s in ("security verification", "captcha", "checkpoint")):
138
+ if any(s in title.lower() for s in ("security verification", "captcha", "checkpoint")):
78
139
  return True, f"title:{title}"
79
-
80
- # Avoid reading private message preview text in messaging mode. The shared
81
- # shell-level detect-gate has already verified /feed/ before this helper
82
- # runs, and URL/title catch the authwall redirects here.
83
- if mode == "messaging":
140
+ # Skip body-text markers on messaging (private previews); URL/title cover it.
141
+ if "/messaging" in url:
84
142
  return False, ""
85
-
86
143
  text = _safe_eval(
87
144
  page,
88
145
  "() => (document.body && document.body.innerText || '').slice(0, 1200)",
89
146
  "",
90
147
  )
91
148
  text_l = str(text or "").lower()
92
- bad_text_markers = (
149
+ for marker in (
93
150
  "security verification",
94
151
  "verify you are human",
95
152
  "captcha",
96
153
  "sign in to linkedin",
97
154
  "join linkedin",
98
- )
99
- for marker in bad_text_markers:
155
+ ):
100
156
  if marker in text_l:
101
157
  return True, f"text:{marker}"
102
158
  return False, ""
103
159
 
104
160
 
105
- def _viewport_point(page: Any) -> tuple[int, int]:
161
+ def _settle(page: Any) -> None:
162
+ try:
163
+ page.wait_for_load_state("domcontentloaded", timeout=15000)
164
+ except Exception:
165
+ pass
166
+ page.wait_for_timeout(random.randint(1500, 3200))
167
+
168
+
169
+ def _scroll(page: Any, up: bool = False) -> None:
106
170
  dims = _safe_eval(
107
171
  page,
108
172
  "() => ({w: window.innerWidth || 1180, h: window.innerHeight || 900})",
109
173
  {"w": 1180, "h": 900},
110
174
  )
111
- if not isinstance(dims, dict):
112
- dims = {"w": 1180, "h": 900}
113
- w = int(dims.get("w") or 1180)
114
- h = int(dims.get("h") or 900)
115
- return max(1, int(w * 0.50)), max(1, int(h * 0.56))
175
+ w = int((dims or {}).get("w") or 1180)
176
+ h = int((dims or {}).get("h") or 900)
177
+ x = int(w * random.uniform(0.35, 0.62))
178
+ y = int(h * random.uniform(0.40, 0.68))
179
+ try:
180
+ page.mouse.move(x, y, steps=random.randint(4, 12))
181
+ except Exception:
182
+ pass
183
+ amount = random.randint(380, 760)
184
+ if up:
185
+ amount = -random.randint(240, 520)
186
+ page.mouse.wheel(0, amount)
187
+ time.sleep(random.uniform(1.2, 4.5))
188
+
189
+
190
+ def _candidate_links(page: Any, category: str, visited: set[str]) -> list[dict]:
191
+ anchors = _safe_eval(page, ANCHOR_SCAN_JS, []) or []
192
+ rx = CLICK_ALLOWLIST[category]
193
+ current = (page.url or "").split("?")[0].rstrip("/")
194
+ out = []
195
+ for a in anchors:
196
+ href = str(a.get("href") or "")
197
+ attr = str(a.get("attr") or "")
198
+ base = href.split("?")[0]
199
+ if not attr or not rx.match(base):
200
+ continue
201
+ if base.rstrip("/") == current:
202
+ continue
203
+ if base in visited and category != "nav":
204
+ continue
205
+ out.append({"href": href, "attr": attr})
206
+ return out
207
+
208
+
209
+ def _click_link(context: Any, page: Any, link: dict) -> tuple[Any, bool]:
210
+ """Click the anchor with this exact href attribute. Returns (page, navigated)."""
211
+ before_url = page.url
212
+ before_pages = len(context.pages)
213
+ loc = page.locator(f'a[href="{link["attr"]}"]').first
214
+ try:
215
+ loc.scroll_into_view_if_needed(timeout=4000)
216
+ page.wait_for_timeout(random.randint(300, 900))
217
+ loc.click(timeout=5000, delay=random.randint(40, 140))
218
+ except Exception:
219
+ return page, False
220
+ page.wait_for_timeout(1200)
221
+ # target=_blank case: browse briefly in the new tab, then close it.
222
+ if len(context.pages) > before_pages:
223
+ new_page = context.pages[-1]
224
+ try:
225
+ _settle(new_page)
226
+ _scroll(new_page)
227
+ time.sleep(random.uniform(1.0, 3.0))
228
+ new_page.close()
229
+ except Exception:
230
+ pass
231
+ return page, True
232
+ _settle(page)
233
+ return page, page.url != before_url
116
234
 
117
235
 
118
236
  def run(args: argparse.Namespace) -> dict[str, Any]:
119
- url = args.url or MODE_URLS.get(args.mode)
120
- if not url:
121
- return {"ok": False, "error": "bad_mode", "mode": args.mode}
122
-
123
- amounts = _csv_ints(args.amounts, [520])
124
- dwells = _csv_ints(args.dwells, [2])
125
- scrolls = max(0, min(int(args.scrolls), len(amounts)))
126
- amounts = amounts[:scrolls]
127
- if len(dwells) < scrolls:
128
- dwells.extend([dwells[-1] if dwells else 2] * (scrolls - len(dwells)))
129
- dwells = dwells[:scrolls]
237
+ if args.seed:
238
+ random.seed(args.seed)
239
+ start = args.start or _weighted_choice(START_WEIGHTS)
240
+ start_url = START_URLS.get(start)
241
+ if not start_url:
242
+ return {"ok": False, "error": "bad_start", "start": start}
243
+
244
+ steps_target = args.steps if args.steps > 0 else random.randint(4, 9)
245
+ deadline = time.monotonic() + max(30, args.max_seconds)
246
+ max_navs = 4
247
+
248
+ counters = {"scrolls": 0, "clicks": 0, "navs": 0, "reads": 0, "skipped": 0}
249
+ visited: set[str] = set()
250
+ trail: list[str] = []
251
+ back_depth = 0
130
252
 
131
253
  from playwright.sync_api import sync_playwright
132
254
 
133
255
  with sync_playwright() as p:
134
256
  context, _owns_context = _connect_to_running_or_launch(p, prefer_cdp=True)
135
257
  page = _pick_page(context)
136
- page.goto(url, wait_until="domcontentloaded", timeout=args.timeout_ms)
137
- page.wait_for_timeout(args.settle_ms)
258
+ page.goto(start_url, wait_until="domcontentloaded", timeout=args.timeout_ms)
259
+ _settle(page)
138
260
 
139
- invalid, detail = _session_invalid(page, args.mode)
261
+ invalid, detail = _session_invalid(page)
140
262
  if invalid:
141
263
  return {
142
264
  "ok": False,
143
265
  "error": "session_invalid",
144
266
  "detail": detail,
145
- "mode": args.mode,
267
+ "start": start,
146
268
  "url": page.url,
147
269
  "title": _safe_title(page),
148
270
  }
271
+ visited.add((page.url or "").split("?")[0])
272
+ trail.append(f"open:{start}")
273
+
274
+ steps_done = 0
275
+ while steps_done < steps_target and time.monotonic() < deadline:
276
+ action = _weighted_choice(ACTION_WEIGHTS)
277
+ if action in CLICK_CATEGORY and counters["navs"] >= max_navs:
278
+ action = "scroll"
279
+
280
+ if action == "scroll":
281
+ _scroll(page)
282
+ counters["scrolls"] += 1
283
+ trail.append("scroll")
284
+ elif action == "scroll_up":
285
+ _scroll(page, up=True)
286
+ counters["scrolls"] += 1
287
+ trail.append("scroll_up")
288
+ elif action == "read":
289
+ time.sleep(random.uniform(2.5, 7.0))
290
+ counters["reads"] += 1
291
+ trail.append("read")
292
+ else:
293
+ category = CLICK_CATEGORY[action]
294
+ links = _candidate_links(page, category, visited)
295
+ if not links:
296
+ counters["skipped"] += 1
297
+ trail.append(f"no_{category}")
298
+ steps_done += 1
299
+ continue
300
+ link = random.choice(links)
301
+ page, navigated = _click_link(context, page, link)
302
+ if navigated:
303
+ counters["clicks"] += 1
304
+ counters["navs"] += 1
305
+ visited.add((page.url or "").split("?")[0])
306
+ trail.append(f"{category}->{(page.url or '').split('?')[0]}")
307
+ invalid, detail = _session_invalid(page)
308
+ if invalid:
309
+ return {
310
+ "ok": False,
311
+ "error": "session_invalid",
312
+ "detail": detail,
313
+ "start": start,
314
+ "url": page.url,
315
+ "title": _safe_title(page),
316
+ "trail": trail,
317
+ }
318
+ # Skim the destination a little.
319
+ if random.random() < 0.8:
320
+ _scroll(page)
321
+ counters["scrolls"] += 1
322
+ back_depth += 1
323
+ # Usually wander back the way a person does.
324
+ if category != "nav" and back_depth > 0 and random.random() < 0.65:
325
+ try:
326
+ page.go_back(wait_until="domcontentloaded", timeout=15000)
327
+ _settle(page)
328
+ back_depth -= 1
329
+ trail.append("back")
330
+ except Exception:
331
+ pass
332
+ else:
333
+ counters["skipped"] += 1
334
+ trail.append(f"miss_{category}")
335
+ steps_done += 1
149
336
 
150
- x, y = _viewport_point(page)
151
- page.mouse.move(x, y)
152
- for amount, dwell in zip(amounts, dwells):
153
- page.mouse.wheel(0, amount)
154
- time.sleep(max(0, dwell))
155
-
156
- scroll_state = _safe_eval(
157
- page,
158
- "() => ({x: window.scrollX || 0, y: window.scrollY || 0, h: document.documentElement.scrollHeight || 0})",
159
- {},
160
- )
161
337
  return {
162
338
  "ok": True,
163
- "mode": args.mode,
339
+ "start": start,
340
+ "steps": steps_done,
341
+ "counters": counters,
342
+ "pages_visited": sorted(visited),
343
+ "trail": trail,
164
344
  "url": page.url,
165
345
  "title": _safe_title(page),
166
- "scrolls": scrolls,
167
- "amounts": amounts,
168
- "dwells": dwells,
169
- "point": {"x": x, "y": y},
170
- "scroll_state": scroll_state,
171
346
  }
172
347
 
173
348
 
174
349
  def main() -> int:
175
- ap = argparse.ArgumentParser(description="Run a read-only LinkedIn presence pass")
176
- ap.add_argument("--mode", required=True, choices=sorted(MODE_URLS))
177
- ap.add_argument("--url", default="")
178
- ap.add_argument("--scrolls", type=int, default=1)
179
- ap.add_argument("--amounts", default="")
180
- ap.add_argument("--dwells", default="")
350
+ ap = argparse.ArgumentParser(description="Run a read-only LinkedIn presence browse session")
351
+ ap.add_argument("--start", default="", choices=[""] + sorted(START_URLS))
352
+ ap.add_argument("--steps", type=int, default=0, help="0 = random 4-9")
353
+ ap.add_argument("--max-seconds", type=int, default=150)
181
354
  ap.add_argument("--timeout-ms", type=int, default=30000)
182
- ap.add_argument("--settle-ms", type=int, default=2000)
355
+ ap.add_argument("--seed", type=int, default=0)
183
356
  args = ap.parse_args()
184
357
 
185
358
  try:
186
359
  result = run(args)
187
360
  except Exception as e:
188
- result = {"ok": False, "error": "exception", "detail": str(e), "mode": args.mode}
361
+ result = {"ok": False, "error": "exception", "detail": str(e)}
189
362
 
190
363
  if result.get("ok"):
364
+ c = result.get("counters") or {}
191
365
  print(
192
366
  "LINKEDIN_PRESENCE_SUMMARY: "
193
- f"mode={result.get('mode')} pages=1 scrolls={result.get('scrolls')} session=ok"
367
+ f"start={result.get('start')} steps={result.get('steps')} "
368
+ f"pages={len(result.get('pages_visited') or []) or 1} "
369
+ f"scrolls={c.get('scrolls', 0)} clicks={c.get('clicks', 0)} session=ok"
194
370
  )
195
371
  print(json.dumps(result, sort_keys=True))
196
372
  return 0
@@ -300,11 +300,17 @@ ensure_linkedin_browser_for_backend() {
300
300
  [ -n "$_stale_pids" ] && { kill -9 $_stale_pids 2>/dev/null || true; sleep 1; }
301
301
  rm -f "$_prof_dir/SingletonLock" "$_prof_dir/SingletonSocket" "$_prof_dir/SingletonCookie" 2>/dev/null || true
302
302
  fi
303
+ # The occlusion/backgrounding flags matter: the window sits offscreen,
304
+ # and without them Chrome stops laying out SPA-rendered content, so
305
+ # every element measures 0x0 and clicks become impossible (2026-07-03).
303
306
  "$_chrome_bin" \
304
307
  --remote-debugging-port=9556 \
305
308
  --user-data-dir="$HOME/.claude/browser-profiles/browser-harness-linkedin" \
306
309
  --no-first-run --no-default-browser-check \
307
310
  --disable-features=ChromeWhatsNewUI \
311
+ --disable-backgrounding-occluded-windows \
312
+ --disable-renderer-backgrounding \
313
+ --disable-background-timer-throttling \
308
314
  "${_extra[@]}" \
309
315
  about:blank >/dev/null 2>&1 &
310
316
  disown
@@ -2,10 +2,13 @@
2
2
  # linkedin-presence.sh - read-only LinkedIn session presence pass.
3
3
  #
4
4
  # Purpose:
5
- # Run a bounded, auditable browser pass in the real linkedin-harness Chrome.
6
- # It only views first-party LinkedIn surfaces and performs small scroll passes.
7
- # It does not like, follow, connect, message, comment, expand comments, or open
8
- # post permalinks.
5
+ # Run a bounded, auditable browsing session in the real linkedin-harness
6
+ # Chrome. It random-walks first-party LinkedIn surfaces like a person:
7
+ # scrolls, dwells, clicks read-only links (top nav tabs, profiles from the
8
+ # feed, company pages, LinkedIn News stories), and navigates back. Clicks
9
+ # are restricted to an href allowlist of read-only linkedin.com pages.
10
+ # It does not like, follow, connect, message, comment, or touch any action
11
+ # button.
9
12
  #
10
13
  # The shell wrapper is the pipeline: scheduling, killswitch, locks, harness
11
14
  # bootstrap, and run_monitor logging. The browser action itself is deterministic
@@ -57,23 +60,9 @@ if [ "$DRY_RUN" != "1" ]; then
57
60
  fi
58
61
  fi
59
62
 
60
- MODE_ROLL=$(( RANDOM % 4 ))
61
- case "$MODE_ROLL" in
62
- 0) MODE="feed"; TARGET_URL="https://www.linkedin.com/feed/" ;;
63
- 1) MODE="notifications"; TARGET_URL="https://www.linkedin.com/notifications/" ;;
64
- 2) MODE="messaging"; TARGET_URL="https://www.linkedin.com/messaging/" ;;
65
- *) MODE="profile"; TARGET_URL="https://www.linkedin.com/in/me/" ;;
66
- esac
67
-
68
- SCROLLS=$(( 1 + (RANDOM % 3) ))
69
- DWELL_A=$(( 2 + (RANDOM % 4) ))
70
- DWELL_B=$(( 2 + (RANDOM % 4) ))
71
- DWELL_C=$(( 2 + (RANDOM % 4) ))
72
- SCROLL_A=$(( 420 + (RANDOM % 260) ))
73
- SCROLL_B=$(( 420 + (RANDOM % 260) ))
74
- SCROLL_C=$(( 420 + (RANDOM % 260) ))
75
-
76
- log "=== LinkedIn Presence Run: $(date) (batch=$BATCH_ID mode=$MODE scrolls=$SCROLLS) ==="
63
+ # The browse session itself (start surface, action mix, scrolls, clicks,
64
+ # dwells) is randomized inside scripts/linkedin_presence.py.
65
+ log "=== LinkedIn Presence Run: $(date) (batch=$BATCH_ID) ==="
77
66
 
78
67
  # shellcheck source=/dev/null
79
68
  [ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
@@ -90,12 +79,24 @@ log_presence_run() {
90
79
  cost=$(python3 "$REPO_DIR/scripts/get_run_cost.py" \
91
80
  --since "$RUN_START" --scripts "linkedin-presence" 2>/dev/null || echo "0.0000")
92
81
 
82
+ # Pull the real session shape from the python summary line if present.
83
+ local scan="pages=1,scrolls=0,clicks=0"
84
+ local summary
85
+ summary=$(grep -o "LINKEDIN_PRESENCE_SUMMARY: .*" "$LOG_FILE" 2>/dev/null | tail -1)
86
+ if [ -n "$summary" ]; then
87
+ local pages scrolls clicks
88
+ pages=$(echo "$summary" | sed -n 's/.*pages=\([0-9]*\).*/\1/p')
89
+ scrolls=$(echo "$summary" | sed -n 's/.*scrolls=\([0-9]*\).*/\1/p')
90
+ clicks=$(echo "$summary" | sed -n 's/.*clicks=\([0-9]*\).*/\1/p')
91
+ scan="pages=${pages:-1},scrolls=${scrolls:-0},clicks=${clicks:-0}"
92
+ fi
93
+
93
94
  local args=(
94
95
  "$REPO_DIR/scripts/log_run.py" --script "presence_linkedin"
95
96
  --posted 0 --skipped 0 --failed "$failed"
96
97
  --cost "$cost" --elapsed "$elapsed"
97
98
  --scanned 1 --checked 1
98
- --scan "pages=1,scrolls=$SCROLLS"
99
+ --scan "$scan"
99
100
  )
100
101
  if [ -n "$failure_reasons" ]; then
101
102
  args+=(--failure-reasons "$failure_reasons")
@@ -115,7 +116,7 @@ cleanup() {
115
116
  trap cleanup EXIT INT TERM HUP
116
117
 
117
118
  if [ "$DRY_RUN" = "1" ]; then
118
- log "DRY_RUN: would run mode=$MODE url=$TARGET_URL scrolls=$SCROLLS"
119
+ log "DRY_RUN: would run a randomized read-only browse session"
119
120
  exit 0
120
121
  fi
121
122
 
@@ -138,17 +139,14 @@ fi
138
139
  PRESENCE_RC=0
139
140
  PYTHON_BIN="${LINKEDIN_DISCOVER_PYTHON:-python3}"
140
141
  TIMEOUT_BIN="$(command -v gtimeout || command -v timeout || true)"
142
+ PRESENCE_STEPS="${LINKEDIN_PRESENCE_STEPS:-0}"
141
143
  set +e
142
144
  if [ -n "$TIMEOUT_BIN" ]; then
143
- "$TIMEOUT_BIN" 180 "$PYTHON_BIN" "$REPO_DIR/scripts/linkedin_presence.py" \
144
- --mode "$MODE" --url "$TARGET_URL" --scrolls "$SCROLLS" \
145
- --amounts "$SCROLL_A,$SCROLL_B,$SCROLL_C" \
146
- --dwells "$DWELL_A,$DWELL_B,$DWELL_C" 2>&1 | tee -a "$LOG_FILE"
145
+ "$TIMEOUT_BIN" 300 "$PYTHON_BIN" "$REPO_DIR/scripts/linkedin_presence.py" \
146
+ --steps "$PRESENCE_STEPS" --max-seconds 200 2>&1 | tee -a "$LOG_FILE"
147
147
  else
148
148
  "$PYTHON_BIN" "$REPO_DIR/scripts/linkedin_presence.py" \
149
- --mode "$MODE" --url "$TARGET_URL" --scrolls "$SCROLLS" \
150
- --amounts "$SCROLL_A,$SCROLL_B,$SCROLL_C" \
151
- --dwells "$DWELL_A,$DWELL_B,$DWELL_C" 2>&1 | tee -a "$LOG_FILE"
149
+ --steps "$PRESENCE_STEPS" --max-seconds 200 2>&1 | tee -a "$LOG_FILE"
152
150
  fi
153
151
  PRESENCE_RC=${PIPESTATUS[0]}
154
152
  set -e