feat: descrição arquivística NOBRADE multinível com exportação EAD 3, EAC-CPF e AtoM

- pipeline/nobrade: normalizadores de datas (ISO 8601-2), séries, autoridades,
  espécie/suporte, direitos, lugar e assuntos; montagem dos níveis 0–5 com
  código de referência BR RJXYZ.BF.<seção>.<série>.<handle>.<imagem>
- validador dos elementos obrigatórios: 0 pendências em 26.454 unidades
- EAD 3 e 457 EAC-CPF validados contra os XSD
- mural: cartão com código, data normalizada, série e reprodução; filtro por série
This commit is contained in:
2026-09-30 21:22:00 +02:00
parent e92df83a78
commit 23b56e8c87
46 changed files with 19562 additions and 68 deletions
+177
View File
@@ -0,0 +1,177 @@
"""Normalizadores de campo único: espécie/suporte (1.5), características
físicas (1.5/4.4), direitos (4.2), idioma (4.3), lugar (1.3.1 data tópica) e
assuntos (8.1)."""
from __future__ import annotations
import re
import unicodedata
CORROMPIDO = re.compile(r'[<>{}]|ns:|PALAVRAS-CHAVE|^[0-9a-f]{6,}|\d[a-f]\d[a-f]\d')
def sem_acento(s: str) -> str:
return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
def corrompido(v: str | None) -> bool:
return bool(v) and bool(CORROMPIDO.search(v))
# ── 1.5 espécie e suporte (a partir de dc.type) ─────────────────────────────
ESPECIES = [ # (padrão, gênero, espécie)
(r'^negativo', 'iconográfico', 'negativo fotográfico'),
(r'^diapositivo', 'iconográfico', 'diapositivo'),
(r'reprodu[cç][aã]o fotomec', 'iconográfico', 'reprodução fotomecânica'),
(r'^(fotografia|vista panor)', 'iconográfico', 'fotografia'),
(r'^gravura', 'iconográfico', 'gravura'),
(r'^desenho', 'iconográfico', 'desenho'),
(r'^[áa]lbum', 'iconográfico', 'álbum fotográfico'),
(r'^manuscrito', 'textual', 'manuscrito'),
(r'^livro', 'bibliográfico', 'livro'),
(r'^artigo de peri', 'bibliográfico', 'periódico'),
]
def especie(tipo: str | None) -> dict:
"""{'genero', 'especie', 'suporte', 'presumida'}. Sem dc.type (ou com lixo),
presume fotografia — o acervo inteiro é um repositório fotográfico — e marca."""
t = (tipo or '').strip()
if t and not corrompido(t):
base, _, sup = t.partition('/')
b = sem_acento(base.strip().lower())
for pat, gen, esp in ESPECIES:
if re.search(pat, b) or re.search(pat, base.strip().lower()):
suporte = sup.strip().lower() or None
if esp == 'negativo fotográfico' and 'flex' in b:
suporte = 'flexível'
return {'genero': gen, 'especie': esp, 'suporte': suporte, 'presumida': False}
return {'genero': 'iconográfico', 'especie': 'fotografia', 'suporte': None, 'presumida': True,
'original': t or None}
# ── características físicas que algumas instituições põem em dc.description ──
FISICO = re.compile(
r'(?i)^\s*(p\s*&\s*b|p/b|preto e branco|cor(ida)?|colorid[ao]|s[ée]pia|'
r'albumina|gelatina|prata|col[oó]dio|cianotipia|platina|carv[aã]o|ambr[oó]tipo|daguerre[oó]tipo|'
r'negativo|acetato|nitrato|vidro|papel|cart[aã]o|\d+[,.]?\d*\s*x\s*\d+|processo:|segunda gera)')
def e_fisico(v: str) -> bool:
return bool(FISICO.match(v)) and len(v) < 120
def cor(fisico: list[str]) -> str | None:
t = ' '.join(fisico).lower()
if re.search(r'p\s*&\s*b|p/b|preto e branco', t):
return 'p&b'
if re.search(r'colorid|\bcor\b', t):
return 'cor'
return None
# ── 4.2 condições de reprodução ─────────────────────────────────────────────
def direitos(textos: list[str], instituicao: str) -> dict:
"""{'categoria', 'detentor', 'texto', 'originais'}."""
limpos = list(dict.fromkeys(t.strip() for t in textos if t and t.strip() and t.strip() != 'None'))
junto = sem_acento(' '.join(limpos).lower())
if re.search(r'dominio publico|omínio público|omi?nio publico', junto):
cat = 'domínio público'
elif 'liberado para uso de natureza cultural' in junto:
cat = 'uso cultural liberado'
elif re.search(r'solicitar|requer libera', junto):
cat = 'requer autorização do detentor'
else:
cat = 'consultar o detentor'
detentor = None
for t in limpos:
a = sem_acento(t.lower())
if corrompido(t) or re.search(r'dominio publico|solicitar|requer|liberado', a) or len(t) < 6:
continue
if 'bibli' in a and 'nac' in a:
detentor = 'Biblioteca Nacional (Brasil)'
elif 'moreira' in a and 'salles' in a and '/' not in t:
detentor = 'Instituto Moreira Salles'
else:
detentor = t
break
detentor = detentor or instituicao
frases = {
'domínio público': 'Imagem em domínio público.',
'uso cultural liberado': 'Uso liberado para fins de natureza cultural.',
'requer autorização do detentor': 'Reprodução sujeita a autorização do detentor dos direitos.',
'consultar o detentor': 'Condições de reprodução não informadas na origem; consultar o detentor.',
}
return {'categoria': cat, 'detentor': detentor,
'texto': f'{frases[cat]} Detentor indicado: {detentor}.',
'originais': limpos or None}
# ── 4.3 idioma ───────────────────────────────────────────────────────────────
NOMES_IDIOMA = {'por': 'português', 'fre': 'francês', 'eng': 'inglês', 'lat': 'latim', 'ger': 'alemão',
'ita': 'italiano', 'ukr': 'ucraniano', 'hun': 'húngaro', 'spa': 'espanhol'}
def idiomas(vs: list[str]) -> list[dict]:
out = []
for v in vs:
c = (v or '').strip().lower()
if c in NOMES_IDIOMA and c not in [o['codigo'] for o in out]:
out.append({'codigo': c, 'nome': NOMES_IDIOMA[c]})
return out
# ── 1.3.1 data tópica e 8.1 lugares ─────────────────────────────────────────
UF = {'AC', 'AL', 'AP', 'AM', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MT', 'MS', 'MG', 'PA', 'PB', 'PR', 'PE',
'PI', 'RJ', 'RN', 'RS', 'RO', 'RR', 'SC', 'SP', 'SE', 'TO'}
ESTADOS = {'rio de janeiro': 'RJ', 'sao paulo': 'SP', 'bahia': 'BA', 'pernambuco': 'PE', 'minas gerais': 'MG',
'para': 'PA', 'amazonas': 'AM', 'parana': 'PR', 'rio grande do sul': 'RS', 'santa catarina': 'SC',
'ceara': 'CE', 'maranhao': 'MA', 'espirito santo': 'ES', 'goias': 'GO', 'mato grosso': 'MT',
'paraiba': 'PB', 'alagoas': 'AL', 'sergipe': 'SE', 'piaui': 'PI', 'rio grande do norte': 'RN'}
PAISES = {'brasil', 'brazil'}
def lugar(coberturas: list[str]) -> str | None:
"""A forma mais específica de cidade: 'Cidade (UF)'. Junta 'Rio de Janeiro' + 'RJ'."""
cs = [c.strip() for c in coberturas if c and c.strip() and not corrompido(c)]
for c in cs:
if m := re.fullmatch(r'(.+?)\s*(?:\(|,\s*|\s-\s*)([A-Z]{2})\)?', c):
if m[2] in UF and sem_acento(m[1].lower()) not in PAISES:
return f'{m[1].strip()} ({m[2]})'
ufs = [c for c in cs if c in UF]
for c in cs:
k = sem_acento(c.lower())
if k in PAISES or c in UF or '(estado)' in k or 'state' in k:
continue
if re.search(r'\(([^)]+)\)$', c): # "Paris (França)", "Rio de Janeiro (Brazil)"
nome, pais = re.match(r'(.+?)\s*\(([^)]+)\)$', c).groups()
if sem_acento(pais.lower()) in PAISES:
uf = ESTADOS.get(sem_acento(nome.lower()))
return f'{nome} ({uf})' if uf else nome
return c
uf = ufs[0] if ufs else ESTADOS.get(k)
return f'{c} ({uf})' if uf else c
return None
# ── 8.1 assuntos: separa as traduções inglesas que o DSpace da BN repete ─────
EN = {'the', 'and', 'of', 'in', 'on', 'women', 'men', 'children', 'streets', 'street', 'buildings', 'building',
'churches', 'church', 'views', 'view', 'slaves', 'railroads', 'railroad', 'harbors', 'harbor', 'landscapes',
'portraits', 'bridges', 'rivers', 'houses', 'squares', 'parks', 'gardens', 'mountains', 'beaches',
'ships', 'soldiers', 'history', 'photographs', 'people', 'indians', 'coffee', 'workers', 'palaces',
'monuments', 'waterfalls', 'boats', 'plantations', 'aerial', 'military', 'city', 'cities', 'public',
'social', 'life', 'customs', 'description', 'travel', 'transportation', 'agriculture', 'industry',
'architecture', 'fountains', 'hospitals', 'schools', 'markets', 'lakes', 'bays', 'islands', 'forts',
'wars', 'war', 'emperors', 'kings', 'queens', 'nobility', 'family', 'families', 'politicians'}
PT = {'de', 'da', 'do', 'das', 'dos', 'e', 'em', 'no', 'na', 'nos', 'nas', 'para', 'com'}
def idioma_termo(t: str) -> str:
if re.search(r'[ãõçáéíóúâêô]', t.lower()):
return 'por'
palavras = set(re.findall(r'[a-z]+', t.lower()))
if palavras & PT:
return 'por'
if palavras & EN or re.search(r'\((?:brazil|state)', t.lower()):
return 'eng'
return 'por'