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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.49",
3
+ "version": "1.6.50",
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"
@@ -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 /in/me/recent-activity/comments/
270
- endpoint that trips the killswitch (confirms it no longer bounces to the
271
- authwall). No Voyager calls, no scroll loops, no permalink fan-out, no
272
- clicks/typing, no programmatic login. Reuses an existing tab and never
273
- closes the shared context.
274
-
275
- Returns (healthy: bool, detail: str). Never raises.
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
- print(json.dumps({"recovered": False, "reason": "probe_unhealthy", "detail": detail}))
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 and overlays the composer on the NEXT
535
- # reply in a batch -> spurious reply_box_not_found for posts 2..N. We dismiss
536
- # them deterministically before looking for the reply box. Targeted by the
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() {