Files
rodado 290c4cc21d 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.
2026-09-13 20:17:02 +02:00

208 lines
9.0 KiB
JavaScript

/* ===================================================================
Coletor da Brasiliana Fotografica -- roda DENTRO da pagina.
POR QUE ASSIM: o Cloudflare do site bloqueia navegador dirigido por
automacao (Playwright headless E headed levam 403), mas nao toca em
fetch() disparado de dentro de uma pagina aberta no seu Chrome --
mesma origem, sessao real, zero sinal de automacao.
Medido: >100 requisicoes seguidas sem bloqueio, a ~2s de intervalo.
ARMAZENAMENTO: IndexedDB, nao localStorage. 13.036 itens com metadados
completos dao ~39 MB; localStorage estoura em ~5 MB.
COMO USAR
1. Abra https://brasilianafotografica.bn.gov.br/brasiliana/
2. DevTools (Cmd+Option+J) > Console > cole este arquivo inteiro
3. Ele comeca sozinho: enumera as 15 comunidades e segue colhendo.
4. Deixe a aba aberta (~7 h). Pode usar o resto do Chrome normalmente.
ACOMPANHAR / CONTROLAR
await BRAS.status() onde esta
BRAS.parar() interrompe (nao perde nada)
BRAS.retomar() continua de onde parou
await BRAS.exportar() baixa brasiliana-catalogo.jsonl
Fechou a aba? Reabra a pagina, cole o arquivo de novo: ele retoma.
=================================================================== */
(() => {
const BASE = 'https://brasilianafotografica.bn.gov.br/brasiliana';
const PREFIX= '20.500.12156.1';
const RPP = 100;
const PAUSA = 2000; // ms entre requisicoes. Nao baixe disso.
const COM = {
'2':'Biblioteca Nacional', '1753':'Instituto Moreira Salles',
'3540':'Arquivo Geral da Cidade do Rio de Janeiro',
'3541':'Diretoria do Patrimonio Historico e Documentacao da Marinha',
'4342':'Leibniz-Institut fuer Laenderkunde, Leipzig', '4884':'Arquivo Nacional',
'4886':'Museu da Republica', '5018':'Fiocruz', '6142':'Museu Historico Nacional',
'6660':'Fundacao Joaquim Nabuco', '7072':'Museu Aeroespacial',
'11464':'Escola de Ciencias Sociais (FGV CPDOC)',
'12796':'Instituto Historico e Geografico Brasileiro',
'13099':'Instituto Brasileiro de Geografia e Estatistica',
'14087':'Museu Paraense Emilio Goeldi',
};
const ESPERADO = 13036;
const sleep = ms => new Promise(r => setTimeout(r, ms));
const jit = () => PAUSA + Math.floor(Math.random() * 600); // ritmo, nao metronomo
// ---------- IndexedDB ----------
let db;
const abrir = () => new Promise((res, rej) => {
const q = indexedDB.open('bras', 1);
q.onupgradeneeded = e => { const d = e.target.result;
if (!d.objectStoreNames.contains('fila')) d.createObjectStore('fila');
if (!d.objectStoreNames.contains('itens')) d.createObjectStore('itens'); };
q.onsuccess = e => res(e.target.result);
q.onerror = () => rej(q.error);
});
const put = (st, k, v) => new Promise((res, rej) => {
const t = db.transaction(st, 'readwrite'); t.objectStore(st).put(v, k);
t.oncomplete = res; t.onerror = () => rej(t.error); });
const putMuitos = (st, pares) => new Promise((res, rej) => {
const t = db.transaction(st, 'readwrite'), o = t.objectStore(st);
for (const [k, v] of pares) o.put(v, k);
t.oncomplete = res; t.onerror = () => rej(t.error); });
const keys = st => new Promise((res, rej) => {
const r = db.transaction(st).objectStore(st).getAllKeys();
r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
const getAll = st => new Promise((res, rej) => {
const r = db.transaction(st).objectStore(st).getAll();
r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
const get = (st, k) => new Promise(res => {
const r = db.transaction(st).objectStore(st).get(k);
r.onsuccess = () => res(r.result); r.onerror = () => res(undefined); });
async function pegar(u) {
const r = await fetch(u, {credentials: 'same-origin'});
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
}
// ---------- extracao ----------
function extrair(html, h, com) {
const d = new DOMParser().parseFromString(html, 'text/html');
const dc = [...d.querySelectorAll('meta[name]')]
.filter(m => /^(DC|DCTERMS|citation)/.test(m.name))
.map(m => ({name: m.name, value: m.content}));
const a = n => (dc.find(x => x.name === n) || {}).value || null;
// a tabela do ?show=full traz campos que nao viram meta tag, entre eles dc.rights
const tb = [...d.querySelectorAll('table.detailtable tr, table.ds-includeSet-table tr')]
.map(tr => { const td = [...tr.querySelectorAll('td')].map(c => c.textContent.trim());
return td.length >= 2 ? {field: td[0], value: td[1]} : null; })
.filter(Boolean);
const leg = dc.filter(x => x.name === 'DC.identifier' && /objdigital/.test(x.value || ''))
.map(x => x.value)[0] || null;
return {
handle: `${PREFIX}/${h}`,
source_url: `${BASE}/handle/${PREFIX}/${h}?show=full`,
fetched_at: new Date().toISOString(),
title: a('DC.title'),
community: com,
// bitstream do DSpace = arquivo de PRESERVACAO (ate 8679x6484, 34,7 MB).
// objdigital = copia MENOR (~2600x2100, ~330 KB), so procedencia.
preservation_url: a('citation_pdf_url'),
legacy_url: leg,
rights: (tb.find(t => /^dc\.rights/i.test(t.field)) || {}).value || null,
dublin_core: dc,
full_table: tb,
};
}
const S = {fase: 'iniciando', atual: '', colhidos: 0, erros: 0, parar: false, t0: Date.now()};
// ---------- fase 1: enumerar ----------
async function descobrir() {
S.fase = 'enumerando';
const RE = new RegExp('handle/' + PREFIX.replace(/\./g, '\\.') + '/(\\d+)', 'g');
for (const [h, nome] of Object.entries(COM)) {
if (S.parar) return;
S.atual = nome;
let off = 0, total = null, vistos = new Set();
while (!S.parar) {
let html;
try { html = await pegar(`${BASE}/handle/${PREFIX}/${h}/browse?type=title&rpp=${RPP}&offset=${off}`); }
catch (e) { console.warn(` ${nome} offset=${off}: ${e.message} -- pausa de 30s`);
await sleep(30000); continue; }
if (total === null) {
const m = html.replace(/<[^>]+>/g, ' ')
.match(/no momento\D{0,24}[\d.,]+\s*-\s*[\d.,]+\s*de\s*([\d.,]+)/i);
total = m ? +m[1].replace(/[.,]/g, '') : null;
}
const ach = new Set([...html.matchAll(RE)].map(m => m[1])); ach.delete(h);
let novos = 0; for (const x of ach) if (!vistos.has(x)) { vistos.add(x); novos++; }
await putMuitos('fila', [...ach].map(x => [x, nome]));
off += RPP;
if (!novos || (total && off >= total)) break;
await sleep(jit());
}
console.log(` ${nome.padEnd(46)} ${String(vistos.size).padStart(6)}`);
await sleep(jit());
}
const n = (await keys('fila')).length;
console.log(`\nEnumerados: ${n} (esperado ${ESPERADO})`);
}
// ---------- fase 2: metadados ----------
async function coletar() {
S.fase = 'colhendo';
const fila = await keys('fila'), feitos = new Set(await keys('itens'));
const pend = fila.filter(h => !feitos.has(h));
console.log(`${pend.length} itens a colher (~${(pend.length * PAUSA / 3600000).toFixed(1)} h)`);
for (let i = 0; i < pend.length; i++) {
if (S.parar) { S.fase = 'parado'; return; }
const h = pend[i];
S.atual = `${i + 1}/${pend.length}`;
try {
const html = await pegar(`${BASE}/handle/${PREFIX}/${h}?show=full`);
await put('itens', h, extrair(html, h, await get('fila', h)));
S.colhidos++;
} catch (e) {
await put('itens', h, {handle: `${PREFIX}/${h}`, erro: String(e).slice(0, 80)});
S.erros++;
// recuou? entao respire, nao insista -- insistir e o que vira bloqueio
if (/HTTP 4|HTTP 5/.test(String(e))) { console.warn(` ${h}: ${e.message} -- pausa de 60s`);
await sleep(60000); }
}
if (i % 100 === 0) console.log(` ${i}/${pend.length} ok=${S.colhidos} erro=${S.erros}`);
await sleep(jit());
}
S.fase = 'concluido';
console.log(`\nConcluido. ok=${S.colhidos} erro=${S.erros}. Agora: await BRAS.exportar()`);
}
// ---------- exportar ----------
async function exportar() {
const todos = await getAll('itens');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([todos.map(o => JSON.stringify(o)).join('\n')],
{type: 'application/x-ndjson'}));
a.download = 'brasiliana-catalogo.jsonl';
document.body.appendChild(a); a.click(); a.remove();
console.log(`exportadas ${todos.length} linhas para a pasta Downloads`);
return todos.length;
}
window.BRAS = {
status: async () => {
const naFila = (await keys('fila')).length, noBanco = (await keys('itens')).length;
const h = (Date.now() - S.t0) / 3600000;
return {fase: S.fase, atual: S.atual, naFila, noBanco, erros: S.erros,
restam: naFila - noBanco, horas: +h.toFixed(2)};
},
parar: () => { S.parar = true; console.log('parando apos a requisicao atual'); },
retomar: () => { S.parar = false; coletar(); },
exportar,
limpar: () => indexedDB.deleteDatabase('bras'),
};
(async () => {
db = await abrir();
if ((await keys('fila')).length < ESPERADO) await descobrir();
await coletar();
})();
console.log('BRAS iniciado. Acompanhe com: await BRAS.status()');
})();