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.
197 lines
7.4 KiB
Python
197 lines
7.4 KiB
Python
#!/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.")
|