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 +380 -26
- 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 +20 -3
- package/skill/scan-twitter-followups.sh +7 -2
- package/skill/stats-instagram.sh +72 -0
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 (
|
|
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
|
|
3253
|
-
if (!
|
|
3254
|
-
return json(res, { error: 'no_target_url',
|
|
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
|
|
3348
|
+
// Per-click log for newsletter rail (humans + bots).
|
|
3257
3349
|
try {
|
|
3258
3350
|
await pool.query(
|
|
3259
|
-
`INSERT INTO
|
|
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]
|
|
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
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
project:
|
|
3273
|
-
kind:
|
|
3274
|
-
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(
|
|
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