Source code for klea_utils.biblio.normalize
#!/usr/bin/env python3
"""
Normalisers for bibliographic API responses and DOI strings.
The Crossref, OpenAlex and Semantic Scholar normalisers map a service
response to a :class:`~klea_utils.biblio.models.BiblioRecord`. They are
shared by the DOI resolver (:mod:`klea_utils.biblio.doi`) and the academic
paper search tool (:mod:`klea_utils.mcp.tool_impls.papers`), so they live
here rather than in either consumer.
File: klea_utils/biblio/normalize.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""
import re
from .models import BiblioRecord
#: JATS/XML tags found in Crossref abstracts.
_JATS_TAG_RE = re.compile(r"<[^>]+>")
[docs]
def normalize_doi(doi: str) -> str:
"""Normalise a DOI by stripping common URL/prefix wrappers.
``https://doi.org/10.x/y``, ``http://dx.doi.org/10.x/y`` and
``doi: 10.x/y`` all become ``10.x/y``. Trailing punctuation is
removed, and a trailing URL path after the DOI suffix is stripped
(a valid DOI suffix contains no ``/``, e.g. the path in
``10.1073/pnas.2201699120/-/DCSupplemental``).
:param doi: DOI string, possibly wrapped in a URL or prefix
:returns: Normalised DOI, or ``""`` when *doi* is empty
"""
if not doi:
return ""
value = str(doi).strip()
value = re.sub(
r"^(?:https?://(?:dx\.)?doi\.org/|doi:\s*)", "", value, flags=re.IGNORECASE
)
value = value.rstrip(".,;:)]}")
# A valid DOI is ``10.<registrant>/<suffix>``; anything after the
# second slash is a URL path, not part of the DOI.
parts = value.split("/", 2)
if len(parts) == 3:
value = f"{parts[0]}/{parts[1]}"
return value
def _reconstruct_inverted_index(inverted: dict | None) -> str | None:
"""Rebuild an abstract from OpenAlex's word->positions map."""
if not inverted:
return None
positioned: dict[int, str] = {}
for word, positions in inverted.items():
for position in positions:
positioned[position] = word
if not positioned:
return None
return " ".join(positioned[pos] for pos in sorted(positioned))
[docs]
def normalize_crossref(data: dict) -> BiblioRecord:
"""Normalise a Crossref ``/works/{doi}`` response."""
message = data.get("message") or {}
titles = message.get("title") or []
authors = []
for author in message.get("author") or []:
given = author.get("given") or ""
family = author.get("family") or ""
name = f"{given} {family}".strip() or (author.get("name") or "")
if name:
authors.append(name)
year = None
issued = message.get("issued") or {}
date_parts = issued.get("date-parts") or []
if date_parts and date_parts[0]:
year = date_parts[0][0]
container = message.get("container-title") or []
return BiblioRecord(
title=(titles[0] if titles else None),
authors=authors,
year=year,
journal=(container[0] if container else None),
abstract=strip_tags(message.get("abstract")),
doi=message.get("DOI") or None,
)
[docs]
def normalize_openalex(data: dict) -> BiblioRecord:
"""Normalise an OpenAlex ``/works/doi:{doi}`` response."""
authors = []
for authorship in data.get("authorships") or []:
author = authorship.get("author") or {}
name = author.get("display_name")
if name:
authors.append(name)
source = (data.get("primary_location") or {}).get("source") or {}
raw_doi = data.get("doi")
return BiblioRecord(
title=data.get("title"),
authors=authors,
year=data.get("publication_year"),
journal=source.get("display_name"),
abstract=_reconstruct_inverted_index(data.get("abstract_inverted_index")),
doi=normalize_doi(str(raw_doi)) if raw_doi else None,
)
[docs]
def normalize_semantic_scholar(data: dict) -> BiblioRecord:
"""Normalise a Semantic Scholar ``/paper/DOI:{doi}`` response."""
authors = [a.get("name") for a in (data.get("authors") or []) if a.get("name")]
external = data.get("externalIds") or {}
return BiblioRecord(
title=data.get("title"),
authors=authors,
year=data.get("year"),
journal=data.get("venue"),
abstract=data.get("abstract"),
doi=external.get("DOI") or None,
)