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 +169 -15
- package/package.json +1 -1
- package/scripts/active_campaigns.py +43 -3
- package/scripts/campaign_bump.py +34 -11
- package/scripts/check_external_pool_depth.py +256 -0
- package/scripts/dm_short_links.py +150 -56
- package/scripts/get_run_cost.py +56 -27
- package/scripts/top_performers.py +122 -62
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">▶</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
|
-
|
|
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
|
-
|
|
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?'
|
|
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
|
-
|
|
13710
|
+
weightCellHtml +
|
|
13613
13711
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + (r.unassigned ? '—' : 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
|
@@ -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
|
|
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 ""
|
package/scripts/campaign_bump.py
CHANGED
|
@@ -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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 {
|
|
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
|
-
[
|
|
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
|
-
[
|
|
52
|
+
[campaign_id],
|
|
45
53
|
)
|
|
46
54
|
conn.commit()
|
|
47
|
-
|
|
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
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
#
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
608
|
-
"
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
package/scripts/get_run_cost.py
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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": [
|
|
610
|
-
"top_posts": [
|
|
611
|
-
"bottom_posts": [
|
|
612
|
-
"fallback_top": [
|
|
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:
|