social-autoposter 1.6.4 → 1.6.6

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/cli.js CHANGED
@@ -130,14 +130,60 @@ function installBrowserAgentConfigs() {
130
130
  console.log(` browser profile dirs ready -> ${profilesDir}/{${BROWSER_PROFILES.join(',')}}`);
131
131
  }
132
132
 
133
+ // Detect whether we are running inside an AppMaker E2B VM. AppMaker provisions
134
+ // a Chromium on port 9222 behind the SOAX residential proxy at 127.0.0.1:3003,
135
+ // and that Chromium is the one the user logs into via the AppMaker UI (profile
136
+ // /root/.chromium-profile). The browser-harness Chrome on port 9555 with its
137
+ // own (logged-out, un-proxied) profile is wrong for this host, so we:
138
+ // 1. skip installBrowserHarness() entirely (saves disk + avoids a second
139
+ // headless Chrome ever spawning).
140
+ // 2. write ~/.social-autoposter-env so skill/lib/twitter-backend.sh sources
141
+ // TWITTER_CDP_URL=http://127.0.0.1:9222 instead of the default 9555.
142
+ // Detection: presence of /opt/startup.sh (the AppMaker bootstrap script that
143
+ // only exists on these VMs) AND a live HTTP response on 127.0.0.1:9222.
144
+ function isAppMakerVm() {
145
+ if (process.platform !== 'linux') return false;
146
+ if (!fs.existsSync('/opt/startup.sh')) return false;
147
+ // Probe Chromium DevTools on 9222. 2s timeout; if it answers, we're on AppMaker.
148
+ const probe = spawnSync('curl', ['-sf', '--max-time', '2', '-o', '/dev/null', 'http://127.0.0.1:9222/json/version'], { stdio: 'ignore' });
149
+ return probe.status === 0;
150
+ }
151
+
152
+ // Write ~/.social-autoposter-env so skill/lib/twitter-backend.sh picks up the
153
+ // AppMaker-specific TWITTER_CDP_URL before its `${VAR:-default}` fallback hits.
154
+ // Idempotent: rewrites the file every invocation so a config edit on the VM
155
+ // can't drift away from what cli.js intends.
156
+ function writeAppMakerEnvFile() {
157
+ const envPath = path.join(HOME, '.social-autoposter-env');
158
+ const body = [
159
+ '# social-autoposter per-host env overrides',
160
+ '# Auto-generated by social-autoposter init/update on AppMaker E2B VMs.',
161
+ '# Edit by hand only if you know what you are doing; it gets rewritten on every update.',
162
+ '',
163
+ '# Point twitter pipeline at AppMaker\'s proxied Chromium (SOAX residential exit',
164
+ '# at 127.0.0.1:3003) instead of the harness Chrome on 9555. The Chromium on',
165
+ '# 9222 is the one the user logs into via the AppMaker UI.',
166
+ 'export TWITTER_CDP_URL="http://127.0.0.1:9222"',
167
+ '',
168
+ ].join('\n');
169
+ fs.writeFileSync(envPath, body);
170
+ console.log(` AppMaker VM detected -> wrote ${envPath} (TWITTER_CDP_URL=http://127.0.0.1:9222)`);
171
+ }
172
+
133
173
  // Provision the browser-harness toolchain that backs the twitter-harness MCP:
134
174
  // 1. install uv (Astral) if missing
135
175
  // 2. git-clone browser-use/browser-harness
136
176
  // 3. uv tool install -e . (provides the `browser-harness` CLI)
137
177
  // 4. ensure `mcp` Python package is importable for server.py
138
178
  // 5. copy our shipped server.py into ~/.claude/mcp-servers/browser-harness/
139
- // All steps are idempotent.
179
+ // All steps are idempotent. Skipped entirely on AppMaker VMs (the proxied
180
+ // Chromium on 9222 replaces the harness).
140
181
  function installBrowserHarness() {
182
+ if (isAppMakerVm()) {
183
+ console.log(' AppMaker VM detected -> skipping browser-harness install (Chromium on 9222 is canonical)');
184
+ writeAppMakerEnvFile();
185
+ return;
186
+ }
141
187
  console.log(' setting up browser-harness (twitter-harness MCP backend)...');
142
188
 
143
189
  // Step 1: uv. Try the official installer first; fall back to pip.
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') ||
@@ -6164,7 +6165,7 @@ async function handleApi(req, res) {
6164
6165
  const windowKey = Object.prototype.hasOwnProperty.call(WINDOW_HOURS, rawWindow) ? rawWindow : '7d';
6165
6166
  const windowHours = WINDOW_HOURS[windowKey];
6166
6167
  const rawPlatform = String(url.searchParams.get('platform') || '').toLowerCase().trim();
6167
- const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github']);
6168
+ const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github', 'instagram']);
6168
6169
  const platformFilter = ALLOWED_PLATFORMS.has(rawPlatform) ? rawPlatform : '';
6169
6170
  const pc = auth.projectClause(req.user, 'pl.project_name', url.searchParams.get('project'));
6170
6171
  if (!pc.ok) return json(res, { destinations: [], window: windowKey, platform: 'all' });
@@ -6290,7 +6291,7 @@ async function handleApi(req, res) {
6290
6291
  const windowKey = Object.prototype.hasOwnProperty.call(WINDOW_HOURS, rawWindow) ? rawWindow : '7d';
6291
6292
  const windowHours = WINDOW_HOURS[windowKey];
6292
6293
  const rawPlatform = String(url.searchParams.get('platform') || '').toLowerCase().trim();
6293
- const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github']);
6294
+ const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'github', 'instagram']);
6294
6295
  const platformFilter = ALLOWED_PLATFORMS.has(rawPlatform) ? rawPlatform : '';
6295
6296
  const pc = auth.projectClause(req.user, 'pl.project_name', url.searchParams.get('project'));
6296
6297
  if (!pc.ok) return json(res, { links: [], window: windowKey, platform: 'all' });
@@ -16559,6 +16560,7 @@ const PROJECT_STATUS_SORT_FIELDS = {
16559
16560
  linkedin: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.linkedin) || 0 },
16560
16561
  moltbook: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.moltbook) || 0 },
16561
16562
  github: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.github) || 0 },
16563
+ instagram: { type: 'numeric', value: r => Number(r.by_platform && r.by_platform.instagram) || 0 },
16562
16564
  };
16563
16565
  function _sortProjectRows(rows) {
16564
16566
  const { field, dir } = _projectStatusSort;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.4",
3
+ "version": "1.6.6",
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"
@@ -498,6 +498,67 @@ def _collect_our_reply_links(page):
498
498
  }}"""))
499
499
 
500
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
+
501
562
  def reply_to_tweet(tweet_url, text, apply_campaigns=True):
502
563
  """Reply to a tweet.
503
564
 
@@ -627,39 +688,54 @@ def reply_to_tweet(tweet_url, text, apply_campaigns=True):
627
688
  except Exception:
628
689
  pass
629
690
 
630
- try:
631
- page.goto(tweet_url, wait_until="load", timeout=60000)
632
- 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):
633
698
  try:
634
- page.goto(tweet_url, wait_until="domcontentloaded", timeout=60000)
699
+ page.goto(tweet_url, wait_until="load", timeout=60000)
635
700
  except Exception:
636
- pass
637
- 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)
638
706
 
639
- # Check if page exists
640
- page_text = page.text_content("main") or ""
641
- if "this page doesn't exist" in page_text.lower():
642
- 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
643
711
 
644
- # Snapshot our reply links before posting (to detect the new one)
645
- 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
646
715
 
647
- # Find the reply textbox. On slower egress (E2B sandbox VMs) x.com
648
- # can need 20-30s to attach the React reply composer; do not lower
649
- # these timeouts.
650
- reply_box = None
651
- try:
652
- reply_box = page.get_by_role("textbox", name="Post text")
653
- reply_box.wait_for(timeout=30000)
654
- except Exception:
655
- # Scroll down to find the reply box
656
- page.evaluate("window.scrollBy(0, 500)")
657
- 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)
658
720
  try:
659
- reply_box = page.get_by_role("textbox", name="Post text")
660
- 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)
661
725
  except Exception:
662
- 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)
663
739
 
664
740
  # Click and type the reply
665
741
  reply_box.click()
@@ -30,10 +30,26 @@
30
30
  # us. Kept as a function only so callers don't have to change.
31
31
 
32
32
  MCP_CONFIG_FILE="$HOME/.claude/browser-agent-configs/twitter-harness-mcp.json"
33
+
34
+ # Per-host env override (written by bin/cli.js when installing on an AppMaker
35
+ # VM, where the canonical browser is Chromium on port 9222 behind the SOAX
36
+ # residential proxy at 127.0.0.1:3003, NOT the harness Chrome on 9555). On a
37
+ # Mac dev box this file does not exist, so the default below kicks in.
38
+ if [ -f "$HOME/.social-autoposter-env" ]; then
39
+ # shellcheck disable=SC1091
40
+ . "$HOME/.social-autoposter-env"
41
+ fi
42
+
33
43
  # Tell twitter_browser.py (and any other Python helper that honors this env
34
- # var) to skip ps-based discovery and connect directly to the harness Chrome
35
- # on port 9555.
36
- export TWITTER_CDP_URL="http://127.0.0.1:9555"
44
+ # var) to skip ps-based discovery and connect directly to the configured CDP
45
+ # endpoint. Default 9555 (Mac harness Chrome). AppMaker VMs pre-set this to
46
+ # http://127.0.0.1:9222 via ~/.social-autoposter-env above.
47
+ export TWITTER_CDP_URL="${TWITTER_CDP_URL:-http://127.0.0.1:9555}"
48
+
49
+ # Default harness URL — used by ensure_twitter_browser_for_backend +
50
+ # cleanup_harness_tabs to decide whether we own this Chrome (and should
51
+ # launch/clean it) or whether it is externally managed (AppMaker, BYO).
52
+ _BH_DEFAULT_URL="http://127.0.0.1:9555"
37
53
  BROWSER_INSTRUCTIONS=$(cat <<'BROWSER_HARNESS_EOF'
38
54
  BROWSER BACKEND: twitter-harness (browser-harness MCP, CDP-driven REAL Google Chrome on
39
55
  port 9555, profile ~/.claude/browser-profiles/browser-harness). The Chrome is already
@@ -131,6 +147,19 @@ _resolve_chrome_bin() {
131
147
  }
132
148
 
133
149
  ensure_twitter_browser_for_backend() {
150
+ # AppMaker / BYO Chrome: TWITTER_CDP_URL points at something other than our
151
+ # default harness URL. Don't touch that browser; just probe it and bail.
152
+ # The AppMaker bootstrap (and any future BYO setup) is responsible for
153
+ # keeping the externally-managed Chrome alive.
154
+ if [ "${TWITTER_CDP_URL:-$_BH_DEFAULT_URL}" != "$_BH_DEFAULT_URL" ]; then
155
+ local _ext_url="${TWITTER_CDP_URL}"
156
+ if curl -sf --max-time 2 -o /dev/null "${_ext_url}/json/version" 2>/dev/null; then
157
+ echo "[$(date +%H:%M:%S)] Using externally-managed Chrome at ${_ext_url} (skipping harness launch + tab cleanup)" >&2
158
+ return 0
159
+ fi
160
+ echo "[$(date +%H:%M:%S)] ERROR: TWITTER_CDP_URL=${_ext_url} not reachable. External Chrome must be managed by host (AppMaker /opt/startup.sh, etc)." >&2
161
+ return 1
162
+ fi
134
163
  # Probe + launch harness Chrome on port 9555 if needed.
135
164
  if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:9555/json/version 2>/dev/null; then
136
165
  echo "[$(date +%H:%M:%S)] Harness Chrome down on port 9555, launching..." >&2
@@ -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