feat(C230): importação em lote do Instagram para a Central (CLI)

CLI operacional `content:instagram:import`: lê o feed oficial do perfil,
filtra a janela por dias (default 60), deduplica pela identidade do post e
processa cada novidade inline pelo pipeline C220 (download, transcrição,
catalogação); --publish publica ao final o que terminou pronto, mais antigo
primeiro, e revalida a Central pública. Guardas C155/C195/C231 (TEQO_ENV +
nome exato do banco, confirmação explícita, S3_* obrigatórias, dry-run padrão).
This commit is contained in:
Francisco Solla 2026-09-29 09:29:40 -03:00
parent ad2b165e5e
commit 35eb2ed432
16 changed files with 1218 additions and 18 deletions

2
.gitignore vendored
View file

@ -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)

View file

@ -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

View file

@ -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.

View file

@ -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`)

View file

@ -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",

View file

@ -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 <n> (default 60), --limit <n> (canary), --out <dir> (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 <n> janela em dias (default 60; 1..365)
--limit <n> canário: processa no máximo n candidatos (o resto fica no lote)
--out <dir> recibos JSON (default data/content-instagram)
--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.')
})

View file

@ -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<typeof planInstagramContentWindow>,
* summary?: ReturnType<typeof summarizeInstagramContentResults>,
* 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
}

View file

@ -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',

View file

@ -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 }
}

View file

@ -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<boolean> => {
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
}

View file

@ -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<ContentPieceProfileImportOutcome> => {
}): Promise<ContentPieceProfileImportCreation> => {
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
}
}

View file

@ -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]

View file

@ -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({

View file

@ -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<string, string> = {}) =>
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<typeof run>) => `${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')
})
})

View file

@ -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')
})
})

View file

@ -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', () => {