Tubarões

O primeiro escolhido é o maior.

Padrão: 4 clássicos

projeto · 25 de out. de 2026

finwatch: o noticiário financeiro de 2026, classificado por probabilidade

Um pipeline na Cloudflare que coleta 11 veículos de economia e o BACEN, rotula cada notícia com um modelo de decisão do Workers AI segundos depois da coleta e serve um dashboard. São 194 mil notícias de 2026, e a classificação custa uns 4 centavos de dólar por dia.

finwatch.stater.dev ↗

Sou dev fullstack TypeScript e queria aprender ciência de dados fazendo um projeto inteiro, da coleta ao gráfico. Escolhi uma pergunta que dá pra responder com dado público: o que a imprensa brasileira está dizendo sobre os bancos em 2026? Quantas matérias sobre o Banco Master saíram em agosto, se o Pix aparece mais que o Open Finance, se o tom sobre um banco piorou depois de um balanço.

O material está nos feeds RSS dos jornais de economia. Ninguém junta isso de graça: cada veículo tem seu formato, metade dos feeds só mostra as últimas horas, a mesma matéria sai em cinco lugares com URLs diferentes e “tom” não vem escrito em lugar nenhum.

O finwatch coleta 11 fontes de notícia (InfoMoney, Valor, Estadão, Exame, Money Times, a CVM e outras) e 7 séries do Banco Central. Cada notícia nova é limpa, deduplicada, rotulada com tema e sentimento, transformada em vetor e agrupada com as reportagens da mesma pauta, tudo em Workers da Cloudflare, segundos depois da coleta. O resultado vai para um dashboard em finwatch.stater.dev.

Em 25/10/2026 eram 194.002 notícias de 2026 na camada limpa, das quais 16.820 (8,7%) falam do setor bancário. Uma notícia leva 7 segundos, na mediana, entre ser gravada e ganhar rótulo. A classificação custa cerca de US$ 0,045 por dia útil.

O que ele não faz: não lê o corpo das matérias (só título e resumo do feed; parte dos veículos tem paywall), não extrai entidades além de uma lista de bancos e não tem gabarito humano. O sentimento é tendência, e o dashboard diz isso num aviso.

A ideia que organiza o projeto

O dado bruto é gravado uma vez e nunca é editado. Tudo depois dele é derivado e pode ser jogado fora e refeito. A chave de cada objeto no R2 é um hash do próprio item:

// ingestion/src/lake/bronze.ts
const id = await contentId(item.guid || item.link || item.title); // sha256, 16 hex
const key = `bronze/${sourceId}/dt=${partitionDate(item.publishedAt)}/${id}.json`;
await lake.put(key, JSON.stringify({ sourceId, collectedAt: new Date().toISOString(), item }));

Coletar o mesmo item duas vezes sobrescreve o mesmo arquivo. A coleta pode rodar quantas vezes quiser sem duplicar nada, e eu nunca precisei de uma tabela de “já vi isso”. Quando uma regra de limpeza muda, reprocesso a partir do bronze.

A segunda decisão é que o coletor não chama o processamento. O R2 publica um evento numa fila a cada objeto criado, e um outro Worker consome a fila. Coleta horária, backfill de RSS e backfill de sitemap alimentam o mesmo processamento sem saber que ele existe.

Três Workers e um banco D1 compartilhado. O cron-finwatch só escreve no R2; o process-finwatch faz todo o resto; o api-finwatch só lê marts.

Nem sempre foi assim. De agosto a setembro o processamento era Python na minha máquina: pandas, sentence-transformers, um LLM chamado por CLI, Parquet e um script que empurrava os resultados pro D1 todo dia. Funcionava, mas dependia de mim. Em 9 e 10 de outubro migrei tudo para TypeScript dentro dos Workers, em fases, cada uma com um teste que provava que o resultado era o mesmo do Python. O Python ficou como laboratório, para calibrar limiares e ajustar centroides.

O explorador abaixo percorre as dez etapas na ordem em que o dado passa por elas.

As dez etapas, na ordem do dado

Clique numa etapa. A pílula diz quem decide: código, modelo, serviço da Cloudflare ou SQL.

Coleta RSS

código
quando
cron de hora em hora
entra
11 feeds RSS de economia
sai
itens normalizados + o item original inteiro
onde
ingestion/src/collect.ts
  • GET condicional: reenvia o ETag e o Last-Modified da rodada anterior. Se o feed não mudou, o servidor responde 304 e nada é baixado.
  • Os validadores só são gravados depois do bronze. Se a gravação falhar, a próxima rodada não pula o feed por um 304 indevido.
  • Uma fonte quebrada vira uma linha de erro em source_runs e as outras seguem.

1. Coleta: o RSS só mostra o presente

A coleta horária é simples. Para cada um dos 11 feeds, um GET condicional reenvia o ETag e o Last-Modified da rodada anterior; se o feed não mudou, o servidor responde 304 e nada é baixado. Os validadores só são salvos depois que o bronze foi gravado, senão uma gravação que falhou viraria um 304 falso na hora seguinte. Uma fonte com erro vira uma linha em source_runs e as outras seguem.

O problema foi o passado. Comecei o projeto em agosto e queria o ano inteiro. Alguns feeds aceitam ?paged=2, ?paged=3 e assim por diante, e esses eu andei página por página com um cursor no R2. Oito fontes ignoram o parâmetro e devolvem sempre a mesma janela. Para elas o único lugar com o histórico é o sitemap XML do site.

Cada site organiza o sitemap de um jeito. O Estadão tem uma URL previsível por dia, então eu gero as datas de 1º de janeiro até hoje e busco uma a uma. O Valor publica um índice que lista os sitemaps diários. O Exame tem dois níveis, um índice de meses que aponta para índices de dias. Escrevi um modo para cada formato e um cron a cada 10 minutos que anda um orçamento de dias por fonte. Ele é ligado por um flag no D1 e se desliga sozinho quando todas as fontes chegam em janeiro.

Sitemap tem URL e data, e mais nada. O título que gravei para essas 98 mil notícias é o slug da URL (petroleo sobe com tensoes entre estados unidos e ira.ghtml), sem acento, sem resumo. Essa aproximação apareceu de novo em quase todas as etapas seguintes.

Um bug da coleta só apareceu na limpeza. A Folha serve o feed em ISO-8859-1 e eu lia a resposta com res.text(), que sempre decodifica como UTF-8. “Bilhão” virava “bilh�o”, e o byte original se perdia no bronze, sem conserto. Hoje o coletor lê os bytes crus e escolhe o decoder pelo charset do header, depois pela declaração do XML. A Folha saiu do catálogo por outro motivo (o feed não tem histórico), mas a coluna has_mojibake continua lá, e os marts ignoram qualquer linha marcada.

O coletor se identifica como finwatch/0.1 (+projeto de estudo). Os robots.txt desses sites bloqueiam bots de IA pelo nome; os caminhos de artigo estão liberados para o agente genérico, e eu só uso sitemap onde o RSS não alcança.

2. Do bronze à fila

O R2 tem notificação de evento: cada object-create no prefixo bronze/ vira uma mensagem na fila bronze-events-finwatch. O process-finwatch consome lotes de até 25 mensagens, com ack e retry por mensagem. Se o Workers AI cai, só a mensagem que falhou volta, com backoff de 30 segundos por tentativa, e depois da quinta vai para uma dead-letter queue.

Fila entrega pelo menos uma vez, e a coleta horária regrava os mesmos itens do feed a cada rodada, o que dispara uma notificação nova. Então o processamento tem de ser idempotente, e a regra de deduplicação mais importante mora num ON CONFLICT:

INSERT INTO news_silver (dedup_key, bronze_key, collected_at, ...)
VALUES (?, ?, ?, ...)
ON CONFLICT(dedup_key) DO UPDATE SET bronze_key = excluded.bronze_key, ...
WHERE excluded.collected_at < news_silver.collected_at  -- fica a coleta mais antiga

No Python isso era sort_values("collected_at").drop_duplicates(keep="first"). Na fila não existe ordem global, então a comparação vai para o banco: chegue na ordem que chegar, sobrevive a primeira coleta de cada chave.

O que acontece com uma mensagem. Só o último caminho paga o modelo; re-entregas e duplicatas param antes.

3. Silver: regras de dicionário, com paridade contra o Python

A silver tira HTML, corta rodapé de feed (“The post X appeared first on Brazil Journal”), gera uma chave de deduplicação e detecta duas coisas: quais bancos a notícia cita e se ela usa algum termo do setor. Uma notícia é “do setor” se citar banco ou termo. Essa é a régua de todos os números do dashboard.

Portar isso para TypeScript deu mais trabalho do que parecia, porque a chave de deduplicação já estava gravada no D1 e tinha de sair igual, byte a byte. Três coisas morderam:

  • \b do JavaScript é ASCII. Em “não post”, ele vê uma fronteira entre o “ã” e o “o” e casa “o post” no meio da palavra. Escrevi as fronteiras com \p{L} para imitar o re do Python.
  • new URL() normaliza demais. Ele resolve .., mexe em percent-encoding e explode com URL relativa. O urlsplit do Python só fatia a string. Reimplementei urlsplit, parse_qsl e quote_plus.
  • \s e \d têm conjuntos diferentes nas duas linguagens. Viraram classes explícitas.

O teste de paridade compara campo a campo a saída do TypeScript com a do Python em 473 documentos reais. Ele quebrou várias vezes antes de passar.

O dicionário de bancos tem 47 instituições e prefere errar por omissão. “Inter” sozinho fica de fora, porque também é Internacional, internet e interbancário; só “banco inter” conta. “Caixa” sozinho também fica. E existe um guard específico: a Caixa opera as loterias, e “Caixa paga prêmio da Mega-Sena” inflava o ranking dela. Quando a Caixa é o único banco e o texto é de loteria, ela sai. O erro para o outro lado também existe: o show do Iron Maiden em São Paulo entrou no setor porque acontece no Nubank Parque.

O que a silver extrai de uma notícia

Escolha um caso ou edite o texto. As regras são as mesmas do worker, rodando no seu navegador.

dedup_key
infomoney.com.br/minhas-financas/varejo-cresce-4-no-dia-das-criancas-diz-getnet-transacoes-via-pix-lideraram-alta
bancos
nenhum
termos do setor
pix
entra no setor?
sim

Nenhum banco citado, mas "pix" é termo do setor. Os parâmetros utm somem da chave.

Uma consequência que só vi com o dado na mão: o corpus é largo. Das 194 mil notícias, só 8,7% passam pela régua do setor. O resto é bolsa americana, eleição, Copa do Mundo e cotação de bitcoin. Por isso o dashboard sempre filtra pelo setor antes de contar.

4. Classificação: cinco motores e nenhum gabarito

O rótulo de cada notícia tem duas partes: temas, de uma taxonomia fechada de 10 (Pix, Open Finance, crédito, juros, fraude, tecnologia, fusão, regulação, ESG, resultados), e sentimento: positivo, crítico ou neutro.

O motor mudou quatro vezes. Comecei com Claude Haiku via OpenRouter. Um bake-off de 20 documentos medindo custo real trocou para o Gemini 2.5 Flash-Lite, 18 vezes mais barato e com a maior concordância com o Haiku entre os baratos. Um teste A/B contra 80 notícias anotadas levou ao Qwen3 235B em meados de agosto. Depois passei para o Codex CLI, que usava uma assinatura que eu já pagava: um processo por documento levava 6,3 s, e mandar 20 documentos numerados por chamada baixou para 0,7 s. Em 1º de outubro a Cloudflare lançou o Clef, e ele virou o quarto.

O Clef é um modelo de decisão. Ele não gera texto: recebe o documento e uma lista de perguntas tipadas e devolve uma probabilidade por opção. Para quem vem de TypeScript, é a diferença entre pedir um JSON a um LLM e torcer para ele vir válido, e chamar uma função que devolve Record<Opção, number>. Eu guardo a resposta crua e derivo o rótulo com limiares:

// processor/src/clef.ts, decide()
sentiment = s.choice; // argmax entre positivo, crítico e neutro
if (sentiment === "neutro" && th.polarAbove !== undefined) {
  const pos = s.probabilities?.positivo ?? 0;
  const crit = s.probabilities?.critico ?? 0;
  if (Math.max(pos, crit) >= th.polarAbove) sentiment = pos >= crit ? "positivo" : "critico";
}

O polarAbove existe porque, pelo argmax, o Clef respondia “neutro” para quase tudo, mesmo dando 0,3 ou 0,4 de probabilidade ao polo certo. Mudar o limiar não custa chamada nova: é rodar decide de novo sobre as respostas guardadas.

O difícil foi decidir se o Clef era melhor que o Codex sem ter gabarito. Eu não queria anotar centenas de notícias à mão, então usei dois juízes LLM às cegas, Haiku e Sonnet. Eles discordaram entre si: numa amostra de 80, o Haiku marcou 26 positivas, 24 críticas e 30 neutras; o Sonnet, 0, 6 e 74. Sentimento sem uma definição operacional não tem resposta certa.

Montei então um teste só de polaridade: 200 notícias do setor, com resumo, pré-filtradas por um léxico de polaridade que não usa o rótulo de nenhum motor. Os juízes leram em blocos de 50, com proibição explícita de rotular por script ou palavra-chave. Essa proibição veio de um erro: na primeira tentativa, o juiz Sonnet gravou só as exceções e preencheu o resto com “neutro”, e o Haiku rotulou pelo título. Descartei as duas rodadas. A referência final são as 129 notícias em que os dois concordaram.

O limiar de produção, 0,3, foi escolhido numa amostra de calibração e fixado antes deste teste. Mexa nele:

Quando o "neutro" do Clef vira polo

129 notícias em que os dois juízes concordaram. Cada ponto é uma notícia; a posição é a probabilidade que o clef-flash deu ao polo mais provável. Arraste o limiar.

0,30
71%polo certo · Codex 98%
8%neutras viradas polo · Codex 35%
0,81macro-F1 · Codex 0,78
  • rótulo final crítico
  • rótulo final positivo
  • rótulo final neutro
  • argmax já era polo
0,000,200,400,60≥ 0,8juízes: polar52 notíciasjuízes: neutra77 notícias

Dados do teste de polaridade de 10/10/2026: 200 notícias do setor, juízes Haiku e Sonnet às cegas, e as respostas cruas do clef-flash guardadas no bake-off. Com o limiar desligado o Clef acerta o polo em 42%. Nenhum motor, em nenhum limiar, inverteu positivo e crítico.

O Codex quase nunca perde uma notícia polar, mas transforma em polar 35% das neutras. Num corpus em que as amostras aleatórias davam 85% a 90% de neutras, isso quer dizer que a maior parte dos “críticos” e “positivos” do Codex era falsa. O Clef com 0,3 acerta menos polos (71%) e inventa bem menos (8%). Dá pra ver também que a macro-F1 fica quase plana entre 0,15 e 0,3; a escolha entre esses valores é quanto falso polar eu aceito, e com 0,10 o Clef se comporta igual ao Codex.

O Clef ganhou por esse equilíbrio e pelo preço, e entrou em produção em 10/10 com clef-flash, o menor dos dois modelos.

5. Quando a régua muda no meio da série

Trocar de classificador muda a definição de sentimento, e a série mensal registrou isso. O histórico até 06/09 ficou com os rótulos do Codex; o que veio depois foi rotulado pelo Clef. Na fatia de notícias críticas do setor, o degrau é grande:

Notícias do setor por mês, 2026

A faixa de baixo mostra quem rotulou cada mês. A linha tracejada marca a quebra que distorce a métrica escolhida.

  • % de notícias críticas
  • rotulado pelo Codex
  • rotulado pelo Clef
0%10%20%30%40%Codex → Clef26,2%jan26,5%fev27,5%mar25,2%abr28,2%mai24,7%jun25,9%jul32,2%ago13,5%set6,2%out*

Dados do mart_month em produção, lidos em 25/10/2026 (* outubro parcial). "Setor" é notícia que cita banco ou termo como Pix, Selic e consignado. O Clef aparece desde janeiro porque duas fontes de sitemap só entraram no processamento depois da troca de motor.

De agosto para setembro o percentual crítico cai de 32,2% para 13,5%, e em outubro, todo rotulado pelo Clef, fica em 6,2%. Não foi o noticiário que melhorou. A outra quebra está no volume: em agosto o Valor passa do sitemap (só slug) para o RSS (com resumo de uns 2,4 mil caracteres), e com mais texto as regras acham mais bancos. O setor salta de 1.286 para 2.408 notícias num mês em que o corpus total caiu.

As duas quebras viraram dado. Um arquivo breaks.ts lista os meses e o tipo de cada uma (volume ou rotulo), o gráfico de tendência desenha uma linha tracejada sobre elas e o KPI da home esconde o delta quando o mês anterior e o atual estão em lados diferentes de uma quebra do mesmo tipo. Comparações mês a mês usam fatia do setor, não contagem, porque uma troca de fonte infla o numerador e o denominador juntos.

A correção de verdade é reclassificar o histórico com o Clef. Para o setor isso são 11.013 notícias e menos de R$ 4; já marquei esses rótulos como vencidos, mas a fila ainda não rodou. O resto do corpus fica para novembro.

6. Embeddings, pautas e “a mesma matéria”

A deduplicação da silver só pega a mesma URL com parâmetros diferentes. A mesma notícia publicada pelo Valor e pela InfoMoney tem URLs distintas, e para juntá-las uso embeddings.

A cada 10 minutos um cron pega as notícias sem vetor, manda para o bge-m3 do Workers AI em lotes de 50 (com 100 a API devolve 413) e grava no Vectorize. Processo em grupos de 200: com mil vetores de 1024 floats em memória o Worker morria sem deixar nada no log.

A busca de vizinhos espera 30 minutos depois do embedding. O Vectorize é eventualmente consistente, e as matérias da mesma pauta chegam juntas; buscar no instante do insert perderia justo os pares que interessam. Vizinhos com cosseno de pelo menos 0,88 viram arestas, e um union-find com o estado no D1 junta arestas novas com grupos antigos. Quando dois grupos se encontram, sobrevive o mais antigo.

O limiar 0,88 foi calibrado contra o modelo que eu usava em Python (MiniLM, onde o corte era 0,90). A concordância entre os dois ficou baixa, F1 de 0,46 nos pares, e olhando as discordâncias nenhum era claramente melhor. Não existe gabarito de near-dup.

Grupos de "mesma matéria", por tamanho

Passe o mouse numa faixa. Multi-fonte é grupo com matérias de veículos diferentes.

2.125grupos com 2+ notícias
860cruzam mais de uma fonte
0multi-fonte com 10+ notícias
  • multi-fonte
  • fonte única
2 notícias1.7953 1224 485–9 11410+ 46

D1 de produção em 25/10/2026: 194 mil notícias embeddadas com bge-m3, vizinhos com cosseno ≥ 0,88, grupos por union-find. Os maiores grupos são séries de uma fonte só: cotação diária do bitcoin (100 notícias), "movimento falimentar" do Valor (89), "Frase do dia" (50).

Os grupos grandes não são as pautas mais repercutidas, são séries de uma fonte só. “Bitcoin hoje” em 100 dias diferentes tem o mesmo formato de texto, e o cosseno de uma manchete curta mede formato mais do que assunto. Eu já tinha visto isso em agosto com resultados de loteria. O sinal que funciona é o número de fontes que o grupo cruza. Uma matéria real tem mais ou menos uma versão por veículo, então o mart de “mais repercutidas” só aceita grupo com pelo menos 2 fontes e no máximo 3 notícias por fonte.

O mesmo vetor serve para as pautas. No laboratório Python rodei k-means com k = 20 numa amostra de 20 mil notícias e salvei os centroides num JSON versionado com o worker, que só atribui o centroide mais próximo. Na primeira versão, com MiniLM e 6.550 notícias, o silhouette foi de 0,04: grupos legíveis, mas sobrepostos. Não refiz a medida com o bge-m3. Os nomes (“Caso Banco Master”, “Guerra Irã × EUA”, “Copa do Mundo”) eu escrevi à mão a partir das palavras de cada cluster. Um deles, “Colunas e cartas de leitores”, existe por causa do formato do texto, não do assunto.

7. Custo e desempenho

O Clef cobra só os tokens de entrada, e a maior parte deles é a definição das perguntas, não a notícia. Cada pergunta custa uns 80 tokens fixos. No primeiro desenho eram 10 perguntas sim/não, uma por tema, mais o sentimento: 1.428 tokens por notícia. Trocar os 10 sim/não por uma escolha de 11 opções (10 temas e “nenhum”) baixou para 686, e o F1 de temas não caiu.

Quanto custa classificar

Tokens por notícia medidos no bake-off; o Clef só cobra input.

737tokens por notícia em produção
US$ 0,045por dia útil (~672 notícias)
7 sdo bronze ao rótulo (mediana)
56 sp99 do mesmo intervalo
clef-flash · 11 sim/não (1.428 tok)US$ 0,129clef-flash · 2 escolhas (686 tok)US$ 0,062clef · 11 sim/não (1.428 tok)US$ 0,343clef · 2 escolhas (686 tok)US$ 0,165

Em destaque, a configuração de produção. Tokens por notícia do bake-off de 09/10/2026; preço por milhão de tokens de input como está no código (US$ 0,09 e 0,24). Produção: 72.469 notícias rotuladas pelo clef-flash de 10 a 25/10, 53.437.639 tokens. A latência vem de 4.592 notícias de RSS coletadas de 18 a 25/10.

Em produção a média ficou em 737 tokens. O número depende do tamanho do resumo: nas 200 notícias do teste de polaridade, todas com resumo, a média foi de 1.164. Num dia útil entram umas 670 notícias, o que dá uns 4,5 centavos de dólar no preço do código. O resto da conta é a assinatura Workers Paid de US$ 5 por mês, que já cobre D1, filas e crons. O refresh horário dos marts lê cerca de 800 mil linhas do D1, uns 600 milhões por mês, dentro da franquia de 25 bilhões.

A latência que importa é entre o bronze e o rótulo: 7 s na mediana e 56 s no p99, medidos em 4.592 notícias de RSS. O p90 foi de 34 s. A fila espera até 30 s para encher um lote (max_batch_timeout), o que deve explicar boa parte da cauda, mas não medi essa espera separada. Entre a publicação e a coleta ainda há até uma hora, porque o cron do RSS é horário.

8. O serve: um mart por grão

No minuto 40 de cada hora, um cron recalcula os marts em SQL: cada um é DROP, CREATE e INSERT … SELECT sobre a silver e os rótulos, e todos vão num único db.batch, que o D1 roda como transação. A versão Python fazia o mesmo em auto-commit, e por alguns segundos o dashboard via tabela vazia.

A API é um Worker Hono que lê os marts por binding e devolve JSON em api.stater.dev. O front é um SPA em React com TanStack Router, Query e Table e gráficos em visx, servido como assets estáticos de outro Worker. Todo filtro fica na URL, então um gráfico sempre termina num link do explorador de notícias.

O caminho de leitura. Só a lista de matérias de uma pauta lê a silver; o explorador lê um mart de 16,8 mil linhas em vez de varrer 194 mil.

O erro que mais me ensinou aqui foi de grão. Os primeiros marts eram banco × mês e tema × mês, e a notícia que cita dois bancos aparece em duas linhas. A home somava essas linhas para mostrar o total de notícias. Em 10/10 a soma dava 11.802, contra 10.402 notícias com banco: 13% a mais, num número que não batia com contagem nenhuma. Não dava pra corrigir no front com COUNT(DISTINCT), porque o mart já tinha perdido a chave da notícia. A saída foi um mart por grão de leitura: mart_month (notícia × mês, sem explodir) para os KPIs, mart_news (uma linha por notícia do setor) para o explorador, e marts próprios para pautas, fontes e frescor.

O frescor também virou dado. O cabeçalho do dashboard mostra há quanto tempo os marts foram recalculados e quantas fontes estão paradas, porque um painel sem isso mostra dado velho com a mesma cara de dado novo.

O que ainda está errado

  • A série de sentimento continua quebrada em setembro. A reclassificação do setor está marcada e não rodou; a do corpus inteiro está agendada para novembro.
  • Não há gabarito humano. Todas as métricas de rótulo são concordância com juízes LLM. O teste de polaridade tem 129 notícias; com isso dá pra comparar motores, não para afirmar uma acurácia.
  • Os títulos de sitemap são slugs. São 98 mil notícias sem acento e sem resumo. Elas formam pautas pelo formato e entram no near-dup como séries.
  • O pico de agosto é artefato. Vem da troca de sitemap por RSS. Calcular o setor só pelo título zera o pico (agosto 1.122 contra 986 em julho), mas isso ainda não está implementado.
  • Datas do sitemap do Estadão. Em 03/02 ainda há 346 matérias antigas com o lastmod em lote no dia do sitemap. O bronze é imutável, então a correção tem de ser na silver.
  • Uma fonte morreu calada. O feed do E-Investidor responde 200 com os mesmos itens desde 27/05/2026. O coletor registrava “ok, 0 itens novos” e só uma checagem de frescor pegou.
  • Near-dup sem gabarito e um union-find que encadeia: um grupo sobre petróleo e Irã junta matérias de janeiro a maio.
  • Os dados do BACEN estão coletados e sem uso. O módulo que cruza Selic e cobertura de crédito está adiado.

Stack

Parte Escolha Por quê
Coleta Worker com 3 crons, fast-xml-parser RSS, sitemap e SGS no mesmo lugar; o backfill liga e desliga por um flag no D1, sem redeploy
Lake R2, chaves com dt= Egress zero e S3-compatível: o Python do laboratório lê tudo de graça
Gatilho Notificação de evento do R2 + Queues O coletor não conhece o processamento; retry por mensagem e dead-letter queue
Regras da silver TypeScript portado do Python Paridade testada em 473 documentos, porque a chave de dedupe já estava no D1
Classificação Clef-flash (Workers AI) Devolve probabilidade em vez de texto; o limiar é calibrável sem chamada nova
Embeddings bge-m3 + Vectorize Multilíngue, 1024 dims, tudo dentro da conta da Cloudflare
Near-dup union-find com estado no D1 Incremental: um Worker não carrega 194 mil vetores
Pautas k-means ajustado em Python, atribuição no Worker Ajustar exige o corpus; atribuir é um produto escalar
Serving Marts em SQL no D1, numa transação Um mart por grão de leitura; o dashboard nunca vê tabela vazia
API Hono Rotas só de leitura, D1 por binding
Front React, TanStack Router/Query/Table, visx Filtro na URL, cache de query, gráficos em SVG
Laboratório Python, pandas, scikit-learn Calibração de limiar, centroides e avaliação; fora da produção