feat: acervo Brasiliana Fotográfica coletado e mural 3D no ar

Captura integral do acervo da Brasiliana Fotográfica (Biblioteca Nacional)
para s3://brasiliana: 13.060 itens, 12.985 com imagem de preservação, 13.059
com miniatura derivada, 2.137 cópias legadas e 636 posts do blog. 41.444
objetos no ledger.

Os 75 itens sem preservação estão em errors.md: a página do item responde
200, mas a origem serve o bitstream com HTTP 500. Nenhum fica fora do mural,
porque todos têm thumbnail.

Não versionado, por viver no bucket ou ser reconstruível: catálogo,
posts .jsonl, atlas .webp, sqlite de estado e logs. Credenciais ficam de
fora pelo .gitignore.
This commit is contained in:
rodado
2026-09-13 20:17:02 +02:00
commit 290c4cc21d
690 changed files with 81187 additions and 0 deletions
+397
View File
@@ -0,0 +1,397 @@
# PLANO — Brasiliana Fotográfica → S3
> **Comece por aqui.** Documento de transferência: uma sessão nova consegue
> retomar o trabalho só com este arquivo. Detalhe técnico completo em
> `README.md`.
**Objetivo:** os 13.036 itens da Brasiliana Fotográfica — metadados e imagens
em qualidade de preservação — depositados em `s3://brasiliana`, organizados
como acervo arquivístico (procedência + checksums).
**Autorização:** a Biblioteca Nacional foi consultada. Confirmou que o material
é público e que não tem equipe técnica para fornecer um dump; a coleta por
scraping é o caminho combinado. Ritmo cortês e identificação continuam valendo.
> **Reorganização de pastas em 2026-09-10.** Scripts + estado (catálogo,
> `.sqlite`, `.log`, `.tmp/`, credenciais) mudaram de raiz para `pipeline/`;
> visualizações foram para `web/`; `exemplo-item/` virou `examples/exemplo-item/`;
> `censo.py` virou `archive/censo.py`. Todo comando de terminal abaixo já foi
> atualizado para o novo caminho. Detalhe completo em `README.md` →
> "Estrutura do repositório".
>
> **Mudança de plano em 2026-09-10 (importante, ver 2.6): o achado 2.4 abaixo
> não se sustentou.** Cloudflare passou a bloquear bitstream via curl também
> pra itens sem cache de borda — não só HTML. **Tudo (Fases 2 e 3) roda pelo
> navegador agora, trilha única**, não mais A/B separadas por tipo de nome.
> A Fase 4 (legado) segue via curl normalmente — domínio diferente
> (`objdigital.bn.br`), sem esse bloqueio.
---
## 1. Estado em 2026-09-10
| | |
|---|---|
| Fase 1 (metadados) | ✅ **completa** — 13.059 registros, 0 erros. 1 falso-positivo conhecido (`.../1`, é a página raiz da comunidade, não um item — excluir na contagem final) |
| Catálogo | `pipeline/catalogo.jsonl`, re-exportado depois dos reparos (152 itens com `all_preservation_urls`) |
| Download principal (1 imagem/item) | 🟡 em andamento no navegador |
| Thumbnails | 🟡 em andamento no navegador |
| Extras (itens com múltiplos bitstreams, 115 itens) | ✅ completo |
| Fase 4 (legado objdigital) | 🟡 em andamento, local (`baixador.py --somente-legado`), 2.707 itens com `legacy_url` |
| `subidor.py` | rodando em `pipeline/`, `--vigiar`, roteia preservacao/thumbnail/legado certo |
Não existe mais aba do Brave rodando isso — é uma aba de Chrome comum,
conectada via extensão Claude-in-Chrome. `coletor.js`/`coletor-resume.js`
grava em IndexedDB a cada item; fechar a aba não perde nada, mas o *loop*
para e precisa recolar o script certo pra retomar (ver `tasks/TODO.md`
pros comandos exatos de cada trilha ativa).
---
## 2. Cinco achados que não devem ser redescobertos
Cada um destes custou horas. Nenhum é óbvio, e vários são contraintuitivos.
### 2.1 O sitemap está sete anos desatualizado
`htmlmap`/`sitemap` são de abril/2019 e listam ~5.300 dos 13.036 itens. Enumerar
por eles perde **59% do acervo sem erro aparente** — o crawl termina limpo, pela
metade. Fonte correta: `browse?type=title&rpp=100&offset=N`, percorrido por
comunidade (o que de quebra etiqueta a instituição de origem).
### 2.2 O arquivo de preservação está no DSpace, não no objdigital
O `DC.identifier` aponta para `objdigital.bn.br` e parece ser o master. É o
contrário: o bitstream do DSpace tem 5533×4065 (7,4 MB) contra ~2600×2100
(147 KB) do objdigital. O maior medido: 8679×6484, 34,7 MB — EXIF mostra
Hasselblad H3D II-39MS a 7216×5412.
**Inverter isso arquiva o acervo a um quarto da resolução, e nada avisa.**
O PDF de 71,9 MB do item 14582 no objdigital não é a mesma imagem melhor: é
outro objeto, um álbum de várias páginas.
### 2.3 O Cloudflare bloqueia automação de navegador — não IP, não geografia
Medido na mesma máquina, mesmo IP, mesmo instante:
| Cliente | Páginas HTML |
|---|---|
| Chrome/Brave do usuário | ✅ 200 |
| `fetch()` de dentro da página | ✅ 200 (>100 seguidas) |
| Playwright *headed* | ❌ 200 na 1ª, 403 depois |
| Playwright *headless* | ❌ 403 |
| curl / httpx | ❌ 403 |
| Servidor (Hetzner Helsinki) | ❌ 403 |
**Proxies não ajudam** — o bloqueio dispara na segunda requisição da mesma
sessão, não por origem. Testado e descartado. Não repita.
### 2.4 Bitstreams passam por curl; páginas HTML não
`bitstream/...` responde 200 a `curl -L` simples, até com UA de curl. Só o HTML
é desafiado. É isso que permite baixar os 116 GB fora do navegador, num servidor.
### 2.5 Nome de arquivo com percent-encoding leva 403
Testado com cinco user-agents (Chrome, Safari, Firefox, Googlebot, curl):
**todos 403** numa URL com `%28`/`%20`, enquanto `curl/8.7.1` puro dá **200**
numa URL de nome simples. O gatilho é o caminho, não o cliente — variar
user-agent não resolve. O navegador busca os dois casos sem problema.
Daí as duas trilhas — **modelo original, superado, ver 2.6**. Itens da BN
usam `imagem.jpg` (trilha A); os do IMS usam nomes descritivos com espaços
e acentos (trilha B).
### 2.6 O achado 2.4 não se sustentou — Cloudflare bloqueia bitstream também
Testado ao vivo em 2026-09-10: item nunca antes acessado, buscado pelo
navegador (200 OK) e **imediatamente depois** via curl na Finlândia — 403,
página real do Cloudflare ("Attention Required"). O único item que passava
por curl (`6066`) só passava porque já tinha sido baixado com sucesso antes
e ficou em cache de borda (`cf-cache-status: HIT`) — sobra de um caso, não
regra geral. Pré-aquecer a URL pelo navegador antes de tentar via curl
**não** transfere o cache pra tornar o curl aceito (testado, falha igual).
**Consequência: não existe mais trilha A via servidor.** Tudo — nome simples
ou com encoding — precisa passar pelo navegador vivo (`fetch()` de dentro da
página). `baixador.py` na Finlândia fica só pra Fase 4 (objdigital, domínio
diferente, sem esse bloqueio — ver 2.11).
### 2.7 Itens com múltiplos bitstreams reais — não é caso raro
115 itens (de ~13.058) têm **2 a 6 imagens reais** no bundle ORIGINAL, não
uma. `citation_pdf_url` não é gerado pra esses (o gerador de citação do
DSpace não sabe qual é "a" imagem quando há mais de uma) — por isso ficam
sem `preservation_url` na extração simples. Sintoma: `full_table` tem
`dc.description` mencionando álbum/página (ex.: "Álbum 0259"), igual ao
caso do PDF de 71,9 MB do achado 2.2 — mesmo fenômeno, mas dentro do
próprio DSpace, não só no objdigital.
**Como enumerar as imagens reais de um item**, em ordem de confiabilidade:
1. Contar `.file-wrapper` na página normal do item (não `?show=full`) — bate
com o número real de bitstreams.
2. Tentar sequências `/bitstream/{PREFIX}/{h}/{seq}/qualquer-nome.jpg` pra
`seq` de 1 até N — funciona quando as sequências são contíguas a partir
de 1 (maioria dos casos).
3. Quando (2) falha (sequências não contíguas): pegar o nome real do
arquivo a partir do `<img src>` de cada `.file-wrapper` (é a miniatura,
nome com `.jpg` duplicado no final — ex. `nome.jpg.jpg`), tirar o último
`.jpg`, e buscar `/bitstream/handle/{PREFIX}/{h}/{nome-real}` (rota por
nome exato, não por sequência).
`preservation_url` = a maior imagem encontrada (compatibilidade com o
pipeline); `all_preservation_urls`/`all_preservation_sizes` guardam todas,
ordenadas da maior pra menor.
**Exceção conhecida sem conserto:** handle `20.500.12156.1/7523` — `HTTP
500` do próprio servidor da BN ao buscar os bitstreams (não é problema de
nome/padrão). Provável arquivo corrompido do lado deles.
### 2.8b Achado 2.8 falha ~62% das vezes em alguns lotes — bitstream de LICENÇA, não thumbnail
`seq_master+1` às vezes é o bitstream de **LICENÇA** do DSpace (texto puro,
"LICENÇA DE VEICULAÇÃO E DISTRIBUIÇÃO..."), não a miniatura — visto em
Marinha e IMS, **2.655 de 4.278 thumbnails** (62%!) baixados errado num
lote, **146 já tinham subido pro bucket** antes de descobrir (limpo).
O filtro de tamanho do v1 (`> 2MB → rejeita`) não pega isso: o texto da
licença é pequeno o bastante (781 bytes) pra passar. **Fix:** validar
magic bytes (JPEG `FFD8`/PNG) antes de aceitar, tentar seq+2/+3 se falhar,
e cair pro método do achado 2.7 (`<img src>` da página) como último
recurso — `baixador-thumb.js` v2. Também blindado no `subidor.py`: rejeita
qualquer upload cujo conteúdo não comece com magic bytes de imagem,
independente do nome do arquivo (defesa em camadas).
**Lição:** tamanho pequeno não é prova de "é a miniatura" — só magic bytes
provam que é imagem de verdade.
### 2.8 Miniatura gerada pelo DSpace: sequência N+1 (quando contígua)
Pra item com bitstream original na sequência N, a miniatura que o DSpace
gera automaticamente costuma ficar na sequência N+1 — confirmado no handle
`6066`: seq 1 = master (7,7 MB), seq 2 = thumb (56 KB). Mesma rota
filename-agnostic do achado 2.4 original (o nome na URL não importa, só a
sequência). Quando as sequências não são contíguas (itens do achado 2.7 via
método 3), a miniatura de cada imagem é o próprio `<img src>` do
`.file-wrapper` correspondente — não precisa de request extra.
### 2.9 objdigital às vezes tem PDF (álbum), não só JPG — extração pegava o identificador errado
Alguns itens têm mais de um `DC.identifier` apontando pro objdigital: um
`.htm` (página de landing) e um `.pdf` (o álbum de verdade, múltiplas
páginas — mesmo fenômeno do PDF de 71,9 MB do item 14582, achado 2.2). A
extração original pegava o **primeiro** identificador que batesse com
"objdigital", que pra 44 itens (a maioria uma sequência do Museu
Aeroespacial, `14526-14570`, mais o `584`) era o `.htm`, não o `.pdf` —
perdendo o conteúdo real. De 235 itens com PDF disponível, 191 já estavam
certos por acaso (o PDF vinha primeiro na lista); só 44 precisaram de
correção manual (feita em `catalogo.jsonl` e no IndexedDB, antes do
`baixador.py --somente-legado` alcançar esses handles — nada foi
contaminado no bucket). **Vale re-checar outros itens com múltiplos
`DC.identifier` de objdigital por precaução, não só os que têm `.pdf`.**
Recheque feito (2026-09-10): mapeamos todos os padrões de extensão em
2.231 itens com 2+ identifiers objdigital. Achados adicionais:
- **5 itens** (`1081, 1082, 1084, 4077, 4084`) têm **só HTML**, sem
`.jpg`/`.pdf` nenhum — são páginas de um visualizador de zoom profundo
(OpenSeadragon/DZI, pirâmide de blocos, não um arquivo único). **3 já
tinham sido baixados e subidos como se fossem imagem** (arquivos de
~2,4 KB, só a página HTML do visualizador) — limpo do bucket, `legacy_url`
zerado pros 5 (não vale a complexidade de espelhar a pirâmide de tiles
pra tão poucos itens).
- **11 itens** têm `.sid` (MrSID, formato comprimido de imagem grande)
além do `.jpg` — não capturado, ficou como gap conhecido (baixo volume,
não priorizado).
### 2.10 O crawl da Fase 1 não é 100% completo — achado via referência cruzada com o WordPress
Cruzando os handles citados nos 636 posts do WordPress contra o catálogo,
2 itens reais (`1799` "Princesa Isabel menina", `4798` "Vista panorâmica de
Canudos...") existem no site mas não foram enumerados pela Fase 1 — ambos
sem `citation_pdf_url`, formato de metadado antigo (um é de 2015-04-16,
uma das primeiras entradas do sistema). Adicionados manualmente ao
catálogo depois de descobertos.
**Isso só foi achado por acaso** (porque um post do blog citava esses
handles especificamente) — não existe verificação exaustiva de que a
enumeração por `browse?type=title` capturou 100% dos itens de cada
comunidade. Pode haver mais itens faltando que nenhum post referencia.
Não investigado a fundo: seria preciso comparar a contagem de cada
comunidade contra alguma fonte independente (a BN não expõe OAI-PMH nem
count via API, até onde sabemos).
### 2.11 O objdigital só responde em HTTPS — não era bloqueio
`legacy_url` na fonte vem como `http://objdigital.bn.br/...`. Conexão HTTP
simples (porta 80) é resetada (`Recv failure: Connection reset by peer`),
local e da Finlândia. HTTPS funciona liso com curl puro, local (não
testado da Finlândia — motivo original de "não alcançável" pode ser outro,
não investigado). Fix: forçar `https://` no `legacy_url` antes de baixar
(já aplicado em `baixador.py`).
### 2.12 Caractere `#` no nome do arquivo corrompe URL no IndexedDB
78 itens (mesmo álbum "Album 0035/0259/0260") têm `#` literal no nome real
do arquivo (ex.: `Alb 0260 199 CT #.jpg`). Algum processo que passou esse
nome por `new URL(...).pathname` sofreu o efeito clássico: `#` não
percentado é tratado como início de **fragmento da URL**, e tudo depois
dele é descartado — a URL salva no IndexedDB virou `.../Alb%20...%20CT%20`
(sem sequência, sem `#`, sem `.jpg`), 404 garantido. O `catalogo.jsonl`
local nunca foi tocado por esse bug (tem a URL certa, com `%23`) — fix é
só reescrever o campo no IndexedDB a partir do catálogo
(`pipeline/reparo-hash-url.js`).
**Lição:** nunca usar `new URL()`/`.pathname` pra manipular nomes de
arquivo vindos de fonte externa sem antes confirmar que não têm `#`, `?`
ou outros caracteres especiais de URL não codificados.
### 2.13 O mesmo `#` quebra de novo — desta vez no redirect do próprio DSpace
Mesmo com a URL do achado 2.12 corrigida no IndexedDB (`%23` correto, forma
antiga `/bitstream/PREFIX/ID/SEQ/arquivo`), os 78 itens continuavam dando
404. O DSpace responde a essa forma antiga com um redirect para a forma
canônica nova (`/bitstream/handle/PREFIX/ID/arquivo`, sem número de
sequência) — e o `Location` do redirect tem o `#` sem escapar de novo,
então o `fetch()` do navegador trunca no fragmento ao seguir o redirect
(`finalUrl` chega faltando `#.jpg`, 404). Fix: pedir direto a forma
canônica (`/bitstream/handle/...`), pulando o redirect — testado, `200`,
JPEG válido. `preservation_url` desses 78 itens reescrito no IndexedDB
para a forma canônica.
**Lição:** com `#` no nome do arquivo, não confiar em nenhum redirect do
servidor — ele pode ter o mesmo bug do lado de lá. Preferir sempre a URL
final/canônica quando ela for conhecida.
### 2.14 Dá pra ler o progresso do BAIXA/BAIXA_THUMB sem o Chrome conectado
O progresso das trilhas via navegador (`localStorage.bras_baixados`,
`bras_thumbs_v2`) sobrevive num arquivo leveldb comum no disco —
`~/Library/Application Support/Google/Chrome/Default/Local Storage/leveldb`
— mesmo com a extensão Claude-in-Chrome desconectada (ex.: conflito de conta
OAuth, sessão anterior perdida). Basta copiar o diretório (evita brigar com
o `LOCK` que o Chrome mantém aberto) e ler com uma lib leveldb qualquer
(`classic-level` via npm, sem dependência nativa externa). O valor vem com
1 byte de prefixo indicando encoding (`0x00`=UTF-16LE, `0x01`=UTF-8/Latin1)
antes do JSON de verdade. Script pronto: `pipeline/tools/ler-localstorage.js`.
**Lição:** perder a conexão com a extensão do navegador não significa perder
visibilidade do progresso — boa parte do estado do app mora em arquivos
comuns no disco do usuário, não só "dentro do navegador".
---
## 3. O acervo
DSpace 4.2, handles `20.500.12156.1/*`. **13.058 itens reais** (13.059
descobertos no crawl, menos 1 falso-positivo — `.../1` é a página raiz da
comunidade, não um item). Contagem original por comunidade abaixo era de
13.036; a diferença é ruído de borda (comunidades ganham/perdem item ao
longo do tempo), não erro de coleta.
| Instituição | Itens | | Instituição | Itens |
|---|--:|---|---|--:|
| Instituto Moreira Salles | 5.133 | | Fiocruz | 548 |
| Biblioteca Nacional | 2.726 | | Marinha (DPHDM) | 456 |
| Museu Aeroespacial | 1.143 | | Arquivo Nacional | 383 |
| Museu da República | 906 | | Arq. Geral da Cidade do RJ | 248 |
| Museu Histórico Nacional | 649 | | Leibniz-Institut, Leipzig | 216 |
| IHGB | 206 | | Fund. Joaquim Nabuco | 190 |
| Museu Goeldi | 95 | | FGV CPDOC | 79 |
| IBGE | 58 | | | |
**~139,5 GB projetados** (recalculado em 2026-09-10 com médias reais de
357-474 arquivos já no bucket por categoria, não mais os 6 originais):
preservação+extras ~136,9 GB (13.152 arquivos, 10,7 MB médio), thumbnails
~0,9 GB, legado ~1,5 GB (taxa de sucesso ~75%, muito 404 genuíno),
metadados+WordPress+catálogo <0,2 GB combinados.
---
## 4. Runbook
### Fase 1 — metadados (navegador) ✅ COMPLETA
Cole `pipeline/coletor-resume.js` (pendentes + erros juntos, numa passada só)
numa aba em `https://brasilianafotografica.bn.gov.br/brasiliana/`. Acompanhar
com `await BRAS.status()`. Deixe a aba **ativa na janela dela**: o Chromium
estrangula `setTimeout` em abas de fundo até 1×/min, e o loop passa a
rastejar sem erro nenhum. Ao terminar: `await BRAS.exportar()`.
### Fases 2 e 3 — trilha única via navegador (não tem mais trilha A/servidor)
Ver achado 2.6. Na mesma aba (mesmo IndexedDB `bras`), cole em sequência:
```js
// pipeline/baixador-navegador.js -- imagem principal, todos os itens
BAIXA.quantos(); BAIXA.iniciar();
// pipeline/baixador-thumb.js -- miniaturas
BAIXA_THUMB.quantos(); BAIXA_THUMB.iniciar();
// pipeline/baixador-extras.js -- 2a+ imagem dos itens com multiplos bitstreams
// (rodar reparo-multibitstream.js antes, se ainda nao rodou)
BAIXA_EXTRA.quantos(); BAIXA_EXTRA.iniciar();
```
Em paralelo, em outro terminal:
```bash
cd pipeline
set -a; . .s3env; set +a
python3 subidor.py --vigiar --catalogo catalogo.jsonl
```
Downloads automáticos do Chrome exigem permissão em
`chrome://settings/content/automaticDownloads` (adicionar
`https://brasilianafotografica.bn.gov.br`) — sem isso o download "funciona"
no JS (sem erro) mas nenhum arquivo chega no disco. Vale pro domínio
inteiro, uma vez só.
### Fase 4 — cópias legadas (máquina local)
```bash
cd pipeline
set -a; . .s3env; set +a
python3 baixador.py --catalogo catalogo.jsonl --somente-legado
```
Via curl direto, sem navegador (ver achado 2.11). ~2.707 itens têm
`legacy_url`, ~328 KB em média.
---
## 5. Arquivos e credenciais
Layout do repo (detalhe em `README.md` → "Estrutura do repositório"):
scripts + estado ao vivo em `pipeline/`, visualizações em `web/`, item de
referência em `examples/`, código morto em `archive/`.
| Arquivo | O quê |
|---|---|
| `tasks/PLANO.md` | Este documento |
| `README.md` | Referência técnica completa (raiz do projeto) |
| `pipeline/coletor.js` | Fase 1 — console do navegador, IndexedDB, autoinício |
| `pipeline/baixador.py` | Fases 2 e 4 — curl → S3, um arquivo por vez |
| `pipeline/baixador-navegador.js` | Fase 3 — navegador → pasta Downloads |
| `pipeline/subidor.py` | Fase 3 — Downloads → S3, apaga na hora |
| `archive/censo.py` | Versão Playwright — **não funciona**, mantida pelo parsing |
| `pipeline/.s3env.exemplo` | Modelo; chaves reais em `/Users/polux/Projetos/tocador/.env` |
**S3:** `hel1.your-objectstorage.com`, bucket `brasiliana` (Hetzner).
Credenciais em `/Users/polux/Projetos/tocador/.env`
(`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`). Cópia local em
`pipeline/.s3env`. Na Finlândia: `/root/.s3env`.
**Servidor:** `ssh finland` → 89.167.95.136 (Hetzner Helsinki, root, 31 GB livres).
---
## 6. Estrutura no bucket
```
s3://brasiliana/
MANIFEST.md o que é, como foi coletado, quando
catalogo.jsonl uma linha por item
itens/20.500.12156.1_6066/
metadata.json Dublin Core + URL de origem + data UTC
preservacao/imagem.jpg 5533×4065, 7,4 MB
legado/icon843660.jpg 147 KB, referência de origem
manifest-sha256.txt checksum de cada arquivo
```
Não negociável: **SHA-256 de tudo** e **URL de origem + data/hora de captura**.
Sem os dois é pasta de imagens, não arquivo.
---
## 7. Pendências
1. Terminar a Fase 1 e exportar o catálogo.
2. Medir a fração da trilha B (`BAIXA.quantos()`) — de graça, sem requisição.
3. Confirmar a projeção de ~116 GB (hoje: 6 arquivos medidos).
4. Decidir sobre os PDFs de álbum do objdigital — objetos distintos, o de
71,9 MB provavelmente não é caso isolado.
5. Gerar `MANIFEST.md` e `catalogo.jsonl` no bucket ao final.
## 8. Direitos
Majoritariamente domínio público por antiguidade, mas as 15 instituições têm
políticas distintas. `dc.rights` e a instituição de origem são preservados
**por item**: a decisão de redistribuir é item a item, não do acervo em bloco.
Arquivar para si é uma coisa; republicar é outra.