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:
@@ -0,0 +1,207 @@
|
||||
/* ===================================================================
|
||||
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()');
|
||||
})();
|
||||
Reference in New Issue
Block a user