social-autoposter 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/server.js CHANGED
@@ -42,13 +42,20 @@ 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 = [
49
49
  // Post Threads row (original threads/posts)
50
50
  { label: 'com.m13v.social-reddit-threads', name: 'Reddit Threads', type: 'Post Threads', platform: 'Reddit', script: 'run-reddit-threads.sh', logPrefix: 'run-reddit-threads-', plist: 'com.m13v.social-reddit-threads.plist' },
51
51
  { label: 'com.m13v.social-twitter-threads', name: 'Twitter Threads', type: 'Post Threads', platform: 'Twitter', script: 'run-twitter-threads.sh', logPrefix: 'run-twitter-threads-', plist: 'com.m13v.social-twitter-threads.plist' },
52
+ // Instagram per-account daily posters (5×/day each, FORCE_ACCOUNT pinned).
53
+ { label: 'com.m13v.social-instagram-daily-matt_diak', name: 'IG Daily (matt_diak)', type: 'Post Threads', platform: 'Instagram', script: 'run-instagram-daily.sh', logPrefix: 'instagram-daily-', plist: 'com.m13v.social-instagram-daily-matt_diak.plist' },
54
+ { label: 'com.m13v.social-instagram-daily-matthewheartful', name: 'IG Daily (matthewheartful)', type: 'Post Threads', platform: 'Instagram', script: 'run-instagram-daily.sh', logPrefix: 'instagram-daily-', plist: 'com.m13v.social-instagram-daily-matthewheartful.plist' },
55
+ // Instagram per-account render (upstream of daily-posters; produces the
56
+ // mp4 + caption draft that the daily-poster then uploads).
57
+ { label: 'com.m13v.social-instagram-render-matt_diak', name: 'IG Render (matt_diak)', type: 'Other', platform: 'Instagram', script: 'run-instagram-render.sh', logPrefix: 'instagram-render-', plist: 'com.m13v.social-instagram-render-matt_diak.plist' },
58
+ { label: 'com.m13v.social-instagram-render-matthewheartful', name: 'IG Render (matthewheartful)', type: 'Other', platform: 'Instagram', script: 'run-instagram-render.sh', logPrefix: 'instagram-render-', plist: 'com.m13v.social-instagram-render-matthewheartful.plist' },
52
59
  // Post Comments row (replies/comments on others' content)
53
60
  { label: 'com.m13v.social-reddit-search', name: 'Reddit', type: 'Post Comments', platform: 'Reddit', script: 'run-reddit-search.sh', logPrefix: 'run-reddit-search-', plist: 'com.m13v.social-reddit-search.plist' },
54
61
  { label: 'com.m13v.social-twitter-cycle', name: 'Twitter', type: 'Post Comments', platform: 'Twitter', script: 'run-twitter-cycle.sh', logPrefix: 'twitter-cycle-', plist: 'com.m13v.social-twitter-cycle.plist' },
@@ -86,6 +93,7 @@ const JOBS = [
86
93
  { 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
94
  { 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
95
  { 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' },
96
+ { 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
97
  // Post Audit row (verify posts still exist / API health)
90
98
  { 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
99
  { 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 +136,9 @@ const REQUIRED_LOCKS = {
128
136
  'link-edit-moltbook.sh': ['link-edit-moltbook'],
129
137
  'link-edit-github.sh': ['link-edit-github'],
130
138
  'stats-reddit.sh': ['reddit-browser'],
139
+ 'stats-instagram.sh': ['instagram-poster'],
140
+ 'run-instagram-daily.sh': ['instagram-poster'],
141
+ 'run-instagram-render.sh': ['instagram-render'],
131
142
  'audit-reddit.sh': ['reddit-browser', 'audit-reddit'],
132
143
  'audit-twitter.sh': ['twitter-browser', 'audit-twitter'],
133
144
  'audit-linkedin.sh': ['linkedin-browser', 'audit-linkedin'],
@@ -154,6 +165,38 @@ function json(res, obj, status = 200) {
154
165
  res.end(JSON.stringify(obj));
155
166
  }
156
167
 
168
+ // Short-link code alphabet matches scripts/dm_short_links.py CODE_ALPHABET
169
+ // so codes minted by either path are visually consistent. 32 chars (no 0/1/l/o
170
+ // to avoid ambiguity in handwritten/printed contexts).
171
+ const SHORT_LINK_ALPHABET = 'abcdefghijkmnpqrstuvwxyz23456789';
172
+
173
+ function mintShortLinkCode(n = 8) {
174
+ let s = '';
175
+ const bytes = crypto.randomBytes(n);
176
+ for (let i = 0; i < n; i++) {
177
+ s += SHORT_LINK_ALPHABET[bytes[i] % SHORT_LINK_ALPHABET.length];
178
+ }
179
+ return s;
180
+ }
181
+
182
+ // Look up a project's wrapper host (where /r/<code> is served) by name from
183
+ // config.json. Mirrors _project_short_links_host in scripts/dm_short_links.py.
184
+ // Falls back to project.website. Returns null if the project is not configured.
185
+ function getProjectWrapperHost(projectName) {
186
+ if (!projectName) return null;
187
+ try {
188
+ const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
189
+ const projects = Array.isArray(config.projects) ? config.projects : [];
190
+ const p = projects.find(x => x && x.name === projectName);
191
+ if (!p) return null;
192
+ const host = (p.short_links_host || p.website || '').trim().replace(/\/+$/, '');
193
+ return host || null;
194
+ } catch (e) {
195
+ console.error('[wrapper-host] config read failed:', e.message);
196
+ return null;
197
+ }
198
+ }
199
+
157
200
  function isJobLoaded(label) {
158
201
  return driver.isLoaded(label);
159
202
  }
@@ -3246,32 +3289,79 @@ async function handleApi(req, res) {
3246
3289
  RETURNING post_id, reply_id, platform, project_name,
3247
3290
  target_url, kind`;
3248
3291
  const postRes = await pool.query(postSql, [code]);
3249
- if (!postRes.rows.length) {
3292
+ if (postRes.rows.length) {
3293
+ const prow = postRes.rows[0];
3294
+ if (!prow.target_url) {
3295
+ return json(res, { error: 'no_target_url', post_id: prow.post_id, reply_id: prow.reply_id }, 404);
3296
+ }
3297
+ // Per-click log for post rail (humans + bots).
3298
+ try {
3299
+ await pool.query(
3300
+ `INSERT INTO post_link_clicks (code, ip_hash, user_agent, is_bot, referrer)
3301
+ VALUES ($1, $2, $3, $4, $5)`,
3302
+ [code, ipHash, fwdUa, isBot, fwdRef]
3303
+ );
3304
+ } catch (e) {
3305
+ console.error('[short-links] post_link_clicks insert failed (non-fatal):', e.message);
3306
+ }
3307
+ let pPlatform = (prow.platform || 'reddit').toLowerCase();
3308
+ if (pPlatform === 'x') pPlatform = 'twitter';
3309
+ return json(res, {
3310
+ post_id: prow.post_id,
3311
+ reply_id: prow.reply_id,
3312
+ platform: pPlatform,
3313
+ project: prow.project_name || null,
3314
+ kind: prow.kind || null,
3315
+ target_url: prow.target_url,
3316
+ });
3317
+ }
3318
+
3319
+ // Final fallback: newsletter_links rail. Outbound broadcast emails
3320
+ // sent by ~/analytics (dash.m13v.com) mint per-recipient codes via
3321
+ // POST /api/newsletter/mint; the resolver here closes the loop on
3322
+ // click attribution. broadcast_product + broadcast_id let the
3323
+ // analytics dashboard cross-reference back into the originating
3324
+ // studyly_broadcast_log / fazm_broadcasts / etc. row. The customer
3325
+ // /r/[code] handler (see ~/seo-components/src/lib/dm-short-link-redirect.ts)
3326
+ // fires `newsletter_short_link_clicked` in PostHog when broadcast_id
3327
+ // is set in the response, so signup attribution stitches via the
3328
+ // same anonymous distinct_id session that landed on the page.
3329
+ const newsSql = isBot
3330
+ ? `SELECT broadcast_product, broadcast_id, recipient_email_hash,
3331
+ recipient_email, target_url, kind, project_name
3332
+ FROM newsletter_links WHERE code = $1`
3333
+ : `UPDATE newsletter_links SET
3334
+ clicks = clicks + 1,
3335
+ first_click_at = COALESCE(first_click_at, NOW()),
3336
+ last_click_at = NOW()
3337
+ WHERE code = $1
3338
+ RETURNING broadcast_product, broadcast_id, recipient_email_hash,
3339
+ recipient_email, target_url, kind, project_name`;
3340
+ const newsRes = await pool.query(newsSql, [code]);
3341
+ if (!newsRes.rows.length) {
3250
3342
  return json(res, { error: 'not_found', code }, 404);
3251
3343
  }
3252
- const prow = postRes.rows[0];
3253
- if (!prow.target_url) {
3254
- return json(res, { error: 'no_target_url', post_id: prow.post_id, reply_id: prow.reply_id }, 404);
3344
+ const nrow = newsRes.rows[0];
3345
+ if (!nrow.target_url) {
3346
+ return json(res, { error: 'no_target_url', broadcast_id: nrow.broadcast_id }, 404);
3255
3347
  }
3256
- // Per-click log for post rail (humans + bots).
3348
+ // Per-click log for newsletter rail (humans + bots).
3257
3349
  try {
3258
3350
  await pool.query(
3259
- `INSERT INTO post_link_clicks (code, ip_hash, user_agent, is_bot, referrer)
3351
+ `INSERT INTO newsletter_link_clicks (code, ip_hash, user_agent, is_bot, referrer)
3260
3352
  VALUES ($1, $2, $3, $4, $5)`,
3261
3353
  [code, ipHash, fwdUa, isBot, fwdRef]
3262
3354
  );
3263
3355
  } catch (e) {
3264
- console.error('[short-links] post_link_clicks insert failed (non-fatal):', e.message);
3356
+ console.error('[short-links] newsletter_link_clicks insert failed (non-fatal):', e.message);
3265
3357
  }
3266
- let pPlatform = (prow.platform || 'reddit').toLowerCase();
3267
- if (pPlatform === 'x') pPlatform = 'twitter';
3268
3358
  return json(res, {
3269
- post_id: prow.post_id,
3270
- reply_id: prow.reply_id,
3271
- platform: pPlatform,
3272
- project: prow.project_name || null,
3273
- kind: prow.kind || null,
3274
- target_url: prow.target_url,
3359
+ broadcast_id: Number(nrow.broadcast_id),
3360
+ broadcast_product: nrow.broadcast_product,
3361
+ recipient_email_hash: nrow.recipient_email_hash,
3362
+ project: nrow.project_name || nrow.broadcast_product || null,
3363
+ kind: nrow.kind || null,
3364
+ target_url: nrow.target_url,
3275
3365
  });
3276
3366
  } catch (e) {
3277
3367
  console.error('[short-links] resolver db error:', e.message);
@@ -3279,6 +3369,218 @@ async function handleApi(req, res) {
3279
3369
  }
3280
3370
  }
3281
3371
 
3372
+ // PUBLIC (shared-secret): newsletter rail mint endpoint.
3373
+ //
3374
+ // Called server-to-server by ~/analytics (dash.m13v.com) at broadcast
3375
+ // send time. For every recipient x URL, mints a code and INSERTs a row
3376
+ // in newsletter_links so /api/short-links/<code> can resolve it later.
3377
+ // The caller passes raw target URLs (already UTM-stamped on its side
3378
+ // per the canonical s4l UTM scheme), this endpoint just wraps them.
3379
+ //
3380
+ // Auth is a NEWSLETTER_API_SECRET shared between analytics + this server.
3381
+ // Without it the endpoint 401s. We deliberately do NOT use the Firebase
3382
+ // auth path because the caller is a Vercel serverless function with no
3383
+ // Firebase ID token of its own.
3384
+ //
3385
+ // Request: { product: "studyly", broadcast_id: 5,
3386
+ // recipient_email: "a@b.com",
3387
+ // urls: [{ target_url: "https://studyly.io/...", kind: "web" }, ...] }
3388
+ // Response: { links: [{ target_url, code, short_url } | { target_url, error }, ...] }
3389
+ if (p === '/api/newsletter/mint' && req.method === 'POST') {
3390
+ const authz = (req.headers['authorization'] || '').toString();
3391
+ const wantSecret = (process.env.NEWSLETTER_API_SECRET || '').trim();
3392
+ if (!wantSecret || authz !== `Bearer ${wantSecret}`) {
3393
+ return json(res, { error: 'unauthorized' }, 401);
3394
+ }
3395
+ let body;
3396
+ try {
3397
+ const raw = await readBody(req);
3398
+ body = JSON.parse(raw || '{}');
3399
+ } catch {
3400
+ return json(res, { error: 'bad_json' }, 400);
3401
+ }
3402
+ const { product, broadcast_id, recipient_email, urls, wrapper_host: wrapperHostInput } = body || {};
3403
+ if (typeof product !== 'string' || !product.trim()) {
3404
+ return json(res, { error: 'product required' }, 400);
3405
+ }
3406
+ const broadcastId = Number(broadcast_id);
3407
+ if (!Number.isFinite(broadcastId)) {
3408
+ return json(res, { error: 'broadcast_id (numeric) required' }, 400);
3409
+ }
3410
+ if (typeof recipient_email !== 'string' || !recipient_email.includes('@')) {
3411
+ return json(res, { error: 'recipient_email required' }, 400);
3412
+ }
3413
+ if (!Array.isArray(urls) || urls.length === 0) {
3414
+ return json(res, { error: 'urls (non-empty array) required' }, 400);
3415
+ }
3416
+ if (urls.length > 50) {
3417
+ return json(res, { error: 'too many urls (max 50 per request)' }, 400);
3418
+ }
3419
+ // Wrapper host (where /r/<code> is served): caller can pass it directly,
3420
+ // otherwise fall back to config.json lookup. The Cloud Run image does NOT
3421
+ // bundle config.json (the local-operator dashboard does), so analytics
3422
+ // always passes it explicitly. Local operator usage just works.
3423
+ let wrapperHost = null;
3424
+ if (typeof wrapperHostInput === 'string' && /^https?:\/\//i.test(wrapperHostInput)) {
3425
+ wrapperHost = wrapperHostInput.trim().replace(/\/+$/, '');
3426
+ } else {
3427
+ wrapperHost = getProjectWrapperHost(product);
3428
+ }
3429
+ if (!wrapperHost) {
3430
+ return json(res, { error: `no wrapper host for project '${product}' (pass wrapper_host in request body or set in config.json)` }, 400);
3431
+ }
3432
+ const pool = getPool();
3433
+ if (!pool) return json(res, { error: 'no_db' }, 500);
3434
+
3435
+ const emailLower = recipient_email.trim().toLowerCase();
3436
+ const emailHash = crypto.createHash('sha256').update(emailLower).digest('hex').slice(0, 16);
3437
+
3438
+ const out = [];
3439
+ for (const u of urls) {
3440
+ if (!u || typeof u !== 'object') {
3441
+ out.push({ target_url: null, error: 'not_an_object' });
3442
+ continue;
3443
+ }
3444
+ const targetUrl = String(u.target_url || '').trim();
3445
+ const kind = String(u.kind || 'web').slice(0, 32);
3446
+ if (!/^https?:\/\//i.test(targetUrl)) {
3447
+ out.push({ target_url: targetUrl, error: 'invalid_url' });
3448
+ continue;
3449
+ }
3450
+ // Mint with PK collision retry (8 chars x 32 alphabet = ~10^12 keyspace,
3451
+ // collisions are astronomically rare but cheap to retry).
3452
+ let code = null;
3453
+ let lastErr = null;
3454
+ for (let attempt = 0; attempt < 5; attempt++) {
3455
+ const candidate = mintShortLinkCode(8);
3456
+ try {
3457
+ await pool.query(
3458
+ `INSERT INTO newsletter_links
3459
+ (code, broadcast_product, broadcast_id, recipient_email_hash,
3460
+ recipient_email, target_url, kind, project_name)
3461
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
3462
+ [candidate, product, broadcastId, emailHash, recipient_email,
3463
+ targetUrl, kind, product]
3464
+ );
3465
+ code = candidate;
3466
+ break;
3467
+ } catch (e) {
3468
+ lastErr = e;
3469
+ if (!/duplicate key/i.test(e.message || '')) {
3470
+ console.error('[newsletter/mint] insert failed:', e.message);
3471
+ break;
3472
+ }
3473
+ }
3474
+ }
3475
+ if (!code) {
3476
+ out.push({ target_url: targetUrl, error: lastErr ? lastErr.message.slice(0, 200) : 'mint_failed' });
3477
+ continue;
3478
+ }
3479
+ out.push({
3480
+ target_url: targetUrl,
3481
+ code,
3482
+ short_url: `${wrapperHost}/r/${code}`,
3483
+ });
3484
+ }
3485
+ return json(res, {
3486
+ product,
3487
+ broadcast_id: broadcastId,
3488
+ recipient_email,
3489
+ recipient_email_hash: emailHash,
3490
+ links: out,
3491
+ });
3492
+ }
3493
+
3494
+ // PUBLIC (shared-secret): newsletter rail aggregate stats.
3495
+ //
3496
+ // Returns click + recipient breakdown for a broadcast so the analytics
3497
+ // dashboard and the social-autoposter dashboard can render the same
3498
+ // numbers without either holding state about the other. Click counts
3499
+ // live here (the canonical clicks store); signup attribution lives
3500
+ // in PostHog and is computed analytics-side via HogQL against the
3501
+ // utm_content this endpoint reports.
3502
+ //
3503
+ // Request: GET /api/newsletter/stats?product=studyly&broadcast_id=5
3504
+ // Response: { product, broadcast_id, total_links, total_recipients,
3505
+ // total_human_clicks, recipients_clicked, human_hits,
3506
+ // bot_hits, per_recipient: [...] }
3507
+ if (p === '/api/newsletter/stats' && req.method === 'GET') {
3508
+ const authz = (req.headers['authorization'] || '').toString();
3509
+ const wantSecret = (process.env.NEWSLETTER_API_SECRET || '').trim();
3510
+ if (!wantSecret || authz !== `Bearer ${wantSecret}`) {
3511
+ return json(res, { error: 'unauthorized' }, 401);
3512
+ }
3513
+ const product = (url.searchParams.get('product') || '').trim();
3514
+ const broadcastIdRaw = (url.searchParams.get('broadcast_id') || '').trim();
3515
+ if (!product || !broadcastIdRaw) {
3516
+ return json(res, { error: 'product and broadcast_id required' }, 400);
3517
+ }
3518
+ const broadcastId = Number(broadcastIdRaw);
3519
+ if (!Number.isFinite(broadcastId)) {
3520
+ return json(res, { error: 'broadcast_id must be numeric' }, 400);
3521
+ }
3522
+ const pool = getPool();
3523
+ if (!pool) return json(res, { error: 'no_db' }, 500);
3524
+
3525
+ try {
3526
+ const agg = await pool.query(
3527
+ `SELECT
3528
+ COUNT(*) AS total_links,
3529
+ COUNT(DISTINCT recipient_email_hash) AS total_recipients,
3530
+ COALESCE(SUM(clicks), 0) AS total_human_clicks,
3531
+ COUNT(DISTINCT recipient_email_hash) FILTER (WHERE clicks > 0) AS recipients_clicked
3532
+ FROM newsletter_links
3533
+ WHERE broadcast_product = $1 AND broadcast_id = $2`,
3534
+ [product, broadcastId]
3535
+ );
3536
+ const hitsRes = await pool.query(
3537
+ `SELECT
3538
+ COUNT(*) FILTER (WHERE is_bot = false) AS human_hits,
3539
+ COUNT(*) FILTER (WHERE is_bot = true) AS bot_hits
3540
+ FROM newsletter_link_clicks nlc
3541
+ JOIN newsletter_links nl ON nl.code = nlc.code
3542
+ WHERE nl.broadcast_product = $1 AND nl.broadcast_id = $2`,
3543
+ [product, broadcastId]
3544
+ );
3545
+ const perRecipient = await pool.query(
3546
+ `SELECT
3547
+ recipient_email,
3548
+ recipient_email_hash,
3549
+ COUNT(*) AS link_count,
3550
+ COALESCE(SUM(clicks), 0) AS clicks,
3551
+ MAX(last_click_at) AS last_click_at
3552
+ FROM newsletter_links
3553
+ WHERE broadcast_product = $1 AND broadcast_id = $2
3554
+ GROUP BY recipient_email, recipient_email_hash
3555
+ ORDER BY clicks DESC, link_count DESC
3556
+ LIMIT 1000`,
3557
+ [product, broadcastId]
3558
+ );
3559
+ const row = agg.rows[0] || {};
3560
+ const hitRow = hitsRes.rows[0] || {};
3561
+ return json(res, {
3562
+ product,
3563
+ broadcast_id: broadcastId,
3564
+ total_links: Number(row.total_links || 0),
3565
+ total_recipients: Number(row.total_recipients || 0),
3566
+ total_human_clicks: Number(row.total_human_clicks || 0),
3567
+ recipients_clicked: Number(row.recipients_clicked || 0),
3568
+ human_hits: Number(hitRow.human_hits || 0),
3569
+ bot_hits: Number(hitRow.bot_hits || 0),
3570
+ per_recipient: perRecipient.rows.map(r => ({
3571
+ email: r.recipient_email,
3572
+ email_hash: r.recipient_email_hash,
3573
+ link_count: Number(r.link_count || 0),
3574
+ clicks: Number(r.clicks || 0),
3575
+ last_click_at: r.last_click_at,
3576
+ })),
3577
+ });
3578
+ } catch (e) {
3579
+ console.error('[newsletter/stats] db error:', e.message);
3580
+ return json(res, { error: 'stats_failed', detail: String(e.message).slice(0, 500) }, 500);
3581
+ }
3582
+ }
3583
+
3282
3584
  // Auth: no-op when CLIENT_MODE is unset (local operator use).
3283
3585
  // When CLIENT_MODE=1, require a Firebase Bearer token and enforce admin/project claims.
3284
3586
  const av = await auth.verifyAuth(req, p);
@@ -4416,8 +4718,12 @@ async function handleApi(req, res) {
4416
4718
  // and scripts/engagement_styles.py picker). clicks weighted ×10 because a
4417
4719
  // real human click outvalues 10 likes of vibes. Clicks come from a
4418
4720
  // bot-filtered subquery against post_link_clicks (matching the picker).
4721
+ // NOTE: reference pl.total_clicks directly here, NOT the `clicks` alias.
4722
+ // Postgres does not let one expression in a SELECT list reference another
4723
+ // expression's alias from the same SELECT; doing so previously caused
4724
+ // `column "clicks" does not exist` and silently returned 0 rows.
4419
4725
  const scoreExpr =
4420
- "(COALESCE(clicks, 0) * 10 + " +
4726
+ "(COALESCE(pl.total_clicks, 0) * 10 + " +
4421
4727
  "COALESCE(comments_count,0) * 3 + " +
4422
4728
  "CASE WHEN LOWER(platform) IN ('reddit', 'moltbook') " +
4423
4729
  "THEN GREATEST(0, COALESCE(upvotes,0) - 1) " +
@@ -4937,7 +5243,7 @@ async function handleApi(req, res) {
4937
5243
  const url = new URL(req.url, 'http://localhost');
4938
5244
  const windowHours = Math.max(1, Math.min(720, parseInt(url.searchParams.get('hours') || '24', 10) || 24));
4939
5245
  const rawProject = (url.searchParams.get('project') || '').trim();
4940
- const ALLOWED_COST_PLATFORMS = new Set(['reddit', 'twitter', 'linkedin', 'moltbook', 'github', 'seo', 'email']);
5246
+ const ALLOWED_COST_PLATFORMS = new Set(['reddit', 'twitter', 'linkedin', 'moltbook', 'github', 'seo', 'email', 'instagram']);
4941
5247
  let rawPlat = String(url.searchParams.get('platform') || '').toLowerCase().trim();
4942
5248
  if (rawPlat === 'x') rawPlat = 'twitter';
4943
5249
  const plat = ALLOWED_COST_PLATFORMS.has(rawPlat) ? rawPlat : '';
@@ -5079,7 +5385,7 @@ async function handleApi(req, res) {
5079
5385
  const project = (rawProject === '' || rawProject.toLowerCase() === 'all') ? '' : rawProject;
5080
5386
  const projectOk = project === '' || /^[A-Za-z0-9_\- ]{1,64}$/.test(project);
5081
5387
  if (!projectOk) return json(res, { error: 'invalid project' }, 400);
5082
- const ALLOWED_COST_PLATFORMS = new Set(['reddit', 'twitter', 'linkedin', 'moltbook', 'github', 'seo', 'email']);
5388
+ const ALLOWED_COST_PLATFORMS = new Set(['reddit', 'twitter', 'linkedin', 'moltbook', 'github', 'seo', 'email', 'instagram']);
5083
5389
  let rawPlat = String(url.searchParams.get('platform') || '').toLowerCase().trim();
5084
5390
  if (rawPlat === 'x') rawPlat = 'twitter';
5085
5391
  const plat = ALLOWED_COST_PLATFORMS.has(rawPlat) ? rawPlat : '';
@@ -5647,7 +5953,7 @@ async function handleApi(req, res) {
5647
5953
  const windowKey = Object.prototype.hasOwnProperty.call(WINDOW_HOURS, rawWindow) ? rawWindow : '7d';
5648
5954
  const windowHours = WINDOW_HOURS[windowKey];
5649
5955
  const rawPlatform = String(url.searchParams.get('platform') || '').toLowerCase().trim();
5650
- const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook']);
5956
+ const ALLOWED_PLATFORMS = new Set(['reddit', 'twitter', 'x', 'linkedin', 'moltbook', 'instagram']);
5651
5957
  const platformFilter = ALLOWED_PLATFORMS.has(rawPlatform) ? rawPlatform : '';
5652
5958
  const rawKind = String(url.searchParams.get('kind') || 'all').toLowerCase().trim();
5653
5959
  const kindFilter = (rawKind === 'threads' || rawKind === 'comments') ? rawKind : 'all';
@@ -6454,7 +6760,7 @@ async function handleApi(req, res) {
6454
6760
  const configuredProjects = Array.isArray(config.projects) ? config.projects : [];
6455
6761
  const weighted = configuredProjects.filter(p => (p.weight || 0) > 0);
6456
6762
  const totalWeight = weighted.reduce((a, p) => a + (p.weight || 0), 0) || 1;
6457
- const platforms = ['reddit', 'twitter', 'linkedin', 'moltbook', 'github'];
6763
+ const platforms = ['reddit', 'twitter', 'linkedin', 'moltbook', 'github', 'instagram'];
6458
6764
  // Per-platform eligibility: a project is eligible to be picked for a
6459
6765
  // platform only if it has the data that platform's picker needs. Mirrors
6460
6766
  // scripts/pick_project.py and scripts/pick_thread_target.py. Projects
@@ -7796,6 +8102,7 @@ const HTML = `<!DOCTYPE html>
7796
8102
  <th>LinkedIn</th>
7797
8103
  <th>MoltBook</th>
7798
8104
  <th>GitHub</th>
8105
+ <th>Instagram</th>
7799
8106
  </tr>
7800
8107
  </thead>
7801
8108
  <tbody id="matrix-body"></tbody>
@@ -8446,7 +8753,7 @@ function fmtInterval(secs) {
8446
8753
  }
8447
8754
 
8448
8755
  let _initialized = false;
8449
- const PLATFORMS = ['Reddit', 'Twitter', 'LinkedIn', 'MoltBook', 'GitHub'];
8756
+ const PLATFORMS = ['Reddit', 'Twitter', 'LinkedIn', 'MoltBook', 'GitHub', 'Instagram'];
8450
8757
  const JOB_TYPES = ['Post Threads', 'Post Comments', 'Engage', 'DM Outreach', 'DM Replies', 'Link Edit', 'Stats', 'Post Audit', 'Octolens'];
8451
8758
 
8452
8759
  function renderToggle(label, loaded) {
@@ -10277,8 +10584,8 @@ const EVENT_DESCRIPTIONS = {
10277
10584
  page_expired: 'SEO page deleted by the daily expire pipeline because it had zero clicks in the last 30 days. The on-disk source file was removed; Next.js now returns 404 for the URL. Logged for audit/revert in seo_expired_pages.',
10278
10585
  resurrected: 'Previously archived or unavailable item brought back into rotation (e.g., a removed post restored after reappearing).',
10279
10586
  };
10280
- const ACTIVITY_PLATFORMS = ['reddit', 'twitter', 'linkedin', 'moltbook', 'github', 'seo'];
10281
- const ACTIVITY_PLATFORM_LABELS = { reddit: 'Reddit', twitter: 'Twitter / X', linkedin: 'LinkedIn', moltbook: 'Moltbook', github: 'GitHub', seo: 'SEO' };
10587
+ const ACTIVITY_PLATFORMS = ['reddit', 'twitter', 'linkedin', 'moltbook', 'github', 'seo', 'instagram'];
10588
+ const ACTIVITY_PLATFORM_LABELS = { reddit: 'Reddit', twitter: 'Twitter / X', linkedin: 'LinkedIn', moltbook: 'Moltbook', github: 'GitHub', seo: 'SEO', instagram: 'Instagram' };
10282
10589
  const PROJECT_LABELS = { tenxats: '10xats' };
10283
10590
  const ACTIVITY_PROJECT_NONE = '(none)';
10284
10591
  const ACTIVITY_CAMPAIGN_ORGANIC = '(organic)';
@@ -14378,6 +14685,20 @@ function renderTopPosts(payload) {
14378
14685
  filterMode: 'none' },
14379
14686
  ],
14380
14687
  });
14688
+ // Reconcile the top-level "Link sent" pill with the column filter that
14689
+ // mountSortableTable just hydrated from localStorage. See
14690
+ // syncTopLinkPillFromColumn for the full reasoning.
14691
+ syncTopLinkPillFromColumn();
14692
+ // Also live-sync the pill when the user changes the inline column dropdown
14693
+ // directly. Without this, the pill would stay stale until the next render
14694
+ // (project switch, window flip, etc.) and the user would see the same
14695
+ // mismatch they just reported. mountSortableTable is generic and shouldn't
14696
+ // know about specific columns, so wire this here at the call site.
14697
+ const linkColDd = document.querySelector('select.activity-col-filter[data-filter-key="link_clicks"]');
14698
+ if (linkColDd && !linkColDd._linkPillSyncWired) {
14699
+ linkColDd.addEventListener('change', () => { syncTopLinkPillFromColumn(); });
14700
+ linkColDd._linkPillSyncWired = true;
14701
+ }
14381
14702
  }
14382
14703
 
14383
14704
  async function loadTopPosts(force) {
@@ -14410,6 +14731,39 @@ async function loadTopPosts(force) {
14410
14731
  }
14411
14732
  }
14412
14733
 
14734
+ // Two layers can set the "link sent" filter on the Top > Posts table:
14735
+ // 1. The top-level pill row (#top-dm-link-pills, persisted at sa.top.dmLink.v1)
14736
+ // 2. The inline column dropdown for the link_clicks column (persisted inside
14737
+ // sa.topTable.v2.filters.link_clicks via mountSortableTable).
14738
+ // They hydrate from different localStorage keys, independently, on every page
14739
+ // load. Without reconciliation the table can be rendering with link_clicks=
14740
+ // 'has_link' (only link-sent rows visible) while the pill row reads "All",
14741
+ // leaving the user with no top-level indication that a filter is active.
14742
+ //
14743
+ // Source of truth = the column filter (it's what actually drives the visible
14744
+ // rows). After mountSortableTable hydrates, mirror its value into the pill so
14745
+ // the top-level UI never lies. has_clicks has no pill equivalent: surface it
14746
+ // as "All" on the pill but leave the column filter intact so the inline
14747
+ // dropdown still works for that advanced case.
14748
+ function syncTopLinkPillFromColumn() {
14749
+ const linkRow = document.getElementById('top-dm-link-pills');
14750
+ if (!linkRow) return;
14751
+ const colVal = (_topTableState && _topTableState.filters && _topTableState.filters.link_clicks) || '';
14752
+ const colToPill = { has_link: 'yes', no_link: 'no' };
14753
+ const desired = colToPill[colVal] || 'all';
14754
+ if (_topDmLink === desired) {
14755
+ // Defensive: even if our in-memory _topDmLink is consistent, the visible
14756
+ // active-class on the pill might still be stale from a prior render path
14757
+ // (e.g. inline column dropdown change that bypassed the pill). Force the
14758
+ // DOM to match.
14759
+ setTopPillActive(linkRow, desired);
14760
+ return;
14761
+ }
14762
+ _topDmLink = desired;
14763
+ try { saSave('sa.top.dmLink.v1', _topDmLink); } catch {}
14764
+ setTopPillActive(linkRow, desired);
14765
+ }
14766
+
14413
14767
  function setTopPillActive(row, value) {
14414
14768
  if (!row) return;
14415
14769
  row.dataset.selected = value || 'all';
@@ -16171,10 +16525,10 @@ async function loadDeployHealth() {
16171
16525
  // hours by platform against config.json weight targets. Each platform cell
16172
16526
  // shows the count plus that project's share of the platform's posts in
16173
16527
  // brackets, so operators can spot imbalance without a separate deficit field.
16174
- const PROJECT_STATUS_PLATFORMS = ['reddit', 'twitter', 'linkedin', 'moltbook', 'github'];
16528
+ const PROJECT_STATUS_PLATFORMS = ['reddit', 'twitter', 'linkedin', 'moltbook', 'github', 'instagram'];
16175
16529
  const PROJECT_STATUS_PLATFORM_LABELS = {
16176
16530
  reddit: 'Reddit', twitter: 'Twitter', linkedin: 'LinkedIn',
16177
- moltbook: 'MoltBook', github: 'GitHub',
16531
+ moltbook: 'MoltBook', github: 'GitHub', instagram: 'Instagram',
16178
16532
  };
16179
16533
  let _projectStatusLoading = false;
16180
16534
  let _projectStatusData = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"