social-autoposter 1.6.3 → 1.6.5

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/bin/server.js CHANGED
@@ -542,7 +542,7 @@ const RUN_LINE_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\s*\|\s*(\S+)\s*\|\s*
542
542
  const PLATFORM_LABELS = {
543
543
  twitter: 'Twitter', reddit: 'Reddit', linkedin: 'LinkedIn',
544
544
  moltbook: 'MoltBook', github: 'GitHub', dev: 'Dev',
545
- hackernews: 'HackerNews', youtube: 'YouTube',
545
+ hackernews: 'HackerNews', youtube: 'YouTube', instagram: 'Instagram',
546
546
  };
547
547
 
548
548
  // Standalone jobs with no platform axis. script_name -> display label.
@@ -624,6 +624,7 @@ function classifyScript(script) {
624
624
  match(/^link_edit_(\w+)$/, 'link-edit', 'Link Edit') ||
625
625
  match(/^thread_(\w+)$/, 'post', 'Post Threads') ||
626
626
  match(/^post_(\w+)$/, 'post-comments', 'Post Comments') ||
627
+ match(/^render_(\w+)$/, 'render', 'Render') ||
627
628
  match(/^engage_(\w+)$/, 'engage', 'Engage') ||
628
629
  match(/^dm_outreach_(\w+)$/, 'dm-outreach', 'DM Outreach') ||
629
630
  match(/^dm_replies_(\w+)$/, 'dm-replies', 'DM Replies') ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
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"
@@ -40,7 +40,24 @@ LOCK_EXPIRY = 300 # process-level mutex TTL; refreshed during long ops
40
40
  LOCK_WAIT_MAX = 45 # seconds to wait for lock to free before giving up
41
41
  LOCK_POLL_INTERVAL = 2
42
42
  VIEWPORT = {"width": 911, "height": 1016}
43
- OUR_HANDLE = "m13v_"
43
+
44
+ # Posting handle. Resolved at call time from AUTOPOSTER_TWITTER_HANDLE env
45
+ # var (set by per-account launchd/systemd units) or config.json
46
+ # accounts.twitter.handle. The "m13v_" fallback keeps the Mac default working
47
+ # when neither source is set, but on the VMs the env var MUST be set so the
48
+ # DOM scrape + CDP URL build target the right profile.
49
+ _DEFAULT_HANDLE = "m13v_"
50
+
51
+ def our_handle():
52
+ try:
53
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
54
+ import account_resolver
55
+ h = account_resolver.resolve("twitter")
56
+ if h:
57
+ return h
58
+ except Exception:
59
+ pass
60
+ return _DEFAULT_HANDLE
44
61
 
45
62
  # DM encryption passcode from .env
46
63
  DM_PASSCODE = os.environ.get("TWITTER_DM_PASSCODE", "")
@@ -468,18 +485,80 @@ def _rate_limit_response(reason, counter=None, url=None):
468
485
 
469
486
 
470
487
  def _collect_our_reply_links(page):
471
- """Collect all /OUR_HANDLE/status/ links currently in the DOM."""
488
+ """Collect all /<our_handle>/status/ links currently in the DOM."""
489
+ handle = our_handle()
472
490
  return set(page.evaluate(f"""() => {{
473
491
  const links = new Set();
474
- document.querySelectorAll('a[href*="/{OUR_HANDLE}/status/"]').forEach(a => {{
492
+ document.querySelectorAll('a[href*="/{handle}/status/"]').forEach(a => {{
475
493
  const href = a.getAttribute('href');
476
- if (href && /\\/{OUR_HANDLE}\\/status\\/\\d+$/.test(href))
494
+ if (href && /\\/{handle}\\/status\\/\\d+$/.test(href))
477
495
  links.add(href);
478
496
  }});
479
497
  return [...links];
480
498
  }}"""))
481
499
 
482
500
 
501
+ def _wait_for_reply_textbox(page, total_timeout_ms=45000):
502
+ """Wait for the reply composer textbox to mount. Returns a locator or None.
503
+
504
+ Polls multiple selectors because the React composer sometimes attaches late
505
+ on slow egress (E2B sandbox) and the aria-label has historically varied
506
+ ("Post text" / "Tweet your reply" / "Post your reply"). The data-testid
507
+ `tweetTextarea_0` has been stable for years and is the primary signal.
508
+ """
509
+ import time as _t
510
+ selectors = (
511
+ '[data-testid="tweetTextarea_0"]',
512
+ '[role="textbox"][aria-label="Post text"]',
513
+ '[role="textbox"][aria-label="Tweet your reply"]',
514
+ '[role="textbox"][aria-label="Post your reply"]',
515
+ )
516
+ deadline = _t.monotonic() + (total_timeout_ms / 1000.0)
517
+ while _t.monotonic() < deadline:
518
+ for sel in selectors:
519
+ try:
520
+ loc = page.locator(sel).first
521
+ if loc.count() > 0 and loc.is_visible():
522
+ return loc
523
+ except Exception:
524
+ pass
525
+ page.wait_for_timeout(500)
526
+ return None
527
+
528
+
529
+ def _dump_reply_failure_diag(page, tweet_url):
530
+ """Dump screenshot + DOM state on reply_box_not_found. Returns a diag dict."""
531
+ import time as _t
532
+ ts = int(_t.time())
533
+ diag = {"ts": ts, "tweet_url": tweet_url}
534
+ try:
535
+ diag["final_url"] = page.url
536
+ except Exception as _e:
537
+ diag["final_url_err"] = str(_e)
538
+ try:
539
+ png_path = f"/tmp/twitter_reply_failure_{ts}.png"
540
+ page.screenshot(path=png_path, full_page=False)
541
+ diag["screenshot"] = png_path
542
+ except Exception as _e:
543
+ diag["screenshot_err"] = str(_e)
544
+ try:
545
+ diag["dom"] = page.evaluate("""() => {
546
+ const tbs = Array.from(document.querySelectorAll('[role="textbox"]'));
547
+ return {
548
+ title: (document.title || '').slice(0, 120),
549
+ textbox_count: tbs.length,
550
+ textbox_labels: tbs.map(t => t.getAttribute('aria-label')),
551
+ has_tweetTextarea_0: !!document.querySelector('[data-testid="tweetTextarea_0"]'),
552
+ has_login_modal: !!document.querySelector('[data-testid="loginButton"]'),
553
+ has_age_gate: !!document.querySelector('[data-testid="sensitive-media-button"]'),
554
+ page_text_snippet: (document.body && document.body.innerText || '').slice(0, 300),
555
+ };
556
+ }""")
557
+ except Exception as _e:
558
+ diag["dom_err"] = str(_e)
559
+ return diag
560
+
561
+
483
562
  def reply_to_tweet(tweet_url, text, apply_campaigns=True):
484
563
  """Reply to a tweet.
485
564
 
@@ -609,39 +688,54 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
609
688
  except Exception:
610
689
  pass
611
690
 
612
- try:
613
- page.goto(tweet_url, wait_until="load", timeout=60000)
614
- except Exception:
691
+ # Navigate + locate reply box. Composer mount is flaky on E2B
692
+ # sandbox egress (~1-in-5 misses on first attempt). Strategy:
693
+ # up to 2 navigation attempts; on miss, scroll-nudge once before
694
+ # re-navigating. On final miss, dump diagnostics for triage.
695
+ reply_box = None
696
+ tweet_not_found = False
697
+ for nav_attempt in (1, 2):
615
698
  try:
616
- page.goto(tweet_url, wait_until="domcontentloaded", timeout=60000)
699
+ page.goto(tweet_url, wait_until="load", timeout=60000)
617
700
  except Exception:
618
- pass
619
- page.wait_for_timeout(15000)
701
+ try:
702
+ page.goto(tweet_url, wait_until="domcontentloaded", timeout=60000)
703
+ except Exception:
704
+ pass
705
+ page.wait_for_timeout(15000 if nav_attempt == 1 else 8000)
620
706
 
621
- # Check if page exists
622
- page_text = page.text_content("main") or ""
623
- if "this page doesn't exist" in page_text.lower():
624
- return {"ok": False, "error": "tweet_not_found"}
707
+ page_text = page.text_content("main") or ""
708
+ if "this page doesn't exist" in page_text.lower():
709
+ tweet_not_found = True
710
+ break
625
711
 
626
- # Snapshot our reply links before posting (to detect the new one)
627
- links_before = _collect_our_reply_links(page)
712
+ reply_box = _wait_for_reply_textbox(page, total_timeout_ms=45000)
713
+ if reply_box:
714
+ break
628
715
 
629
- # Find the reply textbox. On slower egress (E2B sandbox VMs) x.com
630
- # can need 20-30s to attach the React reply composer; do not lower
631
- # these timeouts.
632
- reply_box = None
633
- try:
634
- reply_box = page.get_by_role("textbox", name="Post text")
635
- reply_box.wait_for(timeout=30000)
636
- except Exception:
637
- # Scroll down to find the reply box
638
- page.evaluate("window.scrollBy(0, 500)")
639
- page.wait_for_timeout(3000)
716
+ # Nudge: small scroll + scroll back; sometimes coaxes the
717
+ # composer to attach when React stalled on the initial mount.
718
+ print(f"[reply_to_tweet] reply_box missing on nav_attempt={nav_attempt}; "
719
+ f"nudging + re-navigating", file=sys.stderr)
640
720
  try:
641
- reply_box = page.get_by_role("textbox", name="Post text")
642
- reply_box.wait_for(timeout=15000)
721
+ page.evaluate("window.scrollBy(0, 400)")
722
+ page.wait_for_timeout(1500)
723
+ page.evaluate("window.scrollTo(0, 0)")
724
+ page.wait_for_timeout(1500)
643
725
  except Exception:
644
- return {"ok": False, "error": "reply_box_not_found"}
726
+ pass
727
+
728
+ if tweet_not_found:
729
+ return {"ok": False, "error": "tweet_not_found"}
730
+
731
+ if not reply_box:
732
+ diag = _dump_reply_failure_diag(page, tweet_url)
733
+ print(f"[reply_to_tweet] reply_box_not_found diag: "
734
+ f"{json.dumps(diag, default=str)}", file=sys.stderr)
735
+ return {"ok": False, "error": "reply_box_not_found", "diag": diag}
736
+
737
+ # Snapshot our reply links right before posting (to detect the new one)
738
+ links_before = _collect_our_reply_links(page)
645
739
 
646
740
  # Click and type the reply
647
741
  reply_box.click()
@@ -684,7 +778,7 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
684
778
 
685
779
  # Method 1: CDP network interception (most reliable)
686
780
  if _created_tweet_ids:
687
- reply_url = f"https://x.com/{OUR_HANDLE}/status/{_created_tweet_ids[-1]}"
781
+ reply_url = f"https://x.com/{our_handle()}/status/{_created_tweet_ids[-1]}"
688
782
  print(f"[reply_url] captured via CDP+response-listener: {reply_url}", file=sys.stderr)
689
783
 
690
784
  # Method 2: DOM diff (check if new reply links appeared)
@@ -1185,7 +1279,7 @@ def read_conversation(thread_url, max_messages=20):
1185
1279
  messages: recent,
1186
1280
  total_found: messages.length,
1187
1281
  };
1188
- }""", {"maxMessages": max_messages, "ourHandle": OUR_HANDLE})
1282
+ }""", {"maxMessages": max_messages, "ourHandle": our_handle()})
1189
1283
 
1190
1284
  return result
1191
1285
 
@@ -45,7 +45,32 @@ fi
45
45
 
46
46
  log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
47
47
 
48
- cleanup() { rm -f "$PICK_FILE" "${UNPROVEN_JSON_FILE:-}"; }
48
+ # Run accounting for dashboard Job History (Render · Instagram). Matches the
49
+ # pattern in run-instagram-daily.sh / run-twitter-threads.sh / run-reddit-threads.sh:
50
+ # each exit site updates POSTED_CT / SKIPPED_CT / FAILED_CT; the EXIT trap
51
+ # always emits one log_run.py line so the run shows up under render_instagram.
52
+ # "posted" here means "rendered a fresh draft"; "skipped" means buffer was
53
+ # healthy (>=3 drafts) or the lock was already held; "failed" is any real
54
+ # error path.
55
+ RUN_START_EPOCH=$(date +%s)
56
+ POSTED_CT=0
57
+ SKIPPED_CT=0
58
+ FAILED_CT=0
59
+
60
+ cleanup() {
61
+ local rc=$?
62
+ rm -f "$PICK_FILE" "${UNPROVEN_JSON_FILE:-}"
63
+ if [ "$POSTED_CT" -eq 0 ] && [ "$SKIPPED_CT" -eq 0 ] && [ "$FAILED_CT" -eq 0 ]; then
64
+ if [ "$rc" -eq 0 ]; then SKIPPED_CT=1; else FAILED_CT=1; fi
65
+ fi
66
+ local elapsed=$(( $(date +%s) - RUN_START_EPOCH ))
67
+ local cost
68
+ cost=$(/usr/bin/python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-instagram-render" 2>/dev/null || echo "0.0000")
69
+ /usr/bin/python3 "$REPO_DIR/scripts/log_run.py" \
70
+ --script "render_instagram" \
71
+ --posted "$POSTED_CT" --skipped "$SKIPPED_CT" --failed "$FAILED_CT" \
72
+ --cost "$cost" --elapsed "$elapsed" >/dev/null 2>&1 || true
73
+ }
49
74
  trap cleanup EXIT INT TERM HUP
50
75
 
51
76
  log "=== instagram-render fire: $(date) ==="
@@ -68,6 +93,7 @@ else
68
93
  fi
69
94
  if [ -z "$TARGET_ACCOUNT" ]; then
70
95
  log "ERROR: pick_ig_account.py returned empty; no enabled accounts?"
96
+ FAILED_CT=1
71
97
  exit 1
72
98
  fi
73
99
  export TARGET_ACCOUNT
@@ -318,6 +344,7 @@ PY
318
344
 
319
345
  if [ ! -s "$PICK_FILE" ]; then
320
346
  log "ERROR: pick query produced no output"
347
+ FAILED_CT=1
321
348
  exit 1
322
349
  fi
323
350
 
@@ -333,6 +360,7 @@ log "target=$TARGET selected_project=${SELECTED_PROJECT:-<null/organic>} draft_c
333
360
  # Override with FORCE_RENDER=1 for manual / first-fire runs.
334
361
  if [ "${FORCE_RENDER:-0}" != "1" ] && [ "$DRAFT_COUNT" -ge 3 ]; then
335
362
  log "skipped: $DRAFT_COUNT drafts of $TARGET already in queue (>= 3 buffer); no render needed"
363
+ SKIPPED_CT=1
336
364
  exit 0
337
365
  fi
338
366
 
@@ -612,9 +640,11 @@ if ! "$REPO_DIR/scripts/run_claude.sh" "run-instagram-render" \
612
640
  rm -f "$PROMPT_FILE"
613
641
  if [ "$rc" -eq 79 ]; then
614
642
  log "claude blocked by quota stamp; will retry next cycle"
643
+ SKIPPED_CT=1
615
644
  exit 0
616
645
  fi
617
646
  log "render failed"
647
+ FAILED_CT=1
618
648
  exit 1
619
649
  fi
620
650
  rm -f "$PROMPT_FILE"
@@ -629,10 +659,12 @@ log " expected: $OUT_CAP"
629
659
 
630
660
  if [ ! -f "$OUT_MP4" ]; then
631
661
  log "ERROR: $OUT_MP4 missing"
662
+ FAILED_CT=1
632
663
  exit 1
633
664
  fi
634
665
  if [ ! -f "$OUT_CAP" ]; then
635
666
  log "ERROR: $OUT_CAP missing"
667
+ FAILED_CT=1
636
668
  exit 1
637
669
  fi
638
670
 
@@ -662,7 +694,7 @@ PY
662
694
 
663
695
  case "$ROW_OK" in
664
696
  OK*) log "DB row OK: $ROW_OK" ;;
665
- *) log "ERROR: DB row check failed: $ROW_OK"; exit 1 ;;
697
+ *) log "ERROR: DB row check failed: $ROW_OK"; FAILED_CT=1; exit 1 ;;
666
698
  esac
667
699
 
668
700
  VARIANT=$(echo "$ROW_OK" | sed 's/^OK variant=//')
@@ -754,6 +786,7 @@ PY
754
786
  if [ "$CAP_LEN" -gt "$CAP_LIMIT" ]; then
755
787
  log "ERROR: caption still over limit after 3 tighten attempts (final len=${CAP_LEN})"
756
788
  log "flipping media_posts row to status='caption_too_long' so picker skips it"
789
+ FAILED_CT=1
757
790
  /opt/homebrew/bin/python3.11 - "$NEXT_NUM" "$CAP_LEN" 2>>"$LOG_FILE" <<'PY'
758
791
  import os, sys, psycopg2
759
792
  env = {}
@@ -797,4 +830,5 @@ PY
797
830
  fi
798
831
 
799
832
  log "=== rendered post-${NNN} (${TARGET}, variant=${VARIANT}) successfully ==="
833
+ POSTED_CT=1
800
834
  exit 0