feat(C230): expurgo de peças fora do período eleitoral na Central

O rascunho pré-campanha que o --publish-existing ressuscita precisa sair
do catálogo de vez: o novo CLI content:instagram:prune faz hard delete da
peça e da mídia privada (linha, arquivo e still do C226), então uma
importação futura não a recria. Compartilha o mesmo núcleo de corte da
retirada (data civil na Bahia, peça sem data no feed nunca é tocada) e
espelha as guardas dos irmãos: --before obrigatório, TEQO_ENV casando o
nome exato do banco, CONTENT_INSTAGRAM_PRUNE_CONFIRM=1 e recibos JSON.

Testes: planejador/parser/recibo em unit e guardas do CLI em subprocesso.
Runbook e changelog atualizados.
This commit is contained in:
Francisco Solla 2026-10-01 01:58:10 -03:00
parent 20d3781e4b
commit 3d269725f8
7 changed files with 670 additions and 14 deletions

View file

@ -0,0 +1 @@
**C230-followup — expurgo das peças fora do período eleitoral (2026-10-01):** nasce o terceiro CLI irmão da Central, `pnpm content:instagram:prune --before YYYY-MM-DD [--scan-days 365] [--apply]`: diferente da retirada (kill switch), ele faz **hard delete** da peça e da mídia privada — linha, arquivo e still do C226 — para que o rascunho pré-campanha não seja ressuscitado por uma importação futura nem publicado de novo pelo `--publish-existing` (que lê todo o feed varrido, não só a janela `--days`, causa da recorrência). O planejamento puro compartilha o **mesmo núcleo de corte** da retirada (`planContentPiecesBeforeCutoff` — extraído no `scripts/lib/instagramContentPlan.mjs`, sem segunda regra de data): posts estritamente antes da data civil na Bahia entram; peça que o feed oficial não consegue datar **nunca é tocada** (fail-closed contra apagar peça de campanha) e é nomeada no recibo. Guardas espelhando os irmãos: `--before` obrigatório, `TEQO_ENV` casando o nome exato do banco, `CONTENT_INSTAGRAM_PRUNE_CONFIRM=1` fora de teste, recibos JSON com o período e as falhas, e revalidação da Central só quando alguma publicada saiu. Testes: unit do planejador/parser/recibo e das guardas do CLI em subprocesso. Runbook ganha a seção do expurgo em `docs/ops/teqo-1313-deploy.md`. Na mesma sessão, em produção: a importação da janela de campanha trouxe **5 publicações novas** (2 carrosséis como peça-link) e o `--publish-existing` publicou **25 rascunhos prontos** — incluindo **19 peças de 12–15/08** que a varredura profunda do feed ressuscitou e que a retirada (`--before 2026-08-16`) devolveu a rascunho — e a falha de transcrição da peça do post de 21/09 foi reprocessada e publicada (a de 06/08 falhou de novo e segue rascunho, fora do período). Em seguida o expurgo apagou **72 peças** (posts de 31/07 a 15/08: 71 rascunhos prontos + a falha de 06/08), 0 falhas, mídia e stills limpos; restou 1 rascunho sem data no feed no catálogo (#11, `DdsAC82NXsC`, peça-link "não encontrado"), preservado pelo fail-closed. Estado final: **281 peças publicadas** (todas ≥ 16/08), 1 rascunho; Central pública revalidada e servindo (apagada → 404).

View file

@ -1577,6 +1577,33 @@ docker compose --profile maintenance run --rm \
para datar as peças publicadas; reexecutar converge (o que já é rascunho sai do
conjunto). O recibo nomeia cada peça retirada, o período e as falhas.
**Apagar peças anteriores a um corte (expurgo definitivo).** Diferente da
retirada, o `content:instagram:prune` faz **hard delete** da peça e da mídia
privada (arquivo e still do C226): a linha sai do catálogo, então uma importação
futura não a recria como rascunho nem o `--publish-existing` a publica de novo.
Use quando a peça fora do período não deve mais existir — o caso clássico é o
rascunho pré-campanha que o `--publish-existing` ressuscita. Peça que o feed
oficial não consegue datar **nunca é tocada** (apagar sem data seria risco de
apagar peça de campanha) e aparece nomeada no recibo.
```bash
# 1) plano (dry-run; lista o que sairia, o período e o que não tem data):
docker compose --profile maintenance run --rm \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:prune --before 2026-08-16
# 2) expurgo (exige TEQO_ENV + confirmação; IRREVERSÍVEL):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_PRUNE_CONFIRM=1 \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:prune --before 2026-08-16 --apply
```
`--scan-days <n>` (default **365**) é a profundidade da varredura do feed usada
para datar as peças; o plano varre rascunhos e publicadas. Reexecutar converge
(0 anteriores ao corte). O recibo nomeia cada peça apagada, o período e as
falhas; a revalidação da Central só roda se alguma publicada saiu (mídia órfã é
limpa após a linha, best-effort, como no delete da ficha).
Os recibos JSON ficam em `data/content-instagram/` do container — monte o
volume acima para preservá-los. Com `--publish`, o CLI revalida a Central
pública ao final pela rota oficial de revalidação

View file

@ -37,6 +37,7 @@
"flickr:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ingest-flickr-archive.mjs",
"content:instagram:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-instagram-content.mjs",
"content:instagram:withdraw": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/withdraw-instagram-content.mjs",
"content:instagram:prune": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/prune-instagram-content.mjs",
"archive:catalog": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/catalog-archive-photos.mjs",
"faces:enroll": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-subject.mjs",
"faces:index": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/index-archive-faces.mjs",

View file

@ -36,6 +36,9 @@ const INSTAGRAM_CONTENT_USAGE =
const INSTAGRAM_CONTENT_WITHDRAW_USAGE =
'pnpm content:instagram:withdraw --before YYYY-MM-DD [--scan-days 90] [--apply] [--out data/content-instagram]'
const INSTAGRAM_CONTENT_PRUNE_USAGE =
'pnpm content:instagram:prune --before YYYY-MM-DD [--scan-days 365] [--apply] [--out data/content-instagram]'
/** Report file stamp: the run's ISO instant with `:`/`.` swapped, like C215. */
export const instagramContentReportStamp = (runAt) => runAt.replace(/[:.]/g, '-')
@ -153,6 +156,63 @@ export const parseInstagramContentWithdrawCliArgs = (argv = process.argv.slice(2
return options
}
/**
* Parses the prune CLI arguments. `--before` is a REQUIRED Bahia civil date
* (the campaign cutoff is a legal boundary, never a hidden default) and the
* `--scan-days` walk depth only bounds how far back the feed is read to date
* the catalogue rows; the default is deeper than the withdraw's because a prune
* targets the oldest rows of the catalogue (the whole electoral era fits in a
* year).
*
* @param {string[]} [argv]
*/
export const parseInstagramContentPruneCliArgs = (argv = process.argv.slice(2)) => {
const options = {
apply: false,
before: null,
scanDays: 365,
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_PRUNE_USAGE}.`)
}
return next
}
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--apply') options.apply = true
else if (arg === '--before') options.before = value()
else if (arg === '--scan-days') options.scanDays = Number(value())
else if (arg === '--out') options.out = value()
else throw new Error(`argumento desconhecido: ${arg} — ${INSTAGRAM_CONTENT_PRUNE_USAGE}.`)
}
if (options.help) return options
if (options.before === null) {
throw new Error(`--before é obrigatório — ${INSTAGRAM_CONTENT_PRUNE_USAGE}.`)
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(options.before))) {
throw new Error('--before deve ser uma data civil YYYY-MM-DD.')
}
try {
contentPieceProfilePeriodBounds({ since: options.before })
} catch {
throw new Error('--before deve ser uma data civil válida.')
}
if (!Number.isInteger(options.scanDays) || options.scanDays < 1 || options.scanDays > 365) {
throw new Error('--scan-days deve ser um inteiro entre 1 e 365.')
}
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
@ -218,11 +278,11 @@ export const planInstagramContentDraftsToPublish = ({ pieces, posts }) => {
}
/**
* Splits the published set by the civil cutoff: a post strictly BEFORE the
* `before` civil day (Bahia time, the electoral calendar's timezone) is
* withdrawn; the boundary instant comes from the domain owner of the civil
* dates, never a re-spelled offset. A piece whose post is not in the feed is
* named and left untouched.
* The one cutoff split shared by the withdraw and prune plans: a post strictly
* BEFORE the `before` civil day (Bahia time, the electoral calendar's timezone)
* matches; the boundary instant comes from the domain owner of the civil dates,
* never a re-spelled offset. A piece whose post is not in the feed is named and
* left untouched (fail-closed on an unknown date).
*
* @param {{
* pieces: Array<{ id: number, title?: string | null, sourceUrl?: string | null }>,
@ -230,11 +290,11 @@ export const planInstagramContentDraftsToPublish = ({ pieces, posts }) => {
* before: string,
* }} input
*/
export const planInstagramContentWithdraw = ({ pieces, posts, before }) => {
const planContentPiecesBeforeCutoff = ({ pieces, posts, before }) => {
const { fromIso } = contentPieceProfilePeriodBounds({ since: before })
const cutoffMs = Date.parse(fromIso)
const dates = instagramContentPostDates(posts)
const toWithdraw = []
const matched = []
const undateable = []
for (const piece of pieces) {
@ -245,7 +305,7 @@ export const planInstagramContentWithdraw = ({ pieces, posts, before }) => {
continue
}
if (timestamp < cutoffMs) {
toWithdraw.push({
matched.push({
...piece,
shortcode: link.shortcode,
postTimestamp: timestamp,
@ -253,9 +313,32 @@ export const planInstagramContentWithdraw = ({ pieces, posts, before }) => {
})
}
}
toWithdraw.sort((left, right) => left.postTimestamp - right.postTimestamp)
matched.sort((left, right) => left.postTimestamp - right.postTimestamp)
return { cutoffIso: fromIso, toWithdraw, undateable }
return { cutoffIso: fromIso, matched, undateable }
}
/**
* Splits the published set by the civil cutoff (the withdraw plan): posts before
* the day leave the public listing.
*
* @param {Parameters<typeof planContentPiecesBeforeCutoff>[0]} input
*/
export const planInstagramContentWithdraw = (input) => {
const { cutoffIso, matched, undateable } = planContentPiecesBeforeCutoff(input)
return { cutoffIso, toWithdraw: matched, undateable }
}
/**
* Splits the catalogue set by the civil cutoff (the prune plan): every piece
* before the day — draft or published — is matched for deletion. Same dating
* authority and same fail-closed on undateable rows as the withdraw plan.
*
* @param {Parameters<typeof planContentPiecesBeforeCutoff>[0]} input
*/
export const planInstagramContentPrune = (input) => {
const { cutoffIso, matched, undateable } = planContentPiecesBeforeCutoff(input)
return { cutoffIso, toDelete: matched, undateable }
}
/**
@ -349,7 +432,7 @@ export const formatInstagramContentReport = (report) => {
return lines
}
const WITHDRAW_SAMPLE_LIMIT = 10
const CUTOFF_SAMPLE_LIMIT = 10
/**
* The withdraw receipt lines the operator reads (and the JSON mirrors): the
@ -386,13 +469,13 @@ export const formatInstagramContentWithdrawReport = (report) => {
lines.push(
`período das retiradas: ${new Date(Math.min(...dates)).toISOString()} → ${new Date(Math.max(...dates)).toISOString()}`,
)
for (const entry of toWithdraw.slice(0, WITHDRAW_SAMPLE_LIMIT)) {
for (const entry of toWithdraw.slice(0, CUTOFF_SAMPLE_LIMIT)) {
lines.push(
` - ${entry.shortcode} (${entry.postDate.slice(0, 10)}) ${entry.title ?? ''}`.trim(),
)
}
if (toWithdraw.length > WITHDRAW_SAMPLE_LIMIT) {
lines.push(` … +${toWithdraw.length - WITHDRAW_SAMPLE_LIMIT}`)
if (toWithdraw.length > CUTOFF_SAMPLE_LIMIT) {
lines.push(` … +${toWithdraw.length - CUTOFF_SAMPLE_LIMIT}`)
}
}
for (const entry of undateable) {
@ -413,3 +496,68 @@ export const formatInstagramContentWithdrawReport = (report) => {
if (report.reportPath) lines.push(`recibo: ${report.reportPath}`)
return lines
}
/**
* The prune receipt lines the operator reads (and the JSON mirrors): the
* cutoff, what the feed could date, the set matched for deletion with its
* period and the pieces the feed could not date (never touched, always named).
* The apply line names the hard delete — the row and its private media leave
* the catalogue, unlike the withdraw's reversible kill switch.
*
* @param {{
* target: string,
* mode: 'plan' | 'apply',
* before: string,
* scanDays: number,
* feedCount: number,
* piecesCount: number,
* plan: ReturnType<typeof planInstagramContentPrune>,
* deleted?: number,
* failures?: Array<{ id: number, reason: string }>,
* revalidation?: { attempted: boolean, ok: boolean, reason: string | null },
* reportPath?: string | null,
* }} report
*/
export const formatInstagramContentPruneReport = (report) => {
const lines = []
const { toDelete, undateable } = report.plan
const dates = toDelete.map((entry) => entry.postTimestamp)
lines.push(`[content:instagram:prune] alvo: ${report.target} | modo: ${report.mode}`)
lines.push(
`corte: ${report.before} (Bahia; posts anteriores) · varredura: ${report.scanDays} dias · feed: ${report.feedCount} mídias`,
)
lines.push(
`no catálogo: ${report.piecesCount} · anteriores ao corte: ${toDelete.length} · sem data no feed: ${undateable.length}`,
)
if (toDelete.length > 0) {
lines.push(
`período das peças: ${new Date(Math.min(...dates)).toISOString()} → ${new Date(Math.max(...dates)).toISOString()}`,
)
for (const entry of toDelete.slice(0, CUTOFF_SAMPLE_LIMIT)) {
lines.push(
` - #${entry.id} ${entry.shortcode} (${entry.postDate.slice(0, 10)}) ${entry.title ?? ''}`.trim(),
)
}
if (toDelete.length > CUTOFF_SAMPLE_LIMIT) {
lines.push(` … +${toDelete.length - CUTOFF_SAMPLE_LIMIT}`)
}
}
for (const entry of undateable) {
lines.push(
` ? sem data: #${entry.id} ${entry.title ?? ''} — ${entry.sourceUrl ?? 'sem URL'}`.trim(),
)
}
if (report.mode === 'apply') {
lines.push(
`apagadas: ${report.deleted ?? 0} · falhas: ${report.failures?.length ?? 0} · revalidação: ${
report.revalidation?.ok ? 'ok' : (report.revalidation?.reason ?? 'não tentada')
}`,
)
for (const failure of report.failures ?? []) {
lines.push(` - falha #${failure.id}: ${failure.reason}`)
}
}
if (report.reportPath) lines.push(`recibo: ${report.reportPath}`)
return lines
}

View file

@ -0,0 +1,272 @@
/**
* C230-followup — hard-deletes from the Central de Conteúdos the pieces whose
* original Instagram post is BEFORE a civil cutoff: the off-period rows leave
* the catalogue (row + private media) so a later import/publish run can never
* surface them again. The cutoff (`--before`, required) is a Bahia civil date
* and the post dates come from the official feed; this is the irreversible
* sibling of `content:instagram:withdraw` (which only unpublishes). A piece the
* feed cannot date is NEVER touched, only named — deleting on an unknown date
* would risk a campaign-period piece.
*
* Modes:
* pnpm content:instagram:prune --before 2026-08-16
* plan/dry-run (default): reads the feed and the DB, lists and writes nothing
* pnpm content:instagram:prune --before 2026-08-16 --apply
* deletes; needs CONTENT_INSTAGRAM_PRUNE_CONFIRM=1 on a non-local target and
* the declared TEQO_ENV matching the exact database name
*
* Options: --scan-days <n> (feed walk depth to date the catalogue rows, default
* 365), --out <dir> (receipts, default `data/content-instagram`), --help.
*
* Runbook: on the homeserver, inside the compose maintenance service with the
* stack env file — see docs/ops/teqo-1313-deploy.md.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { getPayload } from 'payload'
import { CONTENT_MEDIA_SLUG } from '../src/lib/contentPiece.ts'
import { contentPieceFrameFilename } from '../src/lib/contentPieceFrame.ts'
import {
assertEnvironmentDatabaseTarget,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
loadCliEnv,
} from './lib/cli.mjs'
import {
loadInstagramContentWindowFeed,
revalidateInstagramContentCentral,
} from './lib/instagramContentCli.mjs'
import {
formatInstagramContentPruneReport,
instagramContentReportStamp,
parseInstagramContentPruneCliArgs,
planInstagramContentPrune,
} from './lib/instagramContentPlan.mjs'
loadCliEnv()
const die = dieWithLabel('content:instagram:prune')
const WRITE_CONFIRM_FLAG = 'CONTENT_INSTAGRAM_PRUNE_CONFIRM'
const HELP = `Uso: pnpm content:instagram:prune --before YYYY-MM-DD [opções]
APAGA (hard delete, irreversível) da Central as peças cujo post original no
Instagram é ANTERIOR à data de corte — rascunhos e publicadas. A linha e a
mídia privada saem do catálogo, então uma importação futura não as recria nem
as publica. Peça que o feed oficial não consegue datar nunca é tocada (seria
risco de apagar peça de campanha) e aparece nomeada no recibo. Sem --apply, é um
plano read-only.
Opções:
--before <data> corte civil na Bahia (obrigatório; posts anteriores saem)
--scan-days <n> profundidade da varredura do feed para datar as peças
(default 365; 1..365)
--apply executa o expurgo (exige ${WRITE_CONFIRM_FLAG}=1 em alvo
não-local)
--out <dir> recibos JSON (default data/content-instagram)
--help esta ajuda
Flags: TEQO_ENV=staging|production (obrigatória no --apply fora de teste; o nome
do banco tem de casar) e ${WRITE_CONFIRM_FLAG}=1 para a escrita.
`
const writeReport = async (options, report, suffix = '') => {
const stamp = instagramContentReportStamp(report.runAt)
const reportPath = join(options.out, `${stamp}${suffix}.json`)
await mkdir(dirname(reportPath), { recursive: true })
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
return reportPath
}
const reportLines = (report) =>
formatInstagramContentPruneReport({
target: report.target,
mode: report.mode,
before: report.before,
scanDays: report.scanDays,
feedCount: report.feed.count,
piecesCount: report.piecesCount,
plan: report.plan,
deleted: report.deleted,
failures: report.failures,
revalidation: report.revalidation,
reportPath: report.reportPath,
})
/**
* Deletes the piece row, then its private media and the C226 frame (a separate
* `contentMedia` row under a deterministic filename), both best-effort after the
* row is gone — the same order and tolerance as the C222 delete action, without
* an actor. Returns whether the deleted piece was published, so the caller only
* busts the public Central when the delete could have changed it.
*
* @param {import('payload').Payload} payload
* @param {{ id: number, status?: string | null }} entry
*/
const deleteContentPieceSystem = async (payload, entry) => {
const deleted = await payload.delete({
collection: 'contentPiece',
id: entry.id,
depth: 0,
select: { media: true },
// Intentional admin bypass: the ops prune owns the rows it listed.
overrideAccess: true,
})
const mediaId = typeof deleted.media === 'number' ? deleted.media : (deleted.media?.id ?? null)
if (mediaId !== null) {
await payload
.delete({
collection: CONTENT_MEDIA_SLUG,
id: mediaId,
// Intentional admin bypass: cleanup of the file that belonged to the
// piece this run was told to prune.
overrideAccess: true,
})
.catch(() => undefined)
}
const frame = await payload
.find({
collection: CONTENT_MEDIA_SLUG,
where: { filename: { equals: contentPieceFrameFilename(entry.id) } },
limit: 1,
depth: 0,
// Intentional admin bypass: the derived-file probe, same as C226.
overrideAccess: true,
})
.catch(() => null)
const frameId = frame?.docs?.[0]?.id ?? null
if (frameId !== null) {
await payload
.delete({ collection: CONTENT_MEDIA_SLUG, id: frameId, overrideAccess: true })
.catch(() => undefined)
}
return entry.status === 'publicado'
}
async function main() {
let options
try {
options = parseInstagramContentPruneCliArgs()
} catch (error) {
die(error instanceof Error ? error.message : String(error))
}
if (options.help) {
console.log(HELP)
process.exit(0)
}
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
const mode = options.apply ? 'apply' : 'plan'
if (mode === 'apply') {
assertWriteConfirm({
label: 'content:instagram:prune',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm content:instagram:prune --before <data> --apply',
})
assertEnvironmentDatabaseTarget()
}
console.log(
`[content:instagram:prune] alvo: ${databaseTarget()} | modo: ${mode} | corte: ${options.before} (Bahia)`,
)
const config = (await import('../src/payload.config.ts')).default
const payload = await getPayload({ config })
const window = await loadInstagramContentWindowFeed({ payload, days: options.scanDays })
if ('error' in window) {
die(
window.error === 'unavailable'
? 'Instagram ainda não configurado (token/ID ausentes ou feed desligado no global Social Feed) — o expurgo precisa das datas oficiais do feed.'
: 'não foi possível ler o feed oficial do Instagram agora (API ou credencial) — tente novamente.',
)
}
const { feed } = window
const pieces = await payload.find({
collection: 'contentPiece',
depth: 0,
limit: 0,
pagination: false,
select: { title: true, sourceUrl: true, status: true },
// Intentional admin bypass: the ops run must see every catalogue row,
// including drafts no actor could read.
overrideAccess: true,
})
const plan = planInstagramContentPrune({
pieces: pieces.docs,
posts: feed.posts,
before: options.before,
})
const runAt = new Date().toISOString()
const report = {
runAt,
mode,
target: databaseTarget(),
before: options.before,
scanDays: options.scanDays,
cutoffIso: plan.cutoffIso,
feed: { username: feed.username, count: feed.posts.length },
piecesCount: pieces.docs.length,
plan,
reportPath: null,
}
if (mode === 'plan') {
report.reportPath = await writeReport(options, report)
for (const line of reportLines(report)) console.log(line)
console.log(
`\n[content:instagram:prune] plano sem escrita — para apagar: --apply com ${WRITE_CONFIRM_FLAG}=1 (e TEQO_ENV).`,
)
process.exit(0)
}
const startedAt = Date.now()
let deleted = 0
let deletedPublished = 0
const failures = []
for (const entry of plan.toDelete) {
try {
if (await deleteContentPieceSystem(payload, entry)) deletedPublished += 1
deleted += 1
if (deleted % 25 === 0) console.log(`[content:instagram:prune] ${deleted}…`)
} catch (error) {
failures.push({
id: entry.id,
reason: error instanceof Error && error.message !== '' ? error.message : 'falha',
})
}
}
let revalidation = { attempted: false, ok: false, reason: null }
if (deletedPublished > 0) revalidation = await revalidateInstagramContentCentral()
report.deleted = deleted
report.deletedPublished = deletedPublished
report.failures = failures
report.revalidation = revalidation
report.durationMs = Date.now() - startedAt
report.reportPath = await writeReport(options, report, '-prune')
for (const line of reportLines(report)) console.log(line)
console.log(`\n[content:instagram:prune] recibo JSON: ${report.reportPath}`)
if (deleted === 0 && plan.toDelete.length > 0) {
die('nenhuma peça foi apagada — veja o recibo.')
}
process.exit(0)
}
main().catch((error) => {
die(error instanceof Error && error.message !== '' ? error.message : 'falha inesperada.')
})

View file

@ -8,14 +8,17 @@ import {
} from '@/lib/contentPieceProfileWindow'
import {
formatInstagramContentPruneReport,
formatInstagramContentReport,
formatInstagramContentWithdrawReport,
instagramContentFeedIdentityUrls,
instagramContentPostDates,
instagramContentReportStamp,
parseInstagramContentCliArgs,
parseInstagramContentPruneCliArgs,
parseInstagramContentWithdrawCliArgs,
planInstagramContentDraftsToPublish,
planInstagramContentPrune,
planInstagramContentWindow,
planInstagramContentWithdraw,
shouldPublishImportedPiece,
@ -255,6 +258,120 @@ describe('pre-campaign withdrawal (C230-followup)', () => {
})
})
describe('pre-campaign pruning (C230-followup)', () => {
const cataloguePosts = [
post('KEEP', { timestamp: '2026-08-16T00:00:00-03:00' }),
post('DROP-LATE', { timestamp: '2026-08-15T23:59:59-03:00' }),
post('DROP-EARLY', { timestamp: '2026-07-31T09:00:00-03:00' }),
]
const catalogue = [
{ id: 1, title: 'KEEP', sourceUrl: 'https://www.instagram.com/reel/KEEP/', status: 'rascunho' },
{
id: 2,
title: 'DROP-LATE',
sourceUrl: 'https://www.instagram.com/p/DROP-LATE/',
status: 'publicado',
},
{
id: 3,
title: 'DROP-EARLY',
sourceUrl: 'https://www.instagram.com/reel/DROP-EARLY/',
status: 'rascunho',
},
{
id: 4,
title: 'SEM-DATA',
sourceUrl: 'https://www.instagram.com/reel/FORA-DO-FEED/',
status: 'rascunho',
},
{ id: 5, title: 'SEM-URL', sourceUrl: null, status: 'rascunho' },
]
it('matches every piece before the cutoff, drafts included, sharing the withdraw split', () => {
const plan = planInstagramContentPrune({
pieces: catalogue,
posts: cataloguePosts,
before: '2026-08-16',
})
const withdrawn = planInstagramContentWithdraw({
pieces: catalogue,
posts: cataloguePosts,
before: '2026-08-16',
})
// 16/08 00:00 na Bahia = 03:00Z: a noite do dia 15 e tudo antes sai; peça
// sem data no feed nunca entra no conjunto (fail-closed).
expect(plan.cutoffIso).toBe('2026-08-16T03:00:00.000Z')
expect(plan.toDelete.map((piece) => piece.id)).toEqual([3, 2])
expect(plan.toDelete.map((piece) => piece.postDate.slice(0, 10))).toEqual([
'2026-07-31',
'2026-08-16',
])
expect(plan.undateable.map((piece) => piece.id)).toEqual([4, 5])
expect(plan.toDelete.map((piece) => piece.id)).toEqual(
withdrawn.toWithdraw.map((piece) => piece.id),
)
})
it('parses the prune arguments with the deep default scan', () => {
expect(parseInstagramContentPruneCliArgs(['--before', '2026-08-16'])).toEqual({
apply: false,
before: '2026-08-16',
scanDays: 365,
out: 'data/content-instagram',
help: false,
})
expect(
parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--apply', '--scan-days', '90']),
).toMatchObject({ apply: true, before: '2026-08-16', scanDays: 90 })
expect(() => parseInstagramContentPruneCliArgs([])).toThrow(/--before é obrigatório/)
expect(() => parseInstagramContentPruneCliArgs(['--before', '16/08/2026'])).toThrow(
/YYYY-MM-DD/,
)
expect(() => parseInstagramContentPruneCliArgs(['--before', '2026-02-30'])).toThrow(
/data civil válida/,
)
expect(() =>
parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--scan-days']),
).toThrow(/faltou valor/)
expect(() =>
parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--scan-days', '0']),
).toThrow(/--scan-days/)
expect(() => parseInstagramContentPruneCliArgs(['--force'])).toThrow(/argumento desconhecido/)
expect(parseInstagramContentPruneCliArgs(['--help'])).toMatchObject({ help: true })
})
it('reports the cutoff, the period, the catalogue size and the undateable rows', () => {
const plan = planInstagramContentPrune({
pieces: catalogue,
posts: cataloguePosts,
before: '2026-08-16',
})
const lines = formatInstagramContentPruneReport({
target: '127.0.0.1/teqo_1313',
mode: 'apply',
before: '2026-08-16',
scanDays: 365,
feedCount: 3,
piecesCount: 5,
plan,
deleted: 2,
failures: [],
revalidation: { attempted: true, ok: true, reason: null },
reportPath: 'data/content-instagram/x-prune.json',
})
const text = lines.join('\n')
expect(text).toContain('alvo: 127.0.0.1/teqo_1313 | modo: apply')
expect(text).toContain('corte: 2026-08-16 (Bahia; posts anteriores)')
expect(text).toContain('no catálogo: 5 · anteriores ao corte: 2 · sem data no feed: 2')
expect(text).toContain('DROP-EARLY (2026-07-31)')
expect(text).toContain('? sem data: #4 SEM-DATA')
expect(text).toContain('apagadas: 2 · falhas: 0 · revalidação: ok')
expect(text).toContain('recibo: data/content-instagram/x-prune.json')
})
})
describe('summaries and receipt', () => {
it('publishes only a ready piece when the run asked for it', () => {
expect(shouldPublishImportedPiece({ processingStatus: 'pronto', publish: true })).toBe(true)

View file

@ -0,0 +1,90 @@
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
// C230-followup — the prune CLI's fail-closed write guards, exercised as a
// subprocess: each refusal must happen before any Payload import, feed call or
// DB connection (a delete is irreversible, so the guard is the last wall).
const repoRoot = process.cwd()
const scriptPath = join(repoRoot, 'scripts', 'prune-instagram-content.mjs')
const run = (args: string[], env: Record<string, string> = {}) =>
spawnSync(process.execPath, [scriptPath, ...args], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 60_000,
env: {
...process.env,
NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs',
NODE_ENV: 'production',
VITEST: '',
DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
PAYLOAD_SECRET: 'test-secret',
CONTENT_INSTAGRAM_PRUNE_CONFIRM: '',
TEQO_ENV: '',
ALLOW_REMOTE_DB: '',
...env,
},
})
const output = (result: ReturnType<typeof run>) => `${result.stdout}${result.stderr}`
describe('content:instagram:prune write guards (C230-followup)', { timeout: 30_000 }, () => {
it('prints the help even without a database', () => {
const result = run(['--help'], { DATABASE_URL: '' })
expect(result.status).toBe(0)
expect(result.stdout).toContain('CONTENT_INSTAGRAM_PRUNE_CONFIRM')
expect(result.stdout).toContain('pnpm content:instagram:prune')
})
it('requires --before before touching anything', () => {
const result = run(['--apply'])
expect(result.status).toBe(1)
expect(output(result)).toContain('--before é obrigatório')
})
it('refuses an invalid cutoff before touching anything', () => {
const result = run(['--before', '16/08/2026'])
expect(result.status).toBe(1)
expect(output(result)).toContain('YYYY-MM-DD')
})
it('refuses --apply on a production target without the intent flag, before any DB import', () => {
const result = run(['--before', '2026-08-16', '--apply'])
expect(result.status).toBe(1)
expect(output(result)).toContain('CONTENT_INSTAGRAM_PRUNE_CONFIRM=1')
expect(output(result)).not.toContain('| modo:')
})
it('requires the declared TEQO_ENV once the intent flag is set', () => {
const result = run(['--before', '2026-08-16', '--apply'], {
CONTENT_INSTAGRAM_PRUNE_CONFIRM: '1',
})
expect(result.status).toBe(1)
expect(output(result)).toContain('TEQO_ENV')
})
it('refuses a target database that does not match the declared environment', () => {
const result = run(['--before', '2026-08-16', '--apply'], {
CONTENT_INSTAGRAM_PRUNE_CONFIRM: '1',
TEQO_ENV: 'staging',
})
expect(result.status).toBe(1)
expect(output(result)).toContain('≠ "teqo_staging"')
})
it('refuses an unknown argument before touching anything', () => {
const result = run(['--force'])
expect(result.status).toBe(1)
expect(output(result)).toContain('argumento desconhecido')
})
})