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,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()
|
||||
Reference in New Issue
Block a user