← back to Dw Slideshow Gen
lib/post.js
103 lines
// lib/post.js — the delivery leg. FAIL-CLOSED by design.
//
// Default (no creds / no --confirm): assemble a local "draft package" — the 6 PNGs
// + caption.txt + POST-INSTRUCTIONS.md, zipped. NO network call. $0.
//
// Live Postiz draft: only attempted when ALL of (POSTIZ_API_KEY, POSTIZ_INTEGRATION_ID,
// --confirm) are present. Even then it creates a TikTok carousel DRAFT with
// privacy SELF_ONLY — never a public post, never auto-publish. Music + publish stay
// manual in the TikTok app (a platform limit: the Content API can't add a trending
// sound — and that human step is exactly where the algorithm is won).
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { readJSON, ensureDir, exists } from './util.js'
import { Postiz, buildDraftBodyMulti, buildScheduleBody, pickIntegration, pickIntegrations } from './postiz.js'
function loadManifest(renderDir) {
const mp = path.join(renderDir, 'render-manifest.json')
if (!exists(mp)) throw new Error(`no render-manifest.json in ${renderDir}`)
const m = readJSON(mp)
const pngs = (m.png_paths || []).filter(exists)
if (!pngs.length) throw new Error('manifest lists no existing PNGs')
return { ...m, png_paths: pngs }
}
export function packageDraft(renderDir, opts = {}) {
const m = loadManifest(renderDir)
const pkgDir = path.join(renderDir, 'post-package')
ensureDir(pkgDir)
// 1..N images, zero-padded for stable carousel order
m.png_paths.forEach((p, i) => fs.copyFileSync(p, path.join(pkgDir, `slide_${String(i + 1).padStart(2, '0')}.png`)))
const caption = `${m.caption || ''}\n\n${(m.hashtags || []).join(' ')}`.trim()
fs.writeFileSync(path.join(pkgDir, 'caption.txt'), caption + '\n')
fs.writeFileSync(path.join(pkgDir, 'POST-INSTRUCTIONS.md'), [
`# Post: ${m.spec_id}`,
``,
`**Platform:** TikTok (photo carousel) — also works for Instagram.`,
`**Slides:** ${m.png_paths.length} × ${m.width}×${m.height}, in slide_01…order.`,
``,
`## Steps (manual, by design)`,
`1. Upload the slides as a **photo carousel**, in order.`,
`2. Paste the caption from \`caption.txt\`.`,
`3. **Add a trending sound** in the TikTok app — the Content API cannot, and`,
` the sound is what drives the algorithm. Keep a human on this step.`,
`4. Post as a **draft (private / SELF_ONLY)** first; review; then publish.`,
``,
`_Generated by dw-slideshow-gen. Render mode: ${m.render_mode}. $0._`,
].join('\n') + '\n')
let zipPath = null
if (opts.zip !== false) {
try {
zipPath = path.join(renderDir, `${m.spec_id}-draft.zip`)
if (exists(zipPath)) fs.rmSync(zipPath)
execFileSync('zip', ['-rjq', zipPath, pkgDir], { stdio: 'ignore' })
} catch (e) { zipPath = null; process.stderr.write(` [post] zip skipped: ${e.message}\n`) }
}
return { spec_id: m.spec_id, pkgDir, zipPath, slides: m.png_paths.length, caption }
}
const captionOf = m => `${m.caption || ''}\n\n${(m.hashtags || []).join(' ')}`.trim()
// Stage a carousel into Postiz. Default = a DRAFT (no settings, sidesteps the TikTok
// UPLOAD #1338 bug, reversible, human publishes from the dashboard). Network calls
// (upload + create) only fire with a key AND opts.confirm — otherwise a dry-run that
// returns the exact request body so the shape is verifiable without posting.
export async function stageToPostiz(renderDir, env, opts = {}) {
const apiKey = env.POSTIZ_API_KEY
if (!apiKey) return { staged: false, gated: true, reason: 'no POSTIZ_API_KEY (Settings → Developers → Public API)' }
const m = loadManifest(renderDir)
const content = captionOf(m)
const dateISO = opts.schedule || new Date().toISOString()
const client = new Postiz(apiKey)
// pick channel(s) (read-only). --channel all|a,b -> multiple
let chosen
try {
const list = await client.integrations()
chosen = pickIntegrations(list, opts.channel)
} catch (e) { return { staged: false, error: `integrations: ${e.message}` } }
if (!chosen.length) return { staged: false, gated: true, reason: 'no connected channels — connect a channel in Postiz first' }
// schedule needs a single provider's settings; draft is multi-channel (each gets its own settings)
const mkBody = media => opts.schedule
? buildScheduleBody({ integrationId: chosen[0].id, identifier: chosen[0].identifier, content, media, dateISO, tiktok: opts.tiktok })
: buildDraftBodyMulti({ integrations: chosen, content, media, dateISO })
// dry-run: show the body with placeholder media, no upload, no post
if (!opts.confirm) {
const placeholder = m.png_paths.map((p, i) => ({ id: `<upload_${i + 1}>`, path: `https://uploads.postiz.com/${path.basename(p)}` }))
return { staged: false, dryRun: true, channels: chosen, type: opts.schedule ? 'schedule' : 'draft',
images: m.png_paths.length, body: mkBody(placeholder) }
}
// live: upload each PNG once (shared across channels), then create the post
const media = []
for (const p of m.png_paths) media.push(await client.upload(p))
const created = await client.createPost(mkBody(media))
const ids2 = (Array.isArray(created) ? created : [created]).map(x => x?.postId || x?.id).filter(Boolean)
return { staged: true, type: opts.schedule ? 'schedule' : 'draft', channels: chosen, postIds: ids2, raw: created }
}