social-autoposter 1.6.49 → 1.6.50
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/package.json
CHANGED
|
@@ -82,6 +82,14 @@ NOTIFICATION_EMAIL = os.environ.get("NOTIFICATION_EMAIL", "i@m13v.com")
|
|
|
82
82
|
RECOVERY_MIN_AGE_HOURS = float(os.environ.get("LINKEDIN_RECOVERY_MIN_AGE_HOURS", "24"))
|
|
83
83
|
LINKEDIN_CDP_URL = os.environ.get("LINKEDIN_CDP_URL", "http://127.0.0.1:9556")
|
|
84
84
|
|
|
85
|
+
# Stop-completely policy (2026-06-03): after the 24h wait, the recovery job runs
|
|
86
|
+
# a read-only probe to see if the session healed on its own. We NEVER attempt a
|
|
87
|
+
# programmatic login (anti-bot rule). If the probe still shows logged-out after
|
|
88
|
+
# this many failed attempts, the session is genuinely dead: we mark the
|
|
89
|
+
# killswitch terminal so the hourly job stops probing entirely and a human must
|
|
90
|
+
# re-auth + clear. Default 1 == "wait 24h, try once, then stop completely".
|
|
91
|
+
RECOVERY_MAX_ATTEMPTS = int(os.environ.get("LINKEDIN_RECOVERY_MAX_ATTEMPTS", "1"))
|
|
92
|
+
|
|
85
93
|
VALID_SIGNALS = {
|
|
86
94
|
"http_999",
|
|
87
95
|
"authwall_redirect",
|
|
@@ -261,33 +269,42 @@ def engage(signal, detail="", run_log_path="", extra=None, send_email=True):
|
|
|
261
269
|
_LOGIN_MARKERS = ("/login", "/checkpoint", "/uas/login", "linkedin.com/authwall")
|
|
262
270
|
|
|
263
271
|
|
|
264
|
-
def _probe_linkedin_health(cdp_url):
|
|
272
|
+
def _probe_linkedin_health(cdp_url, feed_only=False):
|
|
265
273
|
"""Gentle, read-only health probe of the LinkedIn session.
|
|
266
274
|
|
|
267
275
|
Attaches (CDP) to the already-running linkedin-harness Chrome and does the
|
|
268
276
|
minimal nav set the anti-bot carve-out allows: ONE nav to /feed/ (confirms
|
|
269
|
-
we are logged in) and ONE nav to the exact
|
|
270
|
-
endpoint that trips the killswitch (confirms
|
|
271
|
-
authwall). No Voyager calls, no scroll loops, no
|
|
272
|
-
clicks/typing, no programmatic login. Reuses an
|
|
273
|
-
closes the shared context.
|
|
274
|
-
|
|
275
|
-
|
|
277
|
+
we are logged in) and, unless feed_only, ONE nav to the exact
|
|
278
|
+
/in/me/recent-activity/comments/ endpoint that trips the killswitch (confirms
|
|
279
|
+
it no longer bounces to the authwall). No Voyager calls, no scroll loops, no
|
|
280
|
+
permalink fan-out, no clicks/typing, no programmatic login. Reuses an
|
|
281
|
+
existing tab and never closes the shared context.
|
|
282
|
+
|
|
283
|
+
feed_only=True is the per-run detection gate: a single /feed/ nav is enough
|
|
284
|
+
to tell "are we still logged in?" without touching the activity endpoint on
|
|
285
|
+
every healthy pipeline fire.
|
|
286
|
+
|
|
287
|
+
Returns (healthy: bool, detail: str, conclusive: bool). Never raises.
|
|
288
|
+
conclusive=True means we definitively observed login state (healthy feed, or
|
|
289
|
+
a redirect to the authwall/login/checkpoint). conclusive=False means we
|
|
290
|
+
could not determine it (CDP attach failed, nav timeout, Chrome down): an
|
|
291
|
+
infra hiccup, NOT evidence the session is dead, so callers must not engage
|
|
292
|
+
the killswitch or count it as a failed re-login attempt on this.
|
|
276
293
|
"""
|
|
277
294
|
try:
|
|
278
295
|
from playwright.sync_api import sync_playwright
|
|
279
296
|
except Exception as e:
|
|
280
|
-
return False, "playwright import failed: {}".format(e)
|
|
297
|
+
return False, "playwright import failed: {}".format(e), False
|
|
281
298
|
|
|
282
299
|
try:
|
|
283
300
|
with sync_playwright() as p:
|
|
284
301
|
try:
|
|
285
302
|
browser = p.chromium.connect_over_cdp(cdp_url, timeout=8000)
|
|
286
303
|
except Exception as e:
|
|
287
|
-
return False, "cdp attach failed ({}): {}".format(cdp_url, e)
|
|
304
|
+
return False, "cdp attach failed ({}): {}".format(cdp_url, e), False
|
|
288
305
|
contexts = browser.contexts
|
|
289
306
|
if not contexts:
|
|
290
|
-
return False, "cdp attach: zero contexts"
|
|
307
|
+
return False, "cdp attach: zero contexts", False
|
|
291
308
|
ctx = contexts[0]
|
|
292
309
|
|
|
293
310
|
page = None
|
|
@@ -312,7 +329,15 @@ def _probe_linkedin_health(cdp_url):
|
|
|
312
329
|
page.wait_for_timeout(2000)
|
|
313
330
|
u1 = page.url or ""
|
|
314
331
|
if any(m in u1 for m in _LOGIN_MARKERS):
|
|
315
|
-
return False, "feed redirected to auth: {}".format(u1)
|
|
332
|
+
return False, "feed redirected to auth: {}".format(u1), True
|
|
333
|
+
|
|
334
|
+
if feed_only:
|
|
335
|
+
title = ""
|
|
336
|
+
try:
|
|
337
|
+
title = page.title() or ""
|
|
338
|
+
except Exception:
|
|
339
|
+
pass
|
|
340
|
+
return True, "feed renders (title={!r}, url={})".format(title, u1), True
|
|
316
341
|
|
|
317
342
|
# Nav 2: the exact endpoint that engaged the killswitch.
|
|
318
343
|
page.goto(
|
|
@@ -323,14 +348,14 @@ def _probe_linkedin_health(cdp_url):
|
|
|
323
348
|
page.wait_for_timeout(2000)
|
|
324
349
|
u2 = page.url or ""
|
|
325
350
|
if any(m in u2 for m in _LOGIN_MARKERS):
|
|
326
|
-
return False, "activity endpoint redirected to auth: {}".format(u2)
|
|
351
|
+
return False, "activity endpoint redirected to auth: {}".format(u2), True
|
|
327
352
|
|
|
328
353
|
title = ""
|
|
329
354
|
try:
|
|
330
355
|
title = page.title() or ""
|
|
331
356
|
except Exception:
|
|
332
357
|
pass
|
|
333
|
-
return True, "feed+activity render (title={!r}, url={})".format(title, u2)
|
|
358
|
+
return True, "feed+activity render (title={!r}, url={})".format(title, u2), True
|
|
334
359
|
finally:
|
|
335
360
|
if page is not None and not reused:
|
|
336
361
|
try:
|
|
@@ -338,7 +363,7 @@ def _probe_linkedin_health(cdp_url):
|
|
|
338
363
|
except Exception:
|
|
339
364
|
pass
|
|
340
365
|
except Exception as e:
|
|
341
|
-
return False, "probe exception: {}: {}".format(type(e).__name__, e)
|
|
366
|
+
return False, "probe exception: {}: {}".format(type(e).__name__, e), False
|
|
342
367
|
|
|
343
368
|
|
|
344
369
|
def _send_recovery_email(detail, age_sec):
|
|
@@ -387,6 +412,97 @@ def _send_recovery_email(detail, age_sec):
|
|
|
387
412
|
return False, "send failed: " + str(exc)
|
|
388
413
|
|
|
389
414
|
|
|
415
|
+
def is_terminal():
|
|
416
|
+
"""True if auto-recovery has given up (failed re-login after the 24h wait)
|
|
417
|
+
and a human must re-auth + clear. Once terminal, the hourly recovery job
|
|
418
|
+
stops probing entirely."""
|
|
419
|
+
p = read()
|
|
420
|
+
return bool(p and p.get("recovery_terminal"))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _record_failed_recovery(detail):
|
|
424
|
+
"""A read-only recovery probe conclusively showed still-logged-out after the
|
|
425
|
+
24h wait. Increment the attempt counter on the live state file (preserving
|
|
426
|
+
the original ts so age keeps accruing) and, once attempts reach
|
|
427
|
+
RECOVERY_MAX_ATTEMPTS, flip recovery_terminal so we stop completely.
|
|
428
|
+
|
|
429
|
+
Returns (attempts: int, terminal: bool)."""
|
|
430
|
+
p = read() or {}
|
|
431
|
+
attempts = int(p.get("recovery_attempts", 0)) + 1
|
|
432
|
+
p["recovery_attempts"] = attempts
|
|
433
|
+
p["last_recovery_ts"] = _now_iso()
|
|
434
|
+
p["last_recovery_detail"] = str(detail)[:2000]
|
|
435
|
+
terminal = attempts >= RECOVERY_MAX_ATTEMPTS
|
|
436
|
+
if terminal:
|
|
437
|
+
p["recovery_terminal"] = True
|
|
438
|
+
p["recovery_terminal_ts"] = _now_iso()
|
|
439
|
+
_ensure_dir()
|
|
440
|
+
tmp = STATE_FILE + ".tmp"
|
|
441
|
+
with open(tmp, "w") as f:
|
|
442
|
+
json.dump(p, f, indent=2)
|
|
443
|
+
f.write("\n")
|
|
444
|
+
os.replace(tmp, STATE_FILE)
|
|
445
|
+
_append_trail({
|
|
446
|
+
"event": "recovery_failed",
|
|
447
|
+
"ts": _now_iso(),
|
|
448
|
+
"attempts": attempts,
|
|
449
|
+
"terminal": terminal,
|
|
450
|
+
"detail": str(detail)[:500],
|
|
451
|
+
})
|
|
452
|
+
return attempts, terminal
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _send_terminal_email(detail, attempts, age_sec):
|
|
456
|
+
"""Notify that auto-recovery gave up; manual re-auth required."""
|
|
457
|
+
try:
|
|
458
|
+
from google.auth.transport.requests import Request
|
|
459
|
+
from google.oauth2.credentials import Credentials
|
|
460
|
+
from googleapiclient.discovery import build
|
|
461
|
+
|
|
462
|
+
if not os.path.isfile(GMAIL_TOKEN_PATH):
|
|
463
|
+
return False, "gmail token missing"
|
|
464
|
+
|
|
465
|
+
creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
|
|
466
|
+
if creds.expired and creds.refresh_token:
|
|
467
|
+
creds.refresh(Request())
|
|
468
|
+
with open(GMAIL_TOKEN_PATH, "w") as f:
|
|
469
|
+
f.write(creds.to_json())
|
|
470
|
+
|
|
471
|
+
service = build("gmail", "v1", credentials=creds, cache_discovery=False)
|
|
472
|
+
age_h = round(age_sec / 3600.0, 1) if age_sec else "?"
|
|
473
|
+
subject = "[LI KILL] AUTO-RECOVERY FAILED, manual re-auth required"
|
|
474
|
+
body_lines = [
|
|
475
|
+
"LinkedIn auto-recovery has STOPPED COMPLETELY.",
|
|
476
|
+
"",
|
|
477
|
+
"After the " + str(RECOVERY_MIN_AGE_HOURS) + "h wait, the read-only probe",
|
|
478
|
+
"ran " + str(attempts) + " attempt(s) and the session was still logged out",
|
|
479
|
+
"(redirected to the authwall/login). Per the anti-bot rule we never",
|
|
480
|
+
"log in programmatically, so the hourly recovery job will now stop",
|
|
481
|
+
"probing and every LinkedIn pipeline stays paused until you act.",
|
|
482
|
+
"",
|
|
483
|
+
"Killswitch age at give-up: " + str(age_h) + "h",
|
|
484
|
+
"Last probe detail: " + str(detail),
|
|
485
|
+
"",
|
|
486
|
+
"To resume:",
|
|
487
|
+
" 1. Open the linkedin-harness Chrome (port 9556) and sign back in.",
|
|
488
|
+
" 2. Confirm /feed/ renders without an authwall.",
|
|
489
|
+
" 3. Clear the killswitch:",
|
|
490
|
+
" python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear",
|
|
491
|
+
"",
|
|
492
|
+
"State file: " + STATE_FILE,
|
|
493
|
+
"Trail file: " + TRAIL_FILE,
|
|
494
|
+
]
|
|
495
|
+
body = _scrub_dashes("\n".join(body_lines))
|
|
496
|
+
msg = MIMEText(body, "plain", "utf-8")
|
|
497
|
+
msg["to"] = NOTIFICATION_EMAIL
|
|
498
|
+
msg["subject"] = _scrub_dashes(subject)
|
|
499
|
+
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
|
|
500
|
+
service.users().messages().send(userId="me", body={"raw": raw}).execute()
|
|
501
|
+
return True, "sent"
|
|
502
|
+
except Exception as exc:
|
|
503
|
+
return False, "send failed: " + str(exc)
|
|
504
|
+
|
|
505
|
+
|
|
390
506
|
def clear():
|
|
391
507
|
"""Human ack: remove the flag. Trail row records who cleared it."""
|
|
392
508
|
if not is_active():
|
|
@@ -444,6 +560,53 @@ def _cmd_clear(args):
|
|
|
444
560
|
sys.exit(0)
|
|
445
561
|
|
|
446
562
|
|
|
563
|
+
def _cmd_detect_gate(args):
|
|
564
|
+
"""Per-run logout detector, called by ensure_linkedin_browser_for_backend so
|
|
565
|
+
ANY LinkedIn pipeline trips the killswitch on its natural next fire.
|
|
566
|
+
|
|
567
|
+
- If the killswitch is already active: no-op, exit 0 (the file gate / hourly
|
|
568
|
+
recovery already own the situation; don't double-probe).
|
|
569
|
+
- Otherwise run a single read-only /feed/ probe. If it CONCLUSIVELY shows
|
|
570
|
+
logged-out (redirect to authwall/login/checkpoint), engage the killswitch
|
|
571
|
+
(signal login_redirect) and exit 2 so the caller can abort this fire. The
|
|
572
|
+
flag pauses every other pipeline on its next fire and starts the 24h
|
|
573
|
+
recovery clock. Healthy or inconclusive (infra) -> exit 0, proceed."""
|
|
574
|
+
if is_active():
|
|
575
|
+
# Already flagged: nothing to detect. Stay silent + cheap.
|
|
576
|
+
sys.exit(0)
|
|
577
|
+
cdp_url = args.cdp_url or LINKEDIN_CDP_URL
|
|
578
|
+
healthy, detail, conclusive = _probe_linkedin_health(cdp_url, feed_only=True)
|
|
579
|
+
_append_trail({
|
|
580
|
+
"event": "detect_gate",
|
|
581
|
+
"ts": _now_iso(),
|
|
582
|
+
"healthy": healthy,
|
|
583
|
+
"conclusive": conclusive,
|
|
584
|
+
"detail": detail,
|
|
585
|
+
})
|
|
586
|
+
if healthy:
|
|
587
|
+
print("detect-gate: session healthy ({})".format(detail), file=sys.stderr)
|
|
588
|
+
sys.exit(0)
|
|
589
|
+
if not conclusive:
|
|
590
|
+
# Couldn't determine (CDP down, nav timeout). Don't engage on infra
|
|
591
|
+
# noise; let the pipeline's own SESSION_INVALID handling deal with it.
|
|
592
|
+
print("detect-gate: inconclusive ({}), proceeding".format(detail), file=sys.stderr)
|
|
593
|
+
sys.exit(0)
|
|
594
|
+
# Conclusively logged out. Trip the killswitch for the whole fleet.
|
|
595
|
+
run_log_path = os.environ.get("SAPS_RUN_LOG_PATH", "")
|
|
596
|
+
engage(
|
|
597
|
+
signal="login_redirect",
|
|
598
|
+
detail="detect-gate: {}".format(detail),
|
|
599
|
+
run_log_path=run_log_path,
|
|
600
|
+
extra={"detected_by": os.environ.get("SAPS_PIPELINE_NAME", "?"), "probe": "feed_only"},
|
|
601
|
+
send_email=not args.no_email,
|
|
602
|
+
)
|
|
603
|
+
print(
|
|
604
|
+
"detect-gate: LOGGED OUT, killswitch ENGAGED ({}); aborting this fire".format(detail),
|
|
605
|
+
file=sys.stderr,
|
|
606
|
+
)
|
|
607
|
+
sys.exit(2)
|
|
608
|
+
|
|
609
|
+
|
|
447
610
|
def _cmd_recover_check(args):
|
|
448
611
|
"""Gate for the hourly recovery job: exit 0 only if the killswitch is
|
|
449
612
|
active AND has been so for >= RECOVERY_MIN_AGE_HOURS. Lets the shell
|
|
@@ -451,6 +614,13 @@ def _cmd_recover_check(args):
|
|
|
451
614
|
if not is_active():
|
|
452
615
|
print("recover-check: killswitch not active, nothing to recover", file=sys.stderr)
|
|
453
616
|
sys.exit(1)
|
|
617
|
+
if is_terminal():
|
|
618
|
+
print(
|
|
619
|
+
"recover-check: TERMINAL (auto-recovery gave up after failed re-login); "
|
|
620
|
+
"manual re-auth + clear required, not probing",
|
|
621
|
+
file=sys.stderr,
|
|
622
|
+
)
|
|
623
|
+
sys.exit(1)
|
|
454
624
|
age = age_seconds()
|
|
455
625
|
min_age = RECOVERY_MIN_AGE_HOURS * 3600
|
|
456
626
|
if age is None:
|
|
@@ -484,6 +654,9 @@ def _cmd_recover(args):
|
|
|
484
654
|
if not is_active():
|
|
485
655
|
print(json.dumps({"recovered": False, "reason": "not_active"}))
|
|
486
656
|
sys.exit(0)
|
|
657
|
+
if is_terminal():
|
|
658
|
+
print(json.dumps({"recovered": False, "reason": "terminal_manual_required"}))
|
|
659
|
+
sys.exit(0)
|
|
487
660
|
age = age_seconds()
|
|
488
661
|
min_age = RECOVERY_MIN_AGE_HOURS * 3600
|
|
489
662
|
if not args.force and (age is None or age < min_age):
|
|
@@ -495,16 +668,38 @@ def _cmd_recover(args):
|
|
|
495
668
|
sys.exit(0)
|
|
496
669
|
|
|
497
670
|
cdp_url = args.cdp_url or LINKEDIN_CDP_URL
|
|
498
|
-
healthy, detail = _probe_linkedin_health(cdp_url)
|
|
671
|
+
healthy, detail, conclusive = _probe_linkedin_health(cdp_url)
|
|
499
672
|
_append_trail({
|
|
500
673
|
"event": "recover_probe",
|
|
501
674
|
"ts": _now_iso(),
|
|
502
675
|
"healthy": healthy,
|
|
676
|
+
"conclusive": conclusive,
|
|
503
677
|
"detail": detail,
|
|
504
678
|
"age_hours": (round(age / 3600.0, 2) if age else None),
|
|
505
679
|
})
|
|
506
680
|
if not healthy:
|
|
507
|
-
|
|
681
|
+
# Inconclusive (CDP down, nav timeout): infra hiccup, not a dead
|
|
682
|
+
# session. Do NOT count it as a failed re-login; just retry next hour.
|
|
683
|
+
if not conclusive:
|
|
684
|
+
print(json.dumps({
|
|
685
|
+
"recovered": False,
|
|
686
|
+
"reason": "probe_inconclusive",
|
|
687
|
+
"detail": detail,
|
|
688
|
+
}))
|
|
689
|
+
sys.exit(0)
|
|
690
|
+
# Conclusively still logged out after the 24h wait. Record the failed
|
|
691
|
+
# attempt; once we hit RECOVERY_MAX_ATTEMPTS we stop completely.
|
|
692
|
+
attempts, terminal = _record_failed_recovery(detail)
|
|
693
|
+
if terminal and not args.no_email:
|
|
694
|
+
ok, msg = _send_terminal_email(detail, attempts, age)
|
|
695
|
+
_append_trail({"event": "terminal_email", "ok": ok, "msg": msg})
|
|
696
|
+
print(json.dumps({
|
|
697
|
+
"recovered": False,
|
|
698
|
+
"reason": ("recovery_terminal" if terminal else "relogin_failed_retrying"),
|
|
699
|
+
"attempts": attempts,
|
|
700
|
+
"terminal": terminal,
|
|
701
|
+
"detail": detail,
|
|
702
|
+
}))
|
|
508
703
|
sys.exit(0)
|
|
509
704
|
|
|
510
705
|
clear()
|
|
@@ -532,6 +727,13 @@ def main():
|
|
|
532
727
|
|
|
533
728
|
sub.add_parser("clear", help="clear the killswitch (human ack)")
|
|
534
729
|
|
|
730
|
+
dg = sub.add_parser(
|
|
731
|
+
"detect-gate",
|
|
732
|
+
help="per-run logout probe; engage + exit 2 if conclusively logged out",
|
|
733
|
+
)
|
|
734
|
+
dg.add_argument("--cdp-url", default="", help="harness CDP URL (default $LINKEDIN_CDP_URL)")
|
|
735
|
+
dg.add_argument("--no-email", action="store_true", help="skip engage alert email")
|
|
736
|
+
|
|
535
737
|
sub.add_parser(
|
|
536
738
|
"recover-check",
|
|
537
739
|
help="exit 0 if active AND >= RECOVERY_MIN_AGE_HOURS old (else 1)",
|
|
@@ -551,6 +753,7 @@ def main():
|
|
|
551
753
|
"status": _cmd_status,
|
|
552
754
|
"engage": _cmd_engage,
|
|
553
755
|
"clear": _cmd_clear,
|
|
756
|
+
"detect-gate": _cmd_detect_gate,
|
|
554
757
|
"recover-check": _cmd_recover_check,
|
|
555
758
|
"recover": _cmd_recover,
|
|
556
759
|
}[args.cmd](args)
|
|
@@ -531,9 +531,10 @@ def _wait_for_reply_textbox(page, total_timeout_ms=45000):
|
|
|
531
531
|
|
|
532
532
|
# Post-action interstitials X shows AFTER a successful reply (e.g. the
|
|
533
533
|
# "Unlock more on X" graduated-access sheet). They don't block the post that
|
|
534
|
-
# triggered them, but the sheet stays up
|
|
535
|
-
# reply in a batch -> spurious reply_box_not_found for
|
|
536
|
-
#
|
|
534
|
+
# triggered them, but the sheet stays up on screen and would overlay the
|
|
535
|
+
# composer on the NEXT reply in a batch -> spurious reply_box_not_found for
|
|
536
|
+
# posts 2..N. We dismiss them deterministically right after each successful
|
|
537
|
+
# post (not before the next reply), so the sheet never lingers. Targeted by the
|
|
537
538
|
# sheet's CTA label so we never touch a real compose/confirm dialog (those have
|
|
538
539
|
# no "Got it"); best-effort, fast, never raises.
|
|
539
540
|
_OVERLAY_DISMISS_LABELS = ("Got it", "Dismiss")
|
|
@@ -841,12 +842,6 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
|
|
|
841
842
|
tweet_not_found = True
|
|
842
843
|
break
|
|
843
844
|
|
|
844
|
-
# A nudge sheet left over from the previous reply in this batch
|
|
845
|
-
# (e.g. "Unlock more on X") can sit on top of the composer and
|
|
846
|
-
# mask tweetTextarea_0. Clear it first so the wait below sees the
|
|
847
|
-
# real reply box instead of failing reply_box_not_found.
|
|
848
|
-
_dismiss_known_overlays(page)
|
|
849
|
-
|
|
850
845
|
reply_box = _wait_for_reply_textbox(page, total_timeout_ms=45000)
|
|
851
846
|
if reply_box:
|
|
852
847
|
break
|
|
@@ -904,6 +899,14 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
|
|
|
904
899
|
except Exception:
|
|
905
900
|
verified = True
|
|
906
901
|
|
|
902
|
+
# Dismiss the post-success interstitial X shows right after a reply
|
|
903
|
+
# (e.g. the "Unlock more on X" graduated-access sheet). It animates
|
|
904
|
+
# in on top of the composer once the reply lands, so we close it
|
|
905
|
+
# here, immediately after the post succeeds, rather than before the
|
|
906
|
+
# next reply -> the sheet never lingers on screen and never masks
|
|
907
|
+
# the next reply box. Best-effort, fast, never raises.
|
|
908
|
+
_dismiss_known_overlays(page)
|
|
909
|
+
|
|
907
910
|
# Clean up CDP session
|
|
908
911
|
if _cdp_session:
|
|
909
912
|
try:
|
|
@@ -313,6 +313,40 @@ ensure_linkedin_browser_for_backend() {
|
|
|
313
313
|
# Always close leftover tabs from prior runs. Safe under acquire_lock
|
|
314
314
|
# "linkedin-browser" serialization.
|
|
315
315
|
cleanup_harness_tabs
|
|
316
|
+
|
|
317
|
+
# Per-run logout detection (2026-06-03). Every browser pipeline funnels
|
|
318
|
+
# through here before it touches LinkedIn, so this single call makes ANY
|
|
319
|
+
# pipeline trip the killswitch on its natural next fire if the harness
|
|
320
|
+
# Chrome has been logged out (999 / authwall / checkpoint), without editing
|
|
321
|
+
# the chflags-locked top-level scripts. detect-gate is a no-op when the
|
|
322
|
+
# killswitch is already active, and only ENGAGES on a CONCLUSIVE /feed/
|
|
323
|
+
# redirect to auth (infra hiccups -> proceed, so a flaky render never
|
|
324
|
+
# strands the pipeline). On a confirmed logout it engages the flag (which
|
|
325
|
+
# pauses every pipeline on its next fire + starts the 24h recovery clock)
|
|
326
|
+
# and returns 2, so we abort this fire instead of burning a Claude session
|
|
327
|
+
# on a dead session.
|
|
328
|
+
_linkedin_session_detect_gate
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
# Once-per-process guard mirrors _LI_PIPELINE_LOCK_HELD: run-linkedin.sh calls
|
|
332
|
+
# ensure_linkedin_browser_for_backend in both Phase A and Phase B, and we do not
|
|
333
|
+
# want two /feed/ probes per fire.
|
|
334
|
+
_linkedin_session_detect_gate() {
|
|
335
|
+
if [ "${_LI_SESSION_PROBED:-0}" = "1" ]; then
|
|
336
|
+
return 0
|
|
337
|
+
fi
|
|
338
|
+
export _LI_SESSION_PROBED=1
|
|
339
|
+
local _py="${LINKEDIN_DISCOVER_PYTHON:-python3}"
|
|
340
|
+
# `|| _rc=$?` so a nonzero exit (e.g. 2 = logged out) is "handled" and does
|
|
341
|
+
# not trip a caller's `set -e` before we inspect the code ourselves.
|
|
342
|
+
local _rc=0
|
|
343
|
+
"$_py" "$HOME/social-autoposter/scripts/linkedin_killswitch.py" detect-gate \
|
|
344
|
+
--cdp-url "${LINKEDIN_CDP_URL:-$_BH_LINKEDIN_DEFAULT_URL}" >&2 || _rc=$?
|
|
345
|
+
if [ "$_rc" = "2" ]; then
|
|
346
|
+
echo "[$(date +%H:%M:%S)] detect-gate tripped the LinkedIn killswitch; aborting this fire" >&2
|
|
347
|
+
return 1
|
|
348
|
+
fi
|
|
349
|
+
return 0
|
|
316
350
|
}
|
|
317
351
|
|
|
318
352
|
defer_if_foreign_for_backend() {
|