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.
208 lines
9.0 KiB
JavaScript
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()');
|
|
})();
|