mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
59a6480df8
commit
2b0671c91e
16 changed files with 1218 additions and 18 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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)
|
||||
|
|
|
|||
10
Dockerfile
10
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
|
||||
|
|
|
|||
1
docs/changelog/2026-09-29-c230-instagram-import.md
Normal file
1
docs/changelog/2026-09-29-c230-instagram-import.md
Normal 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.
|
||||
|
|
@ -1488,6 +1488,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`)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
437
scripts/import-instagram-content.mjs
Normal file
437
scripts/import-instagram-content.mjs
Normal 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.')
|
||||
})
|
||||
273
scripts/lib/instagramContentPlan.mjs
Normal file
273
scripts/lib/instagramContentPlan.mjs
Normal 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
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
95
tests/unit/instagramContentImportCli.unit.spec.ts
Normal file
95
tests/unit/instagramContentImportCli.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
243
tests/unit/instagramContentPlan.unit.spec.ts
Normal file
243
tests/unit/instagramContentPlan.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue