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 +2 -1
- package/package.json +1 -1
- package/scripts/twitter_browser.py +126 -32
- package/skill/run-instagram-render.sh +36 -2
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
|
@@ -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
|
-
|
|
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
|
|
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*="/{
|
|
492
|
+
document.querySelectorAll('a[href*="/{handle}/status/"]').forEach(a => {{
|
|
475
493
|
const href = a.getAttribute('href');
|
|
476
|
-
if (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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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="
|
|
699
|
+
page.goto(tweet_url, wait_until="load", timeout=60000)
|
|
617
700
|
except Exception:
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
627
|
-
|
|
712
|
+
reply_box = _wait_for_reply_textbox(page, total_timeout_ms=45000)
|
|
713
|
+
if reply_box:
|
|
714
|
+
break
|
|
628
715
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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/{
|
|
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":
|
|
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
|
-
|
|
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
|