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,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Baixa os arquivos da Brasiliana e deposita no S3, um por vez, sem acumular.
|
||||
|
||||
Roda no servidor (Finlandia): mede 4,7 MB/s no bitstream e o bucket e Hetzner
|
||||
tambem, entao a transferencia nao sai do datacenter. NUNCA guarda mais de um
|
||||
arquivo em disco: baixa -> sha256 -> sobe -> apaga. Pico de uso = 1 arquivo.
|
||||
|
||||
Os bitstreams do DSpace passam pelo Cloudflare com curl simples (as paginas
|
||||
HTML e que sao desafiadas -- essas vem do coletor.js, no navegador).
|
||||
A copia legada do objdigital NAO e alcancavel da Finlandia: rode --somente-legado
|
||||
na maquina local para essas.
|
||||
|
||||
python3 baixador.py --catalogo catalogo.jsonl
|
||||
python3 baixador.py --catalogo catalogo.jsonl --somente-legado # na sua maquina
|
||||
python3 baixador.py --status
|
||||
"""
|
||||
import argparse, hashlib, json, os, shutil, sqlite3, subprocess, sys, time
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
|
||||
CONTACT = os.environ.get("ARCHIVE_CONTACT", "")
|
||||
S3_ENDPOINT = os.environ.get("S3_ENDPOINT", "https://hel1.your-objectstorage.com")
|
||||
BUCKET = os.environ.get("ARCHIVE_BUCKET", "brasiliana")
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
DB = ROOT / "baixador.sqlite"
|
||||
TMP = ROOT / ".tmp"
|
||||
PAUSA = 2.0
|
||||
MIN_LIVRE = 2 * 1024**3 # 2 GB de folga; abaixo disso, para
|
||||
|
||||
|
||||
def db():
|
||||
con = sqlite3.connect(DB)
|
||||
con.execute("PRAGMA journal_mode=WAL")
|
||||
con.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS arquivos (
|
||||
handle TEXT, tipo TEXT, url TEXT, nome TEXT,
|
||||
bytes INTEGER, sha256 TEXT, status TEXT, erro TEXT,
|
||||
PRIMARY KEY (handle, tipo));
|
||||
""")
|
||||
return con
|
||||
|
||||
|
||||
def livre():
|
||||
return shutil.disk_usage(ROOT).free
|
||||
|
||||
|
||||
def baixar(url, destino):
|
||||
"""curl -L para arquivo. Retorna (http, bytes) sem manter nada em memoria."""
|
||||
r = subprocess.run(
|
||||
["curl", "-sL", "-o", str(destino), "-w", "%{http_code} %{size_download}",
|
||||
"-A", UA, *(["-H", f"From: {CONTACT}"] if CONTACT else []),
|
||||
"--max-time", "600", "--retry", "2", "--retry-delay", "5", url],
|
||||
capture_output=True, text=True)
|
||||
try:
|
||||
http, size = r.stdout.strip().split()
|
||||
return int(http), int(size)
|
||||
except Exception:
|
||||
return 0, 0
|
||||
|
||||
|
||||
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 nome_de(url):
|
||||
n = unquote(urlparse(url).path.rsplit("/", 1)[-1]) or "arquivo.bin"
|
||||
return n.replace("/", "_")[:150]
|
||||
|
||||
|
||||
def processa(con, rec, tipo, url, args):
|
||||
handle = rec["handle"]
|
||||
ja = con.execute("SELECT status FROM arquivos WHERE handle=? AND tipo=?",
|
||||
(handle, tipo)).fetchone()
|
||||
if ja and ja[0] == "ok":
|
||||
return "pulado"
|
||||
if livre() < MIN_LIVRE:
|
||||
sys.exit(f"\nESPACO BAIXO ({livre()/1024**3:.1f} GB livres). Parando por seguranca.")
|
||||
|
||||
TMP.mkdir(exist_ok=True)
|
||||
nome = nome_de(url)
|
||||
tmp = TMP / nome
|
||||
http, size = baixar(url, tmp)
|
||||
if http != 200 or size <= 0:
|
||||
tmp.unlink(missing_ok=True)
|
||||
con.execute("INSERT OR REPLACE INTO arquivos VALUES (?,?,?,?,NULL,NULL,'erro',?)",
|
||||
(handle, tipo, url, nome, f"HTTP {http}"))
|
||||
con.commit()
|
||||
return f"erro HTTP {http}"
|
||||
|
||||
sha = hashlib.sha256()
|
||||
with tmp.open("rb") as fh:
|
||||
for bloco in iter(lambda: fh.read(1 << 20), b""):
|
||||
sha.update(bloco)
|
||||
dig = sha.hexdigest()
|
||||
|
||||
pasta = handle.replace("/", "_")
|
||||
sub = "preservacao" if tipo == "preservacao" else "legado"
|
||||
ok = s3_put(tmp, f"itens/{pasta}/{sub}/{nome}")
|
||||
tmp.unlink(missing_ok=True) # apaga SEMPRE, deu certo ou nao
|
||||
|
||||
if not ok:
|
||||
con.execute("INSERT OR REPLACE INTO arquivos VALUES (?,?,?,?,?,?,'erro','falha no upload')",
|
||||
(handle, tipo, url, nome, size, dig))
|
||||
con.commit()
|
||||
return "erro no S3"
|
||||
|
||||
con.execute("INSERT OR REPLACE INTO arquivos VALUES (?,?,?,?,?,?,'ok',NULL)",
|
||||
(handle, tipo, url, nome, size, dig))
|
||||
con.commit()
|
||||
return f"{size/1048576:.1f} MB"
|
||||
|
||||
|
||||
def sobe_metadados(con, rec):
|
||||
"""metadata.json + manifest-sha256.txt do item, a partir do catalogo.
|
||||
|
||||
Nome de arquivo temporario inclui o PID: baixador.py e subidor.py
|
||||
moram na mesma pasta (mesmo TMP) e podem rodar ao mesmo tempo -- um
|
||||
nome fixo faz um apagar o arquivo que o outro acabou de criar
|
||||
(achado 2026-09-10, derrubou o baixador.py com FileNotFoundError)."""
|
||||
handle = rec["handle"]; pasta = handle.replace("/", "_")
|
||||
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} {'preservacao' if t=='preservacao' else 'legado'}/{n}"
|
||||
for t, n, sha in con.execute(
|
||||
"SELECT tipo,nome,sha256 FROM arquivos WHERE handle=? AND status='ok'",
|
||||
(handle,))]
|
||||
linhas.append(f"{hashlib.sha256(mj.read_bytes()).hexdigest()} 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 main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--catalogo")
|
||||
ap.add_argument("--limit", type=int, default=0)
|
||||
ap.add_argument("--somente-legado", action="store_true")
|
||||
ap.add_argument("--pular-legado", action="store_true")
|
||||
ap.add_argument("--status", action="store_true")
|
||||
args = ap.parse_args()
|
||||
con = db()
|
||||
|
||||
if args.status:
|
||||
for tipo, st, n, b in con.execute(
|
||||
"SELECT tipo,status,COUNT(*),COALESCE(SUM(bytes),0) FROM arquivos GROUP BY 1,2"):
|
||||
print(f" {tipo:<12} {st:<6} {n:>6} {b/1024**3:.2f} GB")
|
||||
print(f" disco livre: {livre()/1024**3:.1f} GB")
|
||||
return
|
||||
|
||||
if not args.catalogo:
|
||||
sys.exit("informe --catalogo catalogo.jsonl")
|
||||
if not os.environ.get("AWS_ACCESS_KEY_ID"):
|
||||
sys.exit("credenciais S3 ausentes no ambiente")
|
||||
|
||||
recs = [json.loads(l) for l in open(args.catalogo, encoding="utf-8") if l.strip()]
|
||||
recs = [r for r in recs if not r.get("erro")]
|
||||
if args.limit:
|
||||
recs = recs[: args.limit]
|
||||
print(f"{len(recs)} itens | destino s3://{BUCKET}/ | livre {livre()/1024**3:.1f} GB\n")
|
||||
|
||||
for i, rec in enumerate(recs, 1):
|
||||
partes = []
|
||||
if not args.somente_legado and rec.get("preservation_url"):
|
||||
partes.append(("preservacao", rec["preservation_url"]))
|
||||
if not args.pular_legado and rec.get("legacy_url"):
|
||||
# objdigital.bn.br reseta conexao na porta 80 (http simples) --
|
||||
# so responde em https. O DC.identifier na fonte traz http://.
|
||||
legado_url = rec["legacy_url"].replace("http://", "https://", 1)
|
||||
partes.append(("legado", legado_url))
|
||||
if not partes:
|
||||
continue
|
||||
res = []
|
||||
for tipo, url in partes:
|
||||
res.append(f"{tipo}:{processa(con, rec, tipo, url, args)}")
|
||||
time.sleep(PAUSA)
|
||||
sobe_metadados(con, rec)
|
||||
print(f"[{i}/{len(recs)}] {rec['handle'].split('/')[-1]:<6} {' '.join(res)}", flush=True)
|
||||
|
||||
print("\nconcluido")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrompido. Estado salvo -- rode de novo para continuar.")
|
||||
Reference in New Issue
Block a user