Blog syndication: publishing an article posts it to X and Instagram through Blotato (cover via media upload, once per article, retry button, status column)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-13 07:20:47 -05:00
parent 0ad974ca59
commit 53f87f79ba
4 changed files with 106 additions and 7 deletions
+22 -2
View File
@@ -29,7 +29,8 @@ const legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcom
const traffic = require('./traffic'); // public page views by referring domain (admin Traffic tab)
const promos = require('./promos'); // partner promo codes -> free ad credits (link ?promo=CODE or the dashboard box)
const blog = require('./blog');
const adminMember = require('./adminmember'); // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
const adminMember = require('./adminmember');
const syndicate = require('./syndicate'); // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12)
const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch', '/partners', '/earning', '/blog']);
let tankWaitCache = null; // dashboard: who is waiting for a sponsor (refreshed every minute)
const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting
@@ -363,6 +364,8 @@ async function boot() {
blog.init({ dataDir: DATA_DIR });
adminMember.init({ accounts, ads, chain, tank, legacy, promos, messages, dataDir: DATA_DIR });
loadOpenTokens();
syndicate.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR, uploadsDir: UPLOADS_DIR });
console.log('blog syndication:', syndicate.enabled() ? 'on (X + Instagram via Blotato)' : 'off (no blotato.key)');
setInterval(() => tankNotifyTick().catch(e => console.error('tank notify', e.message)), 15 * 60 * 1000); // new tank arrivals -> Telegram
geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message));
setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily
@@ -1183,14 +1186,31 @@ const server = http.createServer(async (req, res) => {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const slug = u.searchParams.get('slug');
if (slug) { const post = await blog.get(slug); return post ? json(res, 200, { post }) : json(res, 404, { error: 'No such post.' }); }
return json(res, 200, { posts: (await blog.listAll()).map(x => ({ slug: x.slug, title: x.title, status: x.status, tags: x.tags, publishedAt: x.publishedAt, updated: x.updated, views: x.views, excerpt: x.excerpt, cover: x.cover })) });
return json(res, 200, { syndication: syndicate.enabled(), posts: (await blog.listAll()).map(x => ({ slug: x.slug, title: x.title, status: x.status, tags: x.tags, publishedAt: x.publishedAt, updated: x.updated, views: x.views, excerpt: x.excerpt, cover: x.cover, syndicated: syndicate.statusOf(x.slug) })) });
}
if (p === '/api/admin/blog' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const before = b.existingSlug ? await blog.get(b.existingSlug) : null;
const r = await blog.save(b, b.existingSlug || null);
if (r.ok && r.post.status === 'published' && (!before || before.status !== 'published') && !b.noSyndicate) {
// first time this article goes live: push it to X + Instagram (never repeated for the same slug)
syndicate.publish(r.post).catch(e => console.error('syndication', e.message));
r.syndicating = syndicate.enabled();
}
r.syndicated = syndicate.statusOf(r.post.slug);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/admin/blog/syndicate' && req.method === 'POST') { // manual: post (or retry) a published article
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const post = await blog.get(String(b.slug || ''));
if (!post) return json(res, 404, { error: 'No such post.' });
if (post.status !== 'published') return json(res, 400, { error: 'Publish the article first.' });
if (!syndicate.enabled()) return json(res, 400, { error: 'No Blotato key on the server.' });
const st = await syndicate.publish(post, { force: !!b.force });
return json(res, 200, { ok: true, syndicated: st });
}
if (p === '/api/admin/blog' && req.method === 'DELETE') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const slug = u.searchParams.get('slug'); if (!slug) return json(res, 400, { error: 'slug' });