social-autoposter 1.3.3 → 1.3.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
@@ -3562,10 +3562,16 @@ async function handleApi(req, res) {
3562
3562
  const rows = await pq(q);
3563
3563
  let events = (rows && rows.length && rows[0].json_agg) ? rows[0].json_agg : [];
3564
3564
  // Non-admin: drop events not tagged with an allowed project (including
3565
- // octolens mentions, which have no project column).
3565
+ // octolens mentions, which have no project column). Strip Claude cost
3566
+ // fields; cost is operator-internal (API spend not charged to clients).
3566
3567
  if (!req.user.admin) {
3567
3568
  const allowed = new Set(req.user.projects);
3568
3569
  events = events.filter(e => e.project && allowed.has(e.project));
3570
+ events.forEach(e => {
3571
+ delete e.cost_usd;
3572
+ delete e.cost_usd_orchestrator;
3573
+ delete e.cost_usd_estimated;
3574
+ });
3569
3575
  }
3570
3576
  return json(res, { events });
3571
3577
  })().catch(e => json(res, { error: e.message }, 500));
@@ -5432,20 +5438,44 @@ async function handleApi(req, res) {
5432
5438
  };
5433
5439
  }).sort((a, b) => b.weight - a.weight || a.name.localeCompare(b.name));
5434
5440
  // Surface any posts that didn't match a weighted project, so the matrix adds up.
5441
+ // configured = the project name is in config.json (just at weight 0), so the
5442
+ // weight editor stays available. unconfigured rows come from stale project
5443
+ // names found in post rows only and aren't editable here.
5435
5444
  const knownNames = new Set(weighted.map(p => p.name));
5445
+ const configuredNames = new Set(configuredProjects.map(p => p.name));
5446
+ const configuredByName = Object.fromEntries(configuredProjects.map(p => [p.name, p]));
5436
5447
  const unassigned = Object.entries(byProject)
5437
5448
  .filter(([name]) => !knownNames.has(name))
5438
5449
  .map(([name, stats]) => ({
5439
5450
  name,
5440
- weight: 0,
5451
+ weight: Number((configuredByName[name] || {}).weight) || 0,
5441
5452
  target_share: 0,
5442
5453
  total: stats.total,
5443
5454
  actual_share: grandTotal > 0 ? stats.total / grandTotal : 0,
5444
5455
  deficit: -(grandTotal > 0 ? stats.total / grandTotal : 0),
5445
5456
  by_platform: Object.fromEntries(platforms.map(pl => [pl, stats.by_platform[pl] || 0])),
5446
- website: null,
5457
+ website: (configuredByName[name] && configuredByName[name].website) || null,
5447
5458
  unassigned: true,
5459
+ configured: configuredNames.has(name),
5448
5460
  }));
5461
+ // Also include configured projects with weight=0 and zero posts in the
5462
+ // window, so an operator can lift them back up from the table.
5463
+ configuredProjects.forEach(cp => {
5464
+ if (knownNames.has(cp.name)) return;
5465
+ if (byProject[cp.name]) return;
5466
+ unassigned.push({
5467
+ name: cp.name,
5468
+ weight: Number(cp.weight) || 0,
5469
+ target_share: 0,
5470
+ total: 0,
5471
+ actual_share: 0,
5472
+ deficit: 0,
5473
+ by_platform: Object.fromEntries(platforms.map(pl => [pl, 0])),
5474
+ website: cp.website || null,
5475
+ unassigned: true,
5476
+ configured: true,
5477
+ });
5478
+ });
5449
5479
  // Per-project Claude cost in the same window. Mirrors /api/cost/stats
5450
5480
  // attribution: per_row_cost = COALESCE(orchestrator_cost_usd,
5451
5481
  // total_cost_usd) / rows_in_session, summed across the activity rows
@@ -5566,11 +5596,44 @@ async function handleApi(req, res) {
5566
5596
  grand_cost_usd_orchestrator: grandCostOrch,
5567
5597
  grand_cost_usd_estimated: grandCostEst,
5568
5598
  cost_available: !!(req.user && req.user.admin),
5599
+ can_edit_weight: !auth.CLIENT_MODE && !!(req.user && req.user.admin),
5569
5600
  projects,
5570
5601
  unassigned,
5571
5602
  });
5572
5603
  }
5573
5604
 
5605
+ // POST /api/project/weight - update one project's weight in config.json.
5606
+ // Admin only and disabled in CLIENT_MODE (no config.json on Cloud Run).
5607
+ // Body: { name: string, weight: number }. Weight must be a non-negative
5608
+ // finite number. The picker reads config.json directly on each pick, so no
5609
+ // restart or cache bust is needed.
5610
+ if (p === '/api/project/weight' && req.method === 'POST') {
5611
+ if (auth.CLIENT_MODE) return json(res, { error: 'config_readonly_in_client_mode' }, 405);
5612
+ if (!req.user || !req.user.admin) return json(res, { error: 'forbidden' }, 403);
5613
+ return readBody(req).then(body => {
5614
+ let payload;
5615
+ try { payload = JSON.parse(body); }
5616
+ catch { return json(res, { error: 'invalid_json' }, 400); }
5617
+ const name = typeof payload.name === 'string' ? payload.name.trim() : '';
5618
+ const weight = Number(payload.weight);
5619
+ if (!name) return json(res, { error: 'name_required' }, 400);
5620
+ if (!Number.isFinite(weight) || weight < 0 || weight > 1e6) {
5621
+ return json(res, { error: 'invalid_weight' }, 400);
5622
+ }
5623
+ let config;
5624
+ try { config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); }
5625
+ catch (e) { return json(res, { error: 'config_read_failed', detail: e.message }, 500); }
5626
+ const projects = Array.isArray(config.projects) ? config.projects : [];
5627
+ const target = projects.find(pr => pr && pr.name === name);
5628
+ if (!target) return json(res, { error: 'project_not_found', name }, 404);
5629
+ const previous = Number(target.weight) || 0;
5630
+ target.weight = weight;
5631
+ try { fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n'); }
5632
+ catch (e) { return json(res, { error: 'config_write_failed', detail: e.message }, 500); }
5633
+ return json(res, { saved: true, name, weight, previous });
5634
+ }).catch(e => json(res, { error: e.message }, 400));
5635
+ }
5636
+
5574
5637
  // GET /api/deploy/status - latest Vercel production deploy per project.
5575
5638
  // Written every ~5 min to skill/cache/deploy_status.json by launchd
5576
5639
  // com.m13v.social-deploy-status (scripts/project_deploy_status.py). If the
@@ -6510,7 +6573,7 @@ const HTML = `<!DOCTYPE html>
6510
6573
  <button type="button" class="style-stats-pill" data-value="30d">Last 30d</button>
6511
6574
  </div>
6512
6575
  </div>
6513
- <details class="style-stats-section" id="cost-stats" open>
6576
+ <details class="style-stats-section sa-admin-only" id="cost-stats" open>
6514
6577
  <summary>
6515
6578
  <span class="style-stats-title"><span class="style-stats-caret">&#9654;</span><span id="cost-stats-heading">Cost per Activity (last 24 hours)</span></span>
6516
6579
  <span class="style-stats-total" id="cost-stats-total"></span>
@@ -6785,7 +6848,7 @@ const HTML = `<!DOCTYPE html>
6785
6848
  <th class="activity-sortable" data-sort="summary">
6786
6849
  <span class="activity-header-label">What <span class="activity-sort-arrow" data-sort-arrow="summary"></span></span>
6787
6850
  </th>
6788
- <th style="width:90px;text-align:right;" class="activity-sortable" data-sort="cost_usd">
6851
+ <th style="width:90px;text-align:right;" class="activity-sortable sa-admin-only" data-sort="cost_usd">
6789
6852
  <span class="activity-header-label">Cost <span class="activity-sort-arrow" data-sort-arrow="cost_usd"></span></span>
6790
6853
  </th>
6791
6854
  <th style="width:40px;text-align:center;">
@@ -9128,6 +9191,27 @@ async function handleDeleteBtnClick(btn) {
9128
9191
  _saDelFailed.delete(payload.key);
9129
9192
  btn.classList.add('is-loading');
9130
9193
  btn.setAttribute('title', 'sending...');
9194
+ // Optimistic add: the Resend roundtrip is ~15s and the activity table can
9195
+ // re-render mid-flight, detaching this button node from the DOM. The fresh
9196
+ // button reads _saDelPending at render time, so adding the key up-front
9197
+ // guarantees the new button paints pending immediately instead of
9198
+ // reverting to the default trash icon.
9199
+ _saDelPending.add(payload.key);
9200
+ const applyPendingState = () => {
9201
+ document.querySelectorAll('button.sa-del-btn[data-sa-del-payload]').forEach(b => {
9202
+ let k = null;
9203
+ try {
9204
+ const raw = b.getAttribute('data-sa-del-payload') || '';
9205
+ if (raw) k = JSON.parse(decodeURIComponent(escape(atob(raw)))).key;
9206
+ } catch {}
9207
+ if (k === payload.key) {
9208
+ b.classList.remove('is-loading', 'is-failed');
9209
+ b.classList.add('is-pending');
9210
+ b.setAttribute('title', 'pending deletion');
9211
+ b.setAttribute('aria-label', 'pending deletion');
9212
+ }
9213
+ });
9214
+ };
9131
9215
  try {
9132
9216
  const r = await fetch('/api/activity/mark-deletion', {
9133
9217
  method: 'POST',
@@ -9135,19 +9219,18 @@ async function handleDeleteBtnClick(btn) {
9135
9219
  body: JSON.stringify(payload),
9136
9220
  });
9137
9221
  const j = await r.json().catch(() => ({}));
9138
- btn.classList.remove('is-loading');
9139
9222
  if (r.ok && (j.status === 'pending' || j.already_existed)) {
9140
- _saDelPending.add(payload.key);
9141
- btn.classList.add('is-pending');
9142
- btn.setAttribute('title', 'pending deletion');
9143
- btn.setAttribute('aria-label', 'pending deletion');
9223
+ applyPendingState();
9144
9224
  } else {
9225
+ _saDelPending.delete(payload.key);
9145
9226
  _saDelFailed.add(payload.key);
9227
+ btn.classList.remove('is-loading');
9146
9228
  btn.classList.add('is-failed');
9147
9229
  const msg = (j && j.error) ? String(j.error) : ('HTTP ' + r.status);
9148
9230
  btn.setAttribute('title', 'failed: ' + msg + ' (click to retry)');
9149
9231
  }
9150
9232
  } catch (e) {
9233
+ _saDelPending.delete(payload.key);
9151
9234
  btn.classList.remove('is-loading');
9152
9235
  _saDelFailed.add(payload.key);
9153
9236
  btn.classList.add('is-failed');
@@ -9395,7 +9478,7 @@ async function loadActivityStats() {
9395
9478
  // post's first-ever snapshot so day 1 never attributes lifetime counts
9396
9479
  // to a capture day; expect those lines to sit at 0 until at least two
9397
9480
  // consecutive days of snapshots have accumulated per post.
9398
- const DAILY_METRICS = [
9481
+ let DAILY_METRICS = [
9399
9482
  { id: 'views', label: 'Views', color: '#6366f1', endpoint: '/api/views/per-day', valueKey: 'views_gained', platformAware: true },
9400
9483
  { id: 'upvotes', label: 'Upvotes', color: '#f97316', endpoint: '/api/upvotes/per-day', valueKey: 'upvotes_gained', platformAware: true },
9401
9484
  { id: 'comments', label: 'Comments', color: '#14b8a6', endpoint: '/api/comments/per-day', valueKey: 'comments_gained', platformAware: true },
@@ -9408,7 +9491,7 @@ const DAILY_METRICS = [
9408
9491
  // platformAware: filters cost to the chosen platform's activity rows.
9409
9492
  // format: 'usd' so the legend pill, Y-axis ticks, bar labels, and
9410
9493
  // hover tooltip render as dollars (e.g. $12.34) instead of K/M counts.
9411
- { id: 'cost', label: 'Cost (USD)', color: '#dc2626', endpoint: '/api/cost/per-day', valueKey: 'cost_usd', platformAware: true, format: 'usd' },
9494
+ { id: 'cost', label: 'Cost (USD)', color: '#dc2626', endpoint: '/api/cost/per-day', valueKey: 'cost_usd', platformAware: true, format: 'usd', adminOnly: true },
9412
9495
  // All funnel metrics count UNIQUE VISITORS (distinct_id), not raw events.
9413
9496
  // A user iterating on the same project (multiple prompts, multiple
9414
9497
  // schedule retries, multiple pageviews) is 1, not N. See
@@ -10108,6 +10191,7 @@ async function loadDailyMetrics() {
10108
10191
  const qsAware = platformAwareParams.join('&');
10109
10192
  const qsProj = projectOnlyParams.join('&');
10110
10193
  try {
10194
+ const costAvail = window.SA_IS_ADMIN !== false;
10111
10195
  const [views, upvotes, comments, clicks, bookings, funnel, cost] = await Promise.all([
10112
10196
  fetchOne('/api/views/per-day?' + qsAware),
10113
10197
  fetchOne('/api/upvotes/per-day?' + qsAware),
@@ -10115,7 +10199,7 @@ async function loadDailyMetrics() {
10115
10199
  fetchOne('/api/clicks/per-day?' + qsAware),
10116
10200
  fetchOne('/api/bookings/per-day?' + qsProj),
10117
10201
  fetchOne('/api/funnel/per-day?' + qsProj),
10118
- fetchOne('/api/cost/per-day?' + qsAware),
10202
+ costAvail ? fetchOne('/api/cost/per-day?' + qsAware) : Promise.resolve({ rows: [], failed: false }),
10119
10203
  ]);
10120
10204
  const allFailed = [views, upvotes, comments, clicks, bookings, funnel, cost].every(r => r.failed);
10121
10205
  if (allFailed) {
@@ -11414,6 +11498,7 @@ function renderCostStats(payload) {
11414
11498
  let _costStatsLoadedFor = null;
11415
11499
  let _costStatsLoading = false;
11416
11500
  async function loadCostStats(force) {
11501
+ if (window.SA_IS_ADMIN === false) return;
11417
11502
  if (_costStatsLoading) return;
11418
11503
  const hours = currentStatusWindow().hours;
11419
11504
  const row = document.getElementById('cost-stats-platform-pills');
@@ -13501,6 +13586,7 @@ function renderProjectStatus(data) {
13501
13586
  const grandTotal = Number(data && data.grand_total) || 0;
13502
13587
  const totals = (data && data.platform_totals) || {};
13503
13588
  const costAvailable = !!(data && data.cost_available);
13589
+ const canEditWeight = !!(data && data.can_edit_weight);
13504
13590
  const grandCost = Number(data && data.grand_cost_usd) || 0;
13505
13591
  const grandCostOrch = Number(data && data.grand_cost_usd_orchestrator) || 0;
13506
13592
  const grandCostEst = Number(data && data.grand_cost_usd_estimated) || 0;
@@ -13607,9 +13693,21 @@ function renderProjectStatus(data) {
13607
13693
  const costCellHtml = costAvailable
13608
13694
  ? costCell(Number(r.cost_usd) || 0, Number(r.cost_usd_orchestrator) || 0, Number(r.cost_usd_estimated) || 0, { extra: 'color:var(--text-secondary);' })
13609
13695
  : '';
13696
+ const weightVal = Number(r.weight) || 0;
13697
+ const editable = canEditWeight && (!r.unassigned || r.configured);
13698
+ const weightCellHtml = editable
13699
+ ? '<td style="text-align:right;font-variant-numeric:tabular-nums;">' +
13700
+ '<input type="number" min="0" step="1" value="' + weightVal + '" ' +
13701
+ 'data-project-weight-input="' + escapeHtml(r.name) + '" ' +
13702
+ 'data-original-weight="' + weightVal + '" ' +
13703
+ 'class="project-weight-input" ' +
13704
+ 'style="width:60px;text-align:right;font-variant-numeric:tabular-nums;padding:2px 6px;border:1px solid var(--border);background:var(--bg-subtle,transparent);color:var(--text);border-radius:4px;font-size:13px;font-weight:600;" ' +
13705
+ 'title="Edit and press Enter or blur to save" />' +
13706
+ '</td>'
13707
+ : '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + weightVal + '</td>';
13610
13708
  return '<tr>' +
13611
13709
  '<td style="text-align:left;font-weight:600;">' + nameLabel + '</td>' +
13612
- '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + (r.weight || 0) + '</td>' +
13710
+ weightCellHtml +
13613
13711
  '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + (r.unassigned ? '&mdash;' : formatPct(r.target_share)) + '</td>' +
13614
13712
  platformCells +
13615
13713
  totalCell +
@@ -13640,6 +13738,61 @@ function renderProjectStatus(data) {
13640
13738
  '<tbody>' + bodyRows + footerHtml + '</tbody>' +
13641
13739
  '</table>' +
13642
13740
  '</div>' + legend;
13741
+ if (canEditWeight) {
13742
+ body.querySelectorAll('input.project-weight-input').forEach(inp => {
13743
+ inp.addEventListener('keydown', e => {
13744
+ if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
13745
+ else if (e.key === 'Escape') {
13746
+ inp.value = inp.dataset.originalWeight || '0';
13747
+ inp.blur();
13748
+ }
13749
+ });
13750
+ inp.addEventListener('blur', () => saveProjectWeight(inp));
13751
+ });
13752
+ }
13753
+ }
13754
+ async function saveProjectWeight(inp) {
13755
+ const name = inp.dataset.projectWeightInput;
13756
+ const original = Number(inp.dataset.originalWeight) || 0;
13757
+ const raw = inp.value.trim();
13758
+ const next = Number(raw);
13759
+ if (!name) return;
13760
+ if (raw === '' || !Number.isFinite(next) || next < 0) {
13761
+ inp.value = String(original);
13762
+ return;
13763
+ }
13764
+ if (Math.trunc(next) === Math.trunc(original) && next === original) return;
13765
+ inp.disabled = true;
13766
+ const prevBorder = inp.style.borderColor;
13767
+ inp.style.borderColor = 'var(--text-muted)';
13768
+ try {
13769
+ const res = await fetch('/api/project/weight', {
13770
+ method: 'POST',
13771
+ headers: { 'Content-Type': 'application/json' },
13772
+ body: JSON.stringify({ name, weight: next }),
13773
+ });
13774
+ const data = await res.json().catch(() => ({}));
13775
+ if (!res.ok || data.error) {
13776
+ inp.value = String(original);
13777
+ inp.style.borderColor = '#b91c1c';
13778
+ setTimeout(() => { inp.style.borderColor = prevBorder; }, 1500);
13779
+ console.error('[project-weight] save failed', data);
13780
+ return;
13781
+ }
13782
+ inp.dataset.originalWeight = String(next);
13783
+ inp.style.borderColor = '#15803d';
13784
+ setTimeout(() => { inp.style.borderColor = prevBorder; }, 800);
13785
+ try { window.posthog && window.posthog.capture('project_weight_edit', { project: name, weight: next, previous: original }); } catch (er) {}
13786
+ _projectStatusLoading = false;
13787
+ loadProjectStatus(true);
13788
+ } catch (e) {
13789
+ inp.value = String(original);
13790
+ inp.style.borderColor = '#b91c1c';
13791
+ setTimeout(() => { inp.style.borderColor = prevBorder; }, 1500);
13792
+ console.error('[project-weight] save error', e);
13793
+ } finally {
13794
+ inp.disabled = false;
13795
+ }
13643
13796
  }
13644
13797
  async function refreshAllData() {
13645
13798
  const icon = document.getElementById('global-refresh-icon');
@@ -13884,7 +14037,7 @@ function renderActivity(events) {
13884
14037
  '</div>' +
13885
14038
  '</td>' +
13886
14039
  '<td class="activity-summary">' + summaryHtml + '</td>' +
13887
- '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-secondary);">' + fmtCostCell(e.cost_usd, e.cost_usd_orchestrator, e.cost_usd_estimated) + '</td>' +
14040
+ '<td class="sa-admin-only" style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-secondary);">' + fmtCostCell(e.cost_usd, e.cost_usd_orchestrator, e.cost_usd_estimated) + '</td>' +
13888
14041
  '<td style="text-align:center;">' + renderDeleteBtnHtml(e) + '</td>' +
13889
14042
  '</tr>';
13890
14043
  }).join('');
@@ -14180,6 +14333,7 @@ function saStartApp() {
14180
14333
  document.body.classList.remove('sa-authed-pending');
14181
14334
  const isCloud = document.body.classList.contains('sa-cloud');
14182
14335
  const isAdmin = window.SA_IS_ADMIN !== false;
14336
+ if (!isAdmin) DAILY_METRICS = DAILY_METRICS.filter(m => !m.adminOnly);
14183
14337
  try { window.posthog && window.posthog.capture('dashboard_opened', { is_admin: isAdmin, is_cloud: isCloud }); } catch (e) {}
14184
14338
  // Status + pending are local-only (UI hidden by body.sa-cloud). Endpoints
14185
14339
  // are admin-only too, so skipping them on cloud also stops 403 spam for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.3.3",
3
+ "version": "1.3.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"
@@ -20,10 +20,39 @@ import os
20
20
  import sys
21
21
 
22
22
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
- import db
24
23
 
25
24
 
26
- def get_active_campaigns(platform):
25
+ def _get_active_via_api(platform):
26
+ from http_api import api_get
27
+ resp = api_get(
28
+ "/api/v1/campaigns",
29
+ query={
30
+ "status": "active",
31
+ "platform": platform,
32
+ "with_budget_remaining": "true",
33
+ "limit": 500,
34
+ },
35
+ )
36
+ rows = ((resp or {}).get("data") or {}).get("campaigns") or []
37
+ out = []
38
+ for r in rows:
39
+ max_total = r.get("max_posts_total")
40
+ posts_made = r.get("posts_made") or 0
41
+ if max_total is None or posts_made >= max_total:
42
+ continue
43
+ out.append({
44
+ "id": int(r["id"]),
45
+ "name": r.get("name"),
46
+ "prompt": r.get("prompt"),
47
+ "max_posts_total": int(max_total),
48
+ "posts_made": int(posts_made),
49
+ "remaining": int(max_total) - int(posts_made),
50
+ })
51
+ return out
52
+
53
+
54
+ def _get_active_via_neon(platform):
55
+ import db
27
56
  conn = db.get_conn()
28
57
  try:
29
58
  cur = conn.execute(
@@ -41,7 +70,6 @@ def get_active_campaigns(platform):
41
70
  rows = cur.fetchall()
42
71
  finally:
43
72
  conn.close()
44
-
45
73
  return [
46
74
  {
47
75
  "id": r[0],
@@ -55,6 +83,18 @@ def get_active_campaigns(platform):
55
83
  ]
56
84
 
57
85
 
86
+ def get_active_campaigns(platform):
87
+ """Active campaigns for `platform` with budget remaining.
88
+
89
+ Routes through /api/v1/campaigns by default so VMs without
90
+ DATABASE_URL still get the active list. Set
91
+ SOCIAL_AUTOPOSTER_LEGACY_NEON=1 for the direct-Neon path.
92
+ """
93
+ if os.environ.get("SOCIAL_AUTOPOSTER_LEGACY_NEON") == "1":
94
+ return _get_active_via_neon(platform)
95
+ return _get_active_via_api(platform)
96
+
97
+
58
98
  def format_prompt_block(campaigns, repo_dir):
59
99
  if not campaigns:
60
100
  return ""
@@ -9,6 +9,9 @@ Usage:
9
9
  The named row's campaign_id column is set to the given campaign, and the
10
10
  campaign's posts_made counter advances by one. Idempotent: if the row already
11
11
  references this campaign, no counter bump happens.
12
+
13
+ Routes through /api/v1/campaigns/bump by default. Set
14
+ SOCIAL_AUTOPOSTER_LEGACY_NEON=1 to use direct Neon.
12
15
  """
13
16
 
14
17
  import argparse
@@ -16,37 +19,57 @@ import os
16
19
  import sys
17
20
 
18
21
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
- import db
20
22
 
21
23
  ALLOWED_TABLES = {"posts", "replies", "dm_messages"}
22
24
 
23
25
 
24
- def main():
25
- ap = argparse.ArgumentParser()
26
- ap.add_argument("--table", required=True, choices=sorted(ALLOWED_TABLES))
27
- ap.add_argument("--id", type=int, required=True)
28
- ap.add_argument("--campaign-id", type=int, required=True)
29
- args = ap.parse_args()
26
+ def _bump_via_api(table, row_id, campaign_id):
27
+ from http_api import api_post
28
+ resp = api_post(
29
+ "/api/v1/campaigns/bump",
30
+ {"table": table, "id": int(row_id), "campaign_id": int(campaign_id)},
31
+ )
32
+ data = (resp or {}).get("data") or {}
33
+ bumped = bool(data.get("bumped"))
34
+ return bumped
30
35
 
36
+
37
+ def _bump_via_neon(table, row_id, campaign_id):
38
+ import db
31
39
  conn = db.get_conn()
32
40
  try:
33
41
  cur = conn.execute(
34
- f"UPDATE {args.table} SET campaign_id = %s "
42
+ f"UPDATE {table} SET campaign_id = %s "
35
43
  f"WHERE id = %s AND (campaign_id IS NULL OR campaign_id <> %s) "
36
44
  f"RETURNING id",
37
- [args.campaign_id, args.id, args.campaign_id],
45
+ [campaign_id, row_id, campaign_id],
38
46
  )
39
47
  bumped = cur.fetchone() is not None
40
48
  if bumped:
41
49
  conn.execute(
42
50
  "UPDATE campaigns SET posts_made = posts_made + 1, updated_at = NOW() "
43
51
  "WHERE id = %s",
44
- [args.campaign_id],
52
+ [campaign_id],
45
53
  )
46
54
  conn.commit()
47
- print(f"table={args.table} id={args.id} campaign={args.campaign_id} bumped={bumped}")
55
+ return bumped
48
56
  finally:
49
57
  conn.close()
58
+
59
+
60
+ def main():
61
+ ap = argparse.ArgumentParser()
62
+ ap.add_argument("--table", required=True, choices=sorted(ALLOWED_TABLES))
63
+ ap.add_argument("--id", type=int, required=True)
64
+ ap.add_argument("--campaign-id", type=int, required=True)
65
+ args = ap.parse_args()
66
+
67
+ if os.environ.get("SOCIAL_AUTOPOSTER_LEGACY_NEON") == "1":
68
+ bumped = _bump_via_neon(args.table, args.id, args.campaign_id)
69
+ else:
70
+ bumped = _bump_via_api(args.table, args.id, args.campaign_id)
71
+
72
+ print(f"table={args.table} id={args.id} campaign={args.campaign_id} bumped={bumped}")
50
73
  return 0
51
74
 
52
75
 
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env python3
2
+ """Email-alert when any external_short_links pool runs low.
3
+
4
+ For every project with `external_short_links: true` in config.json, checks the
5
+ (project, platform) pool depth against two thresholds:
6
+
7
+ WARN -- available / total <= 0.20 (i.e., 80% of the pool has been claimed)
8
+ CRITICAL -- available == 0 (pool exhausted, next post returns
9
+ {ok: false, error: 'pool_exhausted'})
10
+
11
+ Emails go to i@m13v.com via the Gmail DWD lane. State lives in the
12
+ `external_pool_alerts` table so we don't spam: same (project, platform,
13
+ severity) is suppressed for 24h after a send.
14
+
15
+ Designed to run on launchd every 30 min. The 20% threshold gives 7-30 days of
16
+ runway warning before a CRITICAL fires (at typical 5-15 posts/day burn). The
17
+ CRITICAL alert is the "on error" case the user asked for; it fires at most
18
+ once per 24h per (project, platform).
19
+
20
+ Usage:
21
+ python3 scripts/check_external_pool_depth.py # check + alert
22
+ python3 scripts/check_external_pool_depth.py --dry-run # report only, no email/state writes
23
+ python3 scripts/check_external_pool_depth.py --force # ignore 24h cooldown
24
+ """
25
+ from __future__ import annotations
26
+ import argparse
27
+ import json
28
+ import os
29
+ import sys
30
+ from datetime import datetime, timezone
31
+ from email.message import EmailMessage
32
+ import base64
33
+
34
+ REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
35
+ sys.path.insert(0, os.path.join(REPO_DIR, 'scripts'))
36
+ sys.path.insert(0, os.path.expanduser('~/gmail-api'))
37
+
38
+ import db as dbmod # noqa: E402
39
+
40
+ WARN_REMAINING_RATIO = 0.20
41
+ COOLDOWN_HOURS = 24
42
+ NOTIFICATION_EMAIL = os.environ.get('NOTIFICATION_EMAIL', 'i@m13v.com')
43
+ PLATFORMS = ['reddit', 'twitter', 'linkedin', 'github_issues', 'moltbook']
44
+ CONFIG_PATH = os.path.join(REPO_DIR, 'config.json')
45
+
46
+
47
+ def _scrub_dashes(s: str) -> str:
48
+ return s.replace('—', ',').replace('–', ',') if s else s
49
+
50
+
51
+ def _load_external_projects() -> list[dict]:
52
+ with open(CONFIG_PATH) as f:
53
+ cfg = json.load(f)
54
+ return [p for p in cfg.get('projects', []) if p.get('external_short_links')]
55
+
56
+
57
+ def _pool_depth(conn, project: str, platform: str) -> tuple[int, int]:
58
+ cur = conn.execute(
59
+ "SELECT "
60
+ " COUNT(*) FILTER (WHERE post_id IS NULL AND reply_id IS NULL) AS available, "
61
+ " COUNT(*) AS total "
62
+ "FROM post_links "
63
+ "WHERE minted_session LIKE %s AND project_name = %s AND platform = %s",
64
+ ('pool:%', project, platform),
65
+ )
66
+ r = dict(cur.fetchone())
67
+ return int(r['available'] or 0), int(r['total'] or 0)
68
+
69
+
70
+ def _recent_alert_exists(conn, project: str, platform: str, severity: str) -> bool:
71
+ cur = conn.execute(
72
+ "SELECT 1 FROM external_pool_alerts "
73
+ "WHERE project_name=%s AND platform=%s AND severity=%s "
74
+ " AND sent_at > NOW() - INTERVAL %s "
75
+ "LIMIT 1",
76
+ (project, platform, severity, f"{COOLDOWN_HOURS} hours"),
77
+ )
78
+ return cur.fetchone() is not None
79
+
80
+
81
+ def _record_alert(conn, project: str, platform: str, severity: str,
82
+ available: int, total: int, ratio: float) -> None:
83
+ conn.execute(
84
+ "INSERT INTO external_pool_alerts "
85
+ " (project_name, platform, severity, available, total, ratio) "
86
+ "VALUES (%s, %s, %s, %s, %s, %s)",
87
+ (project, platform, severity, available, total, ratio),
88
+ )
89
+ conn.commit()
90
+
91
+
92
+ def _gmail_send(subject: str, body: str) -> None:
93
+ from gmail_dwd_client import gmail_for
94
+ msg = EmailMessage()
95
+ msg['Subject'] = _scrub_dashes(subject)
96
+ msg['From'] = 'social-autoposter <i@m13v.com>'
97
+ msg['To'] = NOTIFICATION_EMAIL
98
+ msg.set_content(_scrub_dashes(body))
99
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode('ascii')
100
+ client = gmail_for('i@m13v.com')
101
+ client.service.users().messages().send(userId='me', body={'raw': raw}).execute()
102
+
103
+
104
+ def _format_subject(project: str, platform: str, severity: str,
105
+ available: int, total: int) -> str:
106
+ pct = f"{(available / total * 100):.0f}%" if total else "0%"
107
+ return f"[POOL {severity}] {project}/{platform}: {available}/{total} left ({pct})"
108
+
109
+
110
+ def _format_body(project: str, platform: str, severity: str,
111
+ available: int, total: int, ratio: float,
112
+ destinations: list[dict]) -> str:
113
+ lines = [
114
+ f"Severity: {severity}",
115
+ f"Project: {project}",
116
+ f"Platform: {platform}",
117
+ f"Available: {available}",
118
+ f"Total minted: {total}",
119
+ f"Remaining: {ratio*100:.1f}%",
120
+ "",
121
+ ]
122
+ if severity == 'CRITICAL':
123
+ lines += [
124
+ "The pool is exhausted. The next post for this (project, platform) will",
125
+ "return {ok: false, error: 'pool_exhausted'} and skip. Refill IMMEDIATELY:",
126
+ "",
127
+ ]
128
+ else:
129
+ lines += [
130
+ f"Pool has dropped under {int(WARN_REMAINING_RATIO*100)}% remaining. Schedule a refill",
131
+ "in the next few days to avoid hitting pool_exhausted on the next cycle.",
132
+ "",
133
+ ]
134
+ lines += [
135
+ "Refill commands (in ~/social-autoposter):",
136
+ " python3 scripts/mint_kent_pool.py # Kent clients (Runner/Agora/Podlog)",
137
+ " # for other external clients: extend mint_kent_pool.py SITE_CONFIG first",
138
+ "",
139
+ "Pool status snapshot:",
140
+ " python3 scripts/mint_kent_pool.py --status",
141
+ "",
142
+ ]
143
+ if destinations:
144
+ lines += ["Per-destination breakdown for this slice:"]
145
+ for d in destinations[:20]:
146
+ lines.append(
147
+ f" {d['minted_session'][-65:]:<65} "
148
+ f"avail={d['available']:>5} claimed={d['claimed']:>5}"
149
+ )
150
+ if len(destinations) > 20:
151
+ lines.append(f" ... and {len(destinations) - 20} more destinations")
152
+ lines.append("")
153
+ lines += [
154
+ f"Cooldown: {COOLDOWN_HOURS}h per (project, platform, severity)",
155
+ f"Re-fire: python3 scripts/check_external_pool_depth.py --force",
156
+ ]
157
+ return "\n".join(lines)
158
+
159
+
160
+ def _destinations_for_slice(conn, project: str, platform: str) -> list[dict]:
161
+ cur = conn.execute(
162
+ "SELECT minted_session, "
163
+ " COUNT(*) FILTER (WHERE post_id IS NULL AND reply_id IS NULL) AS available, "
164
+ " COUNT(*) FILTER (WHERE post_id IS NOT NULL OR reply_id IS NOT NULL) AS claimed "
165
+ "FROM post_links "
166
+ "WHERE minted_session LIKE %s AND project_name = %s AND platform = %s "
167
+ "GROUP BY minted_session ORDER BY available ASC",
168
+ ('pool:%', project, platform),
169
+ )
170
+ return [dict(r) for r in cur.fetchall()]
171
+
172
+
173
+ def check(dry_run: bool = False, force: bool = False,
174
+ warn_ratio: float = WARN_REMAINING_RATIO,
175
+ limit: int | None = None) -> dict:
176
+ projects = _load_external_projects()
177
+ conn = dbmod.get_conn()
178
+ fired: list[dict] = []
179
+ skipped_cooldown: list[dict] = []
180
+ healthy: list[dict] = []
181
+ try:
182
+ for p in projects:
183
+ project_name = p['name']
184
+ for platform in PLATFORMS:
185
+ available, total = _pool_depth(conn, project_name, platform)
186
+ if total == 0:
187
+ continue
188
+ ratio = available / total if total > 0 else 0.0
189
+ if available == 0:
190
+ severity = 'CRITICAL'
191
+ elif ratio <= warn_ratio:
192
+ severity = 'WARN'
193
+ else:
194
+ healthy.append({
195
+ 'project': project_name, 'platform': platform,
196
+ 'available': available, 'total': total, 'ratio': ratio,
197
+ })
198
+ continue
199
+ key = (project_name, platform, severity)
200
+ if not force and _recent_alert_exists(conn, *key):
201
+ skipped_cooldown.append({
202
+ 'project': project_name, 'platform': platform,
203
+ 'severity': severity, 'available': available, 'total': total,
204
+ })
205
+ continue
206
+ fired_row = {
207
+ 'project': project_name, 'platform': platform,
208
+ 'severity': severity, 'available': available, 'total': total,
209
+ 'ratio': ratio,
210
+ }
211
+ fired.append(fired_row)
212
+ if dry_run:
213
+ continue
214
+ if limit is not None and len(fired) > limit:
215
+ continue
216
+ destinations = _destinations_for_slice(conn, project_name, platform)
217
+ subject = _format_subject(project_name, platform, severity, available, total)
218
+ body = _format_body(project_name, platform, severity, available, total,
219
+ ratio, destinations)
220
+ try:
221
+ _gmail_send(subject, body)
222
+ _record_alert(conn, project_name, platform, severity,
223
+ available, total, ratio)
224
+ except Exception as e:
225
+ fired_row['send_error'] = str(e)
226
+ print(f"[pool-check] email send failed for {project_name}/{platform}: {e}",
227
+ file=sys.stderr)
228
+ return {
229
+ 'checked_at': datetime.now(timezone.utc).isoformat(),
230
+ 'fired': fired,
231
+ 'skipped_cooldown': skipped_cooldown,
232
+ 'healthy_count': len(healthy),
233
+ 'dry_run': dry_run,
234
+ }
235
+ finally:
236
+ conn.close()
237
+
238
+
239
+ def main():
240
+ ap = argparse.ArgumentParser(description=__doc__)
241
+ ap.add_argument('--dry-run', action='store_true',
242
+ help='compute and report, do not email or write state')
243
+ ap.add_argument('--force', action='store_true',
244
+ help='ignore 24h cooldown, re-fire matching alerts')
245
+ ap.add_argument('--warn-ratio', type=float, default=WARN_REMAINING_RATIO,
246
+ help=f'WARN threshold for available/total (default {WARN_REMAINING_RATIO})')
247
+ ap.add_argument('--limit', type=int, default=None,
248
+ help='cap the number of alert emails per run (smoke testing)')
249
+ args = ap.parse_args()
250
+ result = check(dry_run=args.dry_run, force=args.force,
251
+ warn_ratio=args.warn_ratio, limit=args.limit)
252
+ print(json.dumps(result, indent=2, default=str))
253
+
254
+
255
+ if __name__ == '__main__':
256
+ main()
@@ -522,6 +522,11 @@ def _mint_one_post(conn, *, target_url: str, projects: list, platform: str,
522
522
  platform UTMs + code in utm_content), so the LLM's URL in the comment text
523
523
  is ignored for routing -- visitors always land on the destination we baked
524
524
  in. Pool depth managed by scripts/mint_external_pool.py.
525
+
526
+ Routes DB ops through social-autoposter-website /api/v1/post-links/* so
527
+ VMs / sandboxes without DATABASE_URL still mint successfully. The `conn`
528
+ arg is accepted for backward compatibility but ignored. Set
529
+ SOCIAL_AUTOPOSTER_LEGACY_NEON=1 to fall back to the direct-Neon path.
525
530
  """
526
531
  target_url = _ensure_scheme((target_url or '').strip())
527
532
  if not target_url or target_url == 'https://':
@@ -540,45 +545,68 @@ def _mint_one_post(conn, *, target_url: str, projects: list, platform: str,
540
545
  'detail': f"no website for project={project_name!r} in config.json",
541
546
  }
542
547
 
548
+ platform_norm = (platform or '').lower()
549
+ if platform_norm == 'x':
550
+ platform_norm = 'twitter'
551
+
543
552
  project_cfg = next((p for p in projects if p.get('name') == project_name), None)
553
+ use_legacy = os.environ.get('SOCIAL_AUTOPOSTER_LEGACY_NEON') == '1'
554
+
544
555
  if project_cfg and project_cfg.get('external_short_links'):
545
- # Pool path. Atomically claim the oldest unclaimed pool row matching
546
- # (project_name, platform). FOR UPDATE SKIP LOCKED makes concurrent
547
- # cycles take different rows instead of contending on the same one.
548
- platform_norm = (platform or '').lower()
549
- if platform_norm == 'x':
550
- platform_norm = 'twitter'
551
- cur = conn.execute(
552
- "SELECT code, target_url FROM post_links "
553
- "WHERE project_name = %s AND platform = %s "
554
- " AND post_id IS NULL AND reply_id IS NULL "
555
- " AND minted_session LIKE 'pool:%%' "
556
- "ORDER BY minted_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED",
557
- (project_name, platform_norm),
558
- )
559
- row = cur.fetchone()
560
- if not row:
561
- return {
562
- 'ok': False,
563
- 'error': 'pool_exhausted',
564
- 'project': project_name,
565
- 'platform': platform_norm,
566
- 'detail': (f"external_short_links pool empty for {project_name}/{platform_norm}; "
567
- f"re-mint via scripts/mint_external_pool.py and send updated CSV to client"),
568
- }
569
- row = dict(row)
570
- pool_code = row['code']
571
- pool_target = row['target_url']
572
- # Re-stamp minted_session with the caller's session so backfill_post_id /
573
- # backfill_reply_id finds this row when log_post returns. Without this,
574
- # the pool row's minted_session stays 'pool:<slug>-<platform>' and the
575
- # backfill UPDATE matches nothing.
576
- conn.execute(
577
- "UPDATE post_links SET minted_session = %s "
578
- "WHERE code = %s",
579
- (minted_session, pool_code),
580
- )
581
- conn.commit()
556
+ # Pool path. Atomically claim the oldest unclaimed pool row.
557
+ if use_legacy:
558
+ cur = conn.execute(
559
+ "SELECT code, target_url FROM post_links "
560
+ "WHERE project_name = %s AND platform = %s "
561
+ " AND post_id IS NULL AND reply_id IS NULL "
562
+ " AND minted_session LIKE 'pool:%%' "
563
+ "ORDER BY minted_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED",
564
+ (project_name, platform_norm),
565
+ )
566
+ row = cur.fetchone()
567
+ if not row:
568
+ return {
569
+ 'ok': False,
570
+ 'error': 'pool_exhausted',
571
+ 'project': project_name,
572
+ 'platform': platform_norm,
573
+ }
574
+ row = dict(row)
575
+ pool_code = row['code']
576
+ pool_target = row['target_url']
577
+ conn.execute(
578
+ "UPDATE post_links SET minted_session = %s WHERE code = %s",
579
+ (minted_session, pool_code),
580
+ )
581
+ conn.commit()
582
+ else:
583
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
584
+ from http_api import api_post
585
+ try:
586
+ resp = api_post(
587
+ "/api/v1/post-links/claim-pool",
588
+ {
589
+ "project_name": project_name,
590
+ "platform": platform_norm,
591
+ "minted_session": minted_session,
592
+ },
593
+ ok_on_conflict=True,
594
+ )
595
+ except Exception as e:
596
+ return {'ok': False, 'error': 'api_unreachable', 'detail': str(e)}
597
+ if not resp or not resp.get('ok'):
598
+ err = (resp or {}).get('error') or {}
599
+ code = err.get('code') if isinstance(err, dict) else None
600
+ return {
601
+ 'ok': False,
602
+ 'error': code or 'pool_exhausted',
603
+ 'project': project_name,
604
+ 'platform': platform_norm,
605
+ 'detail': err.get('message') if isinstance(err, dict) else str(err),
606
+ }
607
+ data = resp.get('data') or {}
608
+ pool_code = data.get('code')
609
+ pool_target = data.get('target_url')
582
610
  return {
583
611
  'ok': True,
584
612
  'code': pool_code,
@@ -596,22 +624,54 @@ def _mint_one_post(conn, *, target_url: str, projects: list, platform: str,
596
624
  platform=platform,
597
625
  )
598
626
 
599
- # Posts mint fresh codes every call no idempotency on (post_id, target_url)
600
- # because post_id is NULL at mint time. The minted_session UUID groups the
601
- # codes so the caller can backfill them all in one UPDATE after log_post
602
- # returns. If a wrap is retried (rare), we get duplicate codes pointing at
603
- # the same target_url; orphans of failed posts are bounded and harmless.
627
+ # Fresh mint: try up to 8 random codes before giving up on collision.
628
+ if use_legacy:
629
+ for _ in range(8):
630
+ code = _gen_code()
631
+ try:
632
+ conn.execute(
633
+ "INSERT INTO post_links (code, platform, project_name, "
634
+ " target_url, kind, project_at_mint, minted_session) "
635
+ "VALUES (%s, %s, %s, %s, %s, %s, %s)",
636
+ (code, platform, project_name, final_target, kind,
637
+ matched_project, minted_session),
638
+ )
639
+ conn.commit()
640
+ return {
641
+ 'ok': True,
642
+ 'code': code,
643
+ 'short_url': f"{website}/r/{code}",
644
+ 'target_url': final_target,
645
+ 'kind': kind,
646
+ }
647
+ except Exception as e:
648
+ if 'duplicate key' in str(e).lower() and 'post_links_pkey' in str(e).lower():
649
+ conn.execute("ROLLBACK")
650
+ continue
651
+ raise
652
+ return {'ok': False, 'error': 'code_collision_after_8_tries'}
653
+
654
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
655
+ from http_api import api_post
604
656
  for _ in range(8):
605
657
  code = _gen_code()
606
658
  try:
607
- conn.execute(
608
- "INSERT INTO post_links (code, platform, project_name, "
609
- " target_url, kind, project_at_mint, minted_session) "
610
- "VALUES (%s, %s, %s, %s, %s, %s, %s)",
611
- (code, platform, project_name, final_target, kind,
612
- matched_project, minted_session),
659
+ resp = api_post(
660
+ "/api/v1/post-links/mint",
661
+ {
662
+ "code": code,
663
+ "platform": platform,
664
+ "project_name": project_name,
665
+ "target_url": final_target,
666
+ "kind": kind,
667
+ "project_at_mint": matched_project,
668
+ "minted_session": minted_session,
669
+ },
670
+ ok_on_conflict=True,
613
671
  )
614
- conn.commit()
672
+ except Exception as e:
673
+ return {'ok': False, 'error': 'api_unreachable', 'detail': str(e)}
674
+ if resp and resp.get('ok'):
615
675
  return {
616
676
  'ok': True,
617
677
  'code': code,
@@ -619,11 +679,15 @@ def _mint_one_post(conn, *, target_url: str, projects: list, platform: str,
619
679
  'target_url': final_target,
620
680
  'kind': kind,
621
681
  }
622
- except Exception as e:
623
- if 'duplicate key' in str(e).lower() and 'post_links_pkey' in str(e).lower():
624
- conn.execute("ROLLBACK")
625
- continue
626
- raise
682
+ err = (resp or {}).get('error') or {}
683
+ err_code = err.get('code') if isinstance(err, dict) else None
684
+ if err_code == 'code_collision':
685
+ continue # try another random code
686
+ return {
687
+ 'ok': False,
688
+ 'error': err_code or 'mint_api_error',
689
+ 'detail': err.get('message') if isinstance(err, dict) else str(err),
690
+ }
627
691
  return {'ok': False, 'error': 'code_collision_after_8_tries'}
628
692
 
629
693
 
@@ -653,7 +717,11 @@ def wrap_text_for_post(*, text: str, platform: str, project_name: str) -> dict:
653
717
 
654
718
  minted_session = str(uuid.uuid4())
655
719
  projects = _load_projects()
656
- conn = dbmod.get_conn()
720
+ # Conn is only opened for the legacy direct-Neon path (env-gated). The
721
+ # default API path doesn't need it; lazy-open avoids a wasted Neon
722
+ # connection on VMs that don't have DATABASE_URL.
723
+ use_legacy = os.environ.get('SOCIAL_AUTOPOSTER_LEGACY_NEON') == '1'
724
+ conn = dbmod.get_conn() if use_legacy else None
657
725
  try:
658
726
  seen = {}
659
727
  codes = []
@@ -704,7 +772,26 @@ def wrap_text_for_post(*, text: str, platform: str, project_name: str) -> dict:
704
772
  'skipped': skipped,
705
773
  }
706
774
  finally:
707
- conn.close()
775
+ if conn is not None:
776
+ conn.close()
777
+
778
+
779
+ def _backfill_via_api(*, minted_session: str, post_id: int | None = None,
780
+ reply_id: int | None = None) -> int:
781
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
782
+ from http_api import api_post
783
+ body: dict = {"minted_session": minted_session}
784
+ if post_id is not None:
785
+ body["post_id"] = int(post_id)
786
+ if reply_id is not None:
787
+ body["reply_id"] = int(reply_id)
788
+ try:
789
+ resp = api_post("/api/v1/post-links/backfill", body)
790
+ except Exception:
791
+ return 0
792
+ if not resp or not resp.get('ok'):
793
+ return 0
794
+ return int((resp.get('data') or {}).get('updated') or 0)
708
795
 
709
796
 
710
797
  def backfill_post_id(*, minted_session: str, post_id: int) -> int:
@@ -714,9 +801,14 @@ def backfill_post_id(*, minted_session: str, post_id: int) -> int:
714
801
  Caller should NOT raise on rowcount==0 because some posts have no URLs
715
802
  and minted_session was None — the caller should skip the backfill in
716
803
  that case.
804
+
805
+ Routes through /api/v1/post-links/backfill by default. Set
806
+ SOCIAL_AUTOPOSTER_LEGACY_NEON=1 for the direct-Neon path.
717
807
  """
718
808
  if not minted_session or post_id is None:
719
809
  return 0
810
+ if os.environ.get('SOCIAL_AUTOPOSTER_LEGACY_NEON') != '1':
811
+ return _backfill_via_api(minted_session=minted_session, post_id=post_id)
720
812
  conn = dbmod.get_conn()
721
813
  try:
722
814
  cur = conn.execute(
@@ -735,6 +827,8 @@ def backfill_reply_id(*, minted_session: str, reply_id: int) -> int:
735
827
  writes to the `replies` table, not `posts`)."""
736
828
  if not minted_session or reply_id is None:
737
829
  return 0
830
+ if os.environ.get('SOCIAL_AUTOPOSTER_LEGACY_NEON') != '1':
831
+ return _backfill_via_api(minted_session=minted_session, reply_id=reply_id)
738
832
  conn = dbmod.get_conn()
739
833
  try:
740
834
  cur = conn.execute(
@@ -70,37 +70,15 @@ def main():
70
70
  return
71
71
 
72
72
  try:
73
- import psycopg2 # noqa: F401 (psycopg2 only needed via dbmod transitively)
74
73
  sys.path.insert(0, os.path.join(ROOT_DIR, 'scripts'))
75
- import db as dbmod
76
- conn = dbmod.get_conn()
77
- if cycle_id:
78
- cur = conn.execute(
79
- """SELECT COALESCE(SUM(total_cost_usd), 0),
80
- COALESCE(SUM(subagent_cost_usd), 0),
81
- COALESCE(SUM(task_call_count), 0),
82
- COALESCE(SUM(subagent_count), 0)
83
- FROM claude_sessions
84
- WHERE cycle_id = %s""",
85
- [cycle_id],
74
+ if os.environ.get('SOCIAL_AUTOPOSTER_LEGACY_NEON') == '1':
75
+ parent_cost, subagent_cost, task_count, subagent_count = _fetch_via_neon(
76
+ cycle_id=cycle_id, since=args.since, scripts=args.scripts,
86
77
  )
87
78
  else:
88
- since_ts = datetime.fromtimestamp(args.since, tz=timezone.utc).isoformat()
89
- placeholders = ','.join(['%s'] * len(args.scripts))
90
- cur = conn.execute(
91
- f"""SELECT COALESCE(SUM(total_cost_usd), 0),
92
- COALESCE(SUM(subagent_cost_usd), 0),
93
- COALESCE(SUM(task_call_count), 0),
94
- COALESCE(SUM(subagent_count), 0)
95
- FROM claude_sessions
96
- WHERE script IN ({placeholders}) AND started_at >= %s""",
97
- args.scripts + [since_ts],
79
+ parent_cost, subagent_cost, task_count, subagent_count = _fetch_via_api(
80
+ cycle_id=cycle_id, since=args.since, scripts=args.scripts,
98
81
  )
99
- row = cur.fetchone()
100
- parent_cost = float(row[0] or 0)
101
- subagent_cost = float(row[1] or 0)
102
- task_count = int(row[2] or 0)
103
- subagent_count = int(row[3] or 0)
104
82
  if args.breakdown:
105
83
  print(f"{parent_cost:.4f} {subagent_cost:.4f} {task_count} {subagent_count}")
106
84
  else:
@@ -109,5 +87,56 @@ def main():
109
87
  print("0.0000")
110
88
 
111
89
 
90
+ def _fetch_via_api(*, cycle_id, since, scripts):
91
+ from http_api import api_get
92
+ if cycle_id:
93
+ query = {"cycle_id": cycle_id}
94
+ else:
95
+ query = {"since_ts": str(int(since)), "scripts": ",".join(scripts)}
96
+ resp = api_get("/api/v1/claude-sessions/cost", query=query)
97
+ data = (resp or {}).get("data") or {}
98
+ return (
99
+ float(data.get("parent_cost") or 0),
100
+ float(data.get("subagent_cost") or 0),
101
+ int(data.get("task_count") or 0),
102
+ int(data.get("subagent_count") or 0),
103
+ )
104
+
105
+
106
+ def _fetch_via_neon(*, cycle_id, since, scripts):
107
+ import psycopg2 # noqa: F401
108
+ import db as dbmod
109
+ conn = dbmod.get_conn()
110
+ if cycle_id:
111
+ cur = conn.execute(
112
+ """SELECT COALESCE(SUM(total_cost_usd), 0),
113
+ COALESCE(SUM(subagent_cost_usd), 0),
114
+ COALESCE(SUM(task_call_count), 0),
115
+ COALESCE(SUM(subagent_count), 0)
116
+ FROM claude_sessions
117
+ WHERE cycle_id = %s""",
118
+ [cycle_id],
119
+ )
120
+ else:
121
+ since_ts = datetime.fromtimestamp(since, tz=timezone.utc).isoformat()
122
+ placeholders = ','.join(['%s'] * len(scripts))
123
+ cur = conn.execute(
124
+ f"""SELECT COALESCE(SUM(total_cost_usd), 0),
125
+ COALESCE(SUM(subagent_cost_usd), 0),
126
+ COALESCE(SUM(task_call_count), 0),
127
+ COALESCE(SUM(subagent_count), 0)
128
+ FROM claude_sessions
129
+ WHERE script IN ({placeholders}) AND started_at >= %s""",
130
+ list(scripts) + [since_ts],
131
+ )
132
+ row = cur.fetchone()
133
+ return (
134
+ float(row[0] or 0),
135
+ float(row[1] or 0),
136
+ int(row[2] or 0),
137
+ int(row[3] or 0),
138
+ )
139
+
140
+
112
141
  if __name__ == '__main__':
113
142
  main()
@@ -536,6 +536,117 @@ def format_report(summary, top, bottom, project=None, platform=None,
536
536
  return "\n".join(lines)
537
537
 
538
538
 
539
+ def _apply_top_filter(rows, limit):
540
+ """Anti-pattern filter applied to top-N candidates.
541
+
542
+ PRODUCT_NAMES: hard-drop self-promotional examples regardless of
543
+ clicks (don't teach Claude to namedrop). URL/www. mention: only
544
+ drop when clicks==0 (a URL-bearing post with real human clicks IS
545
+ the gold example by definition; see 2026-05-12 click-aware fix).
546
+ Caller passes overfetched rows; we trim to `limit` after filter.
547
+ """
548
+ clean = []
549
+ for r in rows:
550
+ content = (r[5] or "")
551
+ clicks = r[11] if len(r) > 11 and r[11] is not None else 0
552
+ lower = content.lower()
553
+ if any(name in lower for name in PRODUCT_NAMES):
554
+ continue
555
+ has_url = ("http://" in lower or "https://" in lower or "www." in lower)
556
+ if has_url and clicks == 0:
557
+ continue
558
+ clean.append(r)
559
+ return clean[:limit]
560
+
561
+
562
+ def _fetch_report_via_api(*, platform, project, top, bottom):
563
+ """Pull all 6 SQL aggregations in one call via the v1 route.
564
+
565
+ Returns (summary, style_perf, top_posts, bottom_posts,
566
+ fallback_top|None, top_by_group|None). Row shapes match
567
+ the column order format_post / format_report expect.
568
+ """
569
+ from http_api import api_get
570
+ resp = api_get(
571
+ "/api/v1/posts/top-performers-report",
572
+ query={
573
+ "platform": platform or "",
574
+ "project": project or "",
575
+ "top": str(top),
576
+ "bottom": str(bottom),
577
+ },
578
+ )
579
+ data = (resp or {}).get("data") or {}
580
+ summary = data.get("summary") or []
581
+ style_perf = data.get("style_perf") or []
582
+ raw_top = data.get("top_posts") or []
583
+ raw_bottom = data.get("bottom_posts") or []
584
+ raw_fallback = data.get("fallback_top") or []
585
+ raw_group = data.get("top_by_group") or {}
586
+
587
+ top_filtered = _apply_top_filter(raw_top, top) if raw_top else []
588
+ fallback_filtered = None
589
+ if project and not top_filtered and raw_fallback:
590
+ fallback_filtered = _apply_top_filter(raw_fallback, top)
591
+ top_by_group = None
592
+ if not project:
593
+ top_by_group = {
594
+ proj: _apply_top_filter(rows, 5)
595
+ for proj, rows in raw_group.items()
596
+ }
597
+ return summary, style_perf, top_filtered, raw_bottom, fallback_filtered, top_by_group
598
+
599
+
600
+ def _fetch_report_via_neon(*, platform, project, top, bottom):
601
+ conn = dbmod.get_conn()
602
+ try:
603
+ summary = get_project_platform_summary(conn, project=project, platform=platform)
604
+ style_perf = get_style_performance(conn, platform=platform)
605
+ top_posts = get_top_posts(conn, project=project, platform=platform, limit=top)
606
+ bottom_posts = get_bottom_posts(conn, project=project, platform=platform, limit=bottom)
607
+ fallback_top = None
608
+ if project and not top_posts:
609
+ fallback_top = get_top_posts(conn, project=None, platform=platform, limit=top)
610
+ top_by_group = None
611
+ if not project:
612
+ top_by_group = {}
613
+ min_score = min_score_for(platform)
614
+ platform_filter = "AND platform = %s" if platform else ""
615
+ platform_params = [platform] if platform else []
616
+ cur = conn.execute(
617
+ f"{POSTS_WITH_CLICKS_CTE}"
618
+ f"SELECT DISTINCT COALESCE(project_name, '(no project)') FROM posts_w_clicks "
619
+ f"WHERE status = 'active' AND platform NOT IN ('github_issues') "
620
+ f"AND our_content IS NOT NULL AND LENGTH(our_content) >= %s "
621
+ f"AND upvotes IS NOT NULL AND {SCORE_SQL} >= %s "
622
+ f"{platform_filter} "
623
+ f"ORDER BY 1",
624
+ [MIN_CONTENT_LEN, min_score] + platform_params,
625
+ )
626
+ projects = [row[0] for row in cur.fetchall()]
627
+ for proj in projects:
628
+ proj_filter = proj if proj != "(no project)" else None
629
+ where_extra = "AND project_name = %s" if proj_filter else "AND project_name IS NULL"
630
+ params = ([proj_filter] if proj_filter else []) + platform_params
631
+ cur = conn.execute(
632
+ f"{POSTS_WITH_CLICKS_CTE}"
633
+ f"SELECT id, platform, upvotes, comments_count, views, "
634
+ f"our_content, thread_title, thread_content, "
635
+ f"project_name, posted_at::date, our_account, clicks "
636
+ f"FROM posts_w_clicks WHERE status = 'active' AND {SCORE_SQL} >= {min_score} "
637
+ f"AND our_content IS NOT NULL AND LENGTH(our_content) >= {MIN_CONTENT_LEN} "
638
+ f"AND platform NOT IN ('github_issues') "
639
+ f"{where_extra} {platform_filter} "
640
+ f"ORDER BY {SCORE_SQL} DESC, COALESCE(clicks,0) DESC, "
641
+ f" {UPVOTES_NET_SQL} DESC LIMIT 5",
642
+ params,
643
+ )
644
+ top_by_group[proj] = cur.fetchall()
645
+ return summary, style_perf, top_posts, bottom_posts, fallback_top, top_by_group
646
+ finally:
647
+ conn.close()
648
+
649
+
539
650
  def main():
540
651
  parser = argparse.ArgumentParser(description="Generate top performers feedback report")
541
652
  parser.add_argument("--platform", default=None, help="Filter to specific platform")
@@ -545,71 +656,20 @@ def main():
545
656
  parser.add_argument("--json", action="store_true", help="Output as JSON")
546
657
  args = parser.parse_args()
547
658
 
548
- conn = dbmod.get_conn()
549
-
550
- summary = get_project_platform_summary(conn, project=args.project, platform=args.platform)
551
- style_perf = get_style_performance(conn, platform=args.platform)
552
- top = get_top_posts(conn, project=args.project, platform=args.platform, limit=args.top)
553
- bottom = get_bottom_posts(conn, project=args.project, platform=args.platform, limit=args.bottom)
554
-
555
- # If project was specified but no posts met the threshold, fall back to
556
- # general high-performing posts on the same platform
557
- fallback_top = None
558
- if args.project and not top:
559
- fallback_top = get_top_posts(conn, project=None, platform=args.platform, limit=args.top)
560
-
561
- # When no project filter, also get top 5 per project for focused examples.
562
- # Uses the same SCORE_SQL composite as get_top_posts so ranking is consistent.
563
- top_by_group = None
564
- if not args.project:
565
- top_by_group = {}
566
- min_score = min_score_for(args.platform)
567
- platform_filter = "AND platform = %s" if args.platform else ""
568
- platform_params = [args.platform] if args.platform else []
569
- # Get distinct projects (respecting platform filter). Uses the
570
- # CTE so the SCORE_SQL >= threshold expression resolves `clicks`.
571
- cur = conn.execute(
572
- f"{POSTS_WITH_CLICKS_CTE}"
573
- f"SELECT DISTINCT COALESCE(project_name, '(no project)') FROM posts_w_clicks "
574
- f"WHERE status = 'active' AND platform NOT IN ('github_issues') "
575
- f"AND our_content IS NOT NULL AND LENGTH(our_content) >= %s "
576
- f"AND upvotes IS NOT NULL AND {SCORE_SQL} >= %s "
577
- f"{platform_filter} "
578
- f"ORDER BY 1",
579
- [MIN_CONTENT_LEN, min_score] + platform_params
580
- )
581
- projects = [row[0] for row in cur.fetchall()]
582
- for proj in projects:
583
- proj_filter = proj if proj != "(no project)" else None
584
- where_extra = "AND project_name = %s" if proj_filter else "AND project_name IS NULL"
585
- params = ([proj_filter] if proj_filter else []) + platform_params
586
- # Per-project top-5 examples — these are what Claude actually
587
- # sees as the few-shot prompt. Click DESC tiebreaker means two
588
- # posts at the same composite score sort the higher-click one
589
- # first, putting the better conversion proof in front of Claude.
590
- cur = conn.execute(
591
- f"{POSTS_WITH_CLICKS_CTE}"
592
- f"SELECT id, platform, upvotes, comments_count, views, "
593
- f"our_content, thread_title, thread_content, "
594
- f"project_name, posted_at::date, our_account, clicks "
595
- f"FROM posts_w_clicks WHERE status = 'active' AND {SCORE_SQL} >= {min_score} "
596
- f"AND our_content IS NOT NULL AND LENGTH(our_content) >= {MIN_CONTENT_LEN} "
597
- f"AND platform NOT IN ('github_issues') "
598
- f"{where_extra} {platform_filter} "
599
- f"ORDER BY {SCORE_SQL} DESC, COALESCE(clicks,0) DESC, "
600
- f" {UPVOTES_NET_SQL} DESC LIMIT 5",
601
- params
602
- )
603
- top_by_group[proj] = cur.fetchall()
604
-
605
- conn.close()
659
+ if os.environ.get("SOCIAL_AUTOPOSTER_LEGACY_NEON") == "1":
660
+ fetch = _fetch_report_via_neon
661
+ else:
662
+ fetch = _fetch_report_via_api
663
+ summary, style_perf, top, bottom, fallback_top, top_by_group = fetch(
664
+ platform=args.platform, project=args.project, top=args.top, bottom=args.bottom,
665
+ )
606
666
 
607
667
  if args.json:
608
668
  output = {
609
- "summary": [dict(row) for row in summary],
610
- "top_posts": [dict(row) for row in top],
611
- "bottom_posts": [dict(row) for row in bottom],
612
- "fallback_top": [dict(row) for row in fallback_top] if fallback_top else [],
669
+ "summary": [list(row) for row in summary],
670
+ "top_posts": [list(row) for row in top],
671
+ "bottom_posts": [list(row) for row in bottom],
672
+ "fallback_top": [list(row) for row in fallback_top] if fallback_top else [],
613
673
  }
614
674
  print(json.dumps(output, indent=2, default=str))
615
675
  else: