mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
Compare commits
4 commits
449ef72190
...
ca843fb4bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca843fb4bb | ||
|
|
cca5f97482 | ||
|
|
3d269725f8 | ||
|
|
20d3781e4b |
17 changed files with 2463 additions and 25 deletions
|
|
@ -292,6 +292,8 @@ This file is the living owner of Teqo's visual decisions — a working document,
|
|||
|
||||
**Public campaign archive actions (C233, 2026-09-26).** On campaign-branded public archive/read surfaces, the primary action is campaign red with white text. Kit yellow is an orientation and keyboard-focus signal — hero edge, restrained eyebrow, current-location underline, and high-contrast focus outline — never the competing CTA fill. This keeps one action dominant, separates public campaign expression from Petition Gold, and makes keyboard location unmistakable over both light and dark media surfaces.
|
||||
|
||||
**Multi-media content gallery (C240, 2026-10-01).** A public piece with multiple media uses one stable stage plus position (“N de M”), previous/next controls, and a horizontally scrollable thumbnail strip; never shrink every thumbnail to force the whole set on screen. Download belongs to the selected media, while the single primary action shares the official source post — do not offer an ambiguous “download all” or make media-sharing compete with the source link. Partial arrival is an internal neutral processing state; the public gallery appears only when the complete set is ready. This keeps the action model honest across image-only, mixed video, and large carousels without inventing a second album product.
|
||||
|
||||
How rules change:
|
||||
|
||||
- **Decide, then update the owner.** When a better pattern wins — anti-slop review (`design-taste-frontend` / `gpt-taste`), a reference study (`awesome-design-md`), a redesign pass (`redesign-existing-projects`), or implementation judgment — update this file in the **same change** and say why. A pattern that only lives in code or in an agent's head is drift, not design.
|
||||
|
|
|
|||
1
docs/changelog/2026-10-01-c230-prune.md
Normal file
1
docs/changelog/2026-10-01-c230-prune.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C230-followup — expurgo das peças fora do período eleitoral (2026-10-01):** nasce o terceiro CLI irmão da Central, `pnpm content:instagram:prune --before YYYY-MM-DD [--scan-days 365] [--apply]`: diferente da retirada (kill switch), ele faz **hard delete** da peça e da mídia privada — linha, arquivo e still do C226 — para que o rascunho pré-campanha não seja ressuscitado por uma importação futura nem publicado de novo pelo `--publish-existing` (que lê todo o feed varrido, não só a janela `--days`, causa da recorrência). O planejamento puro compartilha o **mesmo núcleo de corte** da retirada (`planContentPiecesBeforeCutoff` — extraído no `scripts/lib/instagramContentPlan.mjs`, sem segunda regra de data): posts estritamente antes da data civil na Bahia entram; peça que o feed oficial não consegue datar **nunca é tocada** (fail-closed contra apagar peça de campanha) e é nomeada no recibo. Guardas espelhando os irmãos: `--before` obrigatório, `TEQO_ENV` casando o nome exato do banco, `CONTENT_INSTAGRAM_PRUNE_CONFIRM=1` fora de teste, recibos JSON com o período e as falhas, e revalidação da Central só quando alguma publicada saiu. Testes: unit do planejador/parser/recibo e das guardas do CLI em subprocesso. Runbook ganha a seção do expurgo em `docs/ops/teqo-1313-deploy.md`. Na mesma sessão, em produção: a importação da janela de campanha trouxe **5 publicações novas** (2 carrosséis como peça-link) e o `--publish-existing` publicou **25 rascunhos prontos** — incluindo **19 peças de 12–15/08** que a varredura profunda do feed ressuscitou e que a retirada (`--before 2026-08-16`) devolveu a rascunho — e a falha de transcrição da peça do post de 21/09 foi reprocessada e publicada (a de 06/08 falhou de novo e segue rascunho, fora do período). Em seguida o expurgo apagou **72 peças** (posts de 31/07 a 15/08: 71 rascunhos prontos + a falha de 06/08), 0 falhas, mídia e stills limpos; restou 1 rascunho sem data no feed no catálogo (#11, `DdsAC82NXsC`, peça-link "não encontrado"), preservado pelo fail-closed. Estado final: **281 peças publicadas** (todas ≥ 16/08), 1 rascunho; Central pública revalidada e servindo (apagada → 404).
|
||||
1
docs/changelog/2026-10-01-c230-titulos-semanticos.md
Normal file
1
docs/changelog/2026-10-01-c230-titulos-semanticos.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C230-followup — títulos semânticos na Central: fim do placeholder `Instagram · <código>` (2026-10-01):** o fallback determinístico da catalogação preservava o título provisório de link quando a sugestão por IA falhava (timeout de 8s do `deepseek-flash`) ou quando não havia texto (peça-link/carrossel sem transcrição) — a peça ficava eternamente com `Instagram · Dd...`. Correção no dono: `isContentPieceLinkTitle` nasce em `src/lib/contentPiece.ts` ao lado de `contentPieceLinkTitle`; o fallback do `contentPieceCataloging.ts` passa a gerar título semântico determinístico (tipo + cidade + temas, ex. "Vídeo da campanha em Santa Inês sobre Saúde") em vez de preservar o placeholder, o caminho sem texto troca o placeholder pelo mesmo fallback (título significativo da assessoria continua intocado e `source: 'none'` segue valendo), e a sugestão da IA que ecoa o placeholder é descartada. Testes unit novos/atualizados (detector, fallback com contexto, eco da IA, sem-texto). Backfill em produção: 10 peças corrigidas (9 títulos por IA + 1 determinístico sem conteúdo), 0 falhas — a peça 181, que era link-only `indisponivel`, foi reprocessada, baixou a mídia, transcreveu (494 chars) e ganhou o título "Apoio à reeleição de Jorge Solla (PT-BA)"; restam **0 títulos genéricos** no catálogo (281 publicadas) e a Central pública foi revalidada.
|
||||
|
|
@ -1577,6 +1577,33 @@ docker compose --profile maintenance run --rm \
|
|||
para datar as peças publicadas; reexecutar converge (o que já é rascunho sai do
|
||||
conjunto). O recibo nomeia cada peça retirada, o período e as falhas.
|
||||
|
||||
**Apagar peças anteriores a um corte (expurgo definitivo).** Diferente da
|
||||
retirada, o `content:instagram:prune` faz **hard delete** da peça e da mídia
|
||||
privada (arquivo e still do C226): a linha sai do catálogo, então uma importação
|
||||
futura não a recria como rascunho nem o `--publish-existing` a publica de novo.
|
||||
Use quando a peça fora do período não deve mais existir — o caso clássico é o
|
||||
rascunho pré-campanha que o `--publish-existing` ressuscita. Peça que o feed
|
||||
oficial não consegue datar **nunca é tocada** (apagar sem data seria risco de
|
||||
apagar peça de campanha) e aparece nomeada no recibo.
|
||||
|
||||
```bash
|
||||
# 1) plano (dry-run; lista o que sairia, o período e o que não tem data):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-v ~/content-instagram:/app/data/content-instagram \
|
||||
teqo-1313-migrate pnpm content:instagram:prune --before 2026-08-16
|
||||
# 2) expurgo (exige TEQO_ENV + confirmação; IRREVERSÍVEL):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_PRUNE_CONFIRM=1 \
|
||||
-v ~/content-instagram:/app/data/content-instagram \
|
||||
teqo-1313-migrate pnpm content:instagram:prune --before 2026-08-16 --apply
|
||||
```
|
||||
|
||||
`--scan-days <n>` (default **365**) é a profundidade da varredura do feed usada
|
||||
para datar as peças; o plano varre rascunhos e publicadas. Reexecutar converge
|
||||
(0 anteriores ao corte). O recibo nomeia cada peça apagada, o período e as
|
||||
falhas; a revalidação da Central só roda se alguma publicada saiu (mídia órfã é
|
||||
limpa após a linha, best-effort, como no delete da ficha).
|
||||
|
||||
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
|
||||
|
|
|
|||
1585
docs/plans/central-conteudos-carrossel-ui-design.html
Normal file
1585
docs/plans/central-conteudos-carrossel-ui-design.html
Normal file
File diff suppressed because it is too large
Load diff
99
docs/plans/central-conteudos-carrossel.md
Normal file
99
docs/plans/central-conteudos-carrossel.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# C240 — Central de Conteúdos — carrossel do Instagram como conteúdo de primeira classe
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-10-01
|
||||
Issue: #1399
|
||||
Priority: P1
|
||||
Impeccable: C — encaixe nas telas existentes (ficha interna C211 + catálogo/página pública S27), com um componente novo (galeria multi-mídia)
|
||||
Design UI: docs/plans/central-conteudos-carrossel-ui-design.html
|
||||
Appetite: ~3–4 dias eng; um outcome verificável
|
||||
Responsável: —
|
||||
|
||||
## Intenção
|
||||
|
||||
O carrossel é o formato mais frequente do perfil oficial e hoje entra na Central pela porta dos fundos: vira "peça-link com motivo" e a ficha o apresenta como limitação ("Carrossel: sem arquivo único para baixar"), sem mídia alguma. A decisão original do C220 foi consciente ("carrossel não vira álbum"), mas o dono a reabriu em 2026-09-29 com evidência de produto nova: o carrossel deve ser **conteúdo de primeira classe** — a plataforma baixa automaticamente todas as mídias (imagens e vídeos) dos filhos pela API oficial do próprio perfil, transcreve/cataloga os vídeos como nas demais peças, e o público vê uma **galeria com Baixar (cada mídia) e Compartilhar** (post oficial). Há 50 carrosséis na janela de 60 dias do perfil e a esteira de importação está prestes a trazê-los; sem este item eles nascem incompletos e a assessoria aprende a ler conteúdo legítimo como erro.
|
||||
|
||||
**Decisão de produto registrada (reabre anti-goal do C220):** baixar os filhos do carrossel pela via oficial é permitido e desejado; a mídia da plataforma passa a ter casa na Central como as demais peças. Nada de scraping/terceiro — segue o caminho oficial do C212/C220.
|
||||
|
||||
## Persona e fluxo
|
||||
|
||||
- **Persona / contexto:** assessoria de comunicação (`communicator`; `coordinator`/`candidate` também) importando o perfil e publicando peças; eleitor que consome e repassa o conteúdo na Central pública.
|
||||
- **Job principal:** importar um carrossel e recebê-lo completo (todas as mídias), catalogado e pronto para publicar; eleitor: ver a galeria, baixar e compartilhar.
|
||||
- **Fluxo desejado:** importa do perfil / cola o link → as mídias do carrossel são baixadas automaticamente, os vídeos transcritos e a peça catalogada → a ficha interna mostra "Carrossel" com suas mídias (não uma falha) → publica → o eleitor vê a galeria e usa Baixar (cada mídia) e Compartilhar (post oficial).
|
||||
- **Anti-goals de produto:** não baixar mídia de terceiro (permanente); não exigir anexo manual (a promessa é automático); não publicar automaticamente; não criar segundo modelo de conteúdo/segunda peça; não virar galeria de vaidade com player/slideshow próprio do Instagram.
|
||||
|
||||
### Esboço de fluxo (C)
|
||||
|
||||
```text
|
||||
[Importar do perfil / Adicionar por link] → carrossel reconhecido como "Carrossel"
|
||||
→ API oficial entrega os filhos (media_type + media_url): imagens e vídeos baixados automaticamente
|
||||
→ vídeos dos filhos entram na transcrição; peça catalogada (título, descrição, temas, cidade)
|
||||
→ ficha interna: identidade Carrossel + galeria das mídias; sem caixa de falha
|
||||
→ publicação → catálogo e página pública: galeria + Baixar (cada mídia) + Compartilhar (post oficial)
|
||||
→ recibo da importação conta carrosséis como conteúdo trazido
|
||||
[outcome: carrossel deixa de ser peça-link sem mídia e passa a ser conteúdo completo da Central]
|
||||
```
|
||||
|
||||
### Design UI (C)
|
||||
|
||||
- Design UI (gate): `docs/plans/central-conteudos-carrossel-ui-design.html` (a produzir)
|
||||
|
||||
## Objetivo e aceite
|
||||
|
||||
- Um carrossel importado do perfil (ou adicionado por link) baixa automaticamente **todas** as mídias dos filhos pela API oficial, sem scraping e sem anexo manual.
|
||||
- A peça do carrossel tem identidade própria ("Carrossel") na Central e nunca aparece com a linguagem de falha/limitação que existe hoje.
|
||||
- Vídeos dentro do carrossel entram na transcrição/catálogo (busca por fala/tema consistente com as demais peças).
|
||||
- Publicado, o carrossel aparece no catálogo e na página pública com galeria das mídias e ações **Baixar** (cada mídia) e **Compartilhar** (post oficial), mantendo o compartilhamento do link do Instagram.
|
||||
- O recibo da importação conta o carrossel como conteúdo trazido (não como "peça-link com motivo").
|
||||
- Peças de carrossel já criadas (importações anteriores / backfill) ganham o mesmo tratamento e mídias — nada fica preso no formato antigo.
|
||||
- Guardrails: só o perfil próprio via API oficial (C212); mídia de terceiro nunca é baixada; nada é publicado automaticamente; curadoria e kill switch continuam por peça; sem PII nova.
|
||||
|
||||
## Dados (intenção)
|
||||
|
||||
- **Vou apresentar dados?** Não — apresentação de um tipo de conteúdo, não analytics (C213 segue dono da circulação).
|
||||
- **Decisões desbloqueadas:** N/A — a decisão da assessoria é editorial ("publicar ou não o carrossel").
|
||||
- **Forma:** N/A — sem números novos em tela.
|
||||
|
||||
## Dados da decisão (literais)
|
||||
|
||||
- Nome visível do tipo: **"Carrossel"** (sem sinônimo "álbum").
|
||||
- Ações públicas do carrossel: **Baixar** (cada mídia da galeria) e **Compartilhar** (WhatsApp/link do post oficial) — além de **Copiar link** como nas demais peças.
|
||||
- Recibo da importação: carrossel entra na contagem de conteúdo criado com o rótulo do tipo, e não no bloco de ressalvas de peça-link.
|
||||
- Ficha interna: o motivo `carrossel` deixa de representar limitação; a peça lê como conteúdo completo.
|
||||
- Transcrição: vídeos-filhos entram no mesmo pipeline de transcrição das demais peças.
|
||||
|
||||
## Direção no codebase (hipótese)
|
||||
|
||||
- **Áreas prováveis:** `src/lib/contentPiece.ts` (tipos/labels/razões), `src/utilities/content/contentPieceLink.ts` (resolução do link e download dos filhos), `src/utilities/content/contentPieceJob.ts` (processar N mídias; transcrever vídeos-filhos), `src/utilities/content/contentPieceCataloging.ts`, ficha interna `src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/page.tsx`, catálogo/página pública `src/components/conteudos/` e `src/lib/contentPieceCatalog.ts`, rotas de mídia `src/app/(frontend)/conteudos/[slug]/`, recibo do importador `src/components/campaign/content/ImportContentPieceProfileDialog.tsx`.
|
||||
- **Precedente a olhar:** o fluxo single-media do C211/C220/C230 (que este item generaliza), `MEDIA_FIELDS` com `children{media_type,media_url,thumbnail_url}` (já presente), a galeria/carrossel do site público (`src/components/CampaignCarousel.tsx`) como referência de linguagem, e a taxonomia `video|foto|texto|audio|card`.
|
||||
- **Risco de acoplamento:** o modelo atual é uma mídia por peça (`contentPiece.media`) — a generalização para N mídias precisa preservar as peças existentes e as rotas públicas (`/conteudos/<slug>/midia`, frame, OG); `linkFailureReason` é lido por ficha, importador e testes (não quebrar `nao-encontrado`/`indisponivel`/`sem-credencial`); o S27 já renderiza peça-link — a entrega encaixa nele, sem segundo modelo.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Suaves:** C211 (peça/ficha), C220 (link→peça), C230 (importar do perfil), S27 (catálogo/página pública), C226 (frame) — todos entregues.
|
||||
- **Relação com a operação de importação de 60 dias:** o backfill de 60 dias **já rodou** (2026-09-29) e criou **52 peças de carrossel** — 40 publicadas e 12 rascunhos — todas como peça-link sem arquivo; o aceite cobre o tratamento e as mídias dessas peças existentes.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Stories, destaques e demais formatos sem arquivo.
|
||||
- Reprocessar carrosséis de terceiros (permanente).
|
||||
- Player/slideshow próprio com reprodução automática ou embed do Instagram.
|
||||
- Qualquer mudança no board/feed da home ou na circulação (C213).
|
||||
- Download em pacote único (zip) — se pedido depois, item novo.
|
||||
|
||||
## Rabbit holes de produto
|
||||
|
||||
- **"Já que baixa os filhos, monta um álbum/zip".** Se alguém "só completar": pacote, álbum paralelo, storage duplicado. **Corte:** galeria com download por mídia; pacote é item futuro.
|
||||
- **"Então cria um tipo novo para tudo que é link"** (YouTube, rádio). Se alguém "só completar": taxonomia paralela. **Corte:** o item trata o carrossel; os demais seguem como estão.
|
||||
- **"Publica o carrossel automaticamente".** Se alguém "só completar": auto-publicação. **Corte:** curadoria continua humana.
|
||||
|
||||
## Questões em aberto (produto)
|
||||
|
||||
- **Ordem de execução na Central (galeria antes/depois da transcrição)?** **Opções:** A) peça publica quando as mídias estiverem prontas e o catálogo completa depois (como as demais) | B) segurar até transcrição concluir. **Recomendação:** A — mesmo contrato do C211 (job em background). _(assumido — validar com produto)_
|
||||
- **Capa do carrossel no catálogo?** **Opções:** A) primeiro filho (comportamento atual do thumbnail) | B) escolha manual na ficha. **Recomendação:** A, com troca manual opcional se o picker já existir. _(assumido — validar com produto)_
|
||||
|
||||
## Referências
|
||||
|
||||
- GitHub Issue #— (após registro); design UI (gate): `docs/plans/central-conteudos-carrossel-ui-design.html`.
|
||||
- Planos irmãos: `central-conteudos-ingestao.md` (C211) · `central-conteudos-link-instagram.md` (C220) · `central-conteudos-importar-perfil.md` (C230) · `central-conteudos-publica.md` (S27) · `central-conteudos-preview-frame-video.md` (C226).
|
||||
- Arquivos-pista: `src/utilities/socialFeed/instagramFeed.ts:106-115` (MEDIA_FIELDS com children) · `src/utilities/content/contentPieceLink.ts:304-313` (ramo carrossel atual) · `src/lib/contentPiece.ts:355-379` (motivos) · `src/components/conteudos/ContentPieceDetail.tsx` · `src/components/conteudos/ContentPieceCard.tsx` · `src/components/campaign/content/ImportContentPieceProfileDialog.tsx:50-64` (recibo).
|
||||
|
|
@ -37,6 +37,7 @@
|
|||
"flickr:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ingest-flickr-archive.mjs",
|
||||
"content:instagram:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-instagram-content.mjs",
|
||||
"content:instagram:withdraw": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/withdraw-instagram-content.mjs",
|
||||
"content:instagram:prune": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/prune-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",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ const INSTAGRAM_CONTENT_USAGE =
|
|||
const INSTAGRAM_CONTENT_WITHDRAW_USAGE =
|
||||
'pnpm content:instagram:withdraw --before YYYY-MM-DD [--scan-days 90] [--apply] [--out data/content-instagram]'
|
||||
|
||||
const INSTAGRAM_CONTENT_PRUNE_USAGE =
|
||||
'pnpm content:instagram:prune --before YYYY-MM-DD [--scan-days 365] [--apply] [--out data/content-instagram]'
|
||||
|
||||
/** Report file stamp: the run's ISO instant with `:`/`.` swapped, like C215. */
|
||||
export const instagramContentReportStamp = (runAt) => runAt.replace(/[:.]/g, '-')
|
||||
|
||||
|
|
@ -153,6 +156,63 @@ export const parseInstagramContentWithdrawCliArgs = (argv = process.argv.slice(2
|
|||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the prune CLI arguments. `--before` is a REQUIRED Bahia civil date
|
||||
* (the campaign cutoff is a legal boundary, never a hidden default) and the
|
||||
* `--scan-days` walk depth only bounds how far back the feed is read to date
|
||||
* the catalogue rows; the default is deeper than the withdraw's because a prune
|
||||
* targets the oldest rows of the catalogue (the whole electoral era fits in a
|
||||
* year).
|
||||
*
|
||||
* @param {string[]} [argv]
|
||||
*/
|
||||
export const parseInstagramContentPruneCliArgs = (argv = process.argv.slice(2)) => {
|
||||
const options = {
|
||||
apply: false,
|
||||
before: null,
|
||||
scanDays: 365,
|
||||
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_PRUNE_USAGE}.`)
|
||||
}
|
||||
return next
|
||||
}
|
||||
if (arg === '--help' || arg === '-h') options.help = true
|
||||
else if (arg === '--apply') options.apply = true
|
||||
else if (arg === '--before') options.before = value()
|
||||
else if (arg === '--scan-days') options.scanDays = Number(value())
|
||||
else if (arg === '--out') options.out = value()
|
||||
else throw new Error(`argumento desconhecido: ${arg} — ${INSTAGRAM_CONTENT_PRUNE_USAGE}.`)
|
||||
}
|
||||
|
||||
if (options.help) return options
|
||||
if (options.before === null) {
|
||||
throw new Error(`--before é obrigatório — ${INSTAGRAM_CONTENT_PRUNE_USAGE}.`)
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(options.before))) {
|
||||
throw new Error('--before deve ser uma data civil YYYY-MM-DD.')
|
||||
}
|
||||
try {
|
||||
contentPieceProfilePeriodBounds({ since: options.before })
|
||||
} catch {
|
||||
throw new Error('--before deve ser uma data civil válida.')
|
||||
}
|
||||
if (!Number.isInteger(options.scanDays) || options.scanDays < 1 || options.scanDays > 365) {
|
||||
throw new Error('--scan-days deve ser um inteiro entre 1 e 365.')
|
||||
}
|
||||
if (options.out.split(/[\\/]/).includes('..')) {
|
||||
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* The publish rule of the import: only a piece the pipeline finished (`pronto`)
|
||||
* goes public, and only when the run asked for it. A `falhou` piece stays a
|
||||
|
|
@ -218,11 +278,11 @@ export const planInstagramContentDraftsToPublish = ({ pieces, posts }) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* Splits the published set by the civil cutoff: a post strictly BEFORE the
|
||||
* `before` civil day (Bahia time, the electoral calendar's timezone) is
|
||||
* withdrawn; the boundary instant comes from the domain owner of the civil
|
||||
* dates, never a re-spelled offset. A piece whose post is not in the feed is
|
||||
* named and left untouched.
|
||||
* The one cutoff split shared by the withdraw and prune plans: a post strictly
|
||||
* BEFORE the `before` civil day (Bahia time, the electoral calendar's timezone)
|
||||
* matches; the boundary instant comes from the domain owner of the civil dates,
|
||||
* never a re-spelled offset. A piece whose post is not in the feed is named and
|
||||
* left untouched (fail-closed on an unknown date).
|
||||
*
|
||||
* @param {{
|
||||
* pieces: Array<{ id: number, title?: string | null, sourceUrl?: string | null }>,
|
||||
|
|
@ -230,11 +290,11 @@ export const planInstagramContentDraftsToPublish = ({ pieces, posts }) => {
|
|||
* before: string,
|
||||
* }} input
|
||||
*/
|
||||
export const planInstagramContentWithdraw = ({ pieces, posts, before }) => {
|
||||
const planContentPiecesBeforeCutoff = ({ pieces, posts, before }) => {
|
||||
const { fromIso } = contentPieceProfilePeriodBounds({ since: before })
|
||||
const cutoffMs = Date.parse(fromIso)
|
||||
const dates = instagramContentPostDates(posts)
|
||||
const toWithdraw = []
|
||||
const matched = []
|
||||
const undateable = []
|
||||
|
||||
for (const piece of pieces) {
|
||||
|
|
@ -245,7 +305,7 @@ export const planInstagramContentWithdraw = ({ pieces, posts, before }) => {
|
|||
continue
|
||||
}
|
||||
if (timestamp < cutoffMs) {
|
||||
toWithdraw.push({
|
||||
matched.push({
|
||||
...piece,
|
||||
shortcode: link.shortcode,
|
||||
postTimestamp: timestamp,
|
||||
|
|
@ -253,9 +313,32 @@ export const planInstagramContentWithdraw = ({ pieces, posts, before }) => {
|
|||
})
|
||||
}
|
||||
}
|
||||
toWithdraw.sort((left, right) => left.postTimestamp - right.postTimestamp)
|
||||
matched.sort((left, right) => left.postTimestamp - right.postTimestamp)
|
||||
|
||||
return { cutoffIso: fromIso, toWithdraw, undateable }
|
||||
return { cutoffIso: fromIso, matched, undateable }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the published set by the civil cutoff (the withdraw plan): posts before
|
||||
* the day leave the public listing.
|
||||
*
|
||||
* @param {Parameters<typeof planContentPiecesBeforeCutoff>[0]} input
|
||||
*/
|
||||
export const planInstagramContentWithdraw = (input) => {
|
||||
const { cutoffIso, matched, undateable } = planContentPiecesBeforeCutoff(input)
|
||||
return { cutoffIso, toWithdraw: matched, undateable }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the catalogue set by the civil cutoff (the prune plan): every piece
|
||||
* before the day — draft or published — is matched for deletion. Same dating
|
||||
* authority and same fail-closed on undateable rows as the withdraw plan.
|
||||
*
|
||||
* @param {Parameters<typeof planContentPiecesBeforeCutoff>[0]} input
|
||||
*/
|
||||
export const planInstagramContentPrune = (input) => {
|
||||
const { cutoffIso, matched, undateable } = planContentPiecesBeforeCutoff(input)
|
||||
return { cutoffIso, toDelete: matched, undateable }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -349,7 +432,7 @@ export const formatInstagramContentReport = (report) => {
|
|||
return lines
|
||||
}
|
||||
|
||||
const WITHDRAW_SAMPLE_LIMIT = 10
|
||||
const CUTOFF_SAMPLE_LIMIT = 10
|
||||
|
||||
/**
|
||||
* The withdraw receipt lines the operator reads (and the JSON mirrors): the
|
||||
|
|
@ -386,13 +469,13 @@ export const formatInstagramContentWithdrawReport = (report) => {
|
|||
lines.push(
|
||||
`período das retiradas: ${new Date(Math.min(...dates)).toISOString()} → ${new Date(Math.max(...dates)).toISOString()}`,
|
||||
)
|
||||
for (const entry of toWithdraw.slice(0, WITHDRAW_SAMPLE_LIMIT)) {
|
||||
for (const entry of toWithdraw.slice(0, CUTOFF_SAMPLE_LIMIT)) {
|
||||
lines.push(
|
||||
` - ${entry.shortcode} (${entry.postDate.slice(0, 10)}) ${entry.title ?? ''}`.trim(),
|
||||
)
|
||||
}
|
||||
if (toWithdraw.length > WITHDRAW_SAMPLE_LIMIT) {
|
||||
lines.push(` … +${toWithdraw.length - WITHDRAW_SAMPLE_LIMIT}`)
|
||||
if (toWithdraw.length > CUTOFF_SAMPLE_LIMIT) {
|
||||
lines.push(` … +${toWithdraw.length - CUTOFF_SAMPLE_LIMIT}`)
|
||||
}
|
||||
}
|
||||
for (const entry of undateable) {
|
||||
|
|
@ -413,3 +496,68 @@ export const formatInstagramContentWithdrawReport = (report) => {
|
|||
if (report.reportPath) lines.push(`recibo: ${report.reportPath}`)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The prune receipt lines the operator reads (and the JSON mirrors): the
|
||||
* cutoff, what the feed could date, the set matched for deletion with its
|
||||
* period and the pieces the feed could not date (never touched, always named).
|
||||
* The apply line names the hard delete — the row and its private media leave
|
||||
* the catalogue, unlike the withdraw's reversible kill switch.
|
||||
*
|
||||
* @param {{
|
||||
* target: string,
|
||||
* mode: 'plan' | 'apply',
|
||||
* before: string,
|
||||
* scanDays: number,
|
||||
* feedCount: number,
|
||||
* piecesCount: number,
|
||||
* plan: ReturnType<typeof planInstagramContentPrune>,
|
||||
* deleted?: number,
|
||||
* failures?: Array<{ id: number, reason: string }>,
|
||||
* revalidation?: { attempted: boolean, ok: boolean, reason: string | null },
|
||||
* reportPath?: string | null,
|
||||
* }} report
|
||||
*/
|
||||
export const formatInstagramContentPruneReport = (report) => {
|
||||
const lines = []
|
||||
const { toDelete, undateable } = report.plan
|
||||
const dates = toDelete.map((entry) => entry.postTimestamp)
|
||||
|
||||
lines.push(`[content:instagram:prune] alvo: ${report.target} | modo: ${report.mode}`)
|
||||
lines.push(
|
||||
`corte: ${report.before} (Bahia; posts anteriores) · varredura: ${report.scanDays} dias · feed: ${report.feedCount} mídias`,
|
||||
)
|
||||
lines.push(
|
||||
`no catálogo: ${report.piecesCount} · anteriores ao corte: ${toDelete.length} · sem data no feed: ${undateable.length}`,
|
||||
)
|
||||
if (toDelete.length > 0) {
|
||||
lines.push(
|
||||
`período das peças: ${new Date(Math.min(...dates)).toISOString()} → ${new Date(Math.max(...dates)).toISOString()}`,
|
||||
)
|
||||
for (const entry of toDelete.slice(0, CUTOFF_SAMPLE_LIMIT)) {
|
||||
lines.push(
|
||||
` - #${entry.id} ${entry.shortcode} (${entry.postDate.slice(0, 10)}) ${entry.title ?? ''}`.trim(),
|
||||
)
|
||||
}
|
||||
if (toDelete.length > CUTOFF_SAMPLE_LIMIT) {
|
||||
lines.push(` … +${toDelete.length - CUTOFF_SAMPLE_LIMIT}`)
|
||||
}
|
||||
}
|
||||
for (const entry of undateable) {
|
||||
lines.push(
|
||||
` ? sem data: #${entry.id} ${entry.title ?? ''} — ${entry.sourceUrl ?? 'sem URL'}`.trim(),
|
||||
)
|
||||
}
|
||||
if (report.mode === 'apply') {
|
||||
lines.push(
|
||||
`apagadas: ${report.deleted ?? 0} · falhas: ${report.failures?.length ?? 0} · revalidação: ${
|
||||
report.revalidation?.ok ? 'ok' : (report.revalidation?.reason ?? 'não tentada')
|
||||
}`,
|
||||
)
|
||||
for (const failure of report.failures ?? []) {
|
||||
lines.push(` - falha #${failure.id}: ${failure.reason}`)
|
||||
}
|
||||
}
|
||||
if (report.reportPath) lines.push(`recibo: ${report.reportPath}`)
|
||||
return lines
|
||||
}
|
||||
|
|
|
|||
272
scripts/prune-instagram-content.mjs
Normal file
272
scripts/prune-instagram-content.mjs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
/**
|
||||
* C230-followup — hard-deletes from the Central de Conteúdos the pieces whose
|
||||
* original Instagram post is BEFORE a civil cutoff: the off-period rows leave
|
||||
* the catalogue (row + private media) so a later import/publish run can never
|
||||
* surface them again. The cutoff (`--before`, required) is a Bahia civil date
|
||||
* and the post dates come from the official feed; this is the irreversible
|
||||
* sibling of `content:instagram:withdraw` (which only unpublishes). A piece the
|
||||
* feed cannot date is NEVER touched, only named — deleting on an unknown date
|
||||
* would risk a campaign-period piece.
|
||||
*
|
||||
* Modes:
|
||||
* pnpm content:instagram:prune --before 2026-08-16
|
||||
* plan/dry-run (default): reads the feed and the DB, lists and writes nothing
|
||||
* pnpm content:instagram:prune --before 2026-08-16 --apply
|
||||
* deletes; needs CONTENT_INSTAGRAM_PRUNE_CONFIRM=1 on a non-local target and
|
||||
* the declared TEQO_ENV matching the exact database name
|
||||
*
|
||||
* Options: --scan-days <n> (feed walk depth to date the catalogue rows, default
|
||||
* 365), --out <dir> (receipts, default `data/content-instagram`), --help.
|
||||
*
|
||||
* Runbook: on the homeserver, inside the compose maintenance service with the
|
||||
* stack env file — see docs/ops/teqo-1313-deploy.md.
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { CONTENT_MEDIA_SLUG } from '../src/lib/contentPiece.ts'
|
||||
import { contentPieceFrameFilename } from '../src/lib/contentPieceFrame.ts'
|
||||
import {
|
||||
assertEnvironmentDatabaseTarget,
|
||||
assertWriteConfirm,
|
||||
databaseTarget,
|
||||
dieWithLabel,
|
||||
loadCliEnv,
|
||||
} from './lib/cli.mjs'
|
||||
import {
|
||||
loadInstagramContentWindowFeed,
|
||||
revalidateInstagramContentCentral,
|
||||
} from './lib/instagramContentCli.mjs'
|
||||
import {
|
||||
formatInstagramContentPruneReport,
|
||||
instagramContentReportStamp,
|
||||
parseInstagramContentPruneCliArgs,
|
||||
planInstagramContentPrune,
|
||||
} from './lib/instagramContentPlan.mjs'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const die = dieWithLabel('content:instagram:prune')
|
||||
|
||||
const WRITE_CONFIRM_FLAG = 'CONTENT_INSTAGRAM_PRUNE_CONFIRM'
|
||||
|
||||
const HELP = `Uso: pnpm content:instagram:prune --before YYYY-MM-DD [opções]
|
||||
|
||||
APAGA (hard delete, irreversível) da Central as peças cujo post original no
|
||||
Instagram é ANTERIOR à data de corte — rascunhos e publicadas. A linha e a
|
||||
mídia privada saem do catálogo, então uma importação futura não as recria nem
|
||||
as publica. Peça que o feed oficial não consegue datar nunca é tocada (seria
|
||||
risco de apagar peça de campanha) e aparece nomeada no recibo. Sem --apply, é um
|
||||
plano read-only.
|
||||
|
||||
Opções:
|
||||
--before <data> corte civil na Bahia (obrigatório; posts anteriores saem)
|
||||
--scan-days <n> profundidade da varredura do feed para datar as peças
|
||||
(default 365; 1..365)
|
||||
--apply executa o expurgo (exige ${WRITE_CONFIRM_FLAG}=1 em alvo
|
||||
não-local)
|
||||
--out <dir> recibos JSON (default data/content-instagram)
|
||||
--help esta ajuda
|
||||
|
||||
Flags: TEQO_ENV=staging|production (obrigatória no --apply fora de teste; o nome
|
||||
do banco tem de casar) e ${WRITE_CONFIRM_FLAG}=1 para a escrita.
|
||||
`
|
||||
|
||||
const writeReport = async (options, report, suffix = '') => {
|
||||
const stamp = instagramContentReportStamp(report.runAt)
|
||||
const reportPath = join(options.out, `${stamp}${suffix}.json`)
|
||||
await mkdir(dirname(reportPath), { recursive: true })
|
||||
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||
return reportPath
|
||||
}
|
||||
|
||||
const reportLines = (report) =>
|
||||
formatInstagramContentPruneReport({
|
||||
target: report.target,
|
||||
mode: report.mode,
|
||||
before: report.before,
|
||||
scanDays: report.scanDays,
|
||||
feedCount: report.feed.count,
|
||||
piecesCount: report.piecesCount,
|
||||
plan: report.plan,
|
||||
deleted: report.deleted,
|
||||
failures: report.failures,
|
||||
revalidation: report.revalidation,
|
||||
reportPath: report.reportPath,
|
||||
})
|
||||
|
||||
/**
|
||||
* Deletes the piece row, then its private media and the C226 frame (a separate
|
||||
* `contentMedia` row under a deterministic filename), both best-effort after the
|
||||
* row is gone — the same order and tolerance as the C222 delete action, without
|
||||
* an actor. Returns whether the deleted piece was published, so the caller only
|
||||
* busts the public Central when the delete could have changed it.
|
||||
*
|
||||
* @param {import('payload').Payload} payload
|
||||
* @param {{ id: number, status?: string | null }} entry
|
||||
*/
|
||||
const deleteContentPieceSystem = async (payload, entry) => {
|
||||
const deleted = await payload.delete({
|
||||
collection: 'contentPiece',
|
||||
id: entry.id,
|
||||
depth: 0,
|
||||
select: { media: true },
|
||||
// Intentional admin bypass: the ops prune owns the rows it listed.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const mediaId = typeof deleted.media === 'number' ? deleted.media : (deleted.media?.id ?? null)
|
||||
if (mediaId !== null) {
|
||||
await payload
|
||||
.delete({
|
||||
collection: CONTENT_MEDIA_SLUG,
|
||||
id: mediaId,
|
||||
// Intentional admin bypass: cleanup of the file that belonged to the
|
||||
// piece this run was told to prune.
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const frame = await payload
|
||||
.find({
|
||||
collection: CONTENT_MEDIA_SLUG,
|
||||
where: { filename: { equals: contentPieceFrameFilename(entry.id) } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
// Intentional admin bypass: the derived-file probe, same as C226.
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null)
|
||||
const frameId = frame?.docs?.[0]?.id ?? null
|
||||
if (frameId !== null) {
|
||||
await payload
|
||||
.delete({ collection: CONTENT_MEDIA_SLUG, id: frameId, overrideAccess: true })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
return entry.status === 'publicado'
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let options
|
||||
try {
|
||||
options = parseInstagramContentPruneCliArgs()
|
||||
} 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:prune',
|
||||
flag: WRITE_CONFIRM_FLAG,
|
||||
command: 'pnpm content:instagram:prune --before <data> --apply',
|
||||
})
|
||||
assertEnvironmentDatabaseTarget()
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[content:instagram:prune] alvo: ${databaseTarget()} | modo: ${mode} | corte: ${options.before} (Bahia)`,
|
||||
)
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
const window = await loadInstagramContentWindowFeed({ payload, days: options.scanDays })
|
||||
if ('error' in window) {
|
||||
die(
|
||||
window.error === 'unavailable'
|
||||
? 'Instagram ainda não configurado (token/ID ausentes ou feed desligado no global Social Feed) — o expurgo precisa das datas oficiais do feed.'
|
||||
: 'não foi possível ler o feed oficial do Instagram agora (API ou credencial) — tente novamente.',
|
||||
)
|
||||
}
|
||||
const { feed } = window
|
||||
|
||||
const pieces = await payload.find({
|
||||
collection: 'contentPiece',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { title: true, sourceUrl: true, status: true },
|
||||
// Intentional admin bypass: the ops run must see every catalogue row,
|
||||
// including drafts no actor could read.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const plan = planInstagramContentPrune({
|
||||
pieces: pieces.docs,
|
||||
posts: feed.posts,
|
||||
before: options.before,
|
||||
})
|
||||
|
||||
const runAt = new Date().toISOString()
|
||||
const report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
before: options.before,
|
||||
scanDays: options.scanDays,
|
||||
cutoffIso: plan.cutoffIso,
|
||||
feed: { username: feed.username, count: feed.posts.length },
|
||||
piecesCount: pieces.docs.length,
|
||||
plan,
|
||||
reportPath: null,
|
||||
}
|
||||
|
||||
if (mode === 'plan') {
|
||||
report.reportPath = await writeReport(options, report)
|
||||
for (const line of reportLines(report)) console.log(line)
|
||||
console.log(
|
||||
`\n[content:instagram:prune] plano sem escrita — para apagar: --apply com ${WRITE_CONFIRM_FLAG}=1 (e TEQO_ENV).`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
let deleted = 0
|
||||
let deletedPublished = 0
|
||||
const failures = []
|
||||
for (const entry of plan.toDelete) {
|
||||
try {
|
||||
if (await deleteContentPieceSystem(payload, entry)) deletedPublished += 1
|
||||
deleted += 1
|
||||
if (deleted % 25 === 0) console.log(`[content:instagram:prune] ${deleted}…`)
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
id: entry.id,
|
||||
reason: error instanceof Error && error.message !== '' ? error.message : 'falha',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let revalidation = { attempted: false, ok: false, reason: null }
|
||||
if (deletedPublished > 0) revalidation = await revalidateInstagramContentCentral()
|
||||
|
||||
report.deleted = deleted
|
||||
report.deletedPublished = deletedPublished
|
||||
report.failures = failures
|
||||
report.revalidation = revalidation
|
||||
report.durationMs = Date.now() - startedAt
|
||||
report.reportPath = await writeReport(options, report, '-prune')
|
||||
|
||||
for (const line of reportLines(report)) console.log(line)
|
||||
console.log(`\n[content:instagram:prune] recibo JSON: ${report.reportPath}`)
|
||||
|
||||
if (deleted === 0 && plan.toDelete.length > 0) {
|
||||
die('nenhuma peça foi apagada — veja o recibo.')
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
die(error instanceof Error && error.message !== '' ? error.message : 'falha inesperada.')
|
||||
})
|
||||
|
|
@ -476,6 +476,14 @@ export const contentPieceOriginFromLink = (
|
|||
export const contentPieceLinkTitle = (link: ContentPieceLink): string =>
|
||||
link.origin === 'instagram' ? `Instagram · ${link.shortcode}` : `YouTube · ${link.videoId}`
|
||||
|
||||
/**
|
||||
* Whether a title is still the generated link placeholder (`contentPieceLinkTitle`).
|
||||
* The cataloguing refuses to preserve it: a placeholder is never a title, so the
|
||||
* deterministic fallback (or the assessoria) must replace it.
|
||||
*/
|
||||
export const isContentPieceLinkTitle = (title: string | null | undefined): boolean =>
|
||||
/^(Instagram|YouTube) · \S+$/.test(title?.trim() ?? '')
|
||||
|
||||
/**
|
||||
* C230 — every canonical spelling of one post's `sourceUrl`. The Instagram
|
||||
* kind in the path (`p`, `reel`, `tv`) is presentation: the same post can be
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
CONTENT_PIECE_TITLE_MAX_LENGTH,
|
||||
contentPieceTopicLabel,
|
||||
contentPieceTypeLabels,
|
||||
isContentPieceLinkTitle,
|
||||
type ContentPieceType,
|
||||
} from '@/lib/contentPiece'
|
||||
import { institutionCatalog, institutionSpellings } from '@/lib/institutionCatalog'
|
||||
|
|
@ -89,17 +90,25 @@ export const buildContentPieceFallbackMetadata = ({
|
|||
cityLabel: string | null
|
||||
topics: readonly SpeechTopic[]
|
||||
}): { title: string; description: string; source: 'fallback' } => {
|
||||
const typeLabel = contentPieceTypeLabels[type].toLowerCase()
|
||||
const typeLabel = contentPieceTypeLabels[type]
|
||||
const topicLabels = topics.slice(0, 3).map(contentPieceTopicLabel)
|
||||
const parts = [
|
||||
`${contentPieceTypeLabels[type]} da campanha de Jorge Solla 1313`,
|
||||
const context = [
|
||||
cityLabel ? `em ${cityLabel}` : null,
|
||||
topicLabels.length > 0 ? `sobre ${topicLabels.join(', ')}` : null,
|
||||
].filter((part): part is string => part !== null)
|
||||
// The provisional link placeholder (`Instagram · <shortcode>`) is never a
|
||||
// title: when the AI suggestion is unavailable the deterministic semantic
|
||||
// title (type + city + topics) replaces it instead of being preserved.
|
||||
const preservedTitle = isContentPieceLinkTitle(currentTitle) ? '' : currentTitle
|
||||
|
||||
return {
|
||||
title: clip(currentTitle, CONTENT_PIECE_TITLE_MAX_LENGTH) || `Peça de ${typeLabel}`,
|
||||
description: clip(`${parts.join(' ')}.`, CONTENT_PIECE_DESCRIPTION_MAX_LENGTH),
|
||||
title:
|
||||
clip(preservedTitle, CONTENT_PIECE_TITLE_MAX_LENGTH) ||
|
||||
clip([`${typeLabel} da campanha`, ...context].join(' '), CONTENT_PIECE_TITLE_MAX_LENGTH),
|
||||
description: clip(
|
||||
`${[`${typeLabel} da campanha de Jorge Solla 1313`, ...context].join(' ')}.`,
|
||||
CONTENT_PIECE_DESCRIPTION_MAX_LENGTH,
|
||||
),
|
||||
source: 'fallback',
|
||||
}
|
||||
}
|
||||
|
|
@ -142,7 +151,8 @@ const suggestContentPieceMetadata: ContentPieceSuggester = async ({
|
|||
|
||||
const title = clip(object.title, CONTENT_PIECE_TITLE_MAX_LENGTH)
|
||||
const description = clip(object.description, CONTENT_PIECE_DESCRIPTION_MAX_LENGTH)
|
||||
if (!title || !description) return fallback
|
||||
// A model echoing the provisional placeholder is not a suggestion.
|
||||
if (!title || !description || isContentPieceLinkTitle(title)) return fallback
|
||||
return { title, description, source: 'ai' }
|
||||
} catch {
|
||||
return fallback
|
||||
|
|
@ -187,6 +197,8 @@ export const resolveContentPieceInstitution = (transcript: string): string | und
|
|||
* classifier + the suggestion, and returns only what it is confident about.
|
||||
* An empty transcript (a photo, a card, a silent video) skips every external
|
||||
* call and returns `source: 'none'` — the type/title the upload derived stay.
|
||||
* The one exception is a provisional link placeholder: with no title to keep,
|
||||
* the deterministic semantic fallback replaces it (no external call).
|
||||
*/
|
||||
export const catalogContentPiece = async ({
|
||||
payload,
|
||||
|
|
@ -204,7 +216,18 @@ export const catalogContentPiece = async ({
|
|||
suggest?: ContentPieceSuggester
|
||||
}): Promise<ContentPieceCatalogResult> => {
|
||||
const text = transcript?.trim() ?? ''
|
||||
if (!text) return { source: 'none' }
|
||||
if (!text) {
|
||||
if (isContentPieceLinkTitle(title)) {
|
||||
const fallback = buildContentPieceFallbackMetadata({
|
||||
type,
|
||||
currentTitle: title,
|
||||
cityLabel: null,
|
||||
topics: [],
|
||||
})
|
||||
return { title: fallback.title, description: fallback.description, source: 'fallback' }
|
||||
}
|
||||
return { source: 'none' }
|
||||
}
|
||||
|
||||
let topics: SpeechTopic[] = []
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
contentPieceTypeFromMime,
|
||||
isContentPieceCuratedField,
|
||||
isContentPieceLinkFailureReason,
|
||||
isContentPieceLinkTitle,
|
||||
isContentPieceOrigin,
|
||||
isContentPieceProcessingStatus,
|
||||
isContentPieceStatus,
|
||||
|
|
@ -235,9 +236,20 @@ describe('content piece links', () => {
|
|||
it('gives a link a provisional title', () => {
|
||||
const instagram = parseContentPieceLink('https://www.instagram.com/reel/ABC/')!
|
||||
expect(contentPieceLinkTitle(instagram)).toBe('Instagram · ABC')
|
||||
const youtube = parseContentPieceLink('https://youtu.be/VID')!
|
||||
const youtube = parseContentPieceLink('https://youtu.be/VID/')!
|
||||
expect(contentPieceLinkTitle(youtube)).toBe('YouTube · VID')
|
||||
})
|
||||
|
||||
it('recognizes the generated placeholder and nothing else', () => {
|
||||
expect(isContentPieceLinkTitle('Instagram · ABC')).toBe(true)
|
||||
expect(isContentPieceLinkTitle('YouTube · VID')).toBe(true)
|
||||
expect(isContentPieceLinkTitle(' Instagram · ABC ')).toBe(true)
|
||||
expect(isContentPieceLinkTitle('Instagram · ')).toBe(false)
|
||||
expect(isContentPieceLinkTitle('Instagram para gestores')).toBe(false)
|
||||
expect(isContentPieceLinkTitle('Mensagem do grupo')).toBe(false)
|
||||
expect(isContentPieceLinkTitle('')).toBe(false)
|
||||
expect(isContentPieceLinkTitle(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('C230 — the identity and the import candidate of a profile post', () => {
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ describe('buildContentPieceFallbackMetadata', () => {
|
|||
cityLabel: null,
|
||||
topics: [],
|
||||
})
|
||||
expect(fallback.title).toBe('Peça de vídeo')
|
||||
expect(fallback.title).toBe('Vídeo da campanha')
|
||||
expect(fallback.description).toContain('Vídeo da campanha de Jorge Solla 1313')
|
||||
expect(fallback.source).toBe('fallback')
|
||||
})
|
||||
|
|
@ -126,6 +126,19 @@ describe('buildContentPieceFallbackMetadata', () => {
|
|||
'Áudio da campanha de Jorge Solla 1313 em Feira de Santana sobre Saúde, Economia e Trabalho.',
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces the provisional link placeholder with the semantic title', () => {
|
||||
const fallback = buildContentPieceFallbackMetadata({
|
||||
type: 'video',
|
||||
currentTitle: 'Instagram · Dd6mTjAqzc9',
|
||||
cityLabel: 'Santa Inês',
|
||||
topics: ['saude'],
|
||||
})
|
||||
expect(fallback.title).toBe('Vídeo da campanha em Santa Inês sobre Saúde')
|
||||
expect(fallback.description).toBe(
|
||||
'Vídeo da campanha de Jorge Solla 1313 em Santa Inês sobre Saúde.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('catalogContentPiece', () => {
|
||||
|
|
@ -146,6 +159,27 @@ describe('catalogContentPiece', () => {
|
|||
expect(suggest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still replaces a link placeholder when there is no text', async () => {
|
||||
const classify = classifierOf([])
|
||||
const suggest = suggesterOf('t', 'd')
|
||||
const result = await catalogContentPiece({
|
||||
payload: fakePayload(),
|
||||
type: 'video',
|
||||
title: 'Instagram · DdsAC82NXsC',
|
||||
transcript: null,
|
||||
classify,
|
||||
suggest,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
title: 'Vídeo da campanha',
|
||||
description: 'Vídeo da campanha de Jorge Solla 1313.',
|
||||
source: 'fallback',
|
||||
})
|
||||
expect(classify).not.toHaveBeenCalled()
|
||||
expect(suggest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fills city, topics, institution and the suggestion when confident', async () => {
|
||||
const payload = fakePayload({
|
||||
municipalitySlug: 'feira-de-santana',
|
||||
|
|
@ -208,6 +242,24 @@ describe('catalogContentPiece', () => {
|
|||
expect(result.topics).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back when the model echoes the provisional placeholder', async () => {
|
||||
generateObjectMock.mockReset()
|
||||
generateObjectMock.mockResolvedValue({
|
||||
object: { title: 'Instagram · ABC', description: 'Descrição qualquer.' },
|
||||
})
|
||||
|
||||
const result = await catalogContentPiece({
|
||||
payload: fakePayload(),
|
||||
type: 'video',
|
||||
title: 'Instagram · ABC',
|
||||
transcript: 'Uma fala sobre saúde em Salvador.',
|
||||
classify: classifierOf([]),
|
||||
})
|
||||
|
||||
expect(result.source).toBe('fallback')
|
||||
expect(result.title).toBe('Vídeo da campanha')
|
||||
})
|
||||
|
||||
it('desliga o thinking e mantém teto de saída acima do orçamento de raciocínio', async () => {
|
||||
generateObjectMock.mockReset()
|
||||
generateObjectMock.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const run = (args: string[], env: Record<string, string> = {}) =>
|
|||
|
||||
const output = (result: ReturnType<typeof run>) => `${result.stdout}${result.stderr}`
|
||||
|
||||
describe('content:instagram:import write guards (C230-followup)', () => {
|
||||
describe('content:instagram:import write guards (C230-followup)', { timeout: 30_000 }, () => {
|
||||
it('prints the help even without a database', () => {
|
||||
const result = run(['--help'], { DATABASE_URL: '' })
|
||||
|
||||
|
|
|
|||
|
|
@ -8,14 +8,17 @@ import {
|
|||
} from '@/lib/contentPieceProfileWindow'
|
||||
|
||||
import {
|
||||
formatInstagramContentPruneReport,
|
||||
formatInstagramContentReport,
|
||||
formatInstagramContentWithdrawReport,
|
||||
instagramContentFeedIdentityUrls,
|
||||
instagramContentPostDates,
|
||||
instagramContentReportStamp,
|
||||
parseInstagramContentCliArgs,
|
||||
parseInstagramContentPruneCliArgs,
|
||||
parseInstagramContentWithdrawCliArgs,
|
||||
planInstagramContentDraftsToPublish,
|
||||
planInstagramContentPrune,
|
||||
planInstagramContentWindow,
|
||||
planInstagramContentWithdraw,
|
||||
shouldPublishImportedPiece,
|
||||
|
|
@ -255,6 +258,120 @@ describe('pre-campaign withdrawal (C230-followup)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('pre-campaign pruning (C230-followup)', () => {
|
||||
const cataloguePosts = [
|
||||
post('KEEP', { timestamp: '2026-08-16T00:00:00-03:00' }),
|
||||
post('DROP-LATE', { timestamp: '2026-08-15T23:59:59-03:00' }),
|
||||
post('DROP-EARLY', { timestamp: '2026-07-31T09:00:00-03:00' }),
|
||||
]
|
||||
const catalogue = [
|
||||
{ id: 1, title: 'KEEP', sourceUrl: 'https://www.instagram.com/reel/KEEP/', status: 'rascunho' },
|
||||
{
|
||||
id: 2,
|
||||
title: 'DROP-LATE',
|
||||
sourceUrl: 'https://www.instagram.com/p/DROP-LATE/',
|
||||
status: 'publicado',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'DROP-EARLY',
|
||||
sourceUrl: 'https://www.instagram.com/reel/DROP-EARLY/',
|
||||
status: 'rascunho',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'SEM-DATA',
|
||||
sourceUrl: 'https://www.instagram.com/reel/FORA-DO-FEED/',
|
||||
status: 'rascunho',
|
||||
},
|
||||
{ id: 5, title: 'SEM-URL', sourceUrl: null, status: 'rascunho' },
|
||||
]
|
||||
|
||||
it('matches every piece before the cutoff, drafts included, sharing the withdraw split', () => {
|
||||
const plan = planInstagramContentPrune({
|
||||
pieces: catalogue,
|
||||
posts: cataloguePosts,
|
||||
before: '2026-08-16',
|
||||
})
|
||||
const withdrawn = planInstagramContentWithdraw({
|
||||
pieces: catalogue,
|
||||
posts: cataloguePosts,
|
||||
before: '2026-08-16',
|
||||
})
|
||||
|
||||
// 16/08 00:00 na Bahia = 03:00Z: a noite do dia 15 e tudo antes sai; peça
|
||||
// sem data no feed nunca entra no conjunto (fail-closed).
|
||||
expect(plan.cutoffIso).toBe('2026-08-16T03:00:00.000Z')
|
||||
expect(plan.toDelete.map((piece) => piece.id)).toEqual([3, 2])
|
||||
expect(plan.toDelete.map((piece) => piece.postDate.slice(0, 10))).toEqual([
|
||||
'2026-07-31',
|
||||
'2026-08-16',
|
||||
])
|
||||
expect(plan.undateable.map((piece) => piece.id)).toEqual([4, 5])
|
||||
expect(plan.toDelete.map((piece) => piece.id)).toEqual(
|
||||
withdrawn.toWithdraw.map((piece) => piece.id),
|
||||
)
|
||||
})
|
||||
|
||||
it('parses the prune arguments with the deep default scan', () => {
|
||||
expect(parseInstagramContentPruneCliArgs(['--before', '2026-08-16'])).toEqual({
|
||||
apply: false,
|
||||
before: '2026-08-16',
|
||||
scanDays: 365,
|
||||
out: 'data/content-instagram',
|
||||
help: false,
|
||||
})
|
||||
expect(
|
||||
parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--apply', '--scan-days', '90']),
|
||||
).toMatchObject({ apply: true, before: '2026-08-16', scanDays: 90 })
|
||||
expect(() => parseInstagramContentPruneCliArgs([])).toThrow(/--before é obrigatório/)
|
||||
expect(() => parseInstagramContentPruneCliArgs(['--before', '16/08/2026'])).toThrow(
|
||||
/YYYY-MM-DD/,
|
||||
)
|
||||
expect(() => parseInstagramContentPruneCliArgs(['--before', '2026-02-30'])).toThrow(
|
||||
/data civil válida/,
|
||||
)
|
||||
expect(() =>
|
||||
parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--scan-days']),
|
||||
).toThrow(/faltou valor/)
|
||||
expect(() =>
|
||||
parseInstagramContentPruneCliArgs(['--before', '2026-08-16', '--scan-days', '0']),
|
||||
).toThrow(/--scan-days/)
|
||||
expect(() => parseInstagramContentPruneCliArgs(['--force'])).toThrow(/argumento desconhecido/)
|
||||
expect(parseInstagramContentPruneCliArgs(['--help'])).toMatchObject({ help: true })
|
||||
})
|
||||
|
||||
it('reports the cutoff, the period, the catalogue size and the undateable rows', () => {
|
||||
const plan = planInstagramContentPrune({
|
||||
pieces: catalogue,
|
||||
posts: cataloguePosts,
|
||||
before: '2026-08-16',
|
||||
})
|
||||
const lines = formatInstagramContentPruneReport({
|
||||
target: '127.0.0.1/teqo_1313',
|
||||
mode: 'apply',
|
||||
before: '2026-08-16',
|
||||
scanDays: 365,
|
||||
feedCount: 3,
|
||||
piecesCount: 5,
|
||||
plan,
|
||||
deleted: 2,
|
||||
failures: [],
|
||||
revalidation: { attempted: true, ok: true, reason: null },
|
||||
reportPath: 'data/content-instagram/x-prune.json',
|
||||
})
|
||||
|
||||
const text = lines.join('\n')
|
||||
expect(text).toContain('alvo: 127.0.0.1/teqo_1313 | modo: apply')
|
||||
expect(text).toContain('corte: 2026-08-16 (Bahia; posts anteriores)')
|
||||
expect(text).toContain('no catálogo: 5 · anteriores ao corte: 2 · sem data no feed: 2')
|
||||
expect(text).toContain('DROP-EARLY (2026-07-31)')
|
||||
expect(text).toContain('? sem data: #4 SEM-DATA')
|
||||
expect(text).toContain('apagadas: 2 · falhas: 0 · revalidação: ok')
|
||||
expect(text).toContain('recibo: data/content-instagram/x-prune.json')
|
||||
})
|
||||
})
|
||||
|
||||
describe('summaries and receipt', () => {
|
||||
it('publishes only a ready piece when the run asked for it', () => {
|
||||
expect(shouldPublishImportedPiece({ processingStatus: 'pronto', publish: true })).toBe(true)
|
||||
|
|
|
|||
90
tests/unit/instagramContentPruneCli.unit.spec.ts
Normal file
90
tests/unit/instagramContentPruneCli.unit.spec.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { spawnSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// C230-followup — the prune CLI's fail-closed write guards, exercised as a
|
||||
// subprocess: each refusal must happen before any Payload import, feed call or
|
||||
// DB connection (a delete is irreversible, so the guard is the last wall).
|
||||
|
||||
const repoRoot = process.cwd()
|
||||
const scriptPath = join(repoRoot, 'scripts', 'prune-instagram-content.mjs')
|
||||
|
||||
const run = (args: string[], env: Record<string, string> = {}) =>
|
||||
spawnSync(process.execPath, [scriptPath, ...args], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: 60_000,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs',
|
||||
NODE_ENV: 'production',
|
||||
VITEST: '',
|
||||
DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
|
||||
PAYLOAD_SECRET: 'test-secret',
|
||||
CONTENT_INSTAGRAM_PRUNE_CONFIRM: '',
|
||||
TEQO_ENV: '',
|
||||
ALLOW_REMOTE_DB: '',
|
||||
...env,
|
||||
},
|
||||
})
|
||||
|
||||
const output = (result: ReturnType<typeof run>) => `${result.stdout}${result.stderr}`
|
||||
|
||||
describe('content:instagram:prune write guards (C230-followup)', { timeout: 30_000 }, () => {
|
||||
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_PRUNE_CONFIRM')
|
||||
expect(result.stdout).toContain('pnpm content:instagram:prune')
|
||||
})
|
||||
|
||||
it('requires --before before touching anything', () => {
|
||||
const result = run(['--apply'])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('--before é obrigatório')
|
||||
})
|
||||
|
||||
it('refuses an invalid cutoff before touching anything', () => {
|
||||
const result = run(['--before', '16/08/2026'])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('YYYY-MM-DD')
|
||||
})
|
||||
|
||||
it('refuses --apply on a production target without the intent flag, before any DB import', () => {
|
||||
const result = run(['--before', '2026-08-16', '--apply'])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('CONTENT_INSTAGRAM_PRUNE_CONFIRM=1')
|
||||
expect(output(result)).not.toContain('| modo:')
|
||||
})
|
||||
|
||||
it('requires the declared TEQO_ENV once the intent flag is set', () => {
|
||||
const result = run(['--before', '2026-08-16', '--apply'], {
|
||||
CONTENT_INSTAGRAM_PRUNE_CONFIRM: '1',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('TEQO_ENV')
|
||||
})
|
||||
|
||||
it('refuses a target database that does not match the declared environment', () => {
|
||||
const result = run(['--before', '2026-08-16', '--apply'], {
|
||||
CONTENT_INSTAGRAM_PRUNE_CONFIRM: '1',
|
||||
TEQO_ENV: 'staging',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('≠ "teqo_staging"')
|
||||
})
|
||||
|
||||
it('refuses an unknown argument before touching anything', () => {
|
||||
const result = run(['--force'])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('argumento desconhecido')
|
||||
})
|
||||
})
|
||||
|
|
@ -31,7 +31,7 @@ const run = (args: string[], env: Record<string, string> = {}) =>
|
|||
|
||||
const output = (result: ReturnType<typeof run>) => `${result.stdout}${result.stderr}`
|
||||
|
||||
describe('content:instagram:withdraw write guards (C230-followup)', () => {
|
||||
describe('content:instagram:withdraw write guards (C230-followup)', { timeout: 30_000 }, () => {
|
||||
it('prints the help even without a database', () => {
|
||||
const result = run(['--help'], { DATABASE_URL: '' })
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue