- 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
334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""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
|