Files
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

166 lines
7.1 KiB
Python

"""Nível 3 — séries a partir de `dc.source`.
O campo traz, misturados, o nome da coleção/fundo de origem ("Coleção Gilberto
Ferrez" e "Gilberto Ferrez"), sufixos da instituição ("… / Museu da
República"), notações de caixa ("CX 14") e lixo de extração. A regra:
1. limpa o valor até uma chave (sem prefixo Coleção/Fundo/Arquivo, sem sufixo
institucional, sem notação no fim, sem acento, sem pontuação);
2. chaves com pelo menos MINIMO itens viram série;
3. valor raro que contém a chave de uma série vai para ela (os "Negativo de
vidro MPEG639, Coleção Fotográfica do Museu Paraense…");
4. o resto vai para a série AV (Avulsos) da instituição, e o valor original
fica como notação na origem (5.1).
Correções manuais em `ajustes/series.tsv` (instituicao, dc.source, titulo)
prevalecem sobre a regra.
"""
from __future__ import annotations
import csv
import re
import unicodedata
from collections import Counter, defaultdict
from .config import AJUSTES
MINIMO = 3
# variantes que a limpeza não junta sozinha: chave → chave canônica
ALIAS = {
'd teresa cristina maria': 'thereza christina maria',
'teresa cristina maria': 'thereza christina maria',
'federacao brasileira para o progresso feminino fbpf': 'federacao brasileira pelo progresso feminino',
'federacao brasileira para o progresso feminino': 'federacao brasileira pelo progresso feminino',
'fotografica mpeg': 'fotografica do museu paraense emilio goeldi',
'brascan cem anos no brasil': 'brascan cem anos no brasil',
'escola de ciencias sociais fgv cpdoc': 'escola de ciencias sociais fgv cpdoc',
'escola de ciencias sociais': 'escola de ciencias sociais fgv cpdoc',
'dos eventos institucionais': 'eventos institucionais',
'mestres do sec xix': 'mestres do seculo xix',
'cristovao barcellos': 'cristovao barcelos',
}
# notação de caixa/negativo/foto: é localização na origem (5.1), não série
NOTACAO = re.compile(r'(?i)^\s*(cx|caixas?|nv|foto|negativo|estampa)\b')
PREFIXO = re.compile(r'^(convenio|colecao particular|colecao|fundo|arquivo|acervo|serie)\s+(d[aeo]s?\s+)?')
LIXO = re.compile(r'[<>{}]|ns:|^[0-9a-f]{12,}')
def sem_acento(s: str) -> str:
return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
def limpa(v: str) -> str:
s = v.strip()
s = re.sub(r'\s*/\s*(acervo|museu|ibge|instituto moreira).*$', '', s, flags=re.I)
s = re.sub(r'\s+-\s+(museu|funda|fgv|ibge).*$', '', s, flags=re.I)
s = re.sub(r'\s*\((museu|ibram)[^)]*\)', '', s, flags=re.I)
s = re.sub(r'\s*/\s*ibge$', '', s, flags=re.I)
s = re.sub(r'\s*(?:-\s*|\()\s*\w*\d\w*\)?\s*$', '', s) # "- 1123K", "(148T)", "- EFi9"
s = sem_acento(s.lower())
s = re.sub(r'\bsec\.?\s', 'sec ', s)
s = re.sub(r'[^\w\s]', ' ', s)
s = re.sub(r'\s+', ' ', s).strip()
s = PREFIXO.sub('', s)
return ALIAS.get(s, s)
def sigla(titulo: str, usadas: set[str]) -> str:
if m := re.match(r'(?i)[áa]lbum\s+(\d+)', titulo):
base = f'A{m[1]}'
usadas.add(base)
return base
miolo = PREFIXO.sub('', sem_acento(titulo.lower()))
palavras = [p for p in re.findall(r'[a-z0-9]+', miolo) if p not in {'de', 'da', 'do', 'das', 'dos', 'e', 'a', 'o'}]
base = ''.join(p[0] for p in palavras).upper()[:6] or 'S'
if len(base) < 2 and palavras:
base = palavras[0][:4].upper()
s, n = base, 2
while s in usadas or s == 'AV':
s, n = f'{base}{n}', n + 1
usadas.add(s)
return s
def _titulo(variantes: Counter) -> str:
"""A variante mais frequente; se ela não diz o que é, herda o tipo
(Coleção/Fundo/Série) de outra variante — "Thereza Christina Maria" +
"Coleção D. Teresa…" → "Coleção Thereza Christina Maria"."""
tipo_re = r'(?i)^(cole[cç][aã]o( particular)?|fundo|arquivo|s[ée]rie|conv[eê]nio|[áa]lbum)\b'
_, v = max((n, v) for v, n in variantes.items())
v = re.sub(r'\s+', ' ', v).strip()
v = re.sub(r'\s*[-/]\s*(Acervo|Museu|Fundação|Instituto Moreira|IBGE).*$', '', v)
v = re.sub(r'\s*\((Museu|IBRAM)[^)]*\)', '', v)
if not re.match(tipo_re, v):
tipos = Counter(m[1].capitalize() for x in variantes if (m := re.match(tipo_re, x.strip())))
v = f"{tipos.most_common(1)[0][0] if tipos else 'Coleção'} {v}"
return v
def _ajustes() -> dict[tuple[str, str], str]:
p = AJUSTES / 'series.tsv'
if not p.exists():
return {}
with p.open(encoding='utf-8') as f:
return {(r['instituicao'], r['dc_source']): r['titulo'] for r in csv.DictReader(f, delimiter='\t')}
def monta(fontes: list[tuple[str, str, str | None]]):
"""fontes: (handle, instituição, dc.source | None).
Devolve (por_handle, series): por_handle[handle] = (sigla, notação na origem
ou None); series[(instituição, sigla)] = {'titulo', 'variantes', 'n'}.
"""
ajuste = _ajustes()
chaves = defaultdict(Counter) # (inst, chave) → Counter(valor original)
for _, inst, v in fontes:
if v and not LIXO.search(v) and not NOTACAO.match(v) and (inst, v) not in ajuste:
chaves[(inst, limpa(v))][v] += 1
series: dict[tuple[str, str], dict] = {}
sigla_de: dict[tuple[str, str], str] = {}
usadas: dict[str, set[str]] = defaultdict(set)
fortes = defaultdict(list) # inst → [chave]
for (inst, chave), variantes in sorted(chaves.items(), key=lambda kv: -sum(kv[1].values())):
if not chave or sum(variantes.values()) < MINIMO:
continue
titulo = _titulo(variantes)
sg = sigla(titulo, usadas[inst])
sigla_de[(inst, chave)] = sg
series[(inst, sg)] = {'titulo': titulo, 'variantes': variantes, 'n': 0}
fortes[inst].append(chave)
for (inst, v), titulo in ajuste.items():
k = (inst, 'ajuste:' + titulo)
if k not in sigla_de:
sg = sigla(titulo, usadas[inst])
sigla_de[k] = sg
series[(inst, sg)] = {'titulo': titulo, 'variantes': Counter(), 'n': 0}
por_handle = {}
for handle, inst, v in fontes:
sg = None
if v and (inst, v) in ajuste:
sg = sigla_de[(inst, 'ajuste:' + ajuste[(inst, v)])]
series[(inst, sg)]['variantes'][v] += 1
elif v and not LIXO.search(v):
chave = limpa(v)
sg = sigla_de.get((inst, chave))
if sg is None:
# o valor raro contém o nome de uma série forte da mesma instituição?
for forte in sorted(fortes[inst], key=len, reverse=True):
if len(forte) > 5 and re.search(rf'\b{re.escape(forte)}\b', chave):
sg = sigla_de[(inst, forte)]
series[(inst, sg)]['variantes'][v] += 1
break
if sg is None:
sg = 'AV'
series.setdefault((inst, 'AV'), {'titulo': 'Avulsos', 'variantes': Counter(), 'n': 0})
series[(inst, sg)]['n'] += 1
# o valor original fica como notação na origem quando não é o próprio nome da série
notacao = v if v and (sg == 'AV' or limpa(v) not in {limpa(x) for x in [series[(inst, sg)]['titulo']]}) else None
por_handle[handle] = (sg, notacao)
return por_handle, series