Files
brasiliano/pipeline/nobrade/autoridades.py
T
rodado 23b56e8c87 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
2026-09-30 21:22:00 +02:00

120 lines
5.1 KiB
Python

"""Registros de autoridade (ISAAR(CPF)) para produtores — elemento 2.1 e 8.1.
Junta as grafias de `dc.contributor` ("Malta, Augusto", "Malta, Augusto,
1864-1957", "Marc Ferrez" e "Ferrez, Marc") numa forma autorizada, com as
variantes como remissivas (proc. 8.1.4).
"""
from __future__ import annotations
import re
import unicodedata
from collections import Counter, defaultdict
ANONIMO = re.compile(r'(?i)^\s*(an[oô]nimo|autoria n[aã]o identificada|s/\s*autor|sem autor|desconhecid[oa]|'
r'n[aã]o identificad[oa]|autor n[aã]o identificado|autor desconhecido)\s*\.?$')
COLETIVA = re.compile(r'(?i)\b(escola|officina|oficina|photographia|fotografia|photo|foto|studio|est[uú]dio|atelier|'
r'casa|companhia|cia\.?|museu|instituto|minist[eé]rio|servi[cç]o|diretoria|departamento|'
r'comiss[aã]o|biblioteca|arquivo|editora|lith|litografia|tipografia|typ|&|irm[aã]os|'
r'sociedade|empresa|jornal|revista|ag[eê]ncia|governo|prefeitura|marinha|ex[eé]rcito|'
r'for[cç]a|aeron[aá]utica|avia[cç][aã]o|universidade|fundação)\b')
DATAS = re.compile(r',?\s*((?:ca\.\s*|fl\.\s*|m\.\s*|n\.\s*)?\d{4}\??(?:\s*-\s*(?:ca\.\s*)?\d{0,4}\??)?)\.?\s*$')
PARTICULAS = {'de', 'da', 'do', 'das', 'dos', 'e', 'von', 'van', 'du', 'del', 'di'}
def _chave(s: str) -> str:
s = ''.join(c for c in unicodedata.normalize('NFD', s.lower()) if unicodedata.category(c) != 'Mn')
s = re.sub(r'\(.*?\)', '', s)
return re.sub(r'[^\w,]+', ' ', s).strip(' ,')
def _separa_datas(nome: str) -> tuple[str, str | None]:
nome = nome.strip().rstrip('.')
if m := DATAS.search(nome):
if ',' in nome[:m.start()] or m[1].startswith(('fl', 'm.', 'n.', 'ca')):
return nome[:m.start()].strip(' ,'), m[1]
return nome, None
def _inverte(nome: str) -> str | None:
"""'Marc Ferrez' → 'Ferrez, Marc'. Só para nomes de pessoa sem vírgula."""
partes = nome.split()
if len(partes) < 2 or ',' in nome:
return None
i = len(partes) - 1
while i > 1 and partes[i - 1].lower() in PARTICULAS:
i -= 1
return f"{' '.join(partes[i:])}, {' '.join(partes[:i])}"
def periodo(datas: str | None) -> tuple[int, int] | None:
"""Anos prováveis de atividade: fl. A-B, ou nascimento+15 até a morte."""
if not datas:
return None
anos = [int(a) for a in re.findall(r'\d{4}', datas)]
if not anos:
return None
if datas.startswith('fl'):
return (anos[0], anos[-1]) if len(anos) > 1 else (anos[0], anos[0])
if datas.startswith('m.'):
return None
if len(anos) == 2:
return anos[0] + 15, anos[1]
return None
class Autoridades:
def __init__(self):
self.contagem: Counter = Counter()
def registra(self, nome: str):
if nome and nome.strip() and not ANONIMO.match(nome):
self.contagem[nome.strip()] += 1
def resolve(self):
"""Agrupa as grafias. Depois disso `forma(nome)` devolve o id da autoridade."""
grupos: dict[str, Counter] = defaultdict(Counter)
for nome, n in self.contagem.items():
base, _ = _separa_datas(nome)
grupos[_chave(base)][nome] += n
# grafias diretas ("Marc Ferrez") que batem com uma invertida já conhecida
for k in list(grupos):
if ',' in k:
continue
nome = next(iter(grupos[k]))
if COLETIVA.search(nome):
continue
inv = _inverte(_separa_datas(nome)[0])
if inv and _chave(inv) in grupos and _chave(inv) != k:
grupos[_chave(inv)].update(grupos.pop(k))
self.registros: dict[str, dict] = {}
self._id_de: dict[str, str] = {}
for k, variantes in sorted(grupos.items(), key=lambda kv: -sum(kv[1].values())):
# forma autorizada: a variante mais completa (com datas) e mais usada
# (datas com início e fim > só uma data > nenhuma), depois frequência
def peso(v):
dt = _separa_datas(v)[1] or ''
return (len(re.findall(r'\d{4}', dt)), variantes[v], v)
forma = max(variantes, key=peso)
forma = forma.strip().rstrip('.')
base, datas = _separa_datas(forma)
tipo = 'entidade coletiva' if COLETIVA.search(base) else ('pessoa' if ',' in base else 'pessoa ou entidade')
if re.match(r'(?i)fam[ií]lia\b', base):
tipo = 'família'
aid = 'A' + str(len(self.registros) + 1).zfill(4)
self.registros[aid] = {
'id': aid, 'forma_autorizada': forma, 'nome': base, 'datas_existencia': datas,
'tipo': tipo, 'variantes': sorted(v for v in variantes if v.strip().rstrip('.') != forma),
'ocorrencias': sum(variantes.values()), 'periodo_atividade': periodo(datas),
'fontes': [],
}
for v in variantes:
self._id_de[v] = aid
def id(self, nome: str) -> str | None:
return self._id_de.get((nome or '').strip())
def __getitem__(self, aid: str) -> dict:
return self.registros[aid]