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
+333
View File
@@ -0,0 +1,333 @@
"""Normalização de dc.date para o elemento 1.3 da NOBRADE.
Saída de `interpreta(texto)`:
forma — como a NOBRADE manda exibir: `1914`, `[1860]`, `[1902?]`,
`[ca. 1905]`, `[193-]`, `[18--?]`, `8/2/1890-18/4/1890`
iso — ISO 8601-2 (EDTF), para indexação e intercâmbio (proc. 1.3.2.7):
`1914`, `1902?`, `1905~`, `193X`, `18XX?`, `1931/1937`, `../1921`
inicio, fim — anos inteiros (ou None) para filtrar e ordenar
qualidade — `exata` | `atribuida` | `incerta` | `aproximada`
tipo — `produção` (padrão) ou `publicação`
motivo — quando não deu para interpretar: `sem data`, `registro de sistema`,
`ilegível`
A regra geral: tira do texto as marcas (colchetes, `?`, `circa`, qualificadores
entre parênteses), interpreta o que sobra como ponto ou intervalo e remonta a
forma com as marcas no padrão da norma (proc. 1.3.2.1–1.3.2.4).
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
MESES = {
'jan': 1, 'janeiro': 1, 'fev': 2, 'fevereiro': 2, 'mar': 3, 'março': 3, 'marco': 3,
'abr': 4, 'abril': 4, 'mai': 5, 'maio': 5, 'jun': 6, 'junho': 6,
'jul': 7, 'julho': 7, 'ago': 8, 'agosto': 8, 'set': 9, 'setembro': 9,
'out': 10, 'outubro': 10, 'nov': 11, 'novembro': 11, 'dez': 12, 'dezembro': 12,
}
MES_RE = '(' + '|'.join(sorted(MESES, key=len, reverse=True)) + r')\.?'
SEM_DATA = re.compile(r'^(s\.?\s*/?\s*d\.?|s/data|sem data|s\.?\s*n\.?|n[aã]o identificad[ao])$', re.I)
@dataclass
class Ponto:
ano: int
mes: int | None = None
dia: int | None = None
prec: str = 'ano' # dia | mes | ano | decada | seculo
def forma(self) -> str:
if self.prec == 'dia':
return f'{self.dia}/{self.mes}/{self.ano}'
if self.prec == 'mes':
return f'{self.mes}/{self.ano}'
if self.prec == 'decada':
return f'{self.ano // 10}-'
if self.prec == 'seculo':
return f'{self.ano // 100}--'
return str(self.ano)
def iso(self) -> str:
if self.prec == 'dia':
return f'{self.ano:04d}-{self.mes:02d}-{self.dia:02d}'
if self.prec == 'mes':
return f'{self.ano:04d}-{self.mes:02d}'
if self.prec == 'decada':
return f'{self.ano // 10}X'
if self.prec == 'seculo':
return f'{self.ano // 100}XX'
return f'{self.ano:04d}'
def limites(self) -> tuple[int, int]:
if self.prec == 'decada':
return self.ano, self.ano + 9
if self.prec == 'seculo':
return self.ano, self.ano + 99
return self.ano, self.ano
@dataclass
class Data:
original: str
inicio_p: Ponto | None = None
fim_p: Ponto | None = None
relacao: str = '' # '' | 'antes' | 'depois'
colchete: bool = False
incerta: bool = False
aproximada: bool = False
tipo: str = 'produção'
motivo: str = ''
notas: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return self.inicio_p is not None
@property
def qualidade(self) -> str:
if self.aproximada:
return 'aproximada'
if self.incerta:
return 'incerta'
if self.colchete:
return 'atribuida'
return 'exata'
def forma(self) -> str:
if not self.ok:
return '[s.d.]'
a = self.inicio_p.forma()
if self.relacao == 'antes':
s = f'antes de {a}'
elif self.relacao == 'depois':
s = f'depois de {a}'
elif self.fim_p and self.fim_p != self.inicio_p:
s = f'{a}-{self.fim_p.forma()}'
else:
s = a
if self.aproximada:
s = 'ca. ' + s
if self.incerta:
s += '?'
if self.colchete or self.aproximada or self.incerta or self.relacao:
s = f'[{s}]'
return s
def iso(self) -> str | None:
if not self.ok:
return None
q = ('%' if self.incerta else '~') if self.aproximada else ('?' if self.incerta else '')
a = self.inicio_p.iso() + q
if self.relacao == 'antes':
return f'../{a}'
if self.relacao == 'depois':
return f'{a}/..'
if self.fim_p and self.fim_p != self.inicio_p:
return f'{a}/{self.fim_p.iso()}{q}'
return a
def anos(self) -> tuple[int | None, int | None]:
if not self.ok:
return None, None
i, f = self.inicio_p.limites()
if self.fim_p:
f = self.fim_p.limites()[1]
if self.relacao == 'antes':
return None, f
if self.relacao == 'depois':
return i, None
return i, f
def registro(self) -> dict:
i, f = self.anos()
return {
'original': self.original, 'forma': self.forma(), 'iso': self.iso(),
'inicio': i, 'fim': f, 'qualidade': self.qualidade if self.ok else None,
'tipo': self.tipo, 'motivo': self.motivo or None,
'notas': self.notas or None,
}
def _sem_acento(s: str) -> str:
return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn')
def _ano(s: str, base: int | None = None) -> int:
n = int(s)
if len(s) == 2:
return (base // 100 * 100 if base else 1900) + n
return n
def _ponto(s: str) -> tuple[Ponto | None, bool]:
"""Um ponto no tempo. Devolve (ponto, incerto) — incerto quando o próprio
formato carrega a dúvida, como `193?` (década) ou `?/08/1938` (dia)."""
s = s.strip(' .,:;')
s = re.sub(r'^[-–]\s*', '', s)
if not s:
return None, False
if m := re.fullmatch(r'(\d{4})', s):
return Ponto(int(m[1])), False
if m := re.fullmatch(r'(\d{3})-', s):
return Ponto(int(m[1]) * 10, prec='decada'), False
if m := re.fullmatch(r'(\d{3})-?\?', s):
return Ponto(int(m[1]) * 10, prec='decada'), True
if m := re.fullmatch(r'(\d{2})(?:-{2,3}|_)', s):
return Ponto(int(m[1]) * 100, prec='seculo'), False
if m := re.fullmatch(r'(\d{2})(?:-{2})?\?', s):
return Ponto(int(m[1]) * 100, prec='seculo'), True
if m := re.fullmatch(r'(\d{3})--', s): # [186--]: década escrita com traço a mais
return Ponto(int(m[1]) * 10, prec='decada'), False
# dia/mês/ano, com separadores tortos: 22-12--1938, 14 -07-1928, 05/12-1934
if m := re.fullmatch(r'(\d{1,2})\s*[-/.]\s*(\d{1,2})\s*[-/.]+\s*(\d{4}|\d{2})', s):
d, mo, a = int(m[1]), int(m[2]), _ano(m[3])
if 1 <= mo <= 12 and 1 <= d <= 31:
return Ponto(a, mo, d, 'dia'), len(m[3]) == 2
if m := re.fullmatch(r'(\d{4})\s*[-/]\s*(\d{1,2})\s*[-/]\s*(\d{1,2})', s):
a, mo, d = int(m[1]), int(m[2]), int(m[3])
if 1 <= mo <= 12 and 1 <= d <= 31:
return Ponto(a, mo, d, 'dia'), False
# dia desconhecido: 2-/12/1923, ?/08/1938
if m := re.fullmatch(r'(?:\d?-|\?)\s*/\s*(\d{1,2})\s*/\s*(\d{4})', s):
return Ponto(int(m[2]), int(m[1]), prec='mes'), False
if m := re.fullmatch(r'(\d{1,2})\s*/\s*(\d{4})', s):
if 1 <= int(m[1]) <= 12:
return Ponto(int(m[2]), int(m[1]), prec='mes'), False
if m := re.fullmatch(r'(\d{4})\s*/\s*(\d{1,2})', s):
if 1 <= int(m[2]) <= 12:
return Ponto(int(m[1]), int(m[2]), prec='mes'), False
t = _sem_acento(s.lower())
if m := re.fullmatch(rf'(\d{{1,2}})\s*(?:de\s+)?{MES_RE}\s*,?\s*(?:de\s+)?(\d{{4}})', t):
return Ponto(int(m[3]), MESES[m[2]], int(m[1]), 'dia'), False
if m := re.fullmatch(rf'{MES_RE}\s*,?\s*(?:de\s+)?(\d{{4}})', t):
return Ponto(int(m[2]), MESES[m[1]], prec='mes'), False
if m := re.fullmatch(r'(?:decada de )?(\d{3})\d(?: decada)?( provavel)?', t):
if 'decada' in t:
return Ponto(int(m[1]) * 10, prec='decada'), bool(m[2])
if m := re.fullmatch(r'(\d{2}) seculo', t):
return Ponto((int(m[1]) - 1) * 100, prec='seculo'), False
return None, False
def _intervalo(s: str) -> tuple[Ponto | None, Ponto | None, bool]:
t = _sem_acento(s.lower()).strip(' .,:;')
t = re.sub(r'^entre\s+', '', t)
p, inc = _ponto(t)
if p:
return p, None, inc
# dias ou meses coordenados: "15 e 16 de outubro de 1930", "outubro e novembro de 1930"
if m := re.fullmatch(rf'(\d{{1,2}})\s+e\s+(\d{{1,2}})\s+(?:de\s+)?{MES_RE}\s+(?:de\s+)?(\d{{4}})', t):
mo, a = MESES[m[3]], int(m[4])
return Ponto(a, mo, int(m[1]), 'dia'), Ponto(a, mo, int(m[2]), 'dia'), False
if m := re.fullmatch(rf'{MES_RE}\s+e\s+{MES_RE}\s+(?:de\s+)?(\d{{4}})', t):
a = int(m[3])
return Ponto(a, MESES[m[1]], prec='mes'), Ponto(a, MESES[m[2]], prec='mes'), False
# 1859-61: segundo ano abreviado
if m := re.fullmatch(r'(\d{4})\s*-\s*(\d{2})', t):
a = int(m[1])
b = _ano(m[2], a)
if b > a:
return Ponto(a), Ponto(b), False
# o traço separa só entre um ano e o início do outro — `193-` é década, não intervalo
for sep in (r'\s+e\s+', r'\s+a\s+', r'(?<=[\d?])\s*[-–]\s*(?=\d)', r'\s*/\s*'):
partes = re.split(sep, t)
if len(partes) != 2:
continue
# "/" só separa anos inteiros (1952/1953); com meses é data (1910/12)
if sep == r'\s*/\s*' and not all(re.fullmatch(r'\d{4}', p) for p in partes):
continue
a, ia = _ponto(partes[0])
b, ib = _ponto(partes[1])
if a and b and b.limites()[1] >= a.limites()[0]:
return a, b, ia or ib
return None, None, False
def interpreta(texto: str | None) -> Data:
original = (texto or '').strip()
d = Data(original)
s = original
if not s:
d.motivo = 'sem data'
return d
if re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z', s):
# carimbo de registro no DSpace gravado no campo errado — não é produção
d.motivo = 'registro de sistema'
d.notas.append(f'dc.date traz a data de registro no sistema de origem ({s}), não a de produção')
return d
# partes separadas por ";" com qualificador: fica a de produção, o resto vira nota
partes = [p.strip() for p in re.split(r';(?![^(]*\))', s) if p.strip()]
if len(partes) > 1:
principal = next((p for p in partes if re.search(r'produ', p, re.I)), partes[0])
d.notas += [f'dc.date também registra: {p}' for p in partes if p is not principal]
s = principal
if m := re.search(r'\(([^)]*)\)\s*$', s):
q = m[1].lower()
if 'publica' in q:
d.tipo = 'publicação'
if 'atribu' in q:
d.colchete = True
s = s[:m.start()].strip()
if re.search(r'[\[\]{]', s):
d.colchete = True
s = re.sub(r'[\[\]{}]', '', s)
s = re.sub(r'^\s*-\s*', '', s)
s = re.sub(r'^(RJ|[A-Z]{2}),\s*', '', s) # [RJ, ca. 1860]: lugar vai na data tópica
s = re.sub(r'(\d)\.(\d{3})\b', r'\1\2', s) # 1.885 → 1885
s = re.sub(r'\s*:?\.\s*$', '', s).strip()
if SEM_DATA.fullmatch(s):
d.motivo = 'sem data'
return d
s, n = re.subn(r'\b(circa|cica|cira|ca\.?|c\.)\s*|(?<=\[)c(?=\d)|^c(?=\d)', '', s, flags=re.I)
if n:
d.aproximada = True
s, n = re.subn(r'\s*\b(anterior|antes de)\b\s*', ' ', s, flags=re.I)
if n:
d.relacao = 'antes'
s, n = re.subn(r'\s*\b(posterior|depois de)\b\s*', ' ', s, flags=re.I)
if n:
d.relacao = 'depois'
s = s.strip()
# interrogação no fim vale para a data inteira; dentro de um ponto (193?) é o ponto que diz
if re.search(r'\d{4}\s*\?\s*$', s) or re.search(r'\d{4}\?\s*\]?$', original):
d.incerta = True
s = re.sub(r'\s*\?\s*$', '', s)
# "Foto aérea da Fábrica ... - SP - 23-06-1933": só a data do fim interessa
if not re.match(r'[\d?]', s) and not re.match(rf'(?i){MES_RE}|entre|decada|década', _sem_acento(s)):
if m := re.search(r'(\d{1,2}-\d{1,2}-\d{4}|\d{4})\s*$', s):
d.notas.append(f'data extraída do texto: {original}')
s = m[1]
p, inc = _ponto(s)
if p:
d.inicio_p, d.incerta = p, d.incerta or inc
return d
a, b, inc = _intervalo(s)
if a:
d.inicio_p, d.fim_p, d.incerta = a, b, d.incerta or inc
return d
# "1900-" (colchete aberto): dali em diante
if m := re.fullmatch(r'(\d{4})\s*-', s):
d.inicio_p, d.relacao = Ponto(int(m[1])), 'depois'
return d
d.motivo = 'ilegível'
return d
def atribuida(inicio: int, fim: int, base: str) -> dict:
"""Data atribuída pelo arquivista a partir de outra fonte (proc. 1.3.3.2)."""
d = Data('', Ponto(inicio), Ponto(fim) if fim != inicio else None, incerta=True, colchete=True)
r = d.registro()
r['original'] = None
r['notas'] = [f'data atribuída pelo arquivista: {base}']
return r