feat(C230): lote da Central — publicar rascunhos da janela e retirar pré-campanha

--publish-existing no content:instagram:import (publica rascunhos prontos da
janela, mais antigo primeiro) e CLI novo content:instagram:withdraw (despublica
por corte civil na Bahia, com as datas vindas do feed oficial). Plumbing
compartilhado dos dois em scripts/lib/instagramContentCli.mjs; teto do feed
passa ao da edge (10K) com early-stop por data; guardas C155/C195/C231, testes
unit das guardas e do planejador, runbook e changelog.
This commit is contained in:
Francisco Solla 2026-09-29 22:21:29 -03:00
parent 39933fc004
commit 5eb8fd75ef
10 changed files with 1002 additions and 116 deletions

View file

@ -0,0 +1 @@
**C230-followup — ferramentas de lote da Central do Instagram: publicar rascunhos e retirar pré-campanha (2026-09-30):** o CLI `content:instagram:import` ganha `--publish-existing` (publica os rascunhos prontos já na janela — inclusive o que a rodada criou quando `--publish` não foi usado — do post mais antigo para o mais novo, para o mais recente liderar o listing público ordenado por `-publishedAt`) e o teto do feed deixa de ser 300 mídias para ser o da própria edge (10K, com early-stop por data): uma janela de 60 dias com ~350 mídias não é mais cortada em silêncio. Nasce o CLI irmão `pnpm content:instagram:withdraw --before YYYY-MM-DD [--scan-days 90] [--apply]`: despublica (kill switch — preserva slug, arquivo e `publishedAt`, reversível pela ficha) as peças cujo post original é anterior a uma data civil na Bahia, lida do feed oficial; peça que o feed não consegue datar nunca é tocada e é nomeada no recibo. O corte default não existe de propósito (fronteira legal é explícita): nas Eleições 2026 a propaganda começa em 16/08 (Lei 9.504/97, art. 36; Resolução TSE nº 23.760/2026), então a retirada pré-campanha foi `--before 2026-08-16`. A mecânica compartilhada dos dois CLIs (feed da janela com a credencial do global, token renovado best-effort, lookup chunkado `sourceUrl in` de 2.000 URLs e revalidação da Central pública) vive em `scripts/lib/instagramContentCli.mjs`; o planejamento puro (datas por shortcode, ordenação dos rascunhos, split pelo corte civil, recibos) ficou em `scripts/lib/instagramContentPlan.mjs`, com o boundary vindo do dono das datas civis (`contentPieceProfilePeriodBounds`). Guardas C155/C195/C231 nos dois: `--apply` exige o `TEQO_ENV` casando o nome exato do banco e a flag própria de intenção (`CONTENT_INSTAGRAM_IMPORT_CONFIRM`/`CONTENT_INSTAGRAM_WITHDRAW_CONFIRM`); a importação ainda exige as 4 `S3_*` (a retirada não escreve mídia). Em produção, no mesmo dia: o lote de 60 dias já importado sem publicar teve **325 rascunhos prontos publicados** (`--publish-existing`) e **71 peças com post anterior a 16/08/2026 retiradas** (`withdraw --before 2026-08-16`, posts de 31/07 a 15/08, 0 falhas, sem peças sem data), com revalidação da Central nas duas pontas. Testes: unit do planejador (datas por shortcode, ordem dos rascunhos, boundary 15/08 23:59 × 16/08 00:00 na Bahia, recibo) e das guardas dos dois CLIs em subprocesso (flag/TEQO_ENV/banco/argumentos/corte inválido). Runbook atualizado em `docs/ops/teqo-1313-deploy.md` (lote com `--publish-existing` e seção de retirada com o marco eleitoral). Sem migration, sem UI, sem Consent.

View file

@ -1517,11 +1517,16 @@ O comando `pnpm content:instagram:import` traz as mídias recentes do perfil
oficial `@depjorgesolla` para a Central pelo caminho oficial (Graph API da
própria conta; a credencial é a do global `Social Feed`): cada novidade vira
uma peça no pipeline C220 (download do arquivo do próprio perfil, transcrição e
catalogação) e, com `--publish`, é publicada ao fim do lote o que terminou
`pronto` — falha fica rascunho e é nomeada no recibo. Reexecutar converge
catalogação); com `--publish`, é publicada ao fim do lote o que esta rodada
terminou `pronto`, e com `--publish-existing` também entram os **rascunhos
prontos já na janela** (lote importado sem publicar), do post mais antigo para
o mais novo — o listing público ordena por `-publishedAt`, então o mais recente
lidera. Falha fica rascunho e é nomeada no recibo. Reexecutar converge
("novas / já estavam / falharam com motivo") e nunca duplica: a identidade é o
post, não a grafia da URL. Nada de scraping, terceiro ou stories; a mídia de
terceiro nunca é baixada (o match é dentro do feed da própria conta).
terceiro nunca é baixada (o match é dentro do feed da própria conta). A
varredura lê até o teto da própria edge (10K mídias) com early-stop por data —
a janela nunca é cortada por um teto arbitrário de páginas.
O dry-run é o padrão e não escreve (lê o feed e o banco):
@ -1531,21 +1536,47 @@ ssh homeserver
cd ~/stack
# 1) plano (dry-run; mostra janela, candidatos e peça-link previstos):
docker compose --profile maintenance run --rm \
-v /srv/content-instagram:/app/data/content-instagram \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:import --days 60
# 2) canário no alvo (exige TEQO_ENV + confirmação; as 4 S3_* são obrigatórias):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_IMPORT_CONFIRM=1 \
-v /srv/content-instagram:/app/data/content-instagram \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:import --apply --publish --limit 2
# 3) lote completo — rode em tmux: o processamento baixa, transcreve e cataloga
# cada peça em sequência (docker compose run sem -d segura a sessão SSH):
# --publish-existing fecha a lacuna de um lote que já entrou como rascunho.
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_IMPORT_CONFIRM=1 \
-v /srv/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:import --apply --publish --days 60
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:import --apply --publish --publish-existing --days 60
```
**Retirar peças anteriores a um corte** (ex.: antes do início da campanha
eleitoral). O comando despublica (kill switch: preserva slug, arquivo e
`publishedAt`; reversível pela ficha) as peças cujo **post original** é
anterior à data de corte — uma data civil na Bahia, a mesma do calendário
eleitoral. Nas Eleições 2026 a propaganda é permitida a partir de **16/08**
(Lei 9.504/97, art. 36; Resolução TSE nº 23.760/2026), então o corte é
`--before 2026-08-16`. Peça publicada que o feed não consegue datar nunca é
tocada: aparece no recibo como "sem data no feed".
```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:withdraw --before 2026-08-16
# 2) retirada (exige TEQO_ENV + confirmação):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_WITHDRAW_CONFIRM=1 \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:withdraw --before 2026-08-16 --apply
```
`--scan-days <n>` (default 90) é só a profundidade da varredura do feed usada
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.
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

@ -36,6 +36,7 @@
"reels:ingest": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/reels-ingest.mjs",
"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",
"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

@ -18,7 +18,14 @@
* on a non-local target and the
* complete S3_* set
* pnpm content:instagram:import --apply --publish
* also publishes what is `pronto`
* also publishes what this run
* processed to `pronto`
* pnpm content:instagram:import --apply --publish-existing
* also publishes the READY
* DRAFTS already in the window
* (e.g. a batch imported without
* `--publish`), oldest post
* first so the newest leads
*
* Options: --days <n> (default 60), --limit <n> (canary), --out <dir> (receipts,
* default `data/content-instagram`), --help.
@ -47,11 +54,17 @@ import {
loadCliEnv,
mirroredMediaRequired,
} from './lib/cli.mjs'
import {
findContentPieceSourceRows,
loadInstagramContentWindowFeed,
revalidateInstagramContentCentral,
} from './lib/instagramContentCli.mjs'
import {
formatInstagramContentReport,
instagramContentFeedIdentityUrls,
instagramContentReportStamp,
parseInstagramContentCliArgs,
planInstagramContentDraftsToPublish,
planInstagramContentWindow,
shouldPublishImportedPiece,
summarizeInstagramContentResults,
@ -62,14 +75,6 @@ loadCliEnv()
const die = dieWithLabel('content:instagram:import')
const WRITE_CONFIRM_FLAG = 'CONTENT_INSTAGRAM_IMPORT_CONFIRM'
/** A hung Graph API must not hold the run forever. */
const FEED_TIMEOUT_MS = 60_000
/**
* Feed window of one run: one page serves 50, and the date early-stop ends the
* walk at the page that carries the first post older than `--days`, so the cap
* is only a defensive ceiling (6 pages), never the real window.
*/
const FEED_MAX_RESULTS = 300
const HELP = `Uso: pnpm content:instagram:import [opções]
@ -81,8 +86,12 @@ catalogação). Sem --apply, é um plano read-only (não baixa, não escreve).
Opções:
--apply processa o lote (exige ${WRITE_CONFIRM_FLAG}=1 em alvo
não-local e as 4 S3_*; o job grava a mídia no bucket privado)
--publish publica o que terminar "pronto" (exige --apply); falhas ficam
como rascunho e são nomeadas no recibo
--publish publica o que ESTA rodada terminar "pronto" (exige --apply);
falhas ficam como rascunho e são nomeadas no recibo
--publish-existing
publica os rascunhos PRONTOS já na janela (inclui o que esta
rodada criou se --publish não foi usado), do post mais antigo
para o mais novo (exige --apply)
--days <n> janela em dias (default 60; 1..365)
--limit <n> canário: processa no máximo n candidatos (o resto fica no lote)
--out <dir> recibos JSON (default data/content-instagram)
@ -92,40 +101,6 @@ Flags: TEQO_ENV=staging|production (obrigatória no --apply fora de teste; o nom
do banco tem de casar) e ${WRITE_CONFIRM_FLAG}=1 para a escrita.
`
/**
* The CLI writes straight to the DB, so the collection hook's `revalidateTag`
* is a no-op in this process (the seed loader stubs `next/cache`): the public
* Central is busted over HTTP, best-effort — a failure never fails the run and
* is named in the receipt. Uses the same tag the hook busts.
*/
const revalidatePublicCentral = async () => {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/+$/, '')
const secret = process.env.REVALIDATE_SECRET?.trim()
if (!baseUrl || !secret) {
return { attempted: true, ok: false, reason: 'NEXT_PUBLIC_SITE_URL/REVALIDATE_SECRET ausentes' }
}
try {
const { REVALIDATE_CONTENT_PIECES_TAG } = await import('../src/utilities/revalidateRequest.ts')
const response = await fetch(
`${baseUrl}/api/revalidate?tag=${encodeURIComponent(REVALIDATE_CONTENT_PIECES_TAG)}`,
{
method: 'POST',
headers: { 'x-revalidate-secret': secret },
signal: AbortSignal.timeout(15_000),
},
)
return response.ok
? { attempted: true, ok: true, reason: null }
: { attempted: true, ok: false, reason: `HTTP ${response.status}` }
} catch (error) {
return {
attempted: true,
ok: false,
reason: error instanceof Error && error.message !== '' ? error.message : 'falha de rede',
}
}
}
const writeReport = async (options, report, suffix = '') => {
const stamp = instagramContentReportStamp(report.runAt)
const reportPath = join(options.out, `${stamp}${suffix}.json`)
@ -173,65 +148,30 @@ async function main() {
const { createContentPieceFromProfilePost } =
await import('../src/utilities/content/contentPieceProfileImport.ts')
const { runContentPieceJob } = await import('../src/utilities/content/contentPieceJob.ts')
const { isInstagramFeedConfigured, loadInstagramFeed, persistInstagramAccessToken } =
await import('../src/utilities/socialFeed/instagramFeed.ts')
const { DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD } =
await import('../src/utilities/ai/deepInfraTranscribe.ts')
const payload = await getPayload({ config })
// Intentional admin bypass: the global is admin-only and its credential is
// read solely to call the official API; the token never leaves this process.
const settings = await payload.findGlobal({ slug: 'social-feed-settings', depth: 0 })
if (!isInstagramFeedConfigured(settings)) {
const window = await loadInstagramContentWindowFeed({ payload, days: options.days })
if ('error' in window) {
die(
'Instagram ainda não configurado (token/ID ausentes ou feed desligado no global Social Feed) — a importação só roda pelo caminho oficial.',
window.error === 'unavailable'
? 'Instagram ainda não configurado (token/ID ausentes ou feed desligado no global Social Feed) — a importação só roda pelo caminho oficial.'
: 'não foi possível ler o feed oficial do Instagram agora (API ou credencial) — tente novamente.',
)
}
const { feed, from, to } = window
const to = new Date()
const from = new Date(to.getTime() - options.days * 86_400_000)
let feed
try {
feed = await loadInstagramFeed({
accessToken: settings.instagramAccessToken,
userId: settings.instagramUserId,
maxResults: FEED_MAX_RESULTS,
// The feed is newest-first: the first page carrying a post older than the
// window ends the walk, so the typical run costs one page per 50 medias.
shouldStopAt: (post) => {
const timestamp = Date.parse(post.timestamp)
return Number.isFinite(timestamp) && timestamp < from.getTime()
},
signal: AbortSignal.timeout(FEED_TIMEOUT_MS),
})
} catch {
die(
'não foi possível ler o feed oficial do Instagram agora (API ou credencial) — tente novamente.',
)
}
if (feed.refreshedAccessToken) {
await persistInstagramAccessToken(payload, feed.refreshedAccessToken).catch(() => undefined)
}
// One `sourceUrl in` lookup covers every spelling of every feed post.
// One chunked `sourceUrl in` lookup covers every spelling of every feed post.
const identityUrls = instagramContentFeedIdentityUrls(feed.posts)
let existingSourceUrls = []
if (identityUrls.length > 0) {
const existing = await payload.find({
collection: 'contentPiece',
where: { sourceUrl: { in: identityUrls } },
depth: 0,
limit: 0,
pagination: false,
select: { sourceUrl: true },
// Intentional admin bypass: the ops import must see every catalogue row,
// including drafts no actor could read.
overrideAccess: true,
})
existingSourceUrls = existing.docs
.map((doc) => doc.sourceUrl)
.filter((sourceUrl) => typeof sourceUrl === 'string' && sourceUrl !== '')
}
const catalogued = await findContentPieceSourceRows({
payload,
sourceUrls: identityUrls,
select: { sourceUrl: true },
})
const existingSourceUrls = catalogued
.map((doc) => doc.sourceUrl)
.filter((sourceUrl) => typeof sourceUrl === 'string' && sourceUrl !== '')
const plan = planInstagramContentWindow({
posts: feed.posts,
@ -260,7 +200,7 @@ async function main() {
console.log(line)
}
console.log(
`\n[content:instagram:import] plano sem escrita — para importar: --apply [--publish]`,
`\n[content:instagram:import] plano sem escrita — para importar: --apply [--publish] [--publish-existing]`,
)
console.log(`[content:instagram:import] recibo JSON: ${reportPath}`)
process.exit(0)
@ -344,11 +284,64 @@ async function main() {
}
}
const publishedInWindow = new Set()
let publishedExisting = 0
let publishFailures = 0
const extraFailures = []
if (options.publishExisting && identityUrls.length > 0) {
const drafts = await findContentPieceSourceRows({
payload,
sourceUrls: identityUrls,
where: [{ status: { equals: 'rascunho' } }, { processingStatus: { equals: 'pronto' } }],
select: { title: true, sourceUrl: true, linkFailureReason: true },
})
const planned = planInstagramContentDraftsToPublish({ pieces: drafts, posts: feed.posts })
// Oldest post first so the newest carries the latest `publishedAt` (the
// public listing sorts by `-publishedAt`). This phase runs BEFORE the
// run's own `--publish` so a combined run keeps that order.
for (const piece of planned.ordered) {
try {
await payload.update({
collection: 'contentPiece',
id: piece.id,
data: { status: 'publicado' },
depth: 0,
// Intentional admin bypass: the ops run publishes what it listed.
overrideAccess: true,
})
publishedExisting += 1
publishedInWindow.add(piece.id)
if (publishedExisting % 50 === 0) {
console.log(
`[content:instagram:import] publicando rascunhos da janela: ${publishedExisting}…`,
)
}
} catch (error) {
publishFailures += 1
const message =
error instanceof Error && error.message !== '' ? error.message : 'falha inesperada'
extraFailures.push({ shortcode: `#${piece.id}`, error: `publicação: ${message}` })
}
}
for (const piece of planned.undateable) {
console.log(
`[content:instagram:import] sem data no feed — não publicada: #${piece.id} ${piece.title ?? ''}`.trim(),
)
}
console.log(
`[content:instagram:import] rascunhos prontos da janela: ${planned.ordered.length} · publicadas: ${publishedExisting}${
planned.linkOnly > 0 ? ` (peça-link: ${planned.linkOnly})` : ''
}`,
)
}
if (options.publish) {
// Oldest first so the newest post carries the latest `publishedAt` — the
// public listing sorts by `-publishedAt`, so the newest leads the Central.
for (const entry of [...created].reverse()) {
if (publishedInWindow.has(entry.contentPieceId)) continue
if (!shouldPublishImportedPiece({ ...entry, publish: true })) continue
try {
await payload.update({
@ -377,8 +370,8 @@ async function main() {
}
let revalidation = { attempted: false, ok: false, reason: null }
if (results.some((result) => result.published === true)) {
revalidation = await revalidatePublicCentral()
if (publishedExisting > 0 || results.some((result) => result.published === true)) {
revalidation = await revalidateInstagramContentCentral()
console.log(
`[content:instagram:import] Central pública: ${
revalidation.ok ? 'revalidada' : `revalidação falhou (${revalidation.reason})`
@ -389,9 +382,12 @@ async function main() {
const summary = summarizeInstagramContentResults(results, {
costPerMinuteUsd: DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD,
})
const failures = results
.filter((result) => result.error !== null)
.map((result) => ({ shortcode: result.shortcode, error: result.error }))
const failures = [
...results
.filter((result) => result.error !== null)
.map((result) => ({ shortcode: result.shortcode, error: result.error })),
...extraFailures,
]
const candidatesRemaining = plan.candidates.length - selected.length
const runAt = new Date().toISOString()
const report = {
@ -407,6 +403,7 @@ async function main() {
failures,
candidatesRemaining,
publishFailures,
publishedExisting,
revalidation,
}
const reportPath = await writeReport(options, report, '-apply')
@ -417,6 +414,7 @@ async function main() {
feedCount: report.feed.count,
plan,
summary,
publishedExisting,
failures,
candidatesRemaining,
reportPath,

View file

@ -0,0 +1,154 @@
/**
* C230-followup — shared impure plumbing of the Instagram content ops CLIs
* (`content:instagram:import` and `content:instagram:withdraw`): the window of
* the own profile's official feed read with the global's credential (fail-
* closed, refreshed token persisted best-effort), the chunked `sourceUrl in`
* lookups over the Central and the best-effort revalidation of the public
* Central after a direct-DB write. The planning/reporting stays pure in
* `instagramContentPlan.mjs`; the token never leaves this module.
*/
import { REVALIDATE_CONTENT_PIECES_TAG } from '../../src/utilities/revalidateRequest.ts'
import {
isInstagramFeedConfigured,
loadInstagramFeed,
persistInstagramAccessToken,
} from '../../src/utilities/socialFeed/instagramFeed.ts'
/** A hung Graph API must not hold the run forever. */
const INSTAGRAM_CONTENT_FEED_TIMEOUT_MS = 60_000
/**
* The edge serves up to 10K media; the date early-stop bounds the walk, so the
* cap is the platform's own ceiling — a deep window is never silently cut at
* an arbitrary page count.
*/
const INSTAGRAM_CONTENT_FEED_MAX_RESULTS = 10_000
/**
* Chunk of one `sourceUrl in` lookup: a deep window (up to 10K media × 3 URL
* spellings) would blow the query/payload size in a single `in`.
*/
const CONTENT_PIECE_SOURCE_URL_CHUNK = 2_000
/**
* Reads the own-profile feed window with the global's credential, fail-closed:
* an unconfigured credential never reaches the API, and a feed failure is told
* apart from the missing credential so each CLI prints its own honest message.
* A refreshed token is persisted best-effort, exactly like the link path.
*
* @param {{
* payload: import('payload').Payload,
* days: number,
* maxResults?: number,
* timeoutMs?: number,
* }} input
* @returns {Promise<
* | { feed: Awaited<ReturnType<typeof loadInstagramFeed>>, from: Date, to: Date }
* | { error: 'unavailable' | 'feed' }
* >}
*/
export const loadInstagramContentWindowFeed = async ({
payload,
days,
maxResults = INSTAGRAM_CONTENT_FEED_MAX_RESULTS,
timeoutMs = INSTAGRAM_CONTENT_FEED_TIMEOUT_MS,
}) => {
// Intentional admin bypass: the global is admin-only and its credential is
// read solely to call the official API; the token never leaves this call.
const settings = await payload.findGlobal({ slug: 'social-feed-settings', depth: 0 })
if (!isInstagramFeedConfigured(settings)) return { error: 'unavailable' }
const to = new Date()
const from = new Date(to.getTime() - days * 86_400_000)
let feed
try {
feed = await loadInstagramFeed({
accessToken: settings.instagramAccessToken,
userId: settings.instagramUserId,
maxResults,
// The feed is newest-first: the first page carrying a post older than the
// window ends the walk, so the typical run costs one page per 50 medias.
shouldStopAt: (post) => {
const timestamp = Date.parse(post.timestamp)
return Number.isFinite(timestamp) && timestamp < from.getTime()
},
signal: AbortSignal.timeout(timeoutMs),
})
} catch {
return { error: 'feed' }
}
if (feed.refreshedAccessToken) {
await persistInstagramAccessToken(payload, feed.refreshedAccessToken).catch(() => undefined)
}
return { feed, from, to }
}
/**
* One chunked lookup of the Central by `sourceUrl`, with optional extra `and`
* conditions (status/processing filters). `select` keeps the rows lean.
*
* @param {{
* payload: import('payload').Payload,
* sourceUrls: readonly string[],
* where?: readonly Record<string, unknown>[],
* select: Record<string, true>,
* }} input
*/
export const findContentPieceSourceRows = async ({ payload, sourceUrls, where = [], select }) => {
const unique = [...new Set(sourceUrls)]
const docs = []
for (let index = 0; index < unique.length; index += CONTENT_PIECE_SOURCE_URL_CHUNK) {
const chunk = unique.slice(index, index + CONTENT_PIECE_SOURCE_URL_CHUNK)
const found = await payload.find({
collection: 'contentPiece',
where: { and: [{ sourceUrl: { in: chunk } }, ...where] },
depth: 0,
limit: 0,
pagination: false,
select,
// Intentional admin bypass: the ops run must see every catalogue row,
// including drafts no actor could read.
overrideAccess: true,
})
docs.push(...found.docs)
}
return docs
}
/**
* The CLIs write straight to the DB, so the collection hook's `revalidateTag`
* is a no-op in their process (the seed loader stubs `next/cache`): the public
* Central is busted over HTTP, best-effort — a failure never fails the run and
* is named in the receipt. Uses the same tag the hook busts.
*
* @returns {Promise<{ attempted: boolean, ok: boolean, reason: string | null }>}
*/
export const revalidateInstagramContentCentral = async () => {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/+$/, '')
const secret = process.env.REVALIDATE_SECRET?.trim()
if (!baseUrl || !secret) {
return { attempted: true, ok: false, reason: 'NEXT_PUBLIC_SITE_URL/REVALIDATE_SECRET ausentes' }
}
try {
const response = await fetch(
`${baseUrl}/api/revalidate?tag=${encodeURIComponent(REVALIDATE_CONTENT_PIECES_TAG)}`,
{
method: 'POST',
headers: { 'x-revalidate-secret': secret },
signal: AbortSignal.timeout(15_000),
},
)
return response.ok
? { attempted: true, ok: true, reason: null }
: { attempted: true, ok: false, reason: `HTTP ${response.status}` }
} catch (error) {
return {
attempted: true,
ok: false,
reason: error instanceof Error && error.message !== '' ? error.message : 'falha de rede',
}
}
}

View file

@ -1,14 +1,18 @@
/**
* 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.
* C230-followup — pure planning/reporting of the Instagram content ops CLIs
* (`import-instagram-content.mjs` and `withdraw-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), order the window's ready drafts for
* publication, split the published set by a civil cutoff and aggregate the
* honest run reports. No I/O and no `server-only`: the unit specs read this
* module and the CLIs are the impure shells.
*/
import { parseContentPieceLink } from '../../src/lib/contentPiece.ts'
import {
contentPieceProfileFeedIdentityUrls,
contentPieceProfilePeriodBounds,
planContentPieceProfileWindow,
} from '../../src/lib/contentPieceProfileWindow.ts'
@ -27,7 +31,10 @@ 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]'
'pnpm content:instagram:import [--apply] [--publish] [--publish-existing] [--days 60] [--limit n] [--out data/content-instagram]'
const INSTAGRAM_CONTENT_WITHDRAW_USAGE =
'pnpm content:instagram:withdraw --before YYYY-MM-DD [--scan-days 90] [--apply] [--out data/content-instagram]'
/** Report file stamp: the run's ISO instant with `:`/`.` swapped, like C215. */
export const instagramContentReportStamp = (runAt) => runAt.replace(/[:.]/g, '-')
@ -42,6 +49,7 @@ export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
const options = {
apply: false,
publish: false,
publishExisting: false,
days: INSTAGRAM_CONTENT_DEFAULT_DAYS,
limit: null,
out: INSTAGRAM_CONTENT_DEFAULT_OUT_DIR,
@ -60,6 +68,7 @@ export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
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 === '--publish-existing') options.publishExisting = true
else if (arg === '--days') options.days = Number(value())
else if (arg === '--limit') options.limit = Number(value())
else if (arg === '--out') options.out = value()
@ -70,6 +79,9 @@ export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
if (options.publish && !options.apply) {
throw new Error(`--publish exige --apply — ${INSTAGRAM_CONTENT_USAGE}.`)
}
if (options.publishExisting && !options.apply) {
throw new Error(`--publish-existing exige --apply — ${INSTAGRAM_CONTENT_USAGE}.`)
}
if (
!Number.isInteger(options.days) ||
options.days < 1 ||
@ -86,6 +98,61 @@ export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
return options
}
/**
* Parses the withdraw 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 published pieces.
*
* @param {string[]} [argv]
*/
export const parseInstagramContentWithdrawCliArgs = (argv = process.argv.slice(2)) => {
const options = {
apply: false,
before: null,
scanDays: 90,
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_WITHDRAW_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_WITHDRAW_USAGE}.`)
}
if (options.help) return options
if (options.before === null) {
throw new Error(`--before é obrigatório — ${INSTAGRAM_CONTENT_WITHDRAW_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
@ -94,6 +161,103 @@ export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
export const shouldPublishImportedPiece = ({ processingStatus, publish }) =>
publish === true && processingStatus === 'pronto'
/**
* shortcode → earliest post instant (ms) of the official feed. A link the
* catalogue cannot parse contributes nothing; a repeated post keeps the
* earliest instant (the original publication).
*
* @param {readonly { permalink: string, mediaType: string, mediaUrl?: string | null, timestamp: string }[]} posts
* @returns {Map<string, number>}
*/
export const instagramContentPostDates = (posts) => {
const dates = new Map()
for (const post of posts) {
const link = parseContentPieceLink(post.permalink)
const timestamp = Date.parse(post.timestamp)
if (!link || link.origin !== 'instagram' || !Number.isFinite(timestamp)) continue
const current = dates.get(link.shortcode)
if (current === undefined || timestamp < current) dates.set(link.shortcode, timestamp)
}
return dates
}
/**
* Orders the window's ready drafts for publication: oldest post first, so the
* newest post carries the latest `publishedAt` (the public listing sorts by
* `-publishedAt`). A piece whose post is not in the feed is named, never
* dropped silently.
*
* @param {{
* pieces: Array<{ id: number, title?: string | null, sourceUrl?: string | null, linkFailureReason?: string | null }>,
* posts: Parameters<typeof instagramContentPostDates>[0],
* }} input
*/
export const planInstagramContentDraftsToPublish = ({ pieces, posts }) => {
const dates = instagramContentPostDates(posts)
const ordered = []
const undateable = []
for (const piece of pieces) {
const link = parseContentPieceLink(piece.sourceUrl)
const timestamp = link?.origin === 'instagram' ? dates.get(link.shortcode) : undefined
if (timestamp === undefined) {
undateable.push(piece)
continue
}
ordered.push({ ...piece, shortcode: link.shortcode, postTimestamp: timestamp })
}
ordered.sort((left, right) => left.postTimestamp - right.postTimestamp)
return {
ordered,
undateable,
linkOnly: ordered.filter(
(piece) => typeof piece.linkFailureReason === 'string' && piece.linkFailureReason !== '',
).length,
}
}
/**
* 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.
*
* @param {{
* pieces: Array<{ id: number, title?: string | null, sourceUrl?: string | null }>,
* posts: Parameters<typeof instagramContentPostDates>[0],
* before: string,
* }} input
*/
export const planInstagramContentWithdraw = ({ pieces, posts, before }) => {
const { fromIso } = contentPieceProfilePeriodBounds({ since: before })
const cutoffMs = Date.parse(fromIso)
const dates = instagramContentPostDates(posts)
const toWithdraw = []
const undateable = []
for (const piece of pieces) {
const link = parseContentPieceLink(piece.sourceUrl)
const timestamp = link?.origin === 'instagram' ? dates.get(link.shortcode) : undefined
if (timestamp === undefined) {
undateable.push(piece)
continue
}
if (timestamp < cutoffMs) {
toWithdraw.push({
...piece,
shortcode: link.shortcode,
postTimestamp: timestamp,
postDate: new Date(timestamp).toISOString(),
})
}
}
toWithdraw.sort((left, right) => left.postTimestamp - right.postTimestamp)
return { cutoffIso: fromIso, toWithdraw, undateable }
}
/**
* 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
@ -135,6 +299,7 @@ export const summarizeInstagramContentResults = (results, { costPerMinuteUsd = 0
* feedCount: number,
* plan: ReturnType<typeof planInstagramContentWindow>,
* summary?: ReturnType<typeof summarizeInstagramContentResults>,
* publishedExisting?: number,
* failures?: Array<{ shortcode: string, error: string }>,
* candidatesRemaining?: number,
* reportPath?: string | null,
@ -160,6 +325,11 @@ export const formatInstagramContentReport = (report) => {
`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})`)
if ((report.publishedExisting ?? 0) > 0) {
lines.push(
`publicadas da janela (já importadas, rascunhos prontos): ${report.publishedExisting}`,
)
}
for (const failure of report.failures ?? []) {
lines.push(` - falha ${failure.shortcode}: ${failure.error}`)
}
@ -178,3 +348,68 @@ export const formatInstagramContentReport = (report) => {
if (report.reportPath) lines.push(`recibo: ${report.reportPath}`)
return lines
}
const WITHDRAW_SAMPLE_LIMIT = 10
/**
* The withdraw receipt lines the operator reads (and the JSON mirrors): the
* cutoff, what the feed could date, the withdrawn set with its period and the
* pieces the feed could not date (never touched, always named).
*
* @param {{
* target: string,
* mode: 'plan' | 'apply',
* before: string,
* scanDays: number,
* feedCount: number,
* publishedCount: number,
* plan: ReturnType<typeof planInstagramContentWithdraw>,
* withdrawn?: number,
* failures?: Array<{ id: number, reason: string }>,
* revalidation?: { attempted: boolean, ok: boolean, reason: string | null },
* reportPath?: string | null,
* }} report
*/
export const formatInstagramContentWithdrawReport = (report) => {
const lines = []
const { toWithdraw, undateable } = report.plan
const dates = toWithdraw.map((entry) => entry.postTimestamp)
lines.push(`[content:instagram:withdraw] 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(
`publicadas: ${report.publishedCount} · anteriores ao corte: ${toWithdraw.length} · sem data no feed: ${undateable.length}`,
)
if (toWithdraw.length > 0) {
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)) {
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}`)
}
}
for (const entry of undateable) {
lines.push(
` ? sem data: #${entry.id} ${entry.title ?? ''} — ${entry.sourceUrl ?? 'sem URL'}`.trim(),
)
}
if (report.mode === 'apply') {
lines.push(
`retiradas: ${report.withdrawn ?? 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,218 @@
/**
* C230-followup — withdraws from the public Central the pieces whose original
* Instagram post is BEFORE a civil cutoff (the electoral campaign boundary in
* 2026 is 16/08). The cutoff (`--before`, required) is a Bahia civil date, the
* post dates come from the official feed and the action is the kill switch
* (unpublish): slug, file and `publishedAt` are preserved and the receipt names
* every piece. A published piece the feed cannot date is never touched, only
* named. Re-executing converges — what is already a draft leaves the listing.
*
* Modes:
* pnpm content:instagram:withdraw --before 2026-08-16
* plan/dry-run (default): reads the feed and the DB, lists and writes nothing
* pnpm content:instagram:withdraw --before 2026-08-16 --apply
* withdraws; needs CONTENT_INSTAGRAM_WITHDRAW_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 published pieces,
* default 90), --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 {
assertEnvironmentDatabaseTarget,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
loadCliEnv,
} from './lib/cli.mjs'
import {
loadInstagramContentWindowFeed,
revalidateInstagramContentCentral,
} from './lib/instagramContentCli.mjs'
import {
formatInstagramContentWithdrawReport,
instagramContentReportStamp,
parseInstagramContentWithdrawCliArgs,
planInstagramContentWithdraw,
} from './lib/instagramContentPlan.mjs'
loadCliEnv()
const die = dieWithLabel('content:instagram:withdraw')
const WRITE_CONFIRM_FLAG = 'CONTENT_INSTAGRAM_WITHDRAW_CONFIRM'
const HELP = `Uso: pnpm content:instagram:withdraw --before YYYY-MM-DD [opções]
Retira da Central pública (despublica) as peças cujo post original no Instagram
é ANTERIOR à data de corte — o kill switch preserva slug, arquivo e publishedAt,
então a operação é reversível pela ficha. 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 90; 1..365)
--apply executa a retirada (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) =>
formatInstagramContentWithdrawReport({
target: report.target,
mode: report.mode,
before: report.before,
scanDays: report.scanDays,
feedCount: report.feed.count,
publishedCount: report.publishedCount,
plan: report.plan,
withdrawn: report.withdrawn,
failures: report.failures,
revalidation: report.revalidation,
reportPath: report.reportPath,
})
async function main() {
let options
try {
options = parseInstagramContentWithdrawCliArgs()
} 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:withdraw',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm content:instagram:withdraw --before <data> --apply',
})
assertEnvironmentDatabaseTarget()
}
console.log(
`[content:instagram:withdraw] 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) — a retirada 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 published = await payload.find({
collection: 'contentPiece',
where: { status: { equals: 'publicado' } },
depth: 0,
limit: 0,
pagination: false,
select: { title: true, sourceUrl: true },
// Intentional admin bypass: the ops run must see every published row.
overrideAccess: true,
})
const plan = planInstagramContentWithdraw({
pieces: published.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 },
publishedCount: published.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:withdraw] plano sem escrita — para retirar: --apply com ${WRITE_CONFIRM_FLAG}=1 (e TEQO_ENV).`,
)
process.exit(0)
}
const startedAt = Date.now()
let withdrawn = 0
const failures = []
for (const entry of plan.toWithdraw) {
try {
await payload.update({
collection: 'contentPiece',
id: entry.id,
data: { status: 'rascunho' },
depth: 0,
// Intentional admin bypass: the ops run retires what it listed.
overrideAccess: true,
})
withdrawn += 1
if (withdrawn % 25 === 0) console.log(`[content:instagram:withdraw] ${withdrawn}…`)
} 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 (withdrawn > 0) revalidation = await revalidateInstagramContentCentral()
report.withdrawn = withdrawn
report.failures = failures
report.revalidation = revalidation
report.durationMs = Date.now() - startedAt
report.reportPath = await writeReport(options, report, '-withdraw')
for (const line of reportLines(report)) console.log(line)
console.log(`\n[content:instagram:withdraw] recibo JSON: ${report.reportPath}`)
if (withdrawn === 0 && plan.toWithdraw.length > 0) {
die('nenhuma peça foi retirada — veja o recibo.')
}
process.exit(0)
}
main().catch((error) => {
die(error instanceof Error && error.message !== '' ? error.message : 'falha inesperada.')
})

View file

@ -14,7 +14,7 @@ const run = (args: string[], env: Record<string, string> = {}) =>
spawnSync(process.execPath, [scriptPath, ...args], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 30_000,
timeout: 60_000,
env: {
...process.env,
NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs',

View file

@ -9,10 +9,15 @@ import {
import {
formatInstagramContentReport,
formatInstagramContentWithdrawReport,
instagramContentFeedIdentityUrls,
instagramContentPostDates,
instagramContentReportStamp,
parseInstagramContentCliArgs,
parseInstagramContentWithdrawCliArgs,
planInstagramContentDraftsToPublish,
planInstagramContentWindow,
planInstagramContentWithdraw,
shouldPublishImportedPiece,
summarizeInstagramContentResults,
} from '../../scripts/lib/instagramContentPlan.mjs'
@ -48,6 +53,7 @@ describe('content:instagram:import arguments', () => {
expect(parseInstagramContentCliArgs([])).toEqual({
apply: false,
publish: false,
publishExisting: false,
days: 60,
limit: null,
out: 'data/content-instagram',
@ -55,6 +61,14 @@ describe('content:instagram:import arguments', () => {
})
})
it('accepts --publish-existing only with --apply', () => {
expect(parseInstagramContentCliArgs(['--apply', '--publish-existing'])).toMatchObject({
apply: true,
publishExisting: true,
})
expect(() => parseInstagramContentCliArgs(['--publish-existing'])).toThrow(/exige --apply/)
})
it('accepts the apply/publish pair and the window options', () => {
expect(
parseInstagramContentCliArgs([
@ -99,6 +113,148 @@ describe('planner re-exports (C235)', () => {
})
})
describe('window draft publication (C230-followup)', () => {
it('dates the feed by shortcode, keeping the earliest publication', () => {
const dates = instagramContentPostDates([
post('A', { timestamp: '2026-09-02T12:00:00.000Z' }),
post('A', {
timestamp: '2026-09-01T12:00:00.000Z',
permalink: 'https://www.instagram.com/p/A/',
}),
post('X', { permalink: 'https://example.com/x/' }),
])
expect(dates.get('A')).toBe(Date.parse('2026-09-01T12:00:00.000Z'))
expect(dates.size).toBe(1)
})
it('orders the ready drafts oldest post first and names the undateable', () => {
const planned = planInstagramContentDraftsToPublish({
pieces: [
{ id: 1, title: 'A', sourceUrl: 'https://www.instagram.com/reel/A/' },
{ id: 2, title: 'B', sourceUrl: 'https://www.instagram.com/p/B/' },
{
id: 3,
title: 'D',
sourceUrl: 'https://www.instagram.com/reel/D/',
linkFailureReason: 'carrossel',
},
{ id: 4, title: 'C', sourceUrl: 'https://www.instagram.com/reel/NAO-ESTA/' },
],
posts: [
post('A', { timestamp: '2026-09-01T12:00:00.000Z' }),
post('B', { timestamp: '2026-08-20T12:00:00.000Z' }),
post('D', { timestamp: '2026-08-25T12:00:00.000Z' }),
],
})
expect(planned.ordered.map((piece) => piece.id)).toEqual([2, 3, 1])
expect(planned.ordered.map((piece) => piece.shortcode)).toEqual(['B', 'D', 'A'])
expect(planned.undateable.map((piece) => piece.id)).toEqual([4])
expect(planned.linkOnly).toBe(1)
})
})
describe('pre-campaign withdrawal (C230-followup)', () => {
const publishedPosts = [
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' }),
]
it('splits by the Bahia civil cutoff, oldest withdrawn first', () => {
const plan = planInstagramContentWithdraw({
pieces: [
{ id: 1, title: 'KEEP', sourceUrl: 'https://www.instagram.com/reel/KEEP/' },
{ id: 2, title: 'DROP-LATE', sourceUrl: 'https://www.instagram.com/p/DROP-LATE/' },
{ id: 3, title: 'DROP-EARLY', sourceUrl: 'https://www.instagram.com/reel/DROP-EARLY/' },
{ id: 4, title: 'SEM-DATA', sourceUrl: 'https://www.instagram.com/reel/FORA-DO-FEED/' },
{ id: 5, title: 'SEM-URL', sourceUrl: null },
],
posts: publishedPosts,
before: '2026-08-16',
})
// 16/08 00:00 na Bahia = 03:00Z: o que veio antes sai, inclusive a noite do dia 15.
expect(plan.cutoffIso).toBe('2026-08-16T03:00:00.000Z')
expect(plan.toWithdraw.map((piece) => piece.id)).toEqual([3, 2])
expect(plan.toWithdraw.map((piece) => piece.postDate.slice(0, 10))).toEqual([
'2026-07-31',
'2026-08-16',
])
expect(plan.undateable.map((piece) => piece.id)).toEqual([4, 5])
})
it('refuses an impossible civil date', () => {
expect(() =>
planInstagramContentWithdraw({ pieces: [], posts: [], before: '2026-02-30' }),
).toThrow(/data inicial válida/)
})
it('parses the withdraw arguments with a required cutoff', () => {
expect(parseInstagramContentWithdrawCliArgs(['--before', '2026-08-16'])).toEqual({
apply: false,
before: '2026-08-16',
scanDays: 90,
out: 'data/content-instagram',
help: false,
})
expect(
parseInstagramContentWithdrawCliArgs([
'--before',
'2026-08-16',
'--apply',
'--scan-days',
'60',
]),
).toMatchObject({ apply: true, before: '2026-08-16', scanDays: 60 })
expect(() => parseInstagramContentWithdrawCliArgs([])).toThrow(/--before é obrigatório/)
expect(() => parseInstagramContentWithdrawCliArgs(['--before', '16/08/2026'])).toThrow(
/YYYY-MM-DD/,
)
expect(() => parseInstagramContentWithdrawCliArgs(['--before', '2026-02-30'])).toThrow(
/data civil válida/,
)
expect(() => parseInstagramContentWithdrawCliArgs(['--force'])).toThrow(
/argumento desconhecido/,
)
expect(parseInstagramContentWithdrawCliArgs(['--help'])).toMatchObject({ help: true })
})
it('reports the cutoff, the period and the undateable pieces', () => {
const plan = planInstagramContentWithdraw({
pieces: [
{ id: 3, title: 'DROP-EARLY', sourceUrl: 'https://www.instagram.com/reel/DROP-EARLY/' },
{ id: 4, title: 'SEM-DATA', sourceUrl: null },
],
posts: publishedPosts,
before: '2026-08-16',
})
const lines = formatInstagramContentWithdrawReport({
target: '127.0.0.1/teqo_1313',
mode: 'apply',
before: '2026-08-16',
scanDays: 90,
feedCount: 3,
publishedCount: 2,
plan,
withdrawn: 1,
failures: [],
revalidation: { attempted: true, ok: true, reason: null },
reportPath: 'data/content-instagram/x-withdraw.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('anteriores ao corte: 1 · sem data no feed: 1')
expect(text).toContain('DROP-EARLY (2026-07-31)')
expect(text).toContain('? sem data: #4 SEM-DATA')
expect(text).toContain('retiradas: 1 · falhas: 0 · revalidação: ok')
expect(text).toContain('recibo: data/content-instagram/x-withdraw.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)
@ -158,6 +314,7 @@ describe('summaries and receipt', () => {
summary: summarizeInstagramContentResults([
{ outcome: 'created', processingStatus: 'falhou', error: 'ffmpeg ausente' },
]),
publishedExisting: 3,
failures: [{ shortcode: 'A', error: 'ffmpeg ausente' }],
candidatesRemaining: 1,
reportPath: 'data/content-instagram/x.json',
@ -169,6 +326,7 @@ describe('summaries and receipt', () => {
expect(text).toContain('candidatos: 2 (peça-link previstos: 1)')
expect(text).toContain('falha A: ffmpeg ausente')
expect(text).toContain('peça-link B: carrossel')
expect(text).toContain('publicadas da janela (já importadas, rascunhos prontos): 3')
expect(text).toContain('restam 1 candidatos')
expect(text).toContain('recibo: data/content-instagram/x.json')
})

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 withdraw CLI's fail-closed write guards, exercised as a
// subprocess: each refusal must happen before any Payload import, feed call or
// DB connection.
const repoRoot = process.cwd()
const scriptPath = join(repoRoot, 'scripts', 'withdraw-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_WITHDRAW_CONFIRM: '',
TEQO_ENV: '',
ALLOW_REMOTE_DB: '',
...env,
},
})
const output = (result: ReturnType<typeof run>) => `${result.stdout}${result.stderr}`
describe('content:instagram:withdraw write guards (C230-followup)', () => {
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_WITHDRAW_CONFIRM')
expect(result.stdout).toContain('pnpm content:instagram:withdraw')
})
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_WITHDRAW_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_WITHDRAW_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_WITHDRAW_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')
})
})