teqo-backup/scripts/lib/instagramContentPlan.mjs
Francisco Solla 8608aeb317 feat(C235): importa qualquer janela de publicações do perfil na Central
A importação do perfil oficial deixa a janela fixa de 12 mídias e ganha o recorte por período (desde; até opcional), com prévia honesta (novas · já estavam · total) e confirmação antes de criar; "Recentes" segue o default e o clique único. O planner puro de janela/dedupe passa ao dono em src/lib (CLI re-exporta) e a varredura usa o teto de páginas derivado de maxResults, com early-stop por data e corte denunciado (truncated). Sem migration/Consent; tudo Rascunho; fail-closed de credencial intacto.

Design tier: DEGRADED (central-conteudos-importar-janela-ui-design) — port classe-a-classes das cenas 01–08 com sign-off humano explícito (2026-09-29); a crítica de paridade do designer no tier primário não pôde rodar por quota.
2026-09-29 13:23:01 -03:00

180 lines
7.3 KiB
JavaScript

/**
* C230-followup — pure planning/reporting of the Instagram content import CLI
* (`import-instagram-content.mjs`): filter the official feed to the declared
* recency window, classify each post into an import candidate, dedupe by the
* post identity (what the Central already carries, in any URL spelling) and
* aggregate the honest run report. No I/O and no `server-only`: the unit specs
* read this module and the CLI is the impure shell.
*/
import {
contentPieceProfileFeedIdentityUrls,
planContentPieceProfileWindow,
} from '../../src/lib/contentPieceProfileWindow.ts'
// C235 — the window filter and the identity dedupe moved to their single owner
// in `src/lib` (the server importer reads the same policy); the names below are
// the CLI contract this module has always exported.
export const planInstagramContentWindow = planContentPieceProfileWindow
export const instagramContentFeedIdentityUrls = contentPieceProfileFeedIdentityUrls
/** Default recency window of one import run (the request's 60 days). */
const INSTAGRAM_CONTENT_DEFAULT_DAYS = 60
/** Defensive ceiling: a window longer than a year is a typo, not an intent. */
const INSTAGRAM_CONTENT_MAX_DAYS = 365
const INSTAGRAM_CONTENT_DEFAULT_OUT_DIR = 'data/content-instagram'
const INSTAGRAM_CONTENT_USAGE =
'pnpm content:instagram:import [--apply] [--publish] [--days 60] [--limit n] [--out data/content-instagram]'
/** Report file stamp: the run's ISO instant with `:`/`.` swapped, like C215. */
export const instagramContentReportStamp = (runAt) => runAt.replace(/[:.]/g, '-')
/**
* Parses the CLI arguments. `--publish` only exists with `--apply` (there is
* nothing to publish in the plan mode) and `--out` cannot escape the repo.
*
* @param {string[]} [argv]
*/
export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
const options = {
apply: false,
publish: false,
days: INSTAGRAM_CONTENT_DEFAULT_DAYS,
limit: null,
out: INSTAGRAM_CONTENT_DEFAULT_OUT_DIR,
help: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = () => {
const next = argv[++index]
if (next === undefined || next.startsWith('--')) {
throw new Error(`faltou valor para ${arg} — ${INSTAGRAM_CONTENT_USAGE}.`)
}
return next
}
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--apply') options.apply = true
else if (arg === '--publish') options.publish = true
else if (arg === '--days') options.days = Number(value())
else if (arg === '--limit') options.limit = Number(value())
else if (arg === '--out') options.out = value()
else throw new Error(`argumento desconhecido: ${arg} — ${INSTAGRAM_CONTENT_USAGE}.`)
}
if (options.help) return options
if (options.publish && !options.apply) {
throw new Error(`--publish exige --apply — ${INSTAGRAM_CONTENT_USAGE}.`)
}
if (
!Number.isInteger(options.days) ||
options.days < 1 ||
options.days > INSTAGRAM_CONTENT_MAX_DAYS
) {
throw new Error(`--days deve ser um inteiro entre 1 e ${INSTAGRAM_CONTENT_MAX_DAYS}.`)
}
if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) {
throw new Error('--limit deve ser >= 1.')
}
if (options.out.split(/[\\/]/).includes('..')) {
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
}
return options
}
/**
* The publish rule of the import: only a piece the pipeline finished (`pronto`)
* goes public, and only when the run asked for it. A `falhou` piece stays a
* draft and the receipt names it.
*/
export const shouldPublishImportedPiece = ({ processingStatus, publish }) =>
publish === true && processingStatus === 'pronto'
/**
* Aggregates the per-candidate results of an `--apply` run. `asrCostUsd` uses
* the provider's per-audio-minute price passed by the shell (the app-side
* owner of the constant), never a guessed duration.
*
* @param {Array<{ outcome: string, processingStatus?: string | null, linkFailureReason?: string | null, published?: boolean, durationSeconds?: number | null, error?: string | null }>} results
* @param {{ costPerMinuteUsd?: number }} [options]
*/
export const summarizeInstagramContentResults = (results, { costPerMinuteUsd = 0 } = {}) => {
const count = (predicate) => results.filter(predicate).length
const asrSeconds = results.reduce(
(total, result) => total + (Number(result.durationSeconds) || 0),
0,
)
return {
attempted: results.length,
created: count((result) => result.outcome === 'created'),
existing: count((result) => result.outcome === 'existing'),
ready: count((result) => result.processingStatus === 'pronto'),
failed: count((result) => result.processingStatus === 'falhou'),
published: count((result) => result.published === true),
linkOnly: count(
(result) => typeof result.linkFailureReason === 'string' && result.linkFailureReason !== '',
),
asrSeconds: Math.round(asrSeconds),
asrCostUsd: Number(((asrSeconds / 60) * costPerMinuteUsd).toFixed(4)),
}
}
/**
* The receipt lines the operator reads (and the JSON mirrors). Only facts the
* run actually observed: a failure is named with its shortcode and reason.
*
* @param {{
* target: string,
* mode: 'plan' | 'apply',
* window: { days: number, from: string, to: string },
* feedCount: number,
* plan: ReturnType<typeof planInstagramContentWindow>,
* summary?: ReturnType<typeof summarizeInstagramContentResults>,
* failures?: Array<{ shortcode: string, error: string }>,
* candidatesRemaining?: number,
* reportPath?: string | null,
* }} report
*/
export const formatInstagramContentReport = (report) => {
const lines = []
const plannedLinkOnly = report.plan.candidates.filter(
(candidate) => candidate.linkOnlyReason !== null,
).length
lines.push(`[content:instagram:import] alvo: ${report.target} | modo: ${report.mode}`)
lines.push(`janela: ${report.window.days} dias (${report.window.from} → ${report.window.to})`)
lines.push(
`feed: ${report.feedCount} mídias · na janela: ${report.plan.found} · já na Central: ${report.plan.existingCount} · fora da janela: ${report.plan.outsideWindow} · sem identidade: ${report.plan.malformed}`,
)
lines.push(
`candidatos: ${report.plan.candidates.length} (peça-link previstos: ${plannedLinkOnly})`,
)
if (report.summary) {
lines.push(
`novas: ${report.summary.created} · já estavam: ${report.summary.existing} · prontas: ${report.summary.ready} · falharam: ${report.summary.failed} · publicadas: ${report.summary.published} · peça-link: ${report.summary.linkOnly}`,
)
lines.push(`ASR: ${report.summary.asrSeconds}s (~US$ ${report.summary.asrCostUsd})`)
for (const failure of report.failures ?? []) {
lines.push(` - falha ${failure.shortcode}: ${failure.error}`)
}
for (const candidate of report.plan.candidates.filter(
(entry) => entry.linkOnlyReason !== null,
)) {
lines.push(` - peça-link ${candidate.shortcode}: ${candidate.linkOnlyReason}`)
}
}
if ((report.candidatesRemaining ?? 0) > 0) {
lines.push(
`restam ${report.candidatesRemaining} candidatos no lote — rode de novo para terminar.`,
)
}
if (report.reportPath) lines.push(`recibo: ${report.reportPath}`)
return lines
}