docs(C212): achados da varredura do Instagram — API oficial viável; scraping fora (#1256)

This commit is contained in:
Francisco Solla 2026-09-22 21:13:20 -03:00
parent 6baec42d26
commit 6e1406c812
4 changed files with 307 additions and 1 deletions

View file

@ -0,0 +1 @@
**C212 — Viabilidade da varredura automática do Instagram e da extração de mídia por link (2026-09-22):** investigação entregue como decisão documentada — nenhum arquivo de `src/` muda. Os achados (docs oficiais datadas em 2026-09-22 + repo verificado no commit `6d721ec7` + probes públicos sem credencial) ficam anexados à própria intenção (`docs/plans/central-conteudos-varredura-instagram.md` §Achados), respondendo às quatro perguntas do gate. **Q1:** a Graph API já integrada (Instagram Login, `graph.instagram.com`) pede os campos certos e **descarta o `media_url`** de vídeo/Reel no parse (`instagramFeed.ts:94-103,146-180`) — o arquivo existe no caminho oficial, falta guardá-lo; a edge `/{ig-user-id}/media` devolve até as 10K mídias mais recentes, não cobre stories e documenta paginação time-based (`since`/`until`), com cursor (`paging.cursors`) como paginação geral da Graph API; rate limit é a fórmula BUC `4800 × impressões` com webhooks recomendados (número real da conta: a confirmar na implementação); token long-lived de 60 dias renovável via `refresh_access_token` com ≥24 h de idade, devolvendo `expires_in` (o repo hoje só renova após falha e não persiste validade); a cadência de board (5 min) não serve para acervo — diária + "Sincronizar agora" cabe nos limites. **Q2:** conteúdo próprio extrai pela Graph API; terceiro só por oEmbed/embed — a doc proíbe "consuming, manipulating, extracting, or persisting the metadata and content", e o probe sem token devolveu 403 `Provide valid app ID` ao pedir metadados, então a peça de terceiro circula pelo link e nada é persistido/baixado; a YouTube Data API não tem URL de arquivo (o playback oficial é o embed) e o oEmbed público confirma título/thumbnail/iframe sem chave. **Q3:** tabela oficial × scraping × link manual — scraping **proibido** (Meta Automated Data Collection Terms exige permissão escrita; YouTube Developer Policies vedam scraping e download/armazenamento de audiovisual), recomendação é API oficial, com o que se perde declarado (stories, terceiros, >10K). **Q4:** mecanismo, limites, fora de escopo e o item de implementação **descrito, não criado** (irmão do C211). O refinamento de terceiro que o C211 precisa aplicar virou follow-up rastreado (#1263, `depends: C211`), já que a Issue do C211 está `in-progress` e o plano dela não pode ser editado. Sem migration, sem Consent/access, sem UI (Impeccable A), sem credencial em log, feed da home (S3) intocado.

View file

@ -0,0 +1,49 @@
# C211-FOLLOWUP-OEMBED — C211 — link de terceiro: o oEmbed não permite persistir metadados/conteúdo
Status: rascunho
Atualizado em: 2026-09-22
Issue: (registrada via `pnpm agent:register`; destrava quando o C211 flipar `done`)
Priority: P2
Impeccable: A — N/A sem UI
Design UI: N/A — sem UI
Appetite: ~30 min de ajuste documental no C211 (fill-in); nenhum código nasce aqui
Responsável: —
## Intenção
A investigação do C212 (Issue #1256, achados em `docs/plans/central-conteudos-varredura-instagram.md` §Q2) provou que o oEmbed do Instagram só serve para **renderizar** o post embutido (visão de front-end): "consuming, manipulating, extracting, or persisting the metadata and content … is strictly prohibited" (doc datada em 2026-09-22; probe sem credencial devolveu 403 `Provide valid app ID` ao pedir metadados). O plano do C211 (Issue #1254, `in-progress`) ainda carrega a hipótese B ("entra com thumbnail/embed quando o caminho oficial permitir") na questão em aberto "Peça por link: circula ou é baixada?" — interpretável como persistir thumbnail de terceiro, o que a plataforma proíbe.
Este item garante que, quando o C211 for implementado/refinado, a regra chegue ao plano dele: peça de terceiro circula **pelo link**; o embed pode ser renderizado; **nada é extraído, baixado ou persistido** de terceiro; catalogação/extração de arquivo só da conta própria via Graph API.
## Objetivo e aceite
- Ao destravar (C211 `done`), aplicar no plano/impl do C211 a linha de refinamento na questão "Peça por link" (`docs/plans/central-conteudos-ingestao.md:105`) e, se o aceite `:49` ainda prometer thumbnail de terceiro, alinhar o texto ao limite do oEmbed — citando `docs/plans/central-conteudos-varredura-instagram.md` §Q2.
- Nada além de documentação: sem código, sem schema, sem migration, sem Consent, sem URL.
## Dados da decisão (literais)
- **Guardrail:** mídia de terceiro nunca é baixada (C211 `:68`); o C212 acrescentou o limite do oEmbed: persistir metadados/conteúdo de terceiro é proibido pela plataforma.
- **Caminho oficial:** embed/render para terceiro; Graph API (`media_url`) só para a conta própria.
## Fora de escopo
- Implementar o fluxo de link (é o C211) e a varredura (item próprio descrito no C212 §Q4).
## Rabbit holes de produto
- **"Baixar a thumbnail porque dá."** O endpoint devolve o embed, não licença de persistência. **Corte:** render, nunca persistir.
- **"Editar o plano do C211 durante o in-progress."** Proibido (regra de captura de débitos): este item só age quando o C211 fechar.
## Referências
- Achados: `docs/plans/central-conteudos-varredura-instagram.md` §Q2 · Plano do pai: `docs/plans/central-conteudos-ingestao.md` · Doc datada: Instagram oEmbed (`developers.facebook.com/documentation/instagram-platform/oembed`, consultado em 2026-09-22)
## Self-score (shaping)
1. Fatia = um outcome verificável? **Sim** — a regra do oEmbed no plano do C211, sem código.
2. Appetite declarado e a intenção cabe? **Sim** (~30 min, quando destravar).
3. Persona + job + aceite claros? **Sim** — dono do C211.
4. Direção no codebase é hipótese? **Sim** — duas linhas de plano.
5. Zero decisões duras de engenharia? **Sim**.
**Score: 5/5.**

View file

@ -0,0 +1,163 @@
# Impl: C212 — Central de Conteúdos — viabilidade da varredura automática do Instagram (e da extração de mídia por link)
Status: executado (2026-09-22)
Atualizado em: 2026-09-22
Issue: #1256
Intenção: docs/plans/central-conteudos-varredura-instagram.md
Appetite restante: herdado — ~0,5–1 dia eng, **sem código**. Orçamento: Fase 1 (revalidar repo) ≈1–2 h · Fase 2 (docs oficiais + probes) ≈2–4 h · Fase 3 (redação + changelog + PR) ≈1–2 h.
## Leitura da intenção
- **Outcome:** as quatro perguntas (Q1–Q4) respondidas com evidência datada — repo (`arquivo:linha`) + docs oficiais (com data de consulta) — e uma recomendação de mecanismo, limites e cortes para a varredura do Instagram e a extração de mídia por link; os achados ficam **anexados à própria intenção** (seção de achados). Nada é implementado.
- **O que NÃO negociar:**
- NUNCA scraping/contorno como mecanismo recomendado — nem como fonte de evidência; se só o scraping alcançar algo desejado, a recomendação é NÃO fazer e dizer o que se perde (intenção, guardrails).
- Nada entra na Central sem curadoria (Rascunho → Publicado é regra do C211).
- Sem PII; credencial nunca em log/doc; o token IG é admin-only no `SocialFeedSettings` (`src/globals/SocialFeedSettings.ts:63-66,138,147`).
- Feed da home (S3) intocado: kill switch, snapshot, exclusão por item, cache 5 min, sem embed no load (`docs/plans/secao-conteudos-home-instagram.md:22,42,44,47,58,69,76`; `src/utilities/socialFeed/instagramFeedView.ts:96`).
- Limite/expiração não confirmado na doc = **"a confirmar na implementação"** — nunca estimar.
- Escopo de plataformas: Instagram (`@depjorgesolla`, Business/Creator) e YouTube (`@JorgeSollaDep`); demais redes fora.
- **O que reavaliar (hipóteses do codebase que a execução confirma ou corrige):**
- As linhas citadas abaixo são do baseline do explorador (2026-09-22) e **podem ter deslocado**; a execução revalida cada fato e cita o commit verificado.
- "`media_url` chega e é descartado" — verdadeiro no parse atual, mas o campo É pedido (`instagramFeed.ts:94-103`) e usado como thumbnail de IMAGE/carrossel; VIDEO/REEL usam `thumbnail_url` por design ("never the mp4", `:105-136`).
- "Refresh não lê `expires_in`" — verdadeiro (`instagramFeed.ts:234-255`); a validade real é assunto de doc + runbook (`docs/ops/instagram-feed-token-runbook.md:26-29,43-44,60-62`), não de código.
- "API do YouTube não dá arquivo" — verdadeiro para `search.list`/`videos.list` (`youtubeFeed.ts:168-188`); o que a doc permite para o próprio canal é confirmação da Fase 2.
- As "Questões em aberto (produto)" da intenção são respondidas **na seção de Achados, sem reescrever o texto de produto** (achado complementa; o registro do pedido permanece).
## Abordagem recomendada
```mermaid
flowchart LR
Q["Intenção — Q1–Q4"] --> R["Fase 1 · revalidar repo<br/>arquivo:linha + greps vazios<br/>no commit da execução"]
R --> D["Fase 2 · docs oficiais<br/>fetch + data de consulta"]
D --> P["probes públicos<br/>sem credencial"]
P --> L{"a doc confirma?"}
L -- "sim" --> A["achado citado<br/>URL + data"]
L -- "não" --> U["a confirmar na implementação<br/>nunca estimar"]
A --> S["Fase 3 · seção Achados na intenção<br/>Q1–Q4 + tabela Q3 + Q4<br/>item descrito, não criado"]
U --> S
S --> C["changelog 2026-09-22-c212<br/>+ PR (Closes #1256)"]
C --> G["gate do dono<br/>implementar? qual mecanismo?"]
```
**Opções consideradas:** A) achados anexados à intenção (`docs/plans/central-conteudos-varredura-instagram.md`, seção "Achados"), com este impl plan documentando só o método e as decisões; B) documento novo em `docs/research/…`; C) comentário na Issue #1256.
**Recomendação:** A — é o que a intenção manda ("os achados ficam anexados a este plano"); o registro fica num dono só (o plano que o gate lê) e o diff é mínimo (intenção + changelog). Este impl plan não duplica os achados: fixa método, critérios de evidência e o contrato da seção.
**Rejeitadas:** B — twin de fonte (o achado viveria longe da pergunta que responde; dois lugares para manter, e a intenção diz "anexado a este plano"); C — o tracker acompanha, não é registro de evidência (mutável, fora do versionamento do repo).
### Decisões de engenharia
**A — Onde o entregável vive.**
Opções: A) seção "Achados (C212)" na intenção; B) `docs/research/central-conteudos-varredura-instagram-achados.md` novo; C) comentário na Issue #1256.
Recomendação: **A** — mandato literal da intenção ("Registro: os achados ficam anexados a este plano"), dono único do registro e leitura do gate no mesmo artefato da pergunta.
Rejeitadas: **B** porque cria fonte gêmea para um conteúdo que só existe para responder ao próprio plano; **C** porque Issue é acompanhamento (mutável, não versionado com o repo) e a evidência precisa ficar ao lado da pergunta.
**B — Método de evidência (o que conta como prova).**
Opções: A) revalidação read-only no repo (`arquivo:linha` + greps no commit) + docs oficiais fetched e datadas + probes públicos **sem credencial**; o que a doc não confirmar vira "a confirmar na implementação"; B) probe na Graph API real com o token de produção (somente leitura); C) scraping/perfil espelhado como evidência; D) estimar números de memória/blog/terceiros.
Recomendação: **A** — verificável, auditável, sem credencial e alinhada ao guardrail de não estimar; é a única que responde Q1–Q4 sem violar nada.
Rejeitadas: **B** porque o token vive só no global admin-only/produção (não há credencial no worktree dev) e usar credencial de produção numa investigação é inseguro (LGPD/segurança; a credencial nunca sai do global); **C** porque o mecanismo é proibido pelo item e não pode ser fonte de prova (ToS/reputacional); **D** porque estimativa viola o aceite ("nunca estimar") e um número errado contamina a decisão do gate.
**C — Forma da comparação da Q3.**
Opções: A) tabela de 3 mecanismos — (A) API oficial agendada/recebida · (B) scraping · (C) sem varredura/link manual — × entrega, curadoria, riscos (ToS/bloqueio/jurídico/reputacional/fragilidade operacional) e custo, com veredito por linha e uma seção "o que se perde" se só o scraping alcançar algo; B) prosa por mecanismo; C) matriz de score ponderado.
Recomendação: **A** — o aceite pede comparação explícita; a tabela expõe trade-offs lado a lado e força o veredito; "o que se perde" é obrigatório pelo guardrail.
Rejeitadas: **B** porque esconde a comparação que o gate pediu; **C** porque é falsa precisão (pesos cardinais sobre risco não quantificado viram estimativa disfarçada).
**D — Contrato da seção de Achados.**
Opções: A) Q1–Q4 explícitas + tabela campo-a-campo na Q1 + lista final "a confirmar na implementação" + Q4 com item de implementação **descrito, não criado**; B) relatório livre; C) só o que foi confirmado (gaps omitidos).
Recomendação: **A** — o aceite é por pergunta; a Q1 precisa do nível de campo (pedido × armazenado × descartado); gaps declarados são parte da resposta; a Q4 fecha com recomendação + limites + o que fica de fora + o item que nasceria dali.
Rejeitadas: **B** porque não permite checar cobertura das perguntas; **C** porque omitir o não confirmado é exatamente o que a intenção proíbe.
### Componentes / mudanças
- **Arquivos tocados (4):**
- `docs/plans/central-conteudos-varredura-instagram.md` (editar): nova seção `## Achados (C212)` + `Status:` → `entregue (2026-09-22)`. Nada mais muda — Intenção, Aceite, Dados da decisão, Fora de escopo e Questões em aberto permanecem literais (achado **complementa**, não substitui; D).
- `docs/plans/central-conteudos-varredura-instagram-impl.md` (novo): este plano, incluído no commit da entrega (pipeline).
- `docs/changelog/2026-09-22-c212.md` (novo): entrada curta no formato dos precedentes (pipeline OPS44; o guard de CI exige adição, nunca edição de entrada existente).
- `docs/plans/c211-followup-oembed-terceiro.md` (novo): plano curto do follow-up registrado na triage de débitos (Issue #1263, `depends: C211`) — o C211 está `in-progress` e o plano dele não pode ser editado.
- **Sem migration, sem schema, sem código:** nenhum arquivo de `src/`, `tests/`, `scripts/` ou config é tocado; nenhuma coleção/global/campo nasce (nem `expires_at` de token — se a recomendação precisar, o item de implementação descreve, não implementa).
- **Método (o "componente" real desta entrega):**
- **Revalidação read-only** de cada fato, com `arquivo:linha` + commit; greps que precisam voltar vazios: `oembed` em `src/` (não há fetch por link — a cifra está em `docs/plans/central-conteudos-ingestao.md:75` e `docs/plans/c171-player-youtube-sem-signin-impl.md:86`) e `paging|cursors|429|rate.?limit` em `src/utilities/socialFeed/`.
- **Baseline a revalidar (explorador, 2026-09-22):**
- IG pede `id,caption,media_type,media_url,permalink,thumbnail_url,timestamp,children{media_url,thumbnail_url}` (`src/utilities/socialFeed/instagramFeed.ts:94-103`) e envia via URLSearchParams `fields/limit/access_token` (`:221-225`); `InstagramPost` (`:14-21`) guarda id/caption/mediaType/permalink/thumbnailUrl/timestamp; o parse (`:146-180`) descarta `media_url` — thumbnail de VIDEO/REEL usa `thumbnail_url` ("never the mp4", `:105-136`).
- **Sem paginação:** nenhum `paging`/`cursors` no módulo; só `limit`, capado em `INSTAGRAM_MAX_RESULTS_CAP=50` (`:10,:221-225`); o sync busca `maxItems+10` (`src/utilities/socialFeed/instagramSync.ts:103-110`).
- **Refresh só após falha**, uma vez, sem ler `expires_in` (`instagramFeed.ts:236-255`); token renovado é persistido (`:415-426`); segunda falha → `InstagramApiError` (`:227-229,244-246`), erro vira linguagem de produto (`:307-327`) e o caller cai no snapshot (`instagramFeedView.ts:80-93`).
- **Sync:** snapshot `{username,posts}` sem `media_url` (`instagramSync.ts:113-117`); retry em `src/app/(frontend)/api/social-feed/sync/route.ts:39-46`; timeouts 10 s rota / 5 s hook (`instagramSync.ts:26,34`).
- **Cache do board:** `unstable_cache(['instagram-feed'], {tags:['social-feed'], revalidate:300})` (`instagramFeedView.ts:96`) — cadência de board, não de acervo.
- **YouTube:** `search.list part=snippet&type=video&order=date` (`src/utilities/socialFeed/youtubeFeed.ts:168-175`) + `videos.list part=statistics` (`:184-188`) + `liveStreamingDetails` (`:240`); `YouTubeVideo` = id/title/publishedAt/thumbnailUrl/viewCount (`:15-21`) — sem URL de arquivo; erros genéricos (quota/403 não distinguem, `:179,191`).
- **Config:** `youtubeApiKey :91`, `youtubeChannelId :100`, `instagramAccessToken :138`, `instagramUserId :147`; kill switches `:72-119`; `read/update` admin-only `:63-66`; snapshots/status hidden `:216-235`; **sem campo de expiração de token**.
- **Parsers existentes:** `parseYoutubeVideoId` (`src/lib/speechVod.ts:137-173`) e `youtubeThumbnailUrl` (`:180-181`); **nenhum parser de link IG**.
- **Precedente de mídia:** `downloadSource` (`src/utilities/speech/speechMediaPipeline.ts:25-59`) + ffmpeg → `payload.create({collection:'media', filePath})` (`speechPosterJob.ts:125-145`, `speechCutJob.ts:71-84`); não há uploader genérico remote-URL→media.
- **Precedente de agendamento C199:** `after()` de `next/server` (`src/utilities/recordings/recordingScheduler.ts:15-19`), single-flight condicional (`src/app/(campaign)/campanha/actions/recording.ts:79-94`), reap de stale (`recordingJob.ts:337-363`); não há cron/fila.
- **Contratos vizinhos:** C211 (`docs/plans/central-conteudos-ingestao.md:63-68` tipos/estados/catalogação; `:49,:68` link; `:75,:80` transcrição reusa C199; `:76` coleção de upload nova entra no plugin S3); S3 (`docs/plans/secao-conteudos-home-instagram.md:22,42,44,47,58,69,76`; "token long-lived com refresh ~60 dias" é afirmação do plano-site, não verificada no código).
- **Armadilha de acoplamento:** o sync do hook roda dentro da transação do save, segurando o row lock (`instagramSync.ts:28-34,59-79`), e `instagramFeed.ts` não pode importar `@payload-config` (gate de ciclos; `instagramFeedView.ts:44-48`) — a varredura de acervo **não pode reusar esse caminho como está** (acoplaria a cadência do acervo ao lock/token/global do board); o achado deve dizer isso explicitamente.
- **Docs oficiais (fetch + data; o que extrair):**
- Instagram Platform / Graph API — `https://developers.facebook.com/docs/instagram-platform`: campos de `GET /{ig-user-id}/media` (o que é `media_url` por tipo), paginação (`paging.cursors.after/before`), rate limits documentados, `refresh_access_token`/tempo de vida do long-lived token.
- Instagram oEmbed — `https://developers.facebook.com/docs/instagram/oembed`: o que devolve (embed/thumbnail), exigência de token (app/client) e formas de URL.
- Meta Platform Terms — `https://developers.facebook.com/terms`: coleta automatizada/uso de dados (base do risco de scraping e do que a API permite).
- YouTube Data API v3 — `https://developers.google.com/youtube/v3/docs/videos` e `.../search`: partes disponíveis (`snippet`/`statistics`/`contentDetails`/`liveStreamingDetails`), ausência de URL de arquivo, quota (o número sai da doc datada ou vira "a confirmar").
- YouTube oEmbed — `https://www.youtube.com/oembed` (probe público): resposta de vídeo público (título/thumbnail/embed).
- YouTube Terms/Developer Policies — `https://developers.google.com/youtube/terms`: download/armazenamento/derivação permitidos e proibições de contorno.
- **Formato de citação:** `<título da página> — <URL> (consultado em AAAA-MM-DD)` ao lado da afirmação; afirmação sem página = "a confirmar na implementação".
- **Probes (somente leitura, sem credencial):** YouTube oEmbed em vídeo público (sem chave) — evidência datada da Q2/YT. IG oEmbed sem token: permitido só como probe público do próprio endpoint para datar a exigência de credencial (a resposta é a evidência). **Nenhum** probe na Graph API (Q1): sem credencial no worktree (token admin-only/prod) e com credencial de produção proibido (D-B). Scraping de página como "probe" = proibido.
- **Redação da seção de Achados (contrato):**
1. **Q1 — perfil próprio via Graph API:** tabela campo-a-campo (pedido em `instagramFeed.ts:94-103` × parseado/armazenado em `:14-21,:146-180` × descartado), paginação (ausente no repo × doc), rate limit (doc datada), refresh/validade (código `:236-255` × doc × runbook `:26-29,43-44,60-62`), periodicidade segura **limitada pela doc** (a cadência de board de 5 min não serve; proposta diária + "Sincronizar agora" se a doc folgar; senão "a confirmar") e as lacunas para catalogar/transcrever (o que falta para o arquivo sair do perfil próprio e chegar ao pipeline C199/C211).
2. **Q2 — link IG/YT:** caminho oficial por plataforma — IG próprio = Graph API (`media_url` do próprio, confirmado na doc); IG de terceiro = oEmbed/embed + thumbnail, **arquivo nunca baixado**; sem caminho oficial = a peça circula pelo link e a assessoria anexa o arquivo (refina a regra do C211, sem ampliá-la); YT = Data API sem arquivo + oEmbed público (thumbnail/embed); lista do que NÃO é aceitável (scraping, contorno, download de terceiro/yt-dlp).
3. **Q3 — comparação:** tabela A/B/C (D-C) com curadoria (tudo Rascunho; filtro por tipo/período como decisão de implementação; dedupe por id contra peças existentes), riscos (ToS Meta/YouTube datados, bloqueio de conta/IP, jurídico/reputacional, fragilidade) e custo operacional; recomendação nunca scraping; se só o scraping alcançar algo, o que se perde (ex.: stories, posts de terceiros, mídia fora da janela do `limit`/paginação).
4. **Q4 — recomendação final:** mecanismo + limites + o que fica de fora + **item de implementação descrito, não criado** (nome, dependências C211/S3, e o que ele teria de decidir: agendador vs rota, estado, token/expiração, paginação, download+media, curadoria).
5. **Fechamento:** lista única "a confirmar na implementação" (cada item dizendo o que exatamente verificar na API real, quando houver credencial e item aprovado) + "Questões em aberto" respondidas (validada/corrigida/a confirmar), sem reescrever o texto de produto.
- **UI:** Impeccable A — N/A sem UI; **nenhum dispatch de designer** (não há tela, shape ou craft).
- **Access / Consent:** N/A — nenhum write path, nenhuma coleção/Consent; a leitura de `SocialFeedSettings` é a mesma do admin e não é exercitada com credencial.
### Dados → forma (se aplicável)
- **N/A** — não há superfície de dados para usuário (a intenção já responde "não"). As tabelas dos Achados (Q1 campo-a-campo e a comparação da Q3) são suporte à decisão **no documento**, não UI: sem design system, paleta ou componente.
## Fases verificáveis
1. **Fase 1 — baseline revalidado (~1–2 h).** Re-rodar leitura/greps no commit do início da execução (`git rev-parse --short HEAD`); confirmar cada fato do baseline com `arquivo:linha`; registrar divergências (se houver, o achado usa o fato novo e diz o que mudou). Checkpoint: nenhuma afirmação de repo sem citação; `oembed` em `src/` = 0; `paging|cursors|429` em `src/utilities/socialFeed/` = 0.
2. **Fase 2 — docs oficiais datadas + probes (~2–4 h).** Abrir cada página da lista, datar e extrair exatamente as afirmações de Q1/Q2/Q3; rodar os probes públicos sem credencial e anexar a resposta datada; classificar todo número como "documentado (URL + data)" ou "a confirmar na implementação". Checkpoint: zero número estimado; toda lacuna nomeada.
3. **Fase 3 — redação + changelog + PR (~1–2 h).** Escrever a seção de Achados na intenção (contrato acima), `Status: entregue (2026-09-22)`; criar `docs/changelog/2026-09-22-c212.md`; `pnpm format` nos .md; push via `pnpm push`; abrir PR no GitHub base `main` com `Closes #1256` (o diff inclui `docs/changelog/`, então não é plans-only e o guard de fechamento não incide). Gates: `pnpm format`/`format:check` é o check que importa num diff docs-only; `lint/typecheck/unit/int/e2e` ficam no skip do `ci-scope` por não haver nada em `src/` nem em `tests/` — declarar o escopo docs-only no PR. Checkpoint: diff restrito aos quatro arquivos; `format:check` verde; as quatro perguntas com resposta explícita e a Q4 com o item descrito.
## Rabbit holes / Não escopo (engenharia)
- **"Investigar virou implementar".** Não criar coleção, global, campo (`expires_at`), rota, agendador, job, script, parser, uploader ou migration; nada em `src/`, `tests/`, `scripts/`. Implementação = item descrito na Q4, não criado.
- **"Scraping como atalho de evidência".** Nenhum crawler, login em conta, yt-dlp, mirror de perfil ou download de terceiro — nem para "só conferir". Evidência = repo + docs + probe público sem credencial.
- **"Abrir o board para reusar".** Não tocar no feed da home, no `SocialFeedSettings`, no snapshot, no cache de 5 min nem no picker de exclusão; a investigação não muda nenhum comportamento de produção (S3 intocado).
- **"Credencial no lugar errado".** Nenhum token/ID ecoado em doc, log, probe ou PR; exemplos só com placeholder. Não usar o token de produção (D-B).
- **"Cobrir tudo".** IG + YT da conta própria; sem TikTok/X/Facebook; sem alterar o escopo do C211 (o achado **refina** a regra de link, como o próprio C211 pede); S27/S28/C213 fora.
- **"Virar parecer jurídico".** Citar Meta Platform Terms e YouTube Terms datados como risco é o teto; não produzir análise jurídica nem prometer conformidade.
- **"Doc gêmeo".** Nada de `docs/research/…` novo nem comentário na Issue como registro (D-A).
## Riscos e mitigação
- **Doc oficial inacessível/muda entre a consulta e o gate:** citar URL + data; se a página exigir login ou estiver indisponível, declarar a tentativa e marcar "a confirmar na implementação" (nunca inventar o número). O item de implementação revalida na hora de usar.
- **Linhas deslocadas entre o baseline do explorador e a execução:** Fase 1 revalida e registra divergência; o achado cita o commit verificado.
- **Tentação do probe com token de produção:** proibido por política (credencial admin-only, LGPD); mitigado por D-B e pela regra de que nenhum achado da Q1 precisa do token para ser útil — a doc responde; o que não responder fica na lista de confirmação.
- **Conclusão não decisiva (doc omissa):** o aceite não exige que tudo se confirme; exige "a confirmar na implementação" honesto — o entregável segue completo com gaps nomeados.
- **Scope creep para C211/S3 durante a redação:** diff revisado contra o contrato (quatro arquivos; nenhuma mudança nas seções de produto da intenção).
## Débitos (capture-review-debts)
| ID | Resumo | Origem | Score | Tipo de decisão | Destino |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ----- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| S1 | `mentions` × `mentioned_media` (leitura é `mentioned_media`); citações com linha deslocada; transcrição dos probes ausente; "rate limit folgado"; contradição do anexo de terceiro; URL truncada; "a confirmar" sem quando/fonte; repetição; inventário de arquivos; `src/`/`tests/` | simplify ×2 | — | cheap_polish | **já_resolvido** (aplicado no diff) |
| D1 | Board IG nunca renova o token proativamente (refresh só pós-falha; sem `expires_in`/campo de validade) | pesquisa | 3 | defer_trigger | **descartar deste lote** — pré-existente em `main`, fora do escopo; **gatilho:** primeira perda do feed IG em prod por token expirado (painel S11) **ou** abertura do item de implementação da Q4 (mesmo token) |
| D2 | oEmbed de terceiro proíbe persistir metadados/conteúdo — limite ausente no plano do C211 | pesquisa | 3 | cheap_polish | **registrar** — Issue #1263 (`depends: C211`), plano `docs/plans/c211-followup-oembed-terceiro.md` |
| D3 | Enum `REEL` do `media_type`, oEmbed de perfil com token, limites de webhook | pesquisa | — | coberto | **descartar (coberto)** — já na lista "A confirmar na implementação" da intenção |
- **Prazo (eleição 04/10):** fases timeboxed; se a Fase 2 estourar, corta-se profundidade (páginas secundárias), nunca evidência ou honestidade.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto — Q1–Q4 respondidas explicitamente com evidência datada (repo `arquivo:linha` + docs oficiais com data); achados anexados à intenção; Q4 com recomendação e item descrito (não criado); nenhum código.
- [ ] Invariantes AGENTS/engineering-standards — docs-only; sem schema/migration (`push: false` intocado); sem PII/Consent; sem credencial em doc/log; S3/feed da home intocado; C211 não alterado; dono único (achados na intenção, sem twin).
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam — N/A (nenhum access/write path): verificação = Fase 1 (greps vazios + citações) e `pnpm format`; o PR declara o escopo docs-only e o `ci-scope` decide os skips.
- [ ] Changelog `docs/changelog/2026-09-22-c212.md` commitado (pipeline OPS44).
## Self-score (decision-quality)
1. Decisões caras com rejeitadas — **sim** (A onde vive; B método de evidência; C forma da Q3; D contrato dos achados).
2. Cabe no appetite (~0,5–1 dia) — **sim** (3 fases timeboxed, sem código).
3. Rabbit holes nomeados — **sim** (investigar→implementar; scraping; abrir o board; credencial; cobrir tudo; parecer jurídico; doc gêmeo).
4. Depth check: reusa donos existentes — **sim** (intenção como registro; precedentes citados C199/C211/S3/runbook/speechVod; nenhum módulo ou abstração nova).
5. Intenção permanece satisfeita — **sim** (outcome inviolável; nenhum escopo de C211/S3 mudado; guardrails de evidência explícitos).
**Score: 5/5.**

View file

@ -1,6 +1,6 @@
# C212 — Central de Conteúdos — viabilidade da varredura automática do Instagram (e da extração de mídia por link)
Status: rascunho
Status: entregue (2026-09-22 — investigação; ver "Achados (C212)")
Atualizado em: 2026-09-22
Issue: #1256
Priority: P2
@ -79,6 +79,99 @@ O Teqo já tem integração oficial com a Graph API do Instagram (feed da home,
- **Peça por link de perfil de terceiro (ex.: post que menciona o Solla):** **Opções:** A) entra só pelo link, sem extrair/baixar mídia | B) entra com thumbnail/embed quando o caminho oficial (oEmbed) permitir | C) não aceitar link de terceiro. **Recomendação:** B, com o arquivo nunca baixado — o caminho oficial de terceiro dá embed/thumbnail, não o arquivo; extração de mídia só para conteúdo próprio (conta do Solla via Graph API). _(assumido — validar com produto)_
- **Periodicidade, se aprovada:** **Opções:** A) varredura diária agendada + "Sincronizar agora" | B) semanal | C) só sob demanda. **Recomendação:** A — a Central é acervo, não tempo real; a cadência de board (5 min) não serve, e o custo em chamadas da API é assunto da implementação. _(assumido — validar com produto)_
## Achados (C212)
> Investigação entregue em **2026-09-22**. Baseline de código verificado no commit `6d721ec7` (worktree C212); docs oficiais consultadas e datadas na mesma data; probes públicos somente leitura, sem credencial. Nada aqui é implementação — nenhum arquivo de `src/` foi tocado.
### Q1 — a Graph API oficial já integrada entrega as mídias recentes do perfil com metadados e `media_url` para catalogar/transcrever?
**Sim, em campo — e não no que o Teqo guarda hoje.** A integração atual (`src/utilities/socialFeed/instagramFeed.ts`, Instagram API com Instagram Login, host `graph.instagram.com`) **pede** os campos certos e **descarta** o `media_url` de vídeo/Reel por decisão de design do board ("never the mp4", `:105-108`).
Campo a campo (pedido em `instagramFeed.ts:94-103` × parse `:146-180` × o que a doc diz):
| Campo pedido | Hoje no repo | O que a doc oficial diz | Serve para catalogar/transcrever? |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | guardado (`InstagramPost.id`) | id do IG Media | sim — chave de dedupe |
| `caption` | guardado | legenda | sim |
| `media_type` | guardado (`IMAGE`/`VIDEO`/`REEL`/`CAROUSEL_ALBUM` no repo) | doc de referência lista `CAROUSEL_ALBUM`, `IMAGE`, `VIDEO` (`REELS` aparece em `media_product_type`) — enum real do host do Instagram Login a confirmar na implementação | sim |
| `media_url` | **descartado** no parse; usado só como thumbnail quando `IMAGE`/filho de carrossel (`pickInstagramThumbnail :111-136`) | "The URL for the media"; **omitido** em mídia com material protegido por direito autoral (ex.: áudio em Reels) | **é o campo do arquivo**: imagem → imagem; vídeo/Reel → arquivo de vídeo (o comentário do módulo registra que é o mp4). É o que falta guardar |
| `permalink` | guardado | URL permanente do post | sim |
| `thumbnail_url` | guardado | capa; doc: "Only available on `VIDEO` media" | sim (capa) |
| `timestamp` | guardado | criação em ISO 8601 | sim |
| `children{media_url,thumbnail_url}` | só o primeiro filho com URL vira capa de carrossel; nenhum filho é guardado | filhos do carrossel | para catalogar carrossel item a item, hoje não; o `media_url` de cada filho é o arquivo |
**Paginação — não existe no repo; existe na doc.** Grep: `paging|cursors` = 0 em `src/utilities/socialFeed/`. Hoje: só `limit` (capado em 50, `INSTAGRAM_MAX_RESULTS_CAP` `:10`, `instagramFeed.ts:221-225`) e o sync busca `maxItems+10` (`instagramSync.ts:103-110`). A doc da edge `/{ig-user-id}/media` documenta **time-based pagination** (`since`/`until`); a Graph API documenta **cursor pagination** (`paging.cursors.after`/`next` + `limit`) como paginação geral — é o caminho para backfill (varredura inicial e reconciliação). Limite da edge: "Returns a maximum of 10K of the most recently created media"; **stories não são suportados** nessa edge. _(IG User Media — developers.facebook.com/documentation/instagram-platform/instagram-graph-api/reference/ig-user/media · Graph API Results — developers.facebook.com/docs/graph-api/results, consultados em 2026-09-22.)_
**Rate limit — documentado, e não é número fixo.** A Overview da plataforma: as edges estão sujeitas ao Instagram Business Use Case rate limiting, `Calls within 24 hours = 4800 * Number of Impressions` (impressões de conteúdo da conta nas últimas 24 h), e recomenda **webhooks** "to receive notifications about your app users' media objects" em vez de polling. _(Instagram Platform Overview — developers.facebook.com/documentation/instagram-platform/overview, consultado em 2026-09-22.)_ O número absoluto para @depjorgesolla depende das impressões reais — **a confirmar na implementação** (nunca estimado).
**Token — documentado.** Long-lived Instagram User Access Token expira em **60 dias**; `GET /refresh_access_token?grant_type=ig_refresh_token` aceita token com **≥24 h de idade e não expirado** e devolve token novo por mais 60 dias, com `expires_in` em segundos na resposta. _(Refresh Access Token — developers.facebook.com/documentation/instagram-platform/reference/refresh_access_token · Access Token — developers.facebook.com/documentation/instagram-platform/reference/access_token, consultados em 2026-09-22.)_ Hoje o repo chama o refresh **só depois de uma falha**, não lê `expires_in` e não persiste validade (`instagramFeed.ts:236-255`); o `SocialFeedSettings` não tem campo de expiração. O runbook já registra que page token do Facebook Login é recusado (`docs/ops/instagram-feed-token-runbook.md:26-29`).
**Periodicidade segura.** A cadência de board (cache de 5 min, `instagramFeedView.ts:96`) **não serve** para acervo. A doc não fixa cadência; com a fórmula de rate limit e o webhook como caminho recomendado, a proposta do plano (A: diária + "Sincronizar agora") cabe — número final e estratégia (webhook de mídia + reconciliação periódica) são **decisão da implementação**, com as impressões reais como dado.
**Lacunas para o C211 (o que falta para a peça sair do perfil próprio e chegar ao pipeline de transcrição):** (1) guardar `media_url` (o parse descarta); (2) baixar o arquivo do próprio perfil e gravá-lo em `media` — precedente é `downloadSource` do acervo (`src/utilities/speech/speechMediaPipeline.ts:25-59`), não há uploader genérico remote-URL→media; (3) paginar/backfill por cursor; (4) estado próprio da varredura (cursor, última varredura, dedupe por `id`) — **sem** reusar o caminho do board: o sync do hook roda dentro da transação do save segurando o row lock (`instagramSync.ts:28-34,59-79`) e o módulo do feed não pode importar `@payload-config` (gate de ciclos, `instagramFeedView.ts:44-48`); o acervo não pode herdar kill switch, snapshot nem cache de 5 min do board.
### Q2 — peça por link (IG/YT): qual o caminho oficial de extração; o que não é aceitável
**Instagram — conteúdo próprio (conta do Solla):** Graph API oficial; o `media_url` do próprio media é o arquivo (com a ressalva de áudio protegido, em que o campo é omitido). É o caminho para extrair e catalogar no C211.
**Instagram — conteúdo de terceiro (ex.: post que menciona o Solla):** o caminho oficial é oEmbed/embed, e ele tem limite duro. Probe (2026-09-22, sem credencial): `GET /instagram_oembed?url=…` devolveu **200** com o HTML do embed (post público), mas pedir metadados (`fields=thumbnail_url,author_name`) sem app devolveu **403 `(#200) Provide valid app ID`**. A doc do oEmbed é explícita: o endpoint é **só para exibir o post embutido** — "consuming, manipulating, extracting, or persisting the metadata and content … is strictly prohibited" _(Embed an Instagram Post — developers.facebook.com/documentation/instagram-platform/oembed, consultado em 2026-09-22)_. Consequência para o C211: a peça de terceiro circula **pelo link**, o embed/thumbnail pode ser **renderizado** (visão de front-end) e **nada é persistido/baixado**. A edge `mentioned_media` (leitura) cobre mídia em que a conta foi mencionada — as `mentions` são de comentário em menção; nenhuma delas muda a regra de produto: mídia de terceiro nunca é baixada.
**YouTube:** a Data API v3 **não entrega arquivo de vídeo** — as partes de `videos.list` são `brandPartner, contentDetails, fileDetails, id, liveStreamingDetails, …, player, …, snippet, statistics, …`, e a reprodução oficial é o embed do player (`player.embedHtml`); `fileDetails` é só do dono e é metadado do arquivo, não URL de download _(Videos: list — developers.google.com/youtube/v3/docs/videos/list, consultado em 2026-09-22)_. O oEmbed público funciona **sem chave** — probe (2026-09-22): `www.youtube.com/oembed?url=https://www.youtube.com/watch?v=i_fbclWWC5o` devolveu **200** com `title`, `author_name: "Jorge Solla"`, `thumbnail_url` e `html` (iframe) — serve para título/capa/embed, não para o arquivo.
**O que NÃO é aceitável (nem como recomendação, nem como evidência):** scraping; login/automação de conta; contorno de embed, privacidade ou limite; baixar mídia de terceiro (yt-dlp ou similar) — bases citáveis em Q3.
**Sem caminho oficial:** a peça circula pelo link; o arquivo que a assessoria **já tem em mãos** pode ser anexado por ela (gesto humano do C211) — da plataforma nada é extraído, baixado ou persistido. O embed é permitido como visão de front-end.
### Q3 — varredura automática do perfil: vale a pena?
| Critério | (A) API oficial agendada/recebida | (B) Scraping | (C) Sem varredura (link manual) |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- |
| Entrega | mídias recentes do perfil oficial com metadados e `media_url` (próprias); sem stories e além das 10K mais recentes | o que for público (e mais, com sessão) | só o que a assessoria colar |
| Curadoria | tudo como **Rascunho**; dedupe por `id`; filtro tipo/período é decisão da implementação | idem, com triagem maior | natural (gesto humano) |
| ToS | permitido — é a API oficial | **proibido** sem permissão escrita: Automated Data Collection Terms ("You will not engage in Automated Data Collection without first obtaining Meta's express written permission"; Effective October 7, 2024) + Platform Terms §3.a (uso fora dos propósitos permitidos; enforcement pode remover App e conta, §7) + Developer Policies do YouTube ("must not … scrape … or obtain scraped YouTube data or content"; "must not … download, import, backup, cache, or store copies of YouTube audiovisual content") | isento |
| Risco operacional | erro vira status no painel (precedente S11) | bloqueio de conta/IP, quebra de markup, manutenção permanente; barato agora, caro depois | nenhum |
| Jurídico/reputacional | baixo (conteúdo próprio, caminho oficial) | direito autoral de terceiros, dados pessoais em legendas, vínculo do mandato com prática não permitida | baixo |
| Custo | ~1 chamada de mídia por varredura + download das próprias peças; o rate limit tende a sobrar para a cadência diária — número real a confirmar (Q1) | crawler + sessão + proxy + retrabalho contínuo | triagem manual |
| Veredito | **recomendado** | **não fazer** | fallback permanente e caminho padrão do C211 |
_(Bases datadas em 2026-09-22: Automated Data Collection Terms — facebook.com/legal/automated_data_collection_terms · Meta Platform Terms — developers.facebook.com/terms · YouTube API Services Developer Policies — developers.google.com/youtube/terms/developer-policies · YouTube API Services Terms of Service — developers.google.com/youtube/terms/api-services-terms-of-service.)_
**O que se perde sem scraping (declarado, aceito):** stories, posts de terceiros/colaborativos, mídias além das 10K mais recentes (limites da edge `/media`, Q1) e conteúdo de contas que desabilitem embeds. Nada disso justifica o caminho proibido — a recomendação é NÃO fazer e dizer o que se perde.
### Q4 — recomendação final
1. **Mecanismo:** varredura pelo **caminho oficial** — Instagram API com Instagram Login (a mesma credencial já governada pelo `SocialFeedSettings`, admin-only), com webhook de mídia como recebimento preferencial e **reconciliação agendada** (diária + "Sincronizar agora") como rede; paginação por cursor para backfill; tudo entra como **Rascunho** com dedupe por `id`; download do arquivo só do **próprio perfil** (`media_url`) e só no mesmo pipeline de transcrição/catalogação do C211.
2. **Limites:** token long-lived de 60 dias renovável (≥24 h de idade, antes de expirar; guardar `expires_in`/status — decisão de implementação); rate limit pela fórmula BUC (impressões reais, a confirmar na implementação); limites da edge `/media` e a ressalva do `media_url` estão em Q1 — a implementação precisa de fallback (thumb/peça-link) e status visível; **estado da varredura com dono próprio**, sem reusar o caminho do board (lock transacional do hook, snapshot, kill switch e cache de 5 min do S3 não se ampliam).
3. **Fica de fora:** scraping e qualquer contorno (definitivo); download/persistência de mídia de terceiro; stories; automatizar publicação (curadoria humana é regra do C211); redesenhar o feed da home.
4. **Item de implementação que nasceria daí (descrito, não criado):** um item irmão do C211 — "varredura oficial do perfil + extração de mídia por link" — com: (a) cliente Graph API com paginação e estado de cursor/última varredura/dedupe; (b) webhook de mídia + rota de reconciliação (agendador no precedente C199 `after()`/`src/utilities/recordings/recordingScheduler.ts`, ou cron do deploy — decisão do item); (c) download do `media_url` próprio → coleção de mídia do C211 (entra na lista do plugin S3) → pipeline C199 de transcrição; (d) link por plataforma com embed quando oficial (IG oEmbed; YT oEmbed/iframe) e o botão "anexar arquivo" quando não houver caminho; (e) painel de estado (token/expiração/última varredura) e kill switch próprios. Dependências: C211 (dono da peça/catalogação) e S3 (contrato do board intocado); S27/S28 consomem depois. O ID/prioridade são do roadmap (`plan-issue`), não deste plano.
### A confirmar na implementação (sem credencial neste item; verificar quando houver credencial e o item de implementação aprovado; nunca estimado)
- Número absoluto de chamadas disponíveis para @depjorgesolla (depende das impressões reais na fórmula BUC; fonte: Insights da conta profissional no app/painel do Instagram).
- Comportamento da edge `/{ig-user-id}/media` no host `graph.instagram.com` com `limit` + cursor na conta real (a referência da edge descreve time-based; cursor é a paginação geral da Graph API).
- Validade/expiração do CDN por trás de `media_url` (a doc consultada não declara; a implementação deve baixar e gravar na mesma execução, sem depender de reuso do link).
- Enum real de `media_type` para Reels no host do Instagram Login (a referência lista `VIDEO`; o repo trata `REEL` defensivamente).
- oEmbed de **perfil** e com token de app (o probe sem credencial devolveu `Invalid URL`; a doc lista perfis como formato aceito) e a disponibilidade de `thumbnail_url` com token.
- Existência/limites do webhook de mídia para a conta e o atraso de entrega.
### Questões em aberto (produto) — respondidas com a evidência
- **O que a varredura traz?** A (tudo rascunho) **validada** — e delimitada: a varredura só entrega mídia própria; dedupe por `id` é requisito.
- **Peça por link de terceiro:** a hipótese B ("entra com thumbnail/embed quando o caminho oficial permitir") fica **corrigida no limite**: o embed/thumbnail pode ser **renderizado**, mas o oEmbed proíbe persistir metadados/conteúdo — então a peça de terceiro **circula pelo link**, sem catalogar/baixar mídia (C211 preservado).
- **Periodicidade:** A (diária + "Sincronizar agora") **cabe**, condicionada às impressões reais (a confirmar na implementação).
### Método e evidência
- Repo: leitura no commit `6d721ec7` (worktree C212), com greps de controle: `oembed` em `src/` = 0; `paging|cursors|429|rate.?limit` em `src/utilities/socialFeed/` = 0; `expires_in` só em `src/utilities/googleCalendarClient.ts` (fora do social feed).
- Docs oficiais: consultadas e datadas em **2026-09-22** (URLs nas respostas acima).
- Probes: somente leitura, **sem credencial**, em 2026-09-22 — transcrições:
- `https://www.youtube.com/oembed?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Di_fbclWWC5o&format=json` → **200** com `title`, `author_name:"Jorge Solla"`, `author_url:"https://www.youtube.com/@JorgeSollaDep"`, `thumbnail_url:"https://i.ytimg.com/vi/i_fbclWWC5o/hqdefault.jpg"` e `html` (`<iframe … youtube.com/embed/i_fbclWWC5o …>`).
- `https://graph.facebook.com/v25.0/instagram_oembed?url=https%3A%2F%2Fwww.instagram.com%2Fp%2FfA9uwTtkSN%2F` → **200** com `html` (blockquote do embed), sem metadados.
- o mesmo com `&fields=thumbnail_url,author_name` → **403** `(#200) Provide valid app ID`.
- oEmbed com `url` de perfil (`/depjorgesolla/`) → **400** `Invalid URL`.
- Nenhum probe na Graph API autenticada; token de produção não acessado.
- Nenhum scraping, nenhum download, nenhum PII, nenhuma credencial em log.
## Referências
- Planos irmãos: `docs/plans/central-conteudos-ingestao.md` (C211) · `docs/plans/central-conteudos-publica.md` (S27) · `docs/plans/secao-conteudos-home-instagram.md` (S3) · `docs/plans/secao-conteudos-home-youtube.md` (S2)