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
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
Trilha B, segunda metade: leva da pasta Downloads para o S3 e apaga.
O baixador-navegador.js despeja os arquivos em Downloads com o handle no
proprio nome (BRAS__20.500.12156.1_10377__arquivo.jpg). Este script recolhe,
confere SHA-256, sobe pro bucket e APAGA na hora -- o disco nunca acumula.
Rode em paralelo com o navegador, em modo --vigiar:
set -a; . .s3env; set +a
python3 subidor.py --vigiar --catalogo catalogo.jsonl
Uma passada so, sem vigiar:
python3 subidor.py --catalogo catalogo.jsonl
"""
import argparse, hashlib, json, os, sqlite3, subprocess, sys, time
from pathlib import Path
S3_ENDPOINT = os.environ.get("S3_ENDPOINT", "https://hel1.your-objectstorage.com")
BUCKET = os.environ.get("ARCHIVE_BUCKET", "brasiliana")
PREFIXO = "BRAS__"
ROOT = Path(__file__).resolve().parent
DB = ROOT / "subidor.sqlite"
def db():
con = sqlite3.connect(DB)
con.executescript("""
CREATE TABLE IF NOT EXISTS enviados (
handle TEXT, nome TEXT, bytes INTEGER, sha256 TEXT, quando TEXT,
PRIMARY KEY (handle, nome));""")
cols = [r[1] for r in con.execute("PRAGMA table_info(enviados)")]
if "tipo" not in cols:
# tipo entra na migracao, mas a PK antiga (handle,nome) colidiria
# entre preservacao/thumbnail com o mesmo nome de arquivo -- refaz
# a tabela com PK composta, preservando as linhas ja existentes
# (todas eram 'preservacao', de antes do thumbnail existir).
con.executescript("""
ALTER TABLE enviados RENAME TO enviados_old;
CREATE TABLE enviados (
handle TEXT, nome TEXT, bytes INTEGER, sha256 TEXT, quando TEXT,
tipo TEXT DEFAULT 'preservacao',
PRIMARY KEY (handle, tipo, nome));
INSERT INTO enviados (handle,nome,bytes,sha256,quando,tipo)
SELECT handle,nome,bytes,sha256,quando,'preservacao' FROM enviados_old;
DROP TABLE enviados_old;
""")
con.commit()
return con
def s3_put(local, key):
return subprocess.run(
["aws", "--endpoint-url", S3_ENDPOINT, "s3", "cp", str(local),
f"s3://{BUCKET}/{key}", "--only-show-errors"],
capture_output=True, text=True).returncode == 0
def sha256(p):
h = hashlib.sha256()
with p.open("rb") as fh:
for b in iter(lambda: fh.read(1 << 20), b""):
h.update(b)
return h.hexdigest()
def estavel(p):
"""Chrome ainda escrevendo? Compara o tamanho com um intervalo curto."""
try:
a = p.stat().st_size
time.sleep(1.5)
return a == p.stat().st_size and a > 0
except FileNotFoundError:
return False
def carrega_catalogo(caminho):
if not caminho:
return {}
d = {}
for linha in open(caminho, encoding="utf-8"):
if linha.strip():
r = json.loads(linha)
d[r["handle"]] = r
return d
def escreve_metadados(con, handle, cat):
"""metadata.json + manifest-sha256.txt do item."""
rec = cat.get(handle)
if not rec:
return
pasta = handle.replace("/", "_")
# nome com PID: baixador.py roda na mesma pasta e pode escrever no
# mesmo .tmp ao mesmo tempo -- nome fixo faz um processo apagar o
# arquivo que o outro acabou de criar (achado 2026-09-10, crash real).
tmp = ROOT / ".tmp"; tmp.mkdir(exist_ok=True)
mj = tmp / f"metadata.{os.getpid()}.json"
mj.write_text(json.dumps(rec, ensure_ascii=False, indent=2), encoding="utf-8")
s3_put(mj, f"itens/{pasta}/metadata.json")
linhas = [f"{sha} {tipo}/{nome}" for nome, sha, tipo in
con.execute("SELECT nome,sha256,tipo FROM enviados WHERE handle=?", (handle,))]
linhas.append(f"{sha256(mj)} metadata.json")
mf = tmp / f"manifest-sha256.{os.getpid()}.txt"
mf.write_text("\n".join(linhas) + "\n", encoding="utf-8")
s3_put(mf, f"itens/{pasta}/manifest-sha256.txt")
mj.unlink(missing_ok=True); mf.unlink(missing_ok=True)
def uma_passada(con, pasta_downloads, cat, verboso=True):
n = 0
for p in sorted(pasta_downloads.glob(PREFIXO + "*")):
if p.suffix == ".crdownload" or not p.is_file():
continue
if not estavel(p):
continue
resto = p.name[len(PREFIXO):]
if "__" not in resto:
print(f" ignorado (nome inesperado): {p.name}")
continue
pasta_handle, nome = resto.split("__", 1)
handle = pasta_handle.replace("_", "/", 1) if "/" not in pasta_handle else pasta_handle
# 20.500.12156.1_10377 -> 20.500.12156.1/10377
if "_" in pasta_handle:
base, num = pasta_handle.rsplit("_", 1)
handle = f"{base}/{num}"
# THUMB__ no nome roteia pra subpasta thumbnail/ em vez de preservacao/
eh_thumb = nome.startswith("THUMB__")
if eh_thumb:
nome = nome[len("THUMB__"):]
subpasta = "thumbnail" if eh_thumb else "preservacao"
# valida que e imagem de verdade antes de subir -- achado 2026-09-10:
# o palpite de sequencia do achado 2.8 (seq+1 = thumbnail) as vezes
# cai no bitstream de LICENCA do DSpace em vez da miniatura (62% dos
# thumbs de alguns lotes!). Rejeita qualquer coisa que nao comece com
# os magic bytes de JPEG/PNG -- mais barato que confiar no nome.
with p.open("rb") as fh:
cabecalho = fh.read(8)
eh_jpeg = cabecalho[:2] == b"\xff\xd8"
eh_png = cabecalho[:8] == b"\x89PNG\r\n\x1a\n"
if not (eh_jpeg or eh_png):
print(f" REJEITADO (nao e imagem, primeiros bytes: {cabecalho[:20]!r}): {p.name}", flush=True)
p.unlink()
continue
dig, tam = sha256(p), p.stat().st_size
if s3_put(p, f"itens/{pasta_handle}/{subpasta}/{nome}"):
con.execute("INSERT OR REPLACE INTO enviados VALUES (?,?,?,?,?,?)",
(handle, nome, tam, dig,
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), subpasta))
con.commit()
escreve_metadados(con, handle, cat)
p.unlink() # apaga SEMPRE que subiu
n += 1
if verboso:
print(f" {handle:<24} {tam/1048576:6.1f} MB -> S3, local apagado", flush=True)
else:
print(f" FALHA no upload: {p.name} (mantido em disco)", flush=True)
return n
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--downloads", default=str(Path.home() / "Downloads"))
ap.add_argument("--catalogo")
ap.add_argument("--vigiar", action="store_true")
ap.add_argument("--intervalo", type=int, default=20)
args = ap.parse_args()
if not os.environ.get("AWS_ACCESS_KEY_ID"):
sys.exit("credenciais S3 ausentes. Rode: set -a; . .s3env; set +a")
con = db()
pasta = Path(args.downloads)
cat = carrega_catalogo(args.catalogo)
print(f"vigiando {pasta} -> s3://{BUCKET}/ ({len(cat)} itens no catalogo)\n"
if args.vigiar else f"passada unica em {pasta}\n")
total = 0
try:
while True:
total += uma_passada(con, pasta, cat)
if not args.vigiar:
break
time.sleep(args.intervalo)
except KeyboardInterrupt:
pass
enviados, bytes_ = con.execute(
"SELECT COUNT(*), COALESCE(SUM(bytes),0) FROM enviados").fetchone()
print(f"\nnesta sessao: {total} | acumulado: {enviados} arquivos, {bytes_/1024**3:.2f} GB")
if __name__ == "__main__":
main()