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:
+2
-1
@@ -311,6 +311,7 @@
|
||||
<div class="card" id="blogList">
|
||||
<div class="card-head"><h3>Articles</h3><span class="sub" id="blSub"></span></div>
|
||||
<p class="muted small" style="margin:0 0 10px">Coaching and teaching stories, published at <a href="/blog" target="_blank" rel="noopener">instantadpay.com/blog</a>. Every published post gets its own page with a title tag, description, canonical link, social preview card, structured data, and a spot in the sitemap and RSS feed. Drafts are visible only to you (open one from its row to preview the real page).</p>
|
||||
<p class="small muted" id="blSyndNote" hidden style="margin:0 0 10px"></p>
|
||||
<p><button type="button" class="btn small" id="blNew">New article</button></p>
|
||||
<div class="tablewrap"><table class="adm-table" id="blTable"></table></div>
|
||||
</div>
|
||||
@@ -423,6 +424,6 @@
|
||||
</div>
|
||||
|
||||
<script src="/assets/common.js?v=20260913a"></script>
|
||||
<script src="/assets/admin.js?v=20260913c"></script>
|
||||
<script src="/assets/admin.js?v=20260913d"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-4
@@ -533,7 +533,7 @@
|
||||
$('blSlug').value = r.post.slug; $('blPreview').hidden = false; $('blPreview').href = '/blog/' + r.post.slug; $('blDelete').hidden = false;
|
||||
$('blUnpublish').hidden = r.post.status !== 'published'; $('blPublish').textContent = r.post.status === 'published' ? 'Save and publish' : 'Publish';
|
||||
$('blEdTitle').textContent = 'Edit article';
|
||||
blMsg(r.post.status === 'published' ? 'Published. Live at instantadpay.com/blog/' + r.post.slug : 'Draft saved.');
|
||||
blMsg(r.post.status === 'published' ? 'Published. Live at instantadpay.com/blog/' + r.post.slug + (r.syndicating ? ' · posting to X and Instagram now (see the Social column in the list).' : '') : 'Draft saved.');
|
||||
IAP.status(r.post.status === 'published' ? 'Published.' : 'Draft saved.', 'ok');
|
||||
}
|
||||
$('blSaveDraft').addEventListener('click', busy($('blSaveDraft'), () => blSave('draft')));
|
||||
@@ -551,9 +551,16 @@
|
||||
const d = await api('/api/admin/blog');
|
||||
const pub = d.posts.filter(p => p.status === 'published').length;
|
||||
$('blSub').textContent = pub + ' published · ' + (d.posts.length - pub) + ' drafts';
|
||||
$('blTable').innerHTML = '<tr><th>Title</th><th>Status</th><th>Tags</th><th>Views</th><th>Updated</th><th></th></tr>'
|
||||
+ (d.posts.length ? d.posts.map(p => '<tr><td><b>' + esc(p.title) + '</b><br><span class="muted small">/blog/' + esc(p.slug) + '</span></td><td>' + (p.status === 'published' ? '<span class="chip-t on">published</span>' : '<span class="chip-t">draft</span>') + '</td><td>' + esc(p.tags.join(', ')) + '</td><td>' + (p.views || 0) + '</td><td>' + when(p.updated) + '</td><td class="act"><button type="button" class="btn small sec" data-bledit="' + esc(p.slug) + '">Edit</button> <a class="btn small sec" href="/blog/' + esc(p.slug) + '" target="_blank" rel="noopener">View</a></td></tr>').join('')
|
||||
: '<tr><td colspan="6" class="muted">No articles yet. Start with "New article".</td></tr>');
|
||||
const synd = p => { const s = p.syndicated; if (!s) return p.status === 'published' ? '<span class="muted small">not posted</span>' : ''; const r = s.results || {}; const part = ['x', 'instagram'].map(k => r[k] ? (r[k].ok ? k + ' ✓' : k + ' ✗') : k + ' –').join(' · '); return '<span class="small' + (s.done ? '' : ' bad') + '" title="' + esc(Object.values(r).map(v => v.error || '').filter(Boolean).join(' | ') || (s.error || '')) + '">' + part + '</span>'; };
|
||||
$('blSyndNote').hidden = false; $('blSyndNote').textContent = d.syndication ? 'Publishing an article posts it to X (@cryptoteambuild) and Instagram (marketingwithmarty) through Blotato, once per article, with the cover image.' : 'Social syndication is off: no Blotato key on the server.';
|
||||
$('blTable').innerHTML = '<tr><th>Title</th><th>Status</th><th>Social</th><th>Tags</th><th>Views</th><th>Updated</th><th></th></tr>'
|
||||
+ (d.posts.length ? d.posts.map(p => '<tr><td><b>' + esc(p.title) + '</b><br><span class="muted small">/blog/' + esc(p.slug) + '</span></td><td>' + (p.status === 'published' ? '<span class="chip-t on">published</span>' : '<span class="chip-t">draft</span>') + '</td><td>' + synd(p) + '</td><td>' + esc(p.tags.join(', ')) + '</td><td>' + (p.views || 0) + '</td><td>' + when(p.updated) + '</td><td class="act"><button type="button" class="btn small sec" data-bledit="' + esc(p.slug) + '">Edit</button> <a class="btn small sec" href="/blog/' + esc(p.slug) + '" target="_blank" rel="noopener">View</a>' + (p.status === 'published' && d.syndication && !(p.syndicated && p.syndicated.done) ? ' <button type="button" class="btn small sec" data-blsynd="' + esc(p.slug) + '">Post to X + IG</button>' : '') + '</td></tr>').join('')
|
||||
: '<tr><td colspan="7" class="muted">No articles yet. Start with "New article".</td></tr>');
|
||||
$('blTable').querySelectorAll('[data-blsynd]').forEach(b => b.addEventListener('click', busy(b, async () => {
|
||||
const r = await api('/api/admin/blog/syndicate', { slug: b.dataset.blsynd });
|
||||
const res = r.syndicated && r.syndicated.results || {}; const bad = Object.entries(res).filter(([, v]) => !v.ok).map(([k, v]) => k + ': ' + v.error);
|
||||
IAP.status(bad.length ? 'Posted with problems: ' + bad.join(' | ') : 'Posted to X and Instagram.', bad.length ? 'bad' : 'ok'); loadBlog().catch(() => {});
|
||||
})));
|
||||
$('blTable').querySelectorAll('[data-bledit]').forEach(b => b.addEventListener('click', async () => {
|
||||
try { const r = await api('/api/admin/blog?slug=' + encodeURIComponent(b.dataset.bledit)); blOpen(r.post); } catch (e) { IAP.status(e.message, 'bad'); }
|
||||
}));
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Blog social syndication (Marty, 2026-09-13): when an article goes from draft to published, push it
|
||||
// through Blotato to X (@cryptoteambuild, account 7998) and Instagram (marketingwithmarty, 16261).
|
||||
// Key: DATA_DIR/blotato.key (never logged). Record per slug in DATA_DIR/blog-syndication.json so a
|
||||
// republish never double-posts; admin can see the result on the article row. No Facebook (Marty's call).
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
let DATA_DIR = null, PUBLIC_DIR = null, UPLOADS_DIR = null;
|
||||
const SITE = 'https://instantadpay.com';
|
||||
const ACCOUNTS = { x: { id: 7998, target: { targetType: 'twitter' }, platform: 'twitter' }, instagram: { id: 16261, target: { targetType: 'instagram' }, platform: 'instagram' } };
|
||||
const FILE = () => path.join(DATA_DIR, 'blog-syndication.json');
|
||||
function init(opts) { DATA_DIR = opts.dataDir; PUBLIC_DIR = opts.publicDir; UPLOADS_DIR = opts.uploadsDir; }
|
||||
function key() { try { return fs.readFileSync(path.join(DATA_DIR, 'blotato.key'), 'utf8').trim(); } catch (e) { return ''; } }
|
||||
function enabled() { return !!key(); }
|
||||
function log() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return {}; } }
|
||||
function saveLog(l) { try { fs.writeFileSync(FILE(), JSON.stringify(l, null, 1)); } catch (e) {} }
|
||||
function statusOf(slug) { return log()[slug] || null; }
|
||||
|
||||
function blotato(pathname, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = JSON.stringify(body);
|
||||
const req = https.request({ hostname: 'backend.blotato.com', path: '/v2' + pathname, method: 'POST', headers: { 'blotato-api-key': key(), 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }, timeout: 60000 }, res => {
|
||||
let out = ''; res.on('data', c => out += c); res.on('end', () => { let j = null; try { j = JSON.parse(out); } catch (e) {} if (res.statusCode >= 200 && res.statusCode < 300) resolve(j || {}); else reject(new Error('Blotato ' + res.statusCode + ': ' + out.slice(0, 200))); });
|
||||
});
|
||||
req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout'))); req.write(data); req.end();
|
||||
});
|
||||
}
|
||||
// the cover goes through Blotato's media store first (a data URL, like the shorts pipeline), so the
|
||||
// post never depends on our origin being fetchable from their side; https covers are passed as-is
|
||||
async function coverMedia(post) {
|
||||
const c = post.cover || '/banners/iap-hero-1200x630.png';
|
||||
if (/^https?:\/\//.test(c)) return c;
|
||||
let file = null;
|
||||
if (c.startsWith('/uploads/')) file = path.join(UPLOADS_DIR, c.slice('/uploads/'.length));
|
||||
else if (PUBLIC_DIR) file = path.join(PUBLIC_DIR, c.replace(/^\//, ''));
|
||||
let buf = null; try { buf = fs.readFileSync(file); } catch (e) {}
|
||||
if (!buf || buf.length > 4.3e6) { try { buf = fs.readFileSync(path.join(PUBLIC_DIR, 'banners/iap-hero-1200x630.png')); } catch (e) { return SITE + c; } }
|
||||
const mime = /\.png$/i.test(file || '') ? 'image/png' : /\.webp$/i.test(file || '') ? 'image/webp' : 'image/jpeg';
|
||||
const r = await blotato('/media', { url: 'data:' + mime + ';base64,' + buf.toString('base64') });
|
||||
return (r && r.url) || (SITE + c);
|
||||
}
|
||||
function xText(post) {
|
||||
const url = SITE + '/blog/' + post.slug;
|
||||
const room = 280 - 23 - 2; // t.co link + spacing
|
||||
let t = post.title + '\n\n' + (post.excerpt || '');
|
||||
if (t.length > room) t = t.slice(0, room - 1).replace(/\s+\S*$/, '') + '…';
|
||||
return t + '\n\n' + url;
|
||||
}
|
||||
function igText(post) {
|
||||
return post.title + '\n\n' + (post.excerpt || '') + '\n\nRead it: instantadpay.com/blog/' + post.slug + '\n\n#InstantAdPay #advertising #teambuilding #polygon #crypto';
|
||||
}
|
||||
// returns { x: {ok, id|error}, instagram: {...} }; never throws, never posts twice for one slug
|
||||
async function publish(post, opts) {
|
||||
const l = log(); const prev = l[post.slug];
|
||||
if (prev && prev.done && !(opts && opts.force)) return prev;
|
||||
if (!enabled()) { const r = { done: false, at: Date.now(), error: 'no Blotato key on the server' }; l[post.slug] = r; saveLog(l); return r; }
|
||||
let media; try { media = await coverMedia(post); } catch (e) { const r = { done: false, at: Date.now(), error: 'cover upload: ' + String(e.message || e).slice(0, 160) }; l[post.slug] = r; saveLog(l); console.log('blog syndication', post.slug, r.error); return r; }
|
||||
const out = { at: Date.now(), done: true, results: {} };
|
||||
for (const [name, a] of Object.entries(ACCOUNTS)) {
|
||||
if (prev && prev.results && prev.results[name] && prev.results[name].ok) { out.results[name] = prev.results[name]; continue; } // retry only what failed
|
||||
try {
|
||||
const text = name === 'x' ? xText(post) : igText(post);
|
||||
const r = await blotato('/posts', { post: { accountId: String(a.id), content: { text, mediaUrls: [media], platform: a.platform }, target: a.target } });
|
||||
out.results[name] = { ok: true, id: (r && (r.postSubmissionId || r.id)) || null, at: Date.now() };
|
||||
} catch (e) { out.results[name] = { ok: false, error: String(e.message || e).slice(0, 200), at: Date.now() }; out.done = false; }
|
||||
}
|
||||
l[post.slug] = out; saveLog(l);
|
||||
console.log('blog syndication', post.slug, JSON.stringify(Object.fromEntries(Object.entries(out.results).map(([k, v]) => [k, v.ok ? 'ok' : v.error]))));
|
||||
return out;
|
||||
}
|
||||
module.exports = { init, enabled, publish, statusOf, log };
|
||||
Reference in New Issue
Block a user