← back to Paul Conrad Archive
src/conrad/normalize.py
146 lines
"""Date, title, subject and name normalisation."""
from __future__ import annotations
import calendar
import re
import unicodedata
MONTHS = {m.lower(): i for i, m in enumerate(calendar.month_abbr) if m}
MONTHS.update({m.lower(): i for i, m in enumerate(calendar.month_name) if m})
MONTHS["sept"] = 9
def _mon(word: str | None) -> int | None:
if not word:
return None
return MONTHS.get(word.lower().rstrip(".")) or MONTHS.get(word.lower()[:3])
def _last(y: int, m: int) -> int:
return calendar.monthrange(y, m)[1]
def _iso(y, m=None, d=None) -> str:
return f"{y:04d}" + (f"-{m:02d}" if m else "") + (f"-{d:02d}" if d else "")
def parse_date(text: str | None) -> dict:
"""Parse free-text archival dates into {date_exact, date_start, date_end, year, estimate}.
date_exact is only set when a full day is known. Ranges set date_start/date_end.
"""
out = {"date_exact": None, "date_start": None, "date_end": None, "year": None, "estimate": False}
if not text:
return out
t = str(text).strip()
t = re.sub(r"\[(publication date|date created|printed later)\]", "", t, flags=re.I).strip()
if re.search(r"\?|circa|ca\.|approximately", t, re.I):
out["estimate"] = True
# ISO: 1989-07-16 / 1969-03 / 1969
m = re.fullmatch(r"\[?(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?\]?", t)
if m:
y, mo, d = int(m[1]), int(m[2]) if m[2] else None, int(m[3]) if m[3] else None
return _fill(out, y, mo, d)
# "1984 Mar. 13" / "1984 March" / "Mar 13, 1984" / "March 1964"
m = re.search(r"(\d{4})\s+([A-Za-z]{3,9})\.?(?:\s+(\d{1,2}))?", t)
if m and _mon(m[2]):
return _fill(out, int(m[1]), _mon(m[2]), int(m[3]) if m[3] else None)
m = re.search(r"([A-Za-z]{3,9})\.?\s+(?:(\d{1,2}),?\s+)?(\d{4})", t)
if m and _mon(m[1]):
return _fill(out, int(m[3]), _mon(m[1]), int(m[2]) if m[2] else None)
# "between 1971 and 1980", "1950s-1963", "1967-1968", "1950s"
yrs = [int(y) for y in re.findall(r"(1[89]\d\d|20[0-2]\d)", t)]
decade = re.search(r"(1[89]\d0|20[0-2]0)s", t)
if yrs:
lo, hi = min(yrs), max(yrs)
if decade and int(decade[1]) == lo and len(yrs) == 1:
hi = lo + 9
if lo == hi:
return _fill(out, lo, None, None)
out.update(date_start=f"{lo}-01-01", date_end=f"{hi}-12-31", year=None)
return out
return out
def _fill(out: dict, y: int, mo: int | None, d: int | None) -> dict:
out["year"] = y
if mo and d:
out["date_exact"] = _iso(y, mo, d)
out["date_start"] = out["date_end"] = out["date_exact"]
elif mo:
out["date_start"], out["date_end"] = _iso(y, mo, 1), _iso(y, mo, _last(y, mo))
else:
out["date_start"], out["date_end"] = f"{y}-01-01", f"{y}-12-31"
return out
def norm_title(t: str | None) -> str:
if not t:
return ""
t = unicodedata.normalize("NFKD", t)
t = re.sub(r"--\s*news item\.?", "", t, flags=re.I)
t = t.lower().replace("…", " ").replace("&", " and ")
t = re.sub(r"[^a-z0-9 ]+", " ", t)
t = re.sub(r"\s+", " ", t).strip()
return "" if t in ("untitled", "untitled drawing", "") else t
def clean_subject(s: str) -> str:
s = re.sub(r"--\s*\d{4}-\d{4}\.?$", "", s.strip())
s = re.sub(r",?\s*\d{4}-(\d{4})?\.?$", "", s)
return s.rstrip(" .,").strip()
GENERIC_SUBJECTS = re.compile(r"^(editorial cartoons|political cartoons|drawings|caricatures|cartoons|"
r"american|pen and ink drawings|ink drawings|drawings--american|proofs|prints|graphic arts)\b", re.I)
PERSON_RE = re.compile(r"^([A-Z][A-Za-z'.\-]+(?: [A-Z][A-Za-z'.\-]+)?),\s+([A-Z][A-Za-z.\- ]+?)(?:\s*\(|,?\s*\d{4}|,?--|,?$)")
PRESIDENTS = {
"truman": "Truman, Harry S.", "eisenhower": "Eisenhower, Dwight D.", "kennedy, john": "Kennedy, John F.",
"johnson, lyndon": "Johnson, Lyndon B.", "nixon, richard": "Nixon, Richard M.", "ford, gerald": "Ford, Gerald R.",
"carter, jimmy": "Carter, Jimmy", "reagan, ronald": "Reagan, Ronald", "bush, george h": "Bush, George H. W.",
"clinton, bill": "Clinton, Bill", "bush, george w": "Bush, George W.", "obama": "Obama, Barack",
}
PRESIDENT_WORDS = { # free-text mentions in titles/scope notes
r"\btruman\b": "Truman, Harry S.", r"\beisenhower|\bike\b": "Eisenhower, Dwight D.",
r"\bjfk\b|john f\.? kennedy": "Kennedy, John F.", r"\blbj\b|lyndon": "Johnson, Lyndon B.",
r"\bnixon": "Nixon, Richard M.", r"gerald ford|\bford administration": "Ford, Gerald R.",
r"jimmy carter|\bcarter administration": "Carter, Jimmy", r"\breagan": "Reagan, Ronald",
r"\bclinton\b": "Clinton, Bill", r"\bobama": "Obama, Barack",
}
def is_president(name: str) -> bool:
n = name.lower()
return any(n.startswith(k) for k in PRESIDENTS) or name in PRESIDENTS.values()
def person_from_heading(h: str) -> str | None:
"""'Nixon, Richard M. (Richard Milhous), 1913-1994' -> 'Nixon, Richard M.'; non-person headings -> None."""
h = h.strip()
for k, v in PRESIDENTS.items():
if h.lower().startswith(k):
return v
m = PERSON_RE.match(h) or PERSON_RE.match(clean_subject(h))
if not m:
return None
first = m[2].strip()
# reject corporate/geographic headings that happen to contain a comma
if re.search(r"\b(Inc|Company|Corporation|University|Department|Association|Party|Committee|Council|"
r"County|State|City|Republic|Church|Industry|Office|Court|Commission|Bureau|Army|Navy)\b", h):
return None
if len(first) < 2 or first.lower() in {"the"}:
return None
return f"{m[1]}, {first}".strip()
def presidents_in_text(text: str | None) -> list[str]:
if not text:
return []
return sorted({v for k, v in PRESIDENT_WORDS.items() if re.search(k, text, re.I)})
def decade(y: int | None) -> str:
return f"{y // 10 * 10}s" if y else "undated"