@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 +1 -1
- package/mcp/dist/screencast.js +15 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +54 -57
- package/mcp/menubar/s4l_menubar.py +19 -12
- package/mcp/menubar/s4l_state.py +14 -2
- package/mcp/package.json +2 -1
- package/package.json +1 -1
- package/scripts/linkedin_presence.py +256 -80
- package/skill/lib/linkedin-backend.sh +6 -0
- package/skill/linkedin-presence.sh +28 -30
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
|
|
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
|
}
|
package/mcp/dist/screencast.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
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.6.197-rc.
|
|
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": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
508
|
-
#
|
|
509
|
-
|
|
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 +
|
|
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
|
|
533
|
-
#
|
|
534
|
-
#
|
|
535
|
-
#
|
|
536
|
-
#
|
|
537
|
-
#
|
|
538
|
-
|
|
539
|
-
fb.
|
|
540
|
-
|
|
541
|
-
|
|
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 -
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -222,7 +222,7 @@ _snap_lock = threading.Lock()
|
|
|
222
222
|
_snap_refreshing = [False]
|
|
223
223
|
|
|
224
224
|
|
|
225
|
-
def
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
4
|
+
Simulates a short human browsing session:
|
|
5
5
|
- attach to the already-running linkedin-harness Chrome via CDP
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
|
|
37
|
+
START_URLS = {
|
|
29
38
|
"feed": "https://www.linkedin.com/feed/",
|
|
30
39
|
"notifications": "https://www.linkedin.com/notifications/",
|
|
31
|
-
"
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
133
|
+
def _session_invalid(page: Any) -> tuple[bool, str]:
|
|
71
134
|
url = page.url or ""
|
|
72
135
|
title = _safe_title(page)
|
|
73
|
-
|
|
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
|
|
138
|
+
if any(s in title.lower() for s in ("security verification", "captcha", "checkpoint")):
|
|
78
139
|
return True, f"title:{title}"
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
137
|
-
page
|
|
258
|
+
page.goto(start_url, wait_until="domcontentloaded", timeout=args.timeout_ms)
|
|
259
|
+
_settle(page)
|
|
138
260
|
|
|
139
|
-
invalid, detail = _session_invalid(page
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
176
|
-
ap.add_argument("--
|
|
177
|
-
ap.add_argument("--
|
|
178
|
-
ap.add_argument("--
|
|
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("--
|
|
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)
|
|
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"
|
|
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
|
|
6
|
-
# It
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 "
|
|
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
|
|
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"
|
|
144
|
-
--
|
|
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
|
-
--
|
|
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
|