Files
linkspin/syndicate.js
T

72 lines
5.2 KiB
JavaScript

// 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://linkspin-test.saasy.top';
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: linkspin-test.saasy.top/blog/' + post.slug + '\n\n#LinkSpin #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 };