Files
brasiliano/pipeline/nobrade/datas.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

334 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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