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.
199 lines
7.3 KiB
Python
199 lines
7.3 KiB
Python
#!/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()
|