diff --git a/.gitignore b/.gitignore index 47e19ca6..df6db5a0 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,8 @@ next-env.d.ts /data/falas-web/ # C231 — Flickr photo-archive cache + receipts (pnpm flickr:import) /data/flickr/ +# C230-followup — Instagram content import receipts (pnpm content:instagram:import) +/data/content-instagram/ # C229 — speech semantic index reports (pnpm acervo:index) /data/acervo-index/ # C232 — photo-archive cataloguing receipts (pnpm archive:catalog) diff --git a/Dockerfile b/Dockerfile index 599ee9c4..4783420f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,11 @@ FROM base AS migrator WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +# C230-followup — the maintenance image is also the runtime of the operational +# CLIs, and the Central content import job (`content:instagram:import`) extracts +# audio with ffmpeg; the runbook executes it in this service. The deps/builder +# stages still never pay for it. +RUN apk add --no-cache ffmpeg # Bake the package manager INTO the image: `pnpm migrate` runs at CONTAINER # START, so without a corepack cache the runtime downloads pnpm from # registry.npmjs.org — the maintenance container's outbound is not guaranteed @@ -75,8 +80,9 @@ FROM base AS runner WORKDIR /app # C167 — `ffmpeg` cuts the exact [início,fim] excerpt of an acervo speech -# (speechCutJob). Only the runner stage pays for it: the deps/builder/migrator -# stages never run it. `FFMPEG_PATH` can still override the binary. +# (speechCutJob). Only the runner and migrator stages pay for it (the migrator +# is the maintenance CLI runtime, see above): deps/builder never run it. +# `FFMPEG_PATH` can still override the binary. RUN apk add --no-cache ffmpeg ENV NODE_ENV=production diff --git a/docs/changelog/2026-09-29-c230-instagram-import.md b/docs/changelog/2026-09-29-c230-instagram-import.md new file mode 100644 index 00000000..34be71ef --- /dev/null +++ b/docs/changelog/2026-09-29-c230-instagram-import.md @@ -0,0 +1 @@ +**C230-followup — importação em lote do Instagram para a Central de Conteúdos (CLI) (2026-09-29):** a "Importar do perfil" da tela (C230) é sob demanda e de janela fixa (12 mídias, tudo Rascunho); a comunicação precisava de uma varredura maior, já publicada, sem scraping e pelo caminho oficial. O repo ganha o CLI `pnpm content:instagram:import`: lê o feed do perfil próprio pela Graph API (credencial do global `Social Feed`, fail-closed), filtra a janela por dias (`--days`, default 60), deduplica pela identidade do post (qualquer grafia de URL já catalogada — o predicado compartilhado com a colagem C220 e o importador C230), e com `--apply` cria cada novidade no shape do link e processa **inline** pelo pipeline C220 (download do arquivo do próprio perfil, transcrição e catalogação); com `--publish`, publica ao fim do lote o que terminou `pronto`, do mais antigo para o mais novo (o listing público ordena por `-publishedAt`, então o post mais recente lidera) — `falhou` fica rascunho e é nomeado no recibo, e uma falha de publicação é contada. O dry-run é o padrão (lê feed + banco, não baixa nem escreve) e os recibos JSON datados ficam em `data/content-instagram/`. Guardas no padrão C155/C195/C231: `--apply` exige `TEQO_ENV` casando o nome exato do banco (`teqo_1313`/`teqo_staging`), `CONTENT_INSTAGRAM_IMPORT_CONFIRM=1` fora do dev local, as quatro `S3_*` (a mídia vai ao bucket privado; sem elas o job gravaria em disco efêmero) e recusa `ALLOW_REMOTE_DB`; a credencial nunca entra em log/recibo. Dois donos ganharam um seam de sistema (sem twinning): `contentPieceExistsForPostIdentity` e `createContentPieceFromProfilePost` aceitam `actor: null` (bypass admin intencional, `createdBy` sem carimbo) e a criação devolve `{ outcome, contentPieceId }` — a ação da tela segue devolvendo só `{ outcome }`. O estágio `migrator` do Dockerfile passa a instalar `ffmpeg` (é o runtime dos CLIs de manutenção e o job extrai o áudio), com o comentário do C167 atualizado. Como o CLI escreve direto no banco (fora de request, o `revalidateTag` do hook é no-op nesse processo), o `--publish` revalida a Central pública ao final pela rota oficial (`tag=contentPieces`, best-effort, nomeado no recibo) — a tag entrou na allowlist do `/api/revalidate` como a do álbum no C233. Testes: unit do planejador (janela, dedupe nas duas direções, classificação peça-link, resumo com custo de ASR, recibo), das guardas do CLI em subprocesso (flag/TEQO_ENV/banco/S3/`--publish` sem `--apply`/argumento desconhecido), da allowlist nova e int do modo sistema (cria sem ator, sem gêmea, devolve o id). Runbook novo em `docs/ops/teqo-1313-deploy.md` (dry-run → canário → lote em tmux; staging antes quando possível) com a dependência de rede: o serviço de manutenção precisa da `teqo-ipv6` para o CDN do Instagram (post-mortem `2026-09-25`). Sem migration, sem UI, sem Consent. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 1ecbbdb0..91a13962 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -1511,6 +1511,71 @@ levam vetor nem bytes de imagem). O engine carrega os mesmos modelos do navegador de `node_modules`; a cópia same-origin para o browser é artefato de build (`scripts/copy-face-vision-assets.mjs`). +## C230-followup — importação do Instagram para a Central de Conteúdos + +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 +("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). + +O dry-run é o padrão e não escreve (lê o feed e o banco): + +```bash +# no homeserver: +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 \ + 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 \ + 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): +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 +``` + +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 +(`POST {NEXT_PUBLIC_SITE_URL}/api/revalidate?tag=contentPieces`, com +`REVALIDATE_SECRET` do env do stack — a tag entrou na allowlist do endpoint, +como o álbum do C233); se a revalidação falhar, o +recibo nomeia o motivo e o fallback manual no homeserver é +`curl -X POST http://localhost:1313/api/revalidate?tag=contentPieces -H "x-revalidate-secret: $REVALIDATE_SECRET"` +(no staging, `:1314`). Guardas: fora de teste o +`TEQO_ENV=staging|production` é obrigatório com o **nome exato** do banco +(`teqo_staging`/`teqo_1313`); `ALLOW_REMOTE_DB` é recusado; sem as 4 `S3_*` o +`--apply` recusa (o job gravaria em disco efêmero); e +`CONTENT_INSTAGRAM_IMPORT_CONFIRM=1` é exigido em alvo não-local. Sem +credencial no global o comando falha fechado com a mensagem de produto; o token +nunca entra em recibo/log. + +**Rede:** o job baixa o arquivo do CDN do Instagram e a rota IPv4 do container +é instável (post-mortem `2026-09-25-instagram-cdn-ipv4-instavel.md`) — o +serviço de manutenção usado precisa estar anexado à rede `teqo-ipv6` do stack +(a mesma do container de produção). Sem ela, parte dos vídeos vira peça-link +por `indisponivel` (honesto, mas não é o desejado). + +**Reprocessar uma falha:** um `falhou` fica rascunho e a reimportação **pula** +o que já existe — o retry é o botão "Reprocessar" da ficha (C211), não uma +segunda rodada do CLI. `--limit` deixa a cauda no lote: rode de novo (o CLI +pula o que já entrou). + +Rollback: nada de schema. O kill switch é despublicar/apagar as peças no admin +(a Central pública atualiza na hora pelo tag `contentPieces`). + ## Referências - `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`) diff --git a/package.json b/package.json index ed402402..3ef10555 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "media:recover": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/recover-media.mjs", "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", "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 new file mode 100644 index 00000000..09324565 --- /dev/null +++ b/scripts/import-instagram-content.mjs @@ -0,0 +1,437 @@ +/** + * C230-followup — imports the official Instagram profile's recent media into + * the Central de Conteúdos through the C220 pipeline: the feed comes from the + * official Graph API of the OWN account (no scraping, no third-party media), + * each novelty is created in the link shape and processed inline (download of + * the profile's own file, transcription and automatic cataloguing). With + * `--publish`, every piece the pipeline finished (`pronto`) goes public at the + * end of the batch; a `falhou` piece stays a draft and is named in the receipt. + * Re-running converges ("novas / já estavam / falharam com motivo") and never + * duplicates — the identity is the post, not the URL spelling. + * + * Modes: + * pnpm content:instagram:import plan/dry-run (default): + * reads the feed and the DB, + * writes nothing + * pnpm content:instagram:import --apply processes the window; needs + * CONTENT_INSTAGRAM_IMPORT_CONFIRM=1 + * on a non-local target and the + * complete S3_* set + * pnpm content:instagram:import --apply --publish + * also publishes what is `pronto` + * + * Options: --days (default 60), --limit (canary), --out (receipts, + * default `data/content-instagram`), --help. + * + * Guards: --apply refuses a non-local/production target without the intent flag + * (C155) and demands the declared TEQO_ENV matching the exact database name + * (C195/C231); a remote target also demands the complete S3_* set, because the + * job uploads the extracted media to the private bucket (without S3 the file + * would land on the container's ephemeral disk). The Instagram credential lives + * in the `social-feed-settings` global and never enters a log; without it the + * run fails closed with the product message. + * + * 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, + mirroredMediaRequired, +} from './lib/cli.mjs' +import { + formatInstagramContentReport, + instagramContentFeedIdentityUrls, + instagramContentReportStamp, + parseInstagramContentCliArgs, + planInstagramContentWindow, + shouldPublishImportedPiece, + summarizeInstagramContentResults, +} from './lib/instagramContentPlan.mjs' + +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] + +Importa as mídias recentes do perfil oficial (@depjorgesolla) para a Central de +Conteúdos pelo caminho oficial (Graph API da própria conta), processando cada +novidade pelo pipeline da peça (download do arquivo próprio, transcrição e +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 + --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) + --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. +` + +/** + * 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`) + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') + return reportPath +} + +async function main() { + let options + try { + options = parseInstagramContentCliArgs() + } 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:import', + flag: WRITE_CONFIRM_FLAG, + command: 'pnpm content:instagram:import --apply', + }) + const target = assertEnvironmentDatabaseTarget() + const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts') + const storage = resolveS3StorageEnv(process.env) + if (mirroredMediaRequired({ s3Enabled: storage.enabled })) { + die( + `escrita em produção/remoto (${target.environment}) exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas a mídia iria para o disco efêmero do container.`, + ) + } + } + + console.log( + `[content:instagram:import] alvo: ${databaseTarget()} | modo: ${mode} | janela: ${options.days} dias`, + ) + + const config = (await import('../src/payload.config.ts')).default + 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)) { + die( + 'Instagram ainda não configurado (token/ID ausentes ou feed desligado no global Social Feed) — a importação só roda pelo caminho oficial.', + ) + } + + 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. + 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 plan = planInstagramContentWindow({ + posts: feed.posts, + existingSourceUrls, + from: from.toISOString(), + to: to.toISOString(), + }) + + if (mode === 'plan') { + const report = { + runAt: new Date().toISOString(), + mode, + target: databaseTarget(), + window: { days: options.days, from: from.toISOString(), to: to.toISOString() }, + feed: { username: feed.username, count: feed.posts.length }, + plan, + } + const reportPath = await writeReport(options, report) + for (const line of formatInstagramContentReport({ + target: report.target, + mode, + window: report.window, + feedCount: report.feed.count, + plan, + })) { + console.log(line) + } + console.log( + `\n[content:instagram:import] plano sem escrita — para importar: --apply [--publish]`, + ) + console.log(`[content:instagram:import] recibo JSON: ${reportPath}`) + process.exit(0) + } + + const selected = + options.limit === null ? plan.candidates : plan.candidates.slice(0, options.limit) + const results = [] + const created = [] + const startedAt = Date.now() + + for (const [index, candidate] of selected.entries()) { + const prefix = `[${index + 1}/${selected.length}] ${candidate.shortcode}` + try { + const { outcome, contentPieceId } = await createContentPieceFromProfilePost({ + payload, + actor: null, + url: candidate.url, + // The CLI owns the processing: the job runs inline, not after a response. + startJob: () => {}, + }) + + if (outcome === 'existing' || contentPieceId === null) { + results.push({ + ...candidate, + contentPieceId: null, + outcome: 'existing', + processingStatus: null, + linkFailureReason: null, + durationSeconds: null, + error: null, + published: false, + }) + console.log(`${prefix} — já estava na Central`) + continue + } + + await runContentPieceJob(payload, contentPieceId) + const row = await payload.findByID({ + collection: 'contentPiece', + id: contentPieceId, + depth: 0, + select: { + processingStatus: true, + linkFailureReason: true, + durationSeconds: true, + error: true, + }, + // Intentional admin bypass: the pipeline owns the row it just wrote. + overrideAccess: true, + }) + const failed = row.processingStatus === 'falhou' + results.push({ + ...candidate, + contentPieceId, + outcome: 'created', + processingStatus: row.processingStatus, + linkFailureReason: row.linkFailureReason ?? null, + durationSeconds: row.durationSeconds ?? null, + error: failed ? (row.error ?? 'falha no processamento') : null, + published: false, + }) + if (!failed) created.push({ contentPieceId, processingStatus: row.processingStatus }) + console.log( + `${prefix} — ${row.processingStatus}${row.linkFailureReason ? ` (peça-link: ${row.linkFailureReason})` : ''}`, + ) + } catch (error) { + const message = + error instanceof Error && error.message !== '' ? error.message : 'falha inesperada' + results.push({ + ...candidate, + contentPieceId: null, + outcome: 'failed', + processingStatus: 'falhou', + linkFailureReason: null, + durationSeconds: null, + error: message, + published: false, + }) + console.log(`${prefix} — falhou: ${message}`) + } + } + + let publishFailures = 0 + 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 (!shouldPublishImportedPiece({ ...entry, publish: true })) continue + try { + await payload.update({ + collection: 'contentPiece', + id: entry.contentPieceId, + data: { status: 'publicado' }, + depth: 0, + // Intentional admin bypass: the ops run publishes what it imported. + overrideAccess: true, + }) + const result = results.find((item) => item.contentPieceId === entry.contentPieceId) + if (result) result.published = true + } catch (error) { + publishFailures += 1 + const result = results.find((item) => item.contentPieceId === entry.contentPieceId) + if (result) { + result.error = `publicação: ${ + error instanceof Error && error.message !== '' ? error.message : 'falha inesperada' + }` + } + } + } + if (publishFailures > 0) { + console.log(`[content:instagram:import] ${publishFailures} falha(s) ao publicar`) + } + } + + let revalidation = { attempted: false, ok: false, reason: null } + if (results.some((result) => result.published === true)) { + revalidation = await revalidatePublicCentral() + console.log( + `[content:instagram:import] Central pública: ${ + revalidation.ok ? 'revalidada' : `revalidação falhou (${revalidation.reason})` + }`, + ) + } + + 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 candidatesRemaining = plan.candidates.length - selected.length + const runAt = new Date().toISOString() + const report = { + runAt, + mode, + target: databaseTarget(), + window: { days: options.days, from: from.toISOString(), to: to.toISOString() }, + feed: { username: feed.username, count: feed.posts.length }, + plan, + selected: selected.map((candidate) => candidate.shortcode), + results, + summary: { ...summary, durationMs: Date.now() - startedAt }, + failures, + candidatesRemaining, + publishFailures, + revalidation, + } + const reportPath = await writeReport(options, report, '-apply') + for (const line of formatInstagramContentReport({ + target: report.target, + mode, + window: report.window, + feedCount: report.feed.count, + plan, + summary, + failures, + candidatesRemaining, + reportPath, + })) { + console.log(line) + } + console.log(`\n[content:instagram:import] recibo JSON: ${reportPath}`) + + // Only a systemic failure (nothing entered the Central) is an error exit. + if (results.length > 0 && summary.created === 0 && summary.existing === 0) { + die('nenhuma peça entrou na Central — falhas listadas no recibo.') + } + process.exit(0) +} + +main().catch((error) => { + die(error instanceof Error && error.message !== '' ? error.message : 'falha inesperada.') +}) diff --git a/scripts/lib/instagramContentPlan.mjs b/scripts/lib/instagramContentPlan.mjs new file mode 100644 index 00000000..90f6338c --- /dev/null +++ b/scripts/lib/instagramContentPlan.mjs @@ -0,0 +1,273 @@ +/** + * C230-followup — pure planning/reporting of the Instagram content import CLI + * (`import-instagram-content.mjs`): filter the official feed to the declared + * recency window, classify each post into an import candidate, dedupe by the + * post identity (what the Central already carries, in any URL spelling) and + * aggregate the honest run report. No I/O and no `server-only`: the unit specs + * read this module and the CLI is the impure shell. + */ + +import { + contentPiecePostIdentityUrls, + contentPieceProfileCandidateFromPost, + parseContentPieceLink, +} from '../../src/lib/contentPiece.ts' + +/** Default recency window of one import run (the request's 60 days). */ +const INSTAGRAM_CONTENT_DEFAULT_DAYS = 60 + +/** Defensive ceiling: a window longer than a year is a typo, not an intent. */ +const INSTAGRAM_CONTENT_MAX_DAYS = 365 + +const INSTAGRAM_CONTENT_DEFAULT_OUT_DIR = 'data/content-instagram' + +const INSTAGRAM_CONTENT_USAGE = + 'pnpm content:instagram:import [--apply] [--publish] [--days 60] [--limit n] [--out data/content-instagram]' + +/** Report file stamp: the run's ISO instant with `:`/`.` swapped, like C215. */ +export const instagramContentReportStamp = (runAt) => runAt.replace(/[:.]/g, '-') + +/** + * Parses the CLI arguments. `--publish` only exists with `--apply` (there is + * nothing to publish in the plan mode) and `--out` cannot escape the repo. + * + * @param {string[]} [argv] + */ +export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => { + const options = { + apply: false, + publish: false, + days: INSTAGRAM_CONTENT_DEFAULT_DAYS, + limit: null, + out: INSTAGRAM_CONTENT_DEFAULT_OUT_DIR, + help: false, + } + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + const value = () => { + const next = argv[++index] + if (next === undefined || next.startsWith('--')) { + throw new Error(`faltou valor para ${arg} — ${INSTAGRAM_CONTENT_USAGE}.`) + } + return next + } + if (arg === '--help' || arg === '-h') options.help = true + else if (arg === '--apply') options.apply = true + else if (arg === '--publish') options.publish = true + else if (arg === '--days') options.days = Number(value()) + else if (arg === '--limit') options.limit = Number(value()) + else if (arg === '--out') options.out = value() + else throw new Error(`argumento desconhecido: ${arg} — ${INSTAGRAM_CONTENT_USAGE}.`) + } + + if (options.help) return options + if (options.publish && !options.apply) { + throw new Error(`--publish exige --apply — ${INSTAGRAM_CONTENT_USAGE}.`) + } + if ( + !Number.isInteger(options.days) || + options.days < 1 || + options.days > INSTAGRAM_CONTENT_MAX_DAYS + ) { + throw new Error(`--days deve ser um inteiro entre 1 e ${INSTAGRAM_CONTENT_MAX_DAYS}.`) + } + if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) { + throw new Error('--limit deve ser >= 1.') + } + if (options.out.split(/[\\/]/).includes('..')) { + throw new Error('--out não pode escapar do diretório do repo (sem "..").') + } + return options +} + +/** + * The identity URLs of every parseable post of the feed — what the dedupe + * query asks the Central for in ONE `sourceUrl in` lookup. A permalink the + * catalogue cannot parse contributes nothing (it never becomes a candidate). + * + * @param {Array<{ permalink: string, mediaType: string, mediaUrl?: string | null }>} posts + * @returns {string[]} + */ +export const instagramContentFeedIdentityUrls = (posts) => { + const urls = new Set() + for (const post of posts) { + const candidate = contentPieceProfileCandidateFromPost(post) + if (!candidate) continue + const link = parseContentPieceLink(candidate.url) + if (!link) continue + for (const url of contentPiecePostIdentityUrls(link)) urls.add(url) + } + return [...urls] +} + +/** + * Filters the feed to the recency window and splits it into novelties, pieces + * the Central already had and posts that cannot be imported (outside the + * window, unparseable permalink or a repeated identity inside the feed). + * Candidates keep the feed order (newest first). + * + * `found` counts the parseable posts inside the window: candidates + already + * catalogued + repeated identities. + * + * @param {{ + * posts: Array<{ permalink: string, mediaType: string, mediaUrl?: string | null, timestamp: string }>, + * existingSourceUrls?: string[], + * from: string, + * to: string, + * }} input + */ +export const planInstagramContentWindow = ({ posts, existingSourceUrls = [], from, to }) => { + const fromMs = Date.parse(from) + const toMs = Date.parse(to) + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) { + throw new Error('janela inválida: from/to precisam ser instantes ISO.') + } + + const existing = new Set(existingSourceUrls) + const seen = new Set() + const candidates = [] + const existingShortcodes = [] + let outsideWindow = 0 + let malformed = 0 + let feedDuplicates = 0 + + for (const post of posts) { + const timestamp = Date.parse(post.timestamp) + if (!Number.isFinite(timestamp) || timestamp < fromMs || timestamp > toMs) { + // An item without a usable timestamp cannot be placed in the window. + if (!Number.isFinite(timestamp)) malformed += 1 + else outsideWindow += 1 + continue + } + + const candidate = contentPieceProfileCandidateFromPost(post) + const link = candidate ? parseContentPieceLink(candidate.url) : null + if (!candidate || !link) { + malformed += 1 + continue + } + + const identityUrls = contentPiecePostIdentityUrls(link) + if (identityUrls.some((url) => existing.has(url))) { + existingShortcodes.push(link.shortcode) + continue + } + if (identityUrls.some((url) => seen.has(url))) { + feedDuplicates += 1 + continue + } + identityUrls.forEach((url) => seen.add(url)) + candidates.push({ + url: candidate.url, + shortcode: link.shortcode, + linkOnlyReason: candidate.linkOnlyReason, + timestamp: post.timestamp, + mediaType: post.mediaType, + }) + } + + return { + window: { from, to }, + found: candidates.length + existingShortcodes.length + feedDuplicates, + existingCount: existingShortcodes.length, + existingShortcodes, + feedDuplicates, + malformed, + outsideWindow, + candidates, + } +} + +/** + * The publish rule of the import: only a piece the pipeline finished (`pronto`) + * goes public, and only when the run asked for it. A `falhou` piece stays a + * draft and the receipt names it. + */ +export const shouldPublishImportedPiece = ({ processingStatus, publish }) => + publish === true && processingStatus === 'pronto' + +/** + * Aggregates the per-candidate results of an `--apply` run. `asrCostUsd` uses + * the provider's per-audio-minute price passed by the shell (the app-side + * owner of the constant), never a guessed duration. + * + * @param {Array<{ outcome: string, processingStatus?: string | null, linkFailureReason?: string | null, published?: boolean, durationSeconds?: number | null, error?: string | null }>} results + * @param {{ costPerMinuteUsd?: number }} [options] + */ +export const summarizeInstagramContentResults = (results, { costPerMinuteUsd = 0 } = {}) => { + const count = (predicate) => results.filter(predicate).length + const asrSeconds = results.reduce( + (total, result) => total + (Number(result.durationSeconds) || 0), + 0, + ) + + return { + attempted: results.length, + created: count((result) => result.outcome === 'created'), + existing: count((result) => result.outcome === 'existing'), + ready: count((result) => result.processingStatus === 'pronto'), + failed: count((result) => result.processingStatus === 'falhou'), + published: count((result) => result.published === true), + linkOnly: count( + (result) => typeof result.linkFailureReason === 'string' && result.linkFailureReason !== '', + ), + asrSeconds: Math.round(asrSeconds), + asrCostUsd: Number(((asrSeconds / 60) * costPerMinuteUsd).toFixed(4)), + } +} + +/** + * The receipt lines the operator reads (and the JSON mirrors). Only facts the + * run actually observed: a failure is named with its shortcode and reason. + * + * @param {{ + * target: string, + * mode: 'plan' | 'apply', + * window: { days: number, from: string, to: string }, + * feedCount: number, + * plan: ReturnType, + * summary?: ReturnType, + * failures?: Array<{ shortcode: string, error: string }>, + * candidatesRemaining?: number, + * reportPath?: string | null, + * }} report + */ +export const formatInstagramContentReport = (report) => { + const lines = [] + const plannedLinkOnly = report.plan.candidates.filter( + (candidate) => candidate.linkOnlyReason !== null, + ).length + + lines.push(`[content:instagram:import] alvo: ${report.target} | modo: ${report.mode}`) + lines.push(`janela: ${report.window.days} dias (${report.window.from} → ${report.window.to})`) + lines.push( + `feed: ${report.feedCount} mídias · na janela: ${report.plan.found} · já na Central: ${report.plan.existingCount} · fora da janela: ${report.plan.outsideWindow} · sem identidade: ${report.plan.malformed}`, + ) + lines.push( + `candidatos: ${report.plan.candidates.length} (peça-link previstos: ${plannedLinkOnly})`, + ) + + if (report.summary) { + lines.push( + `novas: ${report.summary.created} · já estavam: ${report.summary.existing} · prontas: ${report.summary.ready} · falharam: ${report.summary.failed} · publicadas: ${report.summary.published} · peça-link: ${report.summary.linkOnly}`, + ) + lines.push(`ASR: ${report.summary.asrSeconds}s (~US$ ${report.summary.asrCostUsd})`) + for (const failure of report.failures ?? []) { + lines.push(` - falha ${failure.shortcode}: ${failure.error}`) + } + for (const candidate of report.plan.candidates.filter( + (entry) => entry.linkOnlyReason !== null, + )) { + lines.push(` - peça-link ${candidate.shortcode}: ${candidate.linkOnlyReason}`) + } + } + + if ((report.candidatesRemaining ?? 0) > 0) { + lines.push( + `restam ${report.candidatesRemaining} candidatos no lote — rode de novo para terminar.`, + ) + } + if (report.reportPath) lines.push(`recibo: ${report.reportPath}`) + return lines +} diff --git a/scripts/lib/test-affected-core.mjs b/scripts/lib/test-affected-core.mjs index 925a308f..fc91cb95 100644 --- a/scripts/lib/test-affected-core.mjs +++ b/scripts/lib/test-affected-core.mjs @@ -86,6 +86,7 @@ export const SCRIPTS_SPEC_PINNED = [ 'scripts/lib/graficosInstagramRender.mjs', 'scripts/lib/imageFill.mjs', 'scripts/lib/imageResize.mjs', + 'scripts/lib/instagramContentPlan.mjs', 'scripts/lib/issues-panel.mjs', 'scripts/lib/markdown-ansi.mjs', 'scripts/lib/mediaBinaries.mjs', diff --git a/src/app/(campaign)/campanha/actions/contentPieces.ts b/src/app/(campaign)/campanha/actions/contentPieces.ts index e64bd41f..d65aad17 100644 --- a/src/app/(campaign)/campanha/actions/contentPieces.ts +++ b/src/app/(campaign)/campanha/actions/contentPieces.ts @@ -407,7 +407,7 @@ export const createContentPieceFromProfilePostForActor = async (input: { if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE) - const outcome = await createContentPieceFromProfilePost({ payload, actor, url: parsed.url }) + const { outcome } = await createContentPieceFromProfilePost({ payload, actor, url: parsed.url }) return { outcome } } diff --git a/src/utilities/content/contentPieceLink.ts b/src/utilities/content/contentPieceLink.ts index c53ab31a..e87b096b 100644 --- a/src/utilities/content/contentPieceLink.ts +++ b/src/utilities/content/contentPieceLink.ts @@ -84,8 +84,11 @@ const matchesInstagramShortcode = /** * C230 — the one identity probe of the vertical: a post is already catalogued * when ANY canonical spelling of its shortcode is a `sourceUrl`. The paste - * path (C220) and the profile importer (C230) share it, so pasting `/p/ABC/` - * answers "já está na Central" for a piece that entered as `/reel/ABC/`. + * path (C220), the profile importer (C230) and the ops import CLI share it, so + * pasting `/p/ABC/` answers "já está na Central" for a piece that entered as + * `/reel/ABC/`. `actor: null` is the ops/system mode: the probe runs with the + * intentional admin bypass (the pipeline owns those rows), never for a request + * path. */ export const contentPieceExistsForPostIdentity = async ({ payload, @@ -93,7 +96,7 @@ export const contentPieceExistsForPostIdentity = async ({ link, }: { payload: Payload - actor: CampaignUser + actor: CampaignUser | null link: ContentPieceLink }): Promise => { const found = await payload.find({ @@ -102,8 +105,9 @@ export const contentPieceExistsForPostIdentity = async ({ depth: 0, limit: 1, pagination: false, - user: actor, - overrideAccess: false, + // Intentional admin bypass only for the ops/system mode (`actor: null`); + // every request path passes the acting campaign user. + ...(actor ? { user: actor, overrideAccess: false } : { overrideAccess: true }), }) return found.docs.length > 0 } diff --git a/src/utilities/content/contentPieceProfileImport.ts b/src/utilities/content/contentPieceProfileImport.ts index cf67db9b..00e48173 100644 --- a/src/utilities/content/contentPieceProfileImport.ts +++ b/src/utilities/content/contentPieceProfileImport.ts @@ -56,6 +56,12 @@ export type ContentPieceProfileImportListing = { export type ContentPieceProfileImportOutcome = 'created' | 'existing' +export type ContentPieceProfileImportCreation = { + outcome: ContentPieceProfileImportOutcome + /** The created row; null when the identity already existed (no new piece). */ + contentPieceId: number | null +} + const cataloguedSourceUrls = async ({ payload, actor, @@ -191,6 +197,10 @@ export const listContentPieceProfileImportCandidates = async ({ * peça-link reason), transcribes and catalogues — the importer never * downloads anything itself. `existing` covers the race with another actor * (the identity probe lost) and the unique index is the last barrier. + * + * `actor: null` is the ops/system mode used by the content import CLI: the + * same shape and probe, with the intentional admin bypass of the pipeline + * (the caller runs the job inline instead of scheduling it after a response). */ export const createContentPieceFromProfilePost = async ({ payload, @@ -199,16 +209,18 @@ export const createContentPieceFromProfilePost = async ({ startJob = startContentPieceJobInBackground, }: { payload: Payload - actor: CampaignUser + actor: CampaignUser | null url: string startJob?: (contentPieceId: number) => void -}): Promise => { +}): Promise => { const link = parseContentPieceLink(url) if (!link || link.origin !== 'instagram') { throw new Error(CONTENT_PIECE_LINK_INVALID_MESSAGE) } - if (await contentPieceExistsForPostIdentity({ payload, actor, link })) return 'existing' + if (await contentPieceExistsForPostIdentity({ payload, actor, link })) { + return { outcome: 'existing', contentPieceId: null } + } try { const piece = await payload.create({ @@ -223,15 +235,18 @@ export const createContentPieceFromProfilePost = async ({ step: 'extraindo', }, depth: 0, - user: actor, - overrideAccess: false, + // Intentional admin bypass only for the ops/system mode (`actor: null`); + // every request path passes the acting campaign user. + ...(actor ? { user: actor, overrideAccess: false } : { overrideAccess: true }), }) startJob(piece.id) - return 'created' + return { outcome: 'created', contentPieceId: piece.id } } catch (error) { // A concurrent import can still hit the unique index; it is the same // duplicate the identity probe answers. - if (isContentPieceSourceUrlDuplicateError(error)) return 'existing' + if (isContentPieceSourceUrlDuplicateError(error)) { + return { outcome: 'existing', contentPieceId: null } + } throw error } } diff --git a/src/utilities/revalidateRequest.ts b/src/utilities/revalidateRequest.ts index 6fcdb3a9..3178faec 100644 --- a/src/utilities/revalidateRequest.ts +++ b/src/utilities/revalidateRequest.ts @@ -14,6 +14,14 @@ export const REVALIDATE_PRIVACY_POLICY_CACHE_TAG = getGlobalCacheTag('privacy-po */ const REVALIDATE_ARCHIVE_PHOTOS_TAG = getCollectionListingTag('archivePhoto') +/** + * C230-followup — the Central de Conteúdos listing tag: the Instagram import + * CLI writes straight to the DB (outside any request, so the collection hook's + * `revalidateTag` is a no-op in that process) and busts it through this + * endpoint after publishing. Derived from the same owner as the hook. + */ +export const REVALIDATE_CONTENT_PIECES_TAG = getCollectionListingTag('contentPiece') + /** * Cache tag of the campaign home content board's external feeds (`unstable_cache` * entry of `getYouTubeFeed`). The `SocialFeedSettings` global's `afterChange` @@ -28,6 +36,7 @@ const ALLOWED_REVALIDATE_TAGS = [ MUNICIPALITY_CATALOG_CACHE_TAG, REVALIDATE_SOCIAL_FEED_TAG, REVALIDATE_ARCHIVE_PHOTOS_TAG, + REVALIDATE_CONTENT_PIECES_TAG, ] as const type AllowedRevalidateTag = (typeof ALLOWED_REVALIDATE_TAGS)[number] diff --git a/tests/int/contentPiece.int.spec.ts b/tests/int/contentPiece.int.spec.ts index 812cdafc..01d805a1 100644 --- a/tests/int/contentPiece.int.spec.ts +++ b/tests/int/contentPiece.int.spec.ts @@ -1739,13 +1739,14 @@ describe('content pieces (C211)', () => { const marker = Date.now().toString(36) const started: number[] = [] - const outcome = await createContentPieceFromProfilePost({ + const result = await createContentPieceFromProfilePost({ payload, actor: communicator, url: `https://www.instagram.com/reel/CR${marker}/`, startJob: (id) => started.push(id), }) - expect(outcome).toBe('created') + expect(result.outcome).toBe('created') + expect(result.contentPieceId).not.toBeNull() const rows = await payload.find({ collection: 'contentPiece', @@ -1781,10 +1782,51 @@ describe('content pieces (C211)', () => { url: `https://www.instagram.com/p/CR${marker}/`, startJob: (id) => started.push(id), }), - ).resolves.toBe('existing') + ).resolves.toEqual({ outcome: 'existing', contentPieceId: null }) expect(started).toHaveLength(1) }) + // The ops import CLI creates without an actor (system mode) and needs the + // created id to run the job inline: same shape, admin bypass, no stamp. + it('creates in system mode without an actor and returns the created id', async () => { + await setInstagramSettings(true) + const marker = Date.now().toString(36) + const started: number[] = [] + + const result = await createContentPieceFromProfilePost({ + payload, + actor: null, + url: `https://www.instagram.com/reel/SYS${marker}/`, + startJob: (id) => started.push(id), + }) + expect(result.outcome).toBe('created') + expect(result.contentPieceId).not.toBeNull() + + const row = await payload.findByID({ + collection: 'contentPiece', + id: result.contentPieceId!, + depth: 0, + overrideAccess: true, + }) + createdPieceIds.add(row.id) + expect(row).toMatchObject({ + title: `Instagram · SYS${marker}`, + status: 'rascunho', + processingStatus: 'processando', + }) + expect(row.createdBy ?? null).toBeNull() + expect(started).toEqual([row.id]) + + // The system probe shares the identity predicate: no twin on re-run. + await expect( + createContentPieceFromProfilePost({ + payload, + actor: null, + url: `https://www.instagram.com/p/SYS${marker}/`, + }), + ).resolves.toEqual({ outcome: 'existing', contentPieceId: null }) + }) + it('refuses a non-Instagram URL in the profile creator', async () => { await expect( createContentPieceFromProfilePost({ diff --git a/tests/unit/instagramContentImportCli.unit.spec.ts b/tests/unit/instagramContentImportCli.unit.spec.ts new file mode 100644 index 00000000..b19c5b11 --- /dev/null +++ b/tests/unit/instagramContentImportCli.unit.spec.ts @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +// C230-followup — the CLI's fail-closed write guards, exercised as a +// subprocess: each refusal must happen before any Payload import, feedback +// call, S3 write or DB connection. + +const repoRoot = process.cwd() +const scriptPath = join(repoRoot, 'scripts', 'import-instagram-content.mjs') + +const run = (args: string[], env: Record = {}) => + spawnSync(process.execPath, [scriptPath, ...args], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 30_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_IMPORT_CONFIRM: '', + TEQO_ENV: '', + ALLOW_REMOTE_DB: '', + S3_BUCKET: '', + S3_ENDPOINT: '', + S3_ACCESS_KEY_ID: '', + S3_SECRET_ACCESS_KEY: '', + ...env, + }, + }) + +const output = (result: ReturnType) => `${result.stdout}${result.stderr}` + +describe('content:instagram:import 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_IMPORT_CONFIRM') + expect(result.stdout).toContain('pnpm content:instagram:import') + }) + + it('refuses --apply on a production target without the intent flag, before any DB import', () => { + const result = run(['--apply']) + + expect(result.status).toBe(1) + expect(output(result)).toContain('CONTENT_INSTAGRAM_IMPORT_CONFIRM=1') + expect(output(result)).not.toContain('| modo:') + }) + + it('requires the declared TEQO_ENV once the intent flag is set', () => { + const result = run(['--apply'], { CONTENT_INSTAGRAM_IMPORT_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(['--apply'], { + CONTENT_INSTAGRAM_IMPORT_CONFIRM: '1', + TEQO_ENV: 'staging', + }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('≠ "teqo_staging"') + }) + + it('refuses --apply without the complete S3_* set on a declared production target', () => { + const result = run(['--apply'], { + CONTENT_INSTAGRAM_IMPORT_CONFIRM: '1', + TEQO_ENV: 'production', + }) + + expect(result.status).toBe(1) + expect(output(result)).toContain('S3_BUCKET') + }) + + it('refuses --publish without --apply before touching anything', () => { + const result = run(['--publish']) + + expect(result.status).toBe(1) + expect(output(result)).toContain('--publish exige --apply') + }) + + it('refuses an unknown argument before touching anything', () => { + const result = run(['--force']) + + expect(result.status).toBe(1) + expect(output(result)).toContain('argumento desconhecido') + }) +}) diff --git a/tests/unit/instagramContentPlan.unit.spec.ts b/tests/unit/instagramContentPlan.unit.spec.ts new file mode 100644 index 00000000..83f7584c --- /dev/null +++ b/tests/unit/instagramContentPlan.unit.spec.ts @@ -0,0 +1,243 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + formatInstagramContentReport, + instagramContentFeedIdentityUrls, + instagramContentReportStamp, + parseInstagramContentCliArgs, + planInstagramContentWindow, + shouldPublishImportedPiece, + summarizeInstagramContentResults, +} from '../../scripts/lib/instagramContentPlan.mjs' + +// C230-followup — the import planner contract: the recency window filter, the +// identity dedupe (in both directions: the feed against the Central and the +// feed against itself), the link-only classification and the honest report. +// The feed is always a plain array; no network, no Payload. + +const post = ( + shortcode: string, + { + mediaType = 'REEL', + mediaUrl = `https://cdn.example/${shortcode}.mp4`, + timestamp = '2026-09-20T12:00:00.000Z', + permalink, + }: { + mediaType?: string + mediaUrl?: string | null + timestamp?: string + permalink?: string + } = {}, +) => ({ + id: shortcode, + permalink: permalink ?? `https://www.instagram.com/reel/${shortcode}/`, + mediaType, + mediaUrl, + timestamp, +}) + +describe('content:instagram:import arguments', () => { + it('defaults to the 60-day plan with no writes', () => { + expect(parseInstagramContentCliArgs([])).toEqual({ + apply: false, + publish: false, + days: 60, + limit: null, + out: 'data/content-instagram', + help: false, + }) + }) + + it('accepts the apply/publish pair and the window options', () => { + expect( + parseInstagramContentCliArgs([ + '--apply', + '--publish', + '--days', + '45', + '--limit', + '3', + '--out', + 'data/out', + ]), + ).toMatchObject({ apply: true, publish: true, days: 45, limit: 3, out: 'data/out' }) + }) + + it('refuses --publish without --apply', () => { + expect(() => parseInstagramContentCliArgs(['--publish'])).toThrow(/exige --apply/) + }) + + it('refuses an unknown argument', () => { + expect(() => parseInstagramContentCliArgs(['--force'])).toThrow(/argumento desconhecido/) + }) + + it('refuses an out-of-range window, a zero limit and an escaping out dir', () => { + expect(() => parseInstagramContentCliArgs(['--days', '0'])).toThrow(/--days/) + expect(() => parseInstagramContentCliArgs(['--days', '366'])).toThrow(/--days/) + expect(() => parseInstagramContentCliArgs(['--limit', '0'])).toThrow(/--limit/) + expect(() => parseInstagramContentCliArgs(['--out', '../etc'])).toThrow(/--out/) + }) + + it('prints the help before any validation', () => { + expect(parseInstagramContentCliArgs(['--help', '--days', '0'])).toMatchObject({ help: true }) + }) +}) + +describe('planInstagramContentWindow', () => { + const from = '2026-08-01T00:00:00.000Z' + const to = '2026-09-30T00:00:00.000Z' + + it('keeps the window novelties, newest first, and classifies each link-only kind', () => { + const plan = planInstagramContentWindow({ + posts: [ + post('NOVO'), + post('CARROSSEL', { mediaType: 'CAROUSEL_ALBUM', mediaUrl: null }), + post('PROTEGIDO', { mediaUrl: null }), + ], + existingSourceUrls: [], + from, + to, + }) + + expect(plan.found).toBe(3) + expect(plan.existingCount).toBe(0) + expect(plan.outsideWindow).toBe(0) + expect(plan.malformed).toBe(0) + expect(plan.candidates).toEqual([ + { + url: 'https://www.instagram.com/reel/NOVO/', + shortcode: 'NOVO', + linkOnlyReason: null, + timestamp: '2026-09-20T12:00:00.000Z', + mediaType: 'REEL', + }, + expect.objectContaining({ shortcode: 'CARROSSEL', linkOnlyReason: 'carrossel' }), + expect.objectContaining({ shortcode: 'PROTEGIDO', linkOnlyReason: 'indisponivel' }), + ]) + }) + + it('drops what the Central already carries in ANY spelling and counts the rest', () => { + const plan = planInstagramContentWindow({ + posts: [ + post('JA', { permalink: 'https://www.instagram.com/reel/JA/' }), + post('FORA', { timestamp: '2026-07-01T00:00:00.000Z' }), + post('SEMID', { permalink: 'https://example.com/no-id/' }), + post('REPETIDA'), + post('REPETIDA', { permalink: 'https://www.instagram.com/p/REPETIDA/' }), + ], + // The catalogue carries the same post under the `p` kind. + existingSourceUrls: ['https://www.instagram.com/p/JA/'], + from, + to, + }) + + expect(plan.found).toBe(3) + expect(plan.existingCount).toBe(1) + expect(plan.existingShortcodes).toEqual(['JA']) + expect(plan.outsideWindow).toBe(1) + expect(plan.malformed).toBe(1) + expect(plan.feedDuplicates).toBe(1) + expect(plan.candidates.map((candidate) => candidate.shortcode)).toEqual(['REPETIDA']) + }) +}) + +describe('instagramContentFeedIdentityUrls', () => { + it('spells every canonical kind of each parseable post once', () => { + const urls = instagramContentFeedIdentityUrls([ + post('A'), + post('B', { permalink: 'https://www.instagram.com/p/B/' }), + post('A', { permalink: 'https://www.instagram.com/p/A/' }), + post('X', { permalink: 'https://example.com/x/' }), + ]) + + expect(urls).toEqual([ + 'https://www.instagram.com/p/A/', + 'https://www.instagram.com/reel/A/', + 'https://www.instagram.com/tv/A/', + 'https://www.instagram.com/p/B/', + 'https://www.instagram.com/reel/B/', + 'https://www.instagram.com/tv/B/', + ]) + }) +}) + +describe('summaries and receipt', () => { + it('publishes only a ready piece when the run asked for it', () => { + expect(shouldPublishImportedPiece({ processingStatus: 'pronto', publish: true })).toBe(true) + expect(shouldPublishImportedPiece({ processingStatus: 'pronto', publish: false })).toBe(false) + expect(shouldPublishImportedPiece({ processingStatus: 'falhou', publish: true })).toBe(false) + }) + + it('aggregates the run with the provider cost', () => { + const summary = summarizeInstagramContentResults( + [ + { + outcome: 'created', + processingStatus: 'pronto', + durationSeconds: 60, + published: true, + linkFailureReason: null, + }, + { + outcome: 'created', + processingStatus: 'pronto', + durationSeconds: 30, + published: true, + linkFailureReason: 'carrossel', + }, + { outcome: 'created', processingStatus: 'falhou', error: 'ffmpeg' }, + { outcome: 'existing' }, + ], + { costPerMinuteUsd: 0.00045 }, + ) + + expect(summary).toMatchObject({ + attempted: 4, + created: 3, + existing: 1, + ready: 2, + failed: 1, + published: 2, + linkOnly: 1, + asrSeconds: 90, + }) + expect(summary.asrCostUsd).toBeCloseTo(0.0007, 4) + }) + + it('reports the window, the plan and the named failures', () => { + const plan = planInstagramContentWindow({ + posts: [post('A'), post('B', { mediaType: 'CAROUSEL_ALBUM', mediaUrl: null })], + existingSourceUrls: [], + from: '2026-08-01T00:00:00.000Z', + to: '2026-09-30T00:00:00.000Z', + }) + const lines = formatInstagramContentReport({ + target: '127.0.0.1/teqo_1313', + mode: 'apply', + window: { days: 60, from: '2026-08-01T00:00:00.000Z', to: '2026-09-30T00:00:00.000Z' }, + feedCount: 2, + plan, + summary: summarizeInstagramContentResults([ + { outcome: 'created', processingStatus: 'falhou', error: 'ffmpeg ausente' }, + ]), + failures: [{ shortcode: 'A', error: 'ffmpeg ausente' }], + candidatesRemaining: 1, + reportPath: 'data/content-instagram/x.json', + }) + + const text = lines.join('\n') + expect(text).toContain('alvo: 127.0.0.1/teqo_1313 | modo: apply') + expect(text).toContain('janela: 60 dias') + 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('restam 1 candidatos') + expect(text).toContain('recibo: data/content-instagram/x.json') + }) + + it('stamps the receipt with the run instant', () => { + expect(instagramContentReportStamp('2026-09-29T12:34:56.789Z')).toBe('2026-09-29T12-34-56-789Z') + }) +}) diff --git a/tests/unit/revalidateRequest.unit.spec.ts b/tests/unit/revalidateRequest.unit.spec.ts index 5dd79ce5..208d3502 100644 --- a/tests/unit/revalidateRequest.unit.spec.ts +++ b/tests/unit/revalidateRequest.unit.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { getGlobalCacheTag } from '@/utilities/globals' import { MUNICIPALITY_CATALOG_CACHE_TAG } from '@/utilities/municipality/municipalityCatalogCache' import { + REVALIDATE_CONTENT_PIECES_TAG, REVALIDATE_POSTS_TAG, REVALIDATE_PRIVACY_POLICY_CACHE_TAG, resolveRevalidateTag, @@ -31,6 +32,11 @@ describe('resolveRevalidateTag', () => { ok: true, tag: MUNICIPALITY_CATALOG_CACHE_TAG, }) + // C230-followup — the import CLI busts the Central's listing through here. + expect(resolveRevalidateTag(REVALIDATE_CONTENT_PIECES_TAG, null)).toEqual({ + ok: true, + tag: REVALIDATE_CONTENT_PIECES_TAG, + }) }) it('prefers query tag over body tag', () => {