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,137 @@
|
||||
/* ===================================================================
|
||||
v2 -- a v1 confiava cegamente que seq_master+1 e a miniatura, so
|
||||
descartando se viesse GRANDE demais. Bug real (2026-09-10): pra
|
||||
muitos itens (varias instituicoes -- visto em Marinha e IMS), essa
|
||||
sequencia cai no bitstream de LICENCA do DSpace (texto puro, poucas
|
||||
centenas de bytes) em vez da miniatura. 62% dos thumbs de um lote
|
||||
sairam errados assim -- pequenos o bastante pra passar no filtro de
|
||||
tamanho, mas nao sao imagem nenhuma.
|
||||
|
||||
FIX: valida os magic bytes (JPEG/PNG) antes de aceitar. Se a
|
||||
primeira tentativa (seq+1) nao for imagem, tenta seq+2 e seq+3 (caso
|
||||
a licenca esteja no meio). Se nenhuma bater, cai pro metodo confiavel
|
||||
do achado 2.7 (extrai o <img src> da pagina do item).
|
||||
|
||||
O subidor.py TAMBEM valida antes de subir agora (defesa em camadas)
|
||||
-- mas o ideal e nao gerar o lixo em primeiro lugar.
|
||||
=================================================================== */
|
||||
(() => {
|
||||
const BASE = 'https://brasilianafotografica.bn.gov.br/brasiliana';
|
||||
const PREFIX = '20.500.12156.1';
|
||||
const PAUSA = 1500;
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const jit = () => PAUSA + Math.floor(Math.random() * 500);
|
||||
|
||||
let db;
|
||||
const abrir = () => new Promise((res, rej) => {
|
||||
const q = indexedDB.open('bras', 1);
|
||||
q.onsuccess = () => res(q.result); q.onerror = () => rej(q.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 S = {fase: 'ocioso', feito: 0, erros: 0, total: 0, atual: '', parar: false};
|
||||
const FEITOS = new Set(JSON.parse(localStorage.getItem('bras_thumbs_v2') || '[]'));
|
||||
const marcar = h => { FEITOS.add(h); localStorage.setItem('bras_thumbs_v2', JSON.stringify([...FEITOS])); };
|
||||
|
||||
function seqDoUrl(u) {
|
||||
const m = u.match(/\/bitstream\/[^/]+\/[^/]+\/(\d+)\//);
|
||||
return m ? +m[1] : null;
|
||||
}
|
||||
|
||||
async function ehImagem(blob) {
|
||||
const buf = new Uint8Array(await blob.slice(0, 8).arrayBuffer());
|
||||
if (buf[0] === 0xFF && buf[1] === 0xD8) return true; // JPEG
|
||||
if (buf[0]===0x89 && buf[1]===0x50 && buf[2]===0x4E && buf[3]===0x47) return true; // PNG
|
||||
return false;
|
||||
}
|
||||
|
||||
async function tentaSequencia(handle, seq) {
|
||||
const url = `${BASE}/bitstream/${PREFIX}/${handle}/${seq}/thumb.jpg`;
|
||||
const r = await fetch(url, {credentials: 'same-origin'});
|
||||
if (!r.ok) return null;
|
||||
const b = await r.blob();
|
||||
if (b.size > 2 * 1024 * 1024) return null; // grande demais pra thumb
|
||||
if (!(await ehImagem(b))) return null; // achado 2026-09-10: pode ser bitstream de licenca
|
||||
return {blob: b, nomeSeq: seq};
|
||||
}
|
||||
|
||||
async function viaImgDaPagina(handle) {
|
||||
const r = await fetch(`${BASE}/handle/${PREFIX}/${handle}`, {credentials: 'same-origin'});
|
||||
if (!r.ok) return null;
|
||||
const html = await r.text();
|
||||
const d = new DOMParser().parseFromString(html, 'text/html');
|
||||
const img = d.querySelector('.file-wrapper img');
|
||||
if (!img) return null;
|
||||
let u;
|
||||
try { u = new URL(img.getAttribute('src'), location.href); } catch(e) { return null; }
|
||||
const rr = await fetch(u.href, {credentials: 'same-origin'});
|
||||
if (!rr.ok) return null;
|
||||
const b = await rr.blob();
|
||||
if (!(await ehImagem(b))) return null;
|
||||
return {blob: b, nomeSeq: 'pagina'};
|
||||
}
|
||||
|
||||
async function alvos() {
|
||||
const itens = await getAll('itens');
|
||||
return itens.filter(o => {
|
||||
if (o.erro || !o.preservation_url) return false;
|
||||
const seqs = (o.all_preservation_urls || [o.preservation_url]).map(seqDoUrl).filter(Boolean);
|
||||
return seqs.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function iniciar() {
|
||||
S.parar = false; S.fase = 'baixando';
|
||||
const lista = (await alvos()).filter(o => !FEITOS.has(o.handle));
|
||||
S.total = lista.length;
|
||||
console.log(`${lista.length} miniaturas a checar/baixar (v2, com validacao)`);
|
||||
for (let i = 0; i < lista.length; i++) {
|
||||
if (S.parar) { S.fase = 'parado'; return; }
|
||||
const o = lista[i];
|
||||
const h = o.handle.split('/').pop();
|
||||
S.atual = `${i + 1}/${lista.length}`;
|
||||
try {
|
||||
const seqs = (o.all_preservation_urls || [o.preservation_url]).map(seqDoUrl).filter(Boolean);
|
||||
const maxSeq = Math.max(...seqs);
|
||||
let achado = null;
|
||||
for (const seq of [maxSeq + 1, maxSeq + 2, maxSeq + 3]) {
|
||||
achado = await tentaSequencia(h, seq);
|
||||
if (achado) break;
|
||||
await sleep(300);
|
||||
}
|
||||
if (!achado) achado = await viaImgDaPagina(h);
|
||||
if (!achado) throw new Error('nenhuma miniatura valida encontrada');
|
||||
|
||||
const nome = `THUMB__thumb_${achado.nomeSeq}.jpg`;
|
||||
const arqNome = `BRAS__${o.handle.replace(/\//g, '_')}__${nome}`;
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(achado.blob); a.download = arqNome;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 30000);
|
||||
marcar(o.handle); S.feito++;
|
||||
} catch (e) {
|
||||
S.erros++; console.warn(` ${o.handle}: ${e.message}`);
|
||||
}
|
||||
if (i % 50 === 0) console.log(` ${i}/${lista.length} ok=${S.feito} erro=${S.erros}`);
|
||||
await sleep(jit());
|
||||
}
|
||||
S.fase = 'concluido';
|
||||
console.log(`\nThumbs v2 concluido. ok=${S.feito} erro=${S.erros}`);
|
||||
}
|
||||
|
||||
window.BAIXA_THUMB = {
|
||||
quantos: async () => { const a = await alvos();
|
||||
return {total: a.length, jaFeitos: FEITOS.size, restam: a.length - FEITOS.size}; },
|
||||
iniciar, status: () => ({...S}),
|
||||
parar: () => { S.parar = true; }, retomar: () => iniciar(),
|
||||
limparMarcas: () => localStorage.removeItem('bras_thumbs_v2'),
|
||||
};
|
||||
|
||||
(async () => { db = await abrir();
|
||||
const q = await window.BAIXA_THUMB.quantos();
|
||||
console.log('BAIXA_THUMB v2 pronto:', q, '\nRode BAIXA_THUMB.iniciar() para comecar.');
|
||||
})();
|
||||
})();
|
||||
Reference in New Issue
Block a user