mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
ce055ad3d8
commit
28daf386f9
10 changed files with 1002 additions and 116 deletions
1
docs/changelog/2026-09-30-c230-instagram-lote-ops.md
Normal file
1
docs/changelog/2026-09-30-c230-instagram-lote-ops.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
154
scripts/lib/instagramContentCli.mjs
Normal file
154
scripts/lib/instagramContentCli.mjs
Normal 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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
218
scripts/withdraw-instagram-content.mjs
Normal file
218
scripts/withdraw-instagram-content.mjs
Normal 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.')
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
90
tests/unit/instagramContentWithdrawCli.unit.spec.ts
Normal file
90
tests/unit/instagramContentWithdrawCli.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue