From 5eb8fd75ef0d4d2c456d6ca4f903f9a6baa9a131 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 29 Sep 2026 22:21:29 -0300 Subject: [PATCH] =?UTF-8?q?feat(C230):=20lote=20da=20Central=20=E2=80=94?= =?UTF-8?q?=20publicar=20rascunhos=20da=20janela=20e=20retirar=20pr=C3=A9-?= =?UTF-8?q?campanha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --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. --- .../2026-09-30-c230-instagram-lote-ops.md | 1 + docs/ops/teqo-1313-deploy.md | 45 +++- package.json | 1 + scripts/import-instagram-content.mjs | 200 +++++++------- scripts/lib/instagramContentCli.mjs | 154 +++++++++++ scripts/lib/instagramContentPlan.mjs | 249 +++++++++++++++++- scripts/withdraw-instagram-content.mjs | 218 +++++++++++++++ .../instagramContentImportCli.unit.spec.ts | 2 +- tests/unit/instagramContentPlan.unit.spec.ts | 158 +++++++++++ .../instagramContentWithdrawCli.unit.spec.ts | 90 +++++++ 10 files changed, 1002 insertions(+), 116 deletions(-) create mode 100644 docs/changelog/2026-09-30-c230-instagram-lote-ops.md create mode 100644 scripts/lib/instagramContentCli.mjs create mode 100644 scripts/withdraw-instagram-content.mjs create mode 100644 tests/unit/instagramContentWithdrawCli.unit.spec.ts diff --git a/docs/changelog/2026-09-30-c230-instagram-lote-ops.md b/docs/changelog/2026-09-30-c230-instagram-lote-ops.md new file mode 100644 index 00000000..98a497c6 --- /dev/null +++ b/docs/changelog/2026-09-30-c230-instagram-lote-ops.md @@ -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. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 91a13962..810d6dc7 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -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 ` (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 diff --git a/package.json b/package.json index 3ef10555..de22de62 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/import-instagram-content.mjs b/scripts/import-instagram-content.mjs index 09324565..2372fe61 100644 --- a/scripts/import-instagram-content.mjs +++ b/scripts/import-instagram-content.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 (default 60), --limit (canary), --out (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 janela em dias (default 60; 1..365) --limit canário: processa no máximo n candidatos (o resto fica no lote) --out 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, diff --git a/scripts/lib/instagramContentCli.mjs b/scripts/lib/instagramContentCli.mjs new file mode 100644 index 00000000..1bb7d46e --- /dev/null +++ b/scripts/lib/instagramContentCli.mjs @@ -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>, 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[], + * select: Record, + * }} 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', + } + } +} diff --git a/scripts/lib/instagramContentPlan.mjs b/scripts/lib/instagramContentPlan.mjs index b3b36ebb..18f8d88c 100644 --- a/scripts/lib/instagramContentPlan.mjs +++ b/scripts/lib/instagramContentPlan.mjs @@ -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} + */ +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[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[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, * summary?: ReturnType, + * 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, + * 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 +} diff --git a/scripts/withdraw-instagram-content.mjs b/scripts/withdraw-instagram-content.mjs new file mode 100644 index 00000000..e60728ed --- /dev/null +++ b/scripts/withdraw-instagram-content.mjs @@ -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 (feed walk depth to date the published pieces, + * default 90), --out (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 corte civil na Bahia (obrigatório; posts anteriores saem) + --scan-days 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 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 --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.') +}) diff --git a/tests/unit/instagramContentImportCli.unit.spec.ts b/tests/unit/instagramContentImportCli.unit.spec.ts index b19c5b11..b45cae90 100644 --- a/tests/unit/instagramContentImportCli.unit.spec.ts +++ b/tests/unit/instagramContentImportCli.unit.spec.ts @@ -14,7 +14,7 @@ const run = (args: string[], env: Record = {}) => 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', diff --git a/tests/unit/instagramContentPlan.unit.spec.ts b/tests/unit/instagramContentPlan.unit.spec.ts index 491ce834..33e187d1 100644 --- a/tests/unit/instagramContentPlan.unit.spec.ts +++ b/tests/unit/instagramContentPlan.unit.spec.ts @@ -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') }) diff --git a/tests/unit/instagramContentWithdrawCli.unit.spec.ts b/tests/unit/instagramContentWithdrawCli.unit.spec.ts new file mode 100644 index 00000000..d86a247a --- /dev/null +++ b/tests/unit/instagramContentWithdrawCli.unit.spec.ts @@ -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 = {}) => + 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) => `${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') + }) +})