social-autoposter 1.6.0 → 1.6.1
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 +363 -19
- package/package.json +1 -1
- package/scripts/account_resolver.py +141 -0
- package/scripts/author_history_block.py +264 -0
- package/scripts/counterparty_history.py +248 -0
- package/scripts/engage_reddit.py +24 -79
- package/scripts/engage_twitter_helper.py +58 -1
- package/scripts/find_threads.py +25 -2
- package/scripts/github_tools.py +45 -7
- package/scripts/migrate_newsletter_links.py +108 -0
- package/scripts/mint_podlog_subpage_10k_topup.py +135 -0
- package/scripts/mint_podlog_subpage_500.py +130 -0
- package/scripts/octolens_threads.py +32 -2
- package/scripts/post_github.py +11 -0
- package/scripts/post_reddit.py +11 -0
- package/scripts/reddit_tools.py +26 -9
- package/scripts/scan_twitter_mentions_browser.py +12 -4
- package/scripts/score_linkedin_candidates.py +21 -5
- package/scripts/score_twitter_candidates.py +10 -3
- package/scripts/sync_ig_to_posts.py +107 -0
- package/scripts/twitter_account.py +20 -55
- package/scripts/twitter_post_plan.py +13 -1
- package/scripts/update_instagram_stats.py +261 -0
- package/skill/run-instagram-daily.sh +9 -0
- package/skill/run-linkedin.sh +10 -0
- package/skill/run-twitter-cycle.sh +12 -1
- package/skill/scan-twitter-followups.sh +7 -2
- package/skill/stats-instagram.sh +72 -0
package/bin/server.js
CHANGED
|
@@ -42,7 +42,7 @@ function agentPath(job) {
|
|
|
42
42
|
|
|
43
43
|
// Matrix: rows = job types, columns = platforms
|
|
44
44
|
// Each cell is a job (or null if that combo doesn't exist)
|
|
45
|
-
const PLATFORMS = ['Reddit', 'Twitter', 'LinkedIn', 'MoltBook', 'GitHub'];
|
|
45
|
+
const PLATFORMS = ['Reddit', 'Twitter', 'LinkedIn', 'MoltBook', 'GitHub', 'Instagram'];
|
|
46
46
|
const JOB_TYPES = ['Post Threads', 'Post Comments', 'Engage', 'DM Outreach', 'DM Replies', 'Link Edit', 'Stats', 'Post Audit', 'Octolens'];
|
|
47
47
|
|
|
48
48
|
const JOBS = [
|
|
@@ -86,6 +86,7 @@ const JOBS = [
|
|
|
86
86
|
{ label: 'com.m13v.social-stats-twitter', name: 'Stats Twitter', type: 'Stats', platform: 'Twitter', script: 'stats-twitter.sh', logPrefix: 'stats-twitter-', plist: 'com.m13v.social-stats-twitter.plist' },
|
|
87
87
|
{ label: 'com.m13v.social-stats-linkedin', name: 'Stats LinkedIn', type: 'Stats', platform: 'LinkedIn', script: 'stats-linkedin.sh', logPrefix: 'stats-linkedin-', plist: 'com.m13v.social-stats-linkedin.plist' },
|
|
88
88
|
{ label: 'com.m13v.social-stats-moltbook', name: 'Stats MoltBook', type: 'Stats', platform: 'MoltBook', script: 'stats-moltbook.sh', logPrefix: 'stats-moltbook-', plist: 'com.m13v.social-stats-moltbook.plist' },
|
|
89
|
+
{ label: 'com.m13v.social-stats-instagram', name: 'Stats Instagram', type: 'Stats', platform: 'Instagram', script: 'stats-instagram.sh', logPrefix: 'stats-instagram-', plist: 'com.m13v.social-stats-instagram.plist' },
|
|
89
90
|
// Post Audit row (verify posts still exist / API health)
|
|
90
91
|
{ label: 'com.m13v.social-audit-reddit', name: 'Post Audit Reddit', type: 'Post Audit', platform: 'Reddit', script: 'audit-reddit.sh', logPrefix: 'audit-reddit-', plist: 'com.m13v.social-audit-reddit.plist' },
|
|
91
92
|
{ label: 'com.m13v.social-audit-twitter', name: 'Post Audit Twitter', type: 'Post Audit', platform: 'Twitter', script: 'audit-twitter.sh', logPrefix: 'audit-twitter-', plist: 'com.m13v.social-audit-twitter.plist' },
|
|
@@ -128,6 +129,7 @@ const REQUIRED_LOCKS = {
|
|
|
128
129
|
'link-edit-moltbook.sh': ['link-edit-moltbook'],
|
|
129
130
|
'link-edit-github.sh': ['link-edit-github'],
|
|
130
131
|
'stats-reddit.sh': ['reddit-browser'],
|
|
132
|
+
'stats-instagram.sh': ['instagram-poster'],
|
|
131
133
|
'audit-reddit.sh': ['reddit-browser', 'audit-reddit'],
|
|
132
134
|
'audit-twitter.sh': ['twitter-browser', 'audit-twitter'],
|
|
133
135
|
'audit-linkedin.sh': ['linkedin-browser', 'audit-linkedin'],
|
|
@@ -154,6 +156,38 @@ function json(res, obj, status = 200) {
|
|
|
154
156
|
res.end(JSON.stringify(obj));
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
// Short-link code alphabet matches scripts/dm_short_links.py CODE_ALPHABET
|
|
160
|
+
// so codes minted by either path are visually consistent. 32 chars (no 0/1/l/o
|
|
161
|
+
// to avoid ambiguity in handwritten/printed contexts).
|
|
162
|
+
const SHORT_LINK_ALPHABET = 'abcdefghijkmnpqrstuvwxyz23456789';
|
|
163
|
+
|
|
164
|
+
function mintShortLinkCode(n = 8) {
|
|
165
|
+
let s = '';
|
|
166
|
+
const bytes = crypto.randomBytes(n);
|
|
167
|
+
for (let i = 0; i < n; i++) {
|
|
168
|
+
s += SHORT_LINK_ALPHABET[bytes[i] % SHORT_LINK_ALPHABET.length];
|
|
169
|
+
}
|
|
170
|
+
return s;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Look up a project's wrapper host (where /r/<code> is served) by name from
|
|
174
|
+
// config.json. Mirrors _project_short_links_host in scripts/dm_short_links.py.
|
|
175
|
+
// Falls back to project.website. Returns null if the project is not configured.
|
|
176
|
+
function getProjectWrapperHost(projectName) {
|
|
177
|
+
if (!projectName) return null;
|
|
178
|
+
try {
|
|
179
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
180
|
+
const projects = Array.isArray(config.projects) ? config.projects : [];
|
|
181
|
+
const p = projects.find(x => x && x.name === projectName);
|
|
182
|
+
if (!p) return null;
|
|
183
|
+
const host = (p.short_links_host || p.website || '').trim().replace(/\/+$/, '');
|
|
184
|
+
return host || null;
|
|
185
|
+
} catch (e) {
|
|
186
|
+
console.error('[wrapper-host] config read failed:', e.message);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
157
191
|
function isJobLoaded(label) {
|
|
158
192
|
return driver.isLoaded(label);
|
|
159
193
|
}
|
|
@@ -3246,32 +3280,79 @@ async function handleApi(req, res) {
|
|
|
3246
3280
|
RETURNING post_id, reply_id, platform, project_name,
|
|
3247
3281
|
target_url, kind`;
|
|
3248
3282
|
const postRes = await pool.query(postSql, [code]);
|
|
3249
|
-
if (
|
|
3283
|
+
if (postRes.rows.length) {
|
|
3284
|
+
const prow = postRes.rows[0];
|
|
3285
|
+
if (!prow.target_url) {
|
|
3286
|
+
return json(res, { error: 'no_target_url', post_id: prow.post_id, reply_id: prow.reply_id }, 404);
|
|
3287
|
+
}
|
|
3288
|
+
// Per-click log for post rail (humans + bots).
|
|
3289
|
+
try {
|
|
3290
|
+
await pool.query(
|
|
3291
|
+
`INSERT INTO post_link_clicks (code, ip_hash, user_agent, is_bot, referrer)
|
|
3292
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
3293
|
+
[code, ipHash, fwdUa, isBot, fwdRef]
|
|
3294
|
+
);
|
|
3295
|
+
} catch (e) {
|
|
3296
|
+
console.error('[short-links] post_link_clicks insert failed (non-fatal):', e.message);
|
|
3297
|
+
}
|
|
3298
|
+
let pPlatform = (prow.platform || 'reddit').toLowerCase();
|
|
3299
|
+
if (pPlatform === 'x') pPlatform = 'twitter';
|
|
3300
|
+
return json(res, {
|
|
3301
|
+
post_id: prow.post_id,
|
|
3302
|
+
reply_id: prow.reply_id,
|
|
3303
|
+
platform: pPlatform,
|
|
3304
|
+
project: prow.project_name || null,
|
|
3305
|
+
kind: prow.kind || null,
|
|
3306
|
+
target_url: prow.target_url,
|
|
3307
|
+
});
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
// Final fallback: newsletter_links rail. Outbound broadcast emails
|
|
3311
|
+
// sent by ~/analytics (dash.m13v.com) mint per-recipient codes via
|
|
3312
|
+
// POST /api/newsletter/mint; the resolver here closes the loop on
|
|
3313
|
+
// click attribution. broadcast_product + broadcast_id let the
|
|
3314
|
+
// analytics dashboard cross-reference back into the originating
|
|
3315
|
+
// studyly_broadcast_log / fazm_broadcasts / etc. row. The customer
|
|
3316
|
+
// /r/[code] handler (see ~/seo-components/src/lib/dm-short-link-redirect.ts)
|
|
3317
|
+
// fires `newsletter_short_link_clicked` in PostHog when broadcast_id
|
|
3318
|
+
// is set in the response, so signup attribution stitches via the
|
|
3319
|
+
// same anonymous distinct_id session that landed on the page.
|
|
3320
|
+
const newsSql = isBot
|
|
3321
|
+
? `SELECT broadcast_product, broadcast_id, recipient_email_hash,
|
|
3322
|
+
recipient_email, target_url, kind, project_name
|
|
3323
|
+
FROM newsletter_links WHERE code = $1`
|
|
3324
|
+
: `UPDATE newsletter_links SET
|
|
3325
|
+
clicks = clicks + 1,
|
|
3326
|
+
first_click_at = COALESCE(first_click_at, NOW()),
|
|
3327
|
+
last_click_at = NOW()
|
|
3328
|
+
WHERE code = $1
|
|
3329
|
+
RETURNING broadcast_product, broadcast_id, recipient_email_hash,
|
|
3330
|
+
recipient_email, target_url, kind, project_name`;
|
|
3331
|
+
const newsRes = await pool.query(newsSql, [code]);
|
|
3332
|
+
if (!newsRes.rows.length) {
|
|
3250
3333
|
return json(res, { error: 'not_found', code }, 404);
|
|
3251
3334
|
}
|
|
3252
|
-
const
|
|
3253
|
-
if (!
|
|
3254
|
-
return json(res, { error: 'no_target_url',
|
|
3335
|
+
const nrow = newsRes.rows[0];
|
|
3336
|
+
if (!nrow.target_url) {
|
|
3337
|
+
return json(res, { error: 'no_target_url', broadcast_id: nrow.broadcast_id }, 404);
|
|
3255
3338
|
}
|
|
3256
|
-
// Per-click log for
|
|
3339
|
+
// Per-click log for newsletter rail (humans + bots).
|
|
3257
3340
|
try {
|
|
3258
3341
|
await pool.query(
|
|
3259
|
-
`INSERT INTO
|
|
3342
|
+
`INSERT INTO newsletter_link_clicks (code, ip_hash, user_agent, is_bot, referrer)
|
|
3260
3343
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
3261
3344
|
[code, ipHash, fwdUa, isBot, fwdRef]
|
|
3262
3345
|
);
|
|
3263
3346
|
} catch (e) {
|
|
3264
|
-
console.error('[short-links]
|
|
3347
|
+
console.error('[short-links] newsletter_link_clicks insert failed (non-fatal):', e.message);
|
|
3265
3348
|
}
|
|
3266
|
-
let pPlatform = (prow.platform || 'reddit').toLowerCase();
|
|
3267
|
-
if (pPlatform === 'x') pPlatform = 'twitter';
|
|
3268
3349
|
return json(res, {
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
project:
|
|
3273
|
-
kind:
|
|
3274
|
-
target_url:
|
|
3350
|
+
broadcast_id: Number(nrow.broadcast_id),
|
|
3351
|
+
broadcast_product: nrow.broadcast_product,
|
|
3352
|
+
recipient_email_hash: nrow.recipient_email_hash,
|
|
3353
|
+
project: nrow.project_name || nrow.broadcast_product || null,
|
|
3354
|
+
kind: nrow.kind || null,
|
|
3355
|
+
target_url: nrow.target_url,
|
|
3275
3356
|
});
|
|
3276
3357
|
} catch (e) {
|
|
3277
3358
|
console.error('[short-links] resolver db error:', e.message);
|
|
@@ -3279,6 +3360,218 @@ async function handleApi(req, res) {
|
|
|
3279
3360
|
}
|
|
3280
3361
|
}
|
|
3281
3362
|
|
|
3363
|
+
// PUBLIC (shared-secret): newsletter rail mint endpoint.
|
|
3364
|
+
//
|
|
3365
|
+
// Called server-to-server by ~/analytics (dash.m13v.com) at broadcast
|
|
3366
|
+
// send time. For every recipient x URL, mints a code and INSERTs a row
|
|
3367
|
+
// in newsletter_links so /api/short-links/<code> can resolve it later.
|
|
3368
|
+
// The caller passes raw target URLs (already UTM-stamped on its side
|
|
3369
|
+
// per the canonical s4l UTM scheme), this endpoint just wraps them.
|
|
3370
|
+
//
|
|
3371
|
+
// Auth is a NEWSLETTER_API_SECRET shared between analytics + this server.
|
|
3372
|
+
// Without it the endpoint 401s. We deliberately do NOT use the Firebase
|
|
3373
|
+
// auth path because the caller is a Vercel serverless function with no
|
|
3374
|
+
// Firebase ID token of its own.
|
|
3375
|
+
//
|
|
3376
|
+
// Request: { product: "studyly", broadcast_id: 5,
|
|
3377
|
+
// recipient_email: "a@b.com",
|
|
3378
|
+
// urls: [{ target_url: "https://studyly.io/...", kind: "web" }, ...] }
|
|
3379
|
+
// Response: { links: [{ target_url, code, short_url } | { target_url, error }, ...] }
|
|
3380
|
+
if (p === '/api/newsletter/mint' && req.method === 'POST') {
|
|
3381
|
+
const authz = (req.headers['authorization'] || '').toString();
|
|
3382
|
+
const wantSecret = (process.env.NEWSLETTER_API_SECRET || '').trim();
|
|
3383
|
+
if (!wantSecret || authz !== `Bearer ${wantSecret}`) {
|
|
3384
|
+
return json(res, { error: 'unauthorized' }, 401);
|
|
3385
|
+
}
|
|
3386
|
+
let body;
|
|
3387
|
+
try {
|
|
3388
|
+
const raw = await readBody(req);
|
|
3389
|
+
body = JSON.parse(raw || '{}');
|
|
3390
|
+
} catch {
|
|
3391
|
+
return json(res, { error: 'bad_json' }, 400);
|
|
3392
|
+
}
|
|
3393
|
+
const { product, broadcast_id, recipient_email, urls, wrapper_host: wrapperHostInput } = body || {};
|
|
3394
|
+
if (typeof product !== 'string' || !product.trim()) {
|
|
3395
|
+
return json(res, { error: 'product required' }, 400);
|
|
3396
|
+
}
|
|
3397
|
+
const broadcastId = Number(broadcast_id);
|
|
3398
|
+
if (!Number.isFinite(broadcastId)) {
|
|
3399
|
+
return json(res, { error: 'broadcast_id (numeric) required' }, 400);
|
|
3400
|
+
}
|
|
3401
|
+
if (typeof recipient_email !== 'string' || !recipient_email.includes('@')) {
|
|
3402
|
+
return json(res, { error: 'recipient_email required' }, 400);
|
|
3403
|
+
}
|
|
3404
|
+
if (!Array.isArray(urls) || urls.length === 0) {
|
|
3405
|
+
return json(res, { error: 'urls (non-empty array) required' }, 400);
|
|
3406
|
+
}
|
|
3407
|
+
if (urls.length > 50) {
|
|
3408
|
+
return json(res, { error: 'too many urls (max 50 per request)' }, 400);
|
|
3409
|
+
}
|
|
3410
|
+
// Wrapper host (where /r/<code> is served): caller can pass it directly,
|
|
3411
|
+
// otherwise fall back to config.json lookup. The Cloud Run image does NOT
|
|
3412
|
+
// bundle config.json (the local-operator dashboard does), so analytics
|
|
3413
|
+
// always passes it explicitly. Local operator usage just works.
|
|
3414
|
+
let wrapperHost = null;
|
|
3415
|
+
if (typeof wrapperHostInput === 'string' && /^https?:\/\//i.test(wrapperHostInput)) {
|
|
3416
|
+
wrapperHost = wrapperHostInput.trim().replace(/\/+$/, '');
|
|
3417
|
+
} else {
|
|
3418
|
+
wrapperHost = getProjectWrapperHost(product);
|
|
3419
|
+
}
|
|
3420
|
+
if (!wrapperHost) {
|
|
3421
|
+
return json(res, { error: `no wrapper host for project '${product}' (pass wrapper_host in request body or set in config.json)` }, 400);
|
|
3422
|
+
}
|
|
3423
|
+
const pool = getPool();
|
|
3424
|
+
if (!pool) return json(res, { error: 'no_db' }, 500);
|
|
3425
|
+
|
|
3426
|
+
const emailLower = recipient_email.trim().toLowerCase();
|
|
3427
|
+
const emailHash = crypto.createHash('sha256').update(emailLower).digest('hex').slice(0, 16);
|
|
3428
|
+
|
|
3429
|
+
const out = [];
|
|
3430
|
+
for (const u of urls) {
|
|
3431
|
+
if (!u || typeof u !== 'object') {
|
|
3432
|
+
out.push({ target_url: null, error: 'not_an_object' });
|
|
3433
|
+
continue;
|
|
3434
|
+
}
|
|
3435
|
+
const targetUrl = String(u.target_url || '').trim();
|
|
3436
|
+
const kind = String(u.kind || 'web').slice(0, 32);
|
|
3437
|
+
if (!/^https?:\/\//i.test(targetUrl)) {
|
|
3438
|
+
out.push({ target_url: targetUrl, error: 'invalid_url' });
|
|
3439
|
+
continue;
|
|
3440
|
+
}
|
|
3441
|
+
// Mint with PK collision retry (8 chars x 32 alphabet = ~10^12 keyspace,
|
|
3442
|
+
// collisions are astronomically rare but cheap to retry).
|
|
3443
|
+
let code = null;
|
|
3444
|
+
let lastErr = null;
|
|
3445
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
3446
|
+
const candidate = mintShortLinkCode(8);
|
|
3447
|
+
try {
|
|
3448
|
+
await pool.query(
|
|
3449
|
+
`INSERT INTO newsletter_links
|
|
3450
|
+
(code, broadcast_product, broadcast_id, recipient_email_hash,
|
|
3451
|
+
recipient_email, target_url, kind, project_name)
|
|
3452
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
3453
|
+
[candidate, product, broadcastId, emailHash, recipient_email,
|
|
3454
|
+
targetUrl, kind, product]
|
|
3455
|
+
);
|
|
3456
|
+
code = candidate;
|
|
3457
|
+
break;
|
|
3458
|
+
} catch (e) {
|
|
3459
|
+
lastErr = e;
|
|
3460
|
+
if (!/duplicate key/i.test(e.message || '')) {
|
|
3461
|
+
console.error('[newsletter/mint] insert failed:', e.message);
|
|
3462
|
+
break;
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
if (!code) {
|
|
3467
|
+
out.push({ target_url: targetUrl, error: lastErr ? lastErr.message.slice(0, 200) : 'mint_failed' });
|
|
3468
|
+
continue;
|
|
3469
|
+
}
|
|
3470
|
+
out.push({
|
|
3471
|
+
target_url: targetUrl,
|
|
3472
|
+
code,
|
|
3473
|
+
short_url: `${wrapperHost}/r/${code}`,
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
3476
|
+
return json(res, {
|
|
3477
|
+
product,
|
|
3478
|
+
broadcast_id: broadcastId,
|
|
3479
|
+
recipient_email,
|
|
3480
|
+
recipient_email_hash: emailHash,
|
|
3481
|
+
links: out,
|
|
3482
|
+
});
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
// PUBLIC (shared-secret): newsletter rail aggregate stats.
|
|
3486
|
+
//
|
|
3487
|
+
// Returns click + recipient breakdown for a broadcast so the analytics
|
|
3488
|
+
// dashboard and the social-autoposter dashboard can render the same
|
|
3489
|
+
// numbers without either holding state about the other. Click counts
|
|
3490
|
+
// live here (the canonical clicks store); signup attribution lives
|
|
3491
|
+
// in PostHog and is computed analytics-side via HogQL against the
|
|
3492
|
+
// utm_content this endpoint reports.
|
|
3493
|
+
//
|
|
3494
|
+
// Request: GET /api/newsletter/stats?product=studyly&broadcast_id=5
|
|
3495
|
+
// Response: { product, broadcast_id, total_links, total_recipients,
|
|
3496
|
+
// total_human_clicks, recipients_clicked, human_hits,
|
|
3497
|
+
// bot_hits, per_recipient: [...] }
|
|
3498
|
+
if (p === '/api/newsletter/stats' && req.method === 'GET') {
|
|
3499
|
+
const authz = (req.headers['authorization'] || '').toString();
|
|
3500
|
+
const wantSecret = (process.env.NEWSLETTER_API_SECRET || '').trim();
|
|
3501
|
+
if (!wantSecret || authz !== `Bearer ${wantSecret}`) {
|
|
3502
|
+
return json(res, { error: 'unauthorized' }, 401);
|
|
3503
|
+
}
|
|
3504
|
+
const product = (url.searchParams.get('product') || '').trim();
|
|
3505
|
+
const broadcastIdRaw = (url.searchParams.get('broadcast_id') || '').trim();
|
|
3506
|
+
if (!product || !broadcastIdRaw) {
|
|
3507
|
+
return json(res, { error: 'product and broadcast_id required' }, 400);
|
|
3508
|
+
}
|
|
3509
|
+
const broadcastId = Number(broadcastIdRaw);
|
|
3510
|
+
if (!Number.isFinite(broadcastId)) {
|
|
3511
|
+
return json(res, { error: 'broadcast_id must be numeric' }, 400);
|
|
3512
|
+
}
|
|
3513
|
+
const pool = getPool();
|
|
3514
|
+
if (!pool) return json(res, { error: 'no_db' }, 500);
|
|
3515
|
+
|
|
3516
|
+
try {
|
|
3517
|
+
const agg = await pool.query(
|
|
3518
|
+
`SELECT
|
|
3519
|
+
COUNT(*) AS total_links,
|
|
3520
|
+
COUNT(DISTINCT recipient_email_hash) AS total_recipients,
|
|
3521
|
+
COALESCE(SUM(clicks), 0) AS total_human_clicks,
|
|
3522
|
+
COUNT(DISTINCT recipient_email_hash) FILTER (WHERE clicks > 0) AS recipients_clicked
|
|
3523
|
+
FROM newsletter_links
|
|
3524
|
+
WHERE broadcast_product = $1 AND broadcast_id = $2`,
|
|
3525
|
+
[product, broadcastId]
|
|
3526
|
+
);
|
|
3527
|
+
const hitsRes = await pool.query(
|
|
3528
|
+
`SELECT
|
|
3529
|
+
COUNT(*) FILTER (WHERE is_bot = false) AS human_hits,
|
|
3530
|
+
COUNT(*) FILTER (WHERE is_bot = true) AS bot_hits
|
|
3531
|
+
FROM newsletter_link_clicks nlc
|
|
3532
|
+
JOIN newsletter_links nl ON nl.code = nlc.code
|
|
3533
|
+
WHERE nl.broadcast_product = $1 AND nl.broadcast_id = $2`,
|
|
3534
|
+
[product, broadcastId]
|
|
3535
|
+
);
|
|
3536
|
+
const perRecipient = await pool.query(
|
|
3537
|
+
`SELECT
|
|
3538
|
+
recipient_email,
|
|
3539
|
+
recipient_email_hash,
|
|
3540
|
+
COUNT(*) AS link_count,
|
|
3541
|
+
COALESCE(SUM(clicks), 0) AS clicks,
|
|
3542
|
+
MAX(last_click_at) AS last_click_at
|
|
3543
|
+
FROM newsletter_links
|
|
3544
|
+
WHERE broadcast_product = $1 AND broadcast_id = $2
|
|
3545
|
+
GROUP BY recipient_email, recipient_email_hash
|
|
3546
|
+
ORDER BY clicks DESC, link_count DESC
|
|
3547
|
+
LIMIT 1000`,
|
|
3548
|
+
[product, broadcastId]
|
|
3549
|
+
);
|
|
3550
|
+
const row = agg.rows[0] || {};
|
|
3551
|
+
const hitRow = hitsRes.rows[0] || {};
|
|
3552
|
+
return json(res, {
|
|
3553
|
+
product,
|
|
3554
|
+
broadcast_id: broadcastId,
|
|
3555
|
+
total_links: Number(row.total_links || 0),
|
|
3556
|
+
total_recipients: Number(row.total_recipients || 0),
|
|
3557
|
+
total_human_clicks: Number(row.total_human_clicks || 0),
|
|
3558
|
+
recipients_clicked: Number(row.recipients_clicked || 0),
|
|
3559
|
+
human_hits: Number(hitRow.human_hits || 0),
|
|
3560
|
+
bot_hits: Number(hitRow.bot_hits || 0),
|
|
3561
|
+
per_recipient: perRecipient.rows.map(r => ({
|
|
3562
|
+
email: r.recipient_email,
|
|
3563
|
+
email_hash: r.recipient_email_hash,
|
|
3564
|
+
link_count: Number(r.link_count || 0),
|
|
3565
|
+
clicks: Number(r.clicks || 0),
|
|
3566
|
+
last_click_at: r.last_click_at,
|
|
3567
|
+
})),
|
|
3568
|
+
});
|
|
3569
|
+
} catch (e) {
|
|
3570
|
+
console.error('[newsletter/stats] db error:', e.message);
|
|
3571
|
+
return json(res, { error: 'stats_failed', detail: String(e.message).slice(0, 500) }, 500);
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3282
3575
|
// Auth: no-op when CLIENT_MODE is unset (local operator use).
|
|
3283
3576
|
// When CLIENT_MODE=1, require a Firebase Bearer token and enforce admin/project claims.
|
|
3284
3577
|
const av = await auth.verifyAuth(req, p);
|
|
@@ -4416,8 +4709,12 @@ async function handleApi(req, res) {
|
|
|
4416
4709
|
// and scripts/engagement_styles.py picker). clicks weighted ×10 because a
|
|
4417
4710
|
// real human click outvalues 10 likes of vibes. Clicks come from a
|
|
4418
4711
|
// bot-filtered subquery against post_link_clicks (matching the picker).
|
|
4712
|
+
// NOTE: reference pl.total_clicks directly here, NOT the `clicks` alias.
|
|
4713
|
+
// Postgres does not let one expression in a SELECT list reference another
|
|
4714
|
+
// expression's alias from the same SELECT; doing so previously caused
|
|
4715
|
+
// `column "clicks" does not exist` and silently returned 0 rows.
|
|
4419
4716
|
const scoreExpr =
|
|
4420
|
-
"(COALESCE(
|
|
4717
|
+
"(COALESCE(pl.total_clicks, 0) * 10 + " +
|
|
4421
4718
|
"COALESCE(comments_count,0) * 3 + " +
|
|
4422
4719
|
"CASE WHEN LOWER(platform) IN ('reddit', 'moltbook') " +
|
|
4423
4720
|
"THEN GREATEST(0, COALESCE(upvotes,0) - 1) " +
|
|
@@ -5647,7 +5944,7 @@ async function handleApi(req, res) {
|
|
|
5647
5944
|
const windowKey = Object.prototype.hasOwnProperty.call(WINDOW_HOURS, rawWindow) ? rawWindow : '7d';
|
|
5648
5945
|
const windowHours = WINDOW_HOURS[windowKey];
|
|
5649
5946
|
const rawPlatform = String(url.searchParams.get('platform') || '').toLowerCase().trim();
|
|
5650
|
-
const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook']);
|
|
5947
|
+
const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'instagram']);
|
|
5651
5948
|
const platformFilter = ALLOWED_PLATFORMS.has(rawPlatform) ? rawPlatform : '';
|
|
5652
5949
|
const rawKind = String(url.searchParams.get('kind') || 'all').toLowerCase().trim();
|
|
5653
5950
|
const kindFilter = (rawKind === 'threads' || rawKind === 'comments') ? rawKind : 'all';
|
|
@@ -8446,7 +8743,7 @@ function fmtInterval(secs) {
|
|
|
8446
8743
|
}
|
|
8447
8744
|
|
|
8448
8745
|
let _initialized = false;
|
|
8449
|
-
const PLATFORMS = ['Reddit', 'Twitter', 'LinkedIn', 'MoltBook', 'GitHub'];
|
|
8746
|
+
const PLATFORMS = ['Reddit', 'Twitter', 'LinkedIn', 'MoltBook', 'GitHub', 'Instagram'];
|
|
8450
8747
|
const JOB_TYPES = ['Post Threads', 'Post Comments', 'Engage', 'DM Outreach', 'DM Replies', 'Link Edit', 'Stats', 'Post Audit', 'Octolens'];
|
|
8451
8748
|
|
|
8452
8749
|
function renderToggle(label, loaded) {
|
|
@@ -14378,6 +14675,20 @@ function renderTopPosts(payload) {
|
|
|
14378
14675
|
filterMode: 'none' },
|
|
14379
14676
|
],
|
|
14380
14677
|
});
|
|
14678
|
+
// Reconcile the top-level "Link sent" pill with the column filter that
|
|
14679
|
+
// mountSortableTable just hydrated from localStorage. See
|
|
14680
|
+
// syncTopLinkPillFromColumn for the full reasoning.
|
|
14681
|
+
syncTopLinkPillFromColumn();
|
|
14682
|
+
// Also live-sync the pill when the user changes the inline column dropdown
|
|
14683
|
+
// directly. Without this, the pill would stay stale until the next render
|
|
14684
|
+
// (project switch, window flip, etc.) and the user would see the same
|
|
14685
|
+
// mismatch they just reported. mountSortableTable is generic and shouldn't
|
|
14686
|
+
// know about specific columns, so wire this here at the call site.
|
|
14687
|
+
const linkColDd = document.querySelector('select.activity-col-filter[data-filter-key="link_clicks"]');
|
|
14688
|
+
if (linkColDd && !linkColDd._linkPillSyncWired) {
|
|
14689
|
+
linkColDd.addEventListener('change', () => { syncTopLinkPillFromColumn(); });
|
|
14690
|
+
linkColDd._linkPillSyncWired = true;
|
|
14691
|
+
}
|
|
14381
14692
|
}
|
|
14382
14693
|
|
|
14383
14694
|
async function loadTopPosts(force) {
|
|
@@ -14410,6 +14721,39 @@ async function loadTopPosts(force) {
|
|
|
14410
14721
|
}
|
|
14411
14722
|
}
|
|
14412
14723
|
|
|
14724
|
+
// Two layers can set the "link sent" filter on the Top > Posts table:
|
|
14725
|
+
// 1. The top-level pill row (#top-dm-link-pills, persisted at sa.top.dmLink.v1)
|
|
14726
|
+
// 2. The inline column dropdown for the link_clicks column (persisted inside
|
|
14727
|
+
// sa.topTable.v2.filters.link_clicks via mountSortableTable).
|
|
14728
|
+
// They hydrate from different localStorage keys, independently, on every page
|
|
14729
|
+
// load. Without reconciliation the table can be rendering with link_clicks=
|
|
14730
|
+
// 'has_link' (only link-sent rows visible) while the pill row reads "All",
|
|
14731
|
+
// leaving the user with no top-level indication that a filter is active.
|
|
14732
|
+
//
|
|
14733
|
+
// Source of truth = the column filter (it's what actually drives the visible
|
|
14734
|
+
// rows). After mountSortableTable hydrates, mirror its value into the pill so
|
|
14735
|
+
// the top-level UI never lies. has_clicks has no pill equivalent: surface it
|
|
14736
|
+
// as "All" on the pill but leave the column filter intact so the inline
|
|
14737
|
+
// dropdown still works for that advanced case.
|
|
14738
|
+
function syncTopLinkPillFromColumn() {
|
|
14739
|
+
const linkRow = document.getElementById('top-dm-link-pills');
|
|
14740
|
+
if (!linkRow) return;
|
|
14741
|
+
const colVal = (_topTableState && _topTableState.filters && _topTableState.filters.link_clicks) || '';
|
|
14742
|
+
const colToPill = { has_link: 'yes', no_link: 'no' };
|
|
14743
|
+
const desired = colToPill[colVal] || 'all';
|
|
14744
|
+
if (_topDmLink === desired) {
|
|
14745
|
+
// Defensive: even if our in-memory _topDmLink is consistent, the visible
|
|
14746
|
+
// active-class on the pill might still be stale from a prior render path
|
|
14747
|
+
// (e.g. inline column dropdown change that bypassed the pill). Force the
|
|
14748
|
+
// DOM to match.
|
|
14749
|
+
setTopPillActive(linkRow, desired);
|
|
14750
|
+
return;
|
|
14751
|
+
}
|
|
14752
|
+
_topDmLink = desired;
|
|
14753
|
+
try { saSave('sa.top.dmLink.v1', _topDmLink); } catch {}
|
|
14754
|
+
setTopPillActive(linkRow, desired);
|
|
14755
|
+
}
|
|
14756
|
+
|
|
14413
14757
|
function setTopPillActive(row, value) {
|
|
14414
14758
|
if (!row) return;
|
|
14415
14759
|
row.dataset.selected = value || 'all';
|
package/package.json
CHANGED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Single source of truth for the posting account on every platform.
|
|
2
|
+
|
|
3
|
+
Resolution order for each platform (first non-empty wins):
|
|
4
|
+
|
|
5
|
+
1. Env var `AUTOPOSTER_<PLATFORM>_HANDLE` (used by the VM / per-account
|
|
6
|
+
systemd or launchd units to override config.json without rewriting the
|
|
7
|
+
checked-in file). Twitter retains the legacy `AUTOPOSTER_TWITTER_HANDLE`
|
|
8
|
+
name as an alias.
|
|
9
|
+
2. The matching field in `config.json` -> `accounts.<platform>.<field>`.
|
|
10
|
+
|
|
11
|
+
The handle is normalized:
|
|
12
|
+
- leading `@` is stripped (twitter)
|
|
13
|
+
- leading `u/` is stripped (reddit)
|
|
14
|
+
- surrounding whitespace is stripped
|
|
15
|
+
So both `@matt_diak` and `matt_diak` resolve to `matt_diak`, both
|
|
16
|
+
`u/Deep_Ad1959` and `Deep_Ad1959` resolve to `Deep_Ad1959`, matching the
|
|
17
|
+
canonical shape stored in `posts.our_account` after the 2026-05-20 migration.
|
|
18
|
+
|
|
19
|
+
Returns None if neither source has a value. Callers should treat None as
|
|
20
|
+
"unknown account" and decline to scope per-account work that needs a handle
|
|
21
|
+
(e.g. dedupe filters).
|
|
22
|
+
|
|
23
|
+
Platform key map:
|
|
24
|
+
twitter -> accounts.twitter.handle (env: AUTOPOSTER_TWITTER_HANDLE)
|
|
25
|
+
reddit -> accounts.reddit.username (env: AUTOPOSTER_REDDIT_USERNAME)
|
|
26
|
+
linkedin -> accounts.linkedin.name (env: AUTOPOSTER_LINKEDIN_NAME)
|
|
27
|
+
github -> accounts.github.username (env: AUTOPOSTER_GITHUB_USERNAME)
|
|
28
|
+
moltbook -> accounts.moltbook.username (env: AUTOPOSTER_MOLTBOOK_USERNAME)
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
from functools import lru_cache
|
|
35
|
+
from typing import Optional
|
|
36
|
+
|
|
37
|
+
_PLATFORM_CONFIG_FIELD = {
|
|
38
|
+
"twitter": ("twitter", "handle"),
|
|
39
|
+
"x": ("twitter", "handle"), # alias for the canonical post-platform
|
|
40
|
+
"reddit": ("reddit", "username"),
|
|
41
|
+
"linkedin": ("linkedin", "name"),
|
|
42
|
+
"github": ("github", "username"),
|
|
43
|
+
"moltbook": ("moltbook", "username"),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_PLATFORM_ENV_NAME = {
|
|
47
|
+
"twitter": "AUTOPOSTER_TWITTER_HANDLE",
|
|
48
|
+
"x": "AUTOPOSTER_TWITTER_HANDLE",
|
|
49
|
+
"reddit": "AUTOPOSTER_REDDIT_USERNAME",
|
|
50
|
+
"linkedin": "AUTOPOSTER_LINKEDIN_NAME",
|
|
51
|
+
"github": "AUTOPOSTER_GITHUB_USERNAME",
|
|
52
|
+
"moltbook": "AUTOPOSTER_MOLTBOOK_USERNAME",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize(handle: Optional[str]) -> Optional[str]:
|
|
57
|
+
"""Canonicalize a raw account handle.
|
|
58
|
+
|
|
59
|
+
Drops leading `@` (twitter) and `u/` (reddit) plus surrounding
|
|
60
|
+
whitespace. Returns None for empty input.
|
|
61
|
+
"""
|
|
62
|
+
if not handle:
|
|
63
|
+
return None
|
|
64
|
+
h = handle.strip()
|
|
65
|
+
if h.startswith("@"):
|
|
66
|
+
h = h[1:]
|
|
67
|
+
elif h.lower().startswith("u/"):
|
|
68
|
+
h = h[2:]
|
|
69
|
+
h = h.strip()
|
|
70
|
+
return h or None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@lru_cache(maxsize=1)
|
|
74
|
+
def _load_config() -> dict:
|
|
75
|
+
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
76
|
+
cfg_path = os.path.join(repo_root, "config.json")
|
|
77
|
+
try:
|
|
78
|
+
with open(cfg_path, "r", encoding="utf-8") as f:
|
|
79
|
+
return json.load(f) or {}
|
|
80
|
+
except (OSError, json.JSONDecodeError):
|
|
81
|
+
return {}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def resolve(platform: str) -> Optional[str]:
|
|
85
|
+
"""Return the normalized posting handle for `platform`, or None."""
|
|
86
|
+
key = (platform or "").strip().lower()
|
|
87
|
+
if key not in _PLATFORM_CONFIG_FIELD:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
env_name = _PLATFORM_ENV_NAME[key]
|
|
91
|
+
env_value = normalize(os.environ.get(env_name))
|
|
92
|
+
if env_value:
|
|
93
|
+
return env_value
|
|
94
|
+
|
|
95
|
+
section, field = _PLATFORM_CONFIG_FIELD[key]
|
|
96
|
+
cfg = _load_config()
|
|
97
|
+
accounts = cfg.get("accounts") or {}
|
|
98
|
+
block = accounts.get(section) or {}
|
|
99
|
+
return normalize(block.get(field))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def require(platform: str) -> str:
|
|
103
|
+
"""Like resolve() but raises if no handle is configured."""
|
|
104
|
+
h = resolve(platform)
|
|
105
|
+
if not h:
|
|
106
|
+
section, field = _PLATFORM_CONFIG_FIELD.get(
|
|
107
|
+
(platform or "").lower(), ("?", "?")
|
|
108
|
+
)
|
|
109
|
+
env_name = _PLATFORM_ENV_NAME.get(
|
|
110
|
+
(platform or "").lower(), f"AUTOPOSTER_{platform.upper()}_HANDLE"
|
|
111
|
+
)
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"No account configured for platform={platform!r}. "
|
|
114
|
+
f"Set env {env_name} or accounts.{section}.{field} in config.json."
|
|
115
|
+
)
|
|
116
|
+
return h
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Backwards-compatible shim so the existing twitter-only call site keeps
|
|
120
|
+
# working without churn. `from twitter_account import resolve_handle` will
|
|
121
|
+
# continue to work; new code should call `account_resolver.resolve('twitter')`.
|
|
122
|
+
def resolve_handle() -> Optional[str]:
|
|
123
|
+
return resolve("twitter")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def require_handle() -> str:
|
|
127
|
+
return require("twitter")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
import sys
|
|
132
|
+
if len(sys.argv) > 1:
|
|
133
|
+
plat = sys.argv[1]
|
|
134
|
+
else:
|
|
135
|
+
plat = "twitter"
|
|
136
|
+
h = resolve(plat)
|
|
137
|
+
if h:
|
|
138
|
+
sys.stdout.write(h + "\n")
|
|
139
|
+
sys.exit(0)
|
|
140
|
+
sys.stderr.write(f"no handle configured for platform={plat}\n")
|
|
141
|
+
sys.exit(1)
|