AXLR / Catalog / research

doi_lookup

Look up a DOI's metadata in the Crossref REST API and return it as CSL-JSON-like fields (title, authors, container, issued date, volume, issue, pages, DOI, URL, type, publisher) with an APA 7 reference in plain text and HTML. One HTTPS request per call, 15 s timeout, no key; an optional mailto joins Crossref's polite pool. Pass the body Crossref returned as response to parse it offline, without any request.

Install

/tools install doi_lookup
/tools install doi_lookup@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · doi_lookup 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool doi_lookup; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
doistringyes10.NNNN/suffix; doi: and https://doi.org/ prefixes are accepted. length ≥ 1; length ≤ 500
mailtostringnoContact e-mail for Crossref's polite pool, sent in the query string and User-Agent. length ≤ 200
responsestringnoThe exact body Crossref returned for this DOI (offline or cached use); no request is made. length ≤ 2000000
Input schema (JSON)
{
  "type": "object",
  "required": [
    "doi"
  ],
  "additionalProperties": false,
  "properties": {
    "doi": {
      "type": "string",
      "minLength": 1,
      "maxLength": 500,
      "description": "10.NNNN/suffix; doi: and https://doi.org/ prefixes are accepted"
    },
    "mailto": {
      "type": "string",
      "maxLength": 200,
      "description": "Contact e-mail for Crossref's polite pool, sent in the query string and User-Agent"
    },
    "response": {
      "type": "string",
      "maxLength": 2000000,
      "description": "The exact body Crossref returned for this DOI (offline or cached use); no request is made"
    }
  }
}

Example

Input

{
  "doi": "10.1037/ppm0000185"
}

Output (stdout)

{
  "apa": "Grady, J. S., Her, M., Moreno, G., Perez, C., & Yelinek, J. (2019). Emotions in storybooks: A comparison of storybooks that represent ethnic and racial groups in the United States. Psychology of Popular Media Culture, 8(3), 207–217. https://doi.org/10.1037/ppm0000185",
  "csl": {
    "type": "article-journal",
    "volume": "8",
    "issue": "3",
    "page": "207–217"
  }
}

Files

The command the install card registers is python3 doi_lookup.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

doi_lookup.py · sha256 2cf0fa6ea177ebb44ea72ada88e9063233dd408123ea228b4477bb650e1190af
#!/usr/bin/env python3
"""Metadata for a DOI from the Crossref REST API, normalised to CSL-JSON-like fields, with an
APA 7 reference.

Input (JSON on stdin):
  doi       "10.1037/ppm0000185" (also "doi:...", "https://doi.org/...")
  mailto    optional contact e-mail for Crossref's polite pool (query string and User-Agent)
  response  optional: the exact body Crossref returned for this DOI (offline or cached use);
            when given, no request is made
One request per call: GET https://api.crossref.org/works/{doi}, timeout 15 s.
"""
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request

API = "https://api.crossref.org/works/"
UA = "AXLR-catalog-tool/1.0 (+https://axlr.underpassai.com)"
TIMEOUT = 15
DOI_RE = re.compile(r"^10\.\d{4,9}/\S+$")
MAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def normalise_doi(value):
    if not isinstance(value, str) or not value.strip():
        fail("doi must be a non-empty string")
    doi = value.strip()
    doi = re.sub(r"^(https?://(dx\.)?doi\.org/|doi:\s*)", "", doi, flags=re.I)
    if not DOI_RE.match(doi):
        fail("doi must look like 10.NNNN/suffix")
    return doi


def fetch(doi, mailto):
    url = API + urllib.parse.quote(doi, safe="/")
    agent = UA
    if mailto:
        url += "?" + urllib.parse.urlencode({"mailto": mailto})
        agent += f" mailto:{mailto}"
    if not url.startswith("https://"):
        fail("only HTTPS is allowed")
    request = urllib.request.Request(url, headers={"User-Agent": agent, "Accept": "application/json"})
    try:
        with urllib.request.urlopen(request, timeout=TIMEOUT) as reply:
            return url, reply.read().decode("utf-8")
    except urllib.error.HTTPError as error:
        body = error.read().decode("utf-8", "replace")
        if error.code == 404:
            return url, body
        fail(f"Crossref answered HTTP {error.code}: {body.strip()[:200]}")
    except (urllib.error.URLError, OSError, ValueError) as error:
        fail(f"request to Crossref failed: {error}")


def first(values):
    if isinstance(values, list):
        return values[0] if values else None
    return values


def clean(text):
    if not isinstance(text, str):
        return None
    text = re.sub(r"<[^>]+>", "", text)  # JATS markup such as <i>…</i> in titles
    return re.sub(r"\s+", " ", text).strip() or None


def date_parts(work):
    for key in ("issued", "published", "published-print", "published-online", "created"):
        parts = (work.get(key) or {}).get("date-parts") or []
        if parts and parts[0] and parts[0][0] is not None:
            return [int(p) for p in parts[0] if p is not None]
    return None


def initials(given):
    out = []
    for word in given.split():
        pieces = [p.strip(".") for p in word.split("-") if p.strip(".")]
        if pieces:
            out.append("-".join(p[0].upper() + "." for p in pieces))
    return " ".join(out)


def apa_author(person):
    if person.get("literal"):
        return person["literal"]
    family, given = person.get("family", ""), person.get("given", "")
    if not given:
        return family
    return f"{family}, {initials(given)}"


def apa_authors(authors):
    names = [apa_author(a) for a in authors if apa_author(a)]
    if not names:
        return ""
    if len(names) == 1:
        return names[0]
    if len(names) <= 20:
        return ", ".join(names[:-1]) + ", & " + names[-1]
    return ", ".join(names[:19]) + ", . . . " + names[-1]


def end_with_period(text):
    return text if text.endswith((".", "?", "!")) else text + "."


def apa(csl):
    authors = apa_authors(csl["author"])
    year = csl["issued"]["date-parts"][0][0] if csl["issued"] else "n.d."
    title = csl["title"] or "[Untitled]"
    head = f"{end_with_period(authors)} ({year}). " if authors else f"{end_with_period(title)} ({year}). "
    plain = html = head
    if authors:
        plain += end_with_period(title) + " "
        html += end_with_period(title) + " "
    container = csl["container-title"]
    if container:
        tail_plain, tail_html = container, f"<i>{container}</i>"
        if csl["volume"]:
            tail_plain += f", {csl['volume']}"
            tail_html += f", <i>{csl['volume']}</i>"
        if csl["issue"]:
            tail_plain += f"({csl['issue']})"
            tail_html += f"({csl['issue']})"
        if csl["page"]:
            tail_plain += f", {csl['page']}"
            tail_html += f", {csl['page']}"
        plain += tail_plain + ". "
        html += tail_html + ". "
    elif csl["publisher"] and csl["type"] in ("book", "monograph", "report", "edited-book"):
        plain += csl["publisher"] + ". "
        html += csl["publisher"] + ". "
    link = f"https://doi.org/{csl['DOI']}"
    return (plain + link).strip(), (html + link).strip()


CSL_TYPES = {"journal-article": "article-journal", "proceedings-article": "paper-conference",
             "book-chapter": "chapter", "posted-content": "article", "dataset": "dataset",
             "book": "book", "edited-book": "book", "monograph": "book", "report": "report",
             "dissertation": "thesis", "reference-entry": "entry"}


def parse(text, doi):
    try:
        payload = json.loads(text)
    except ValueError:
        if "not found" in text.lower():
            fail(f"DOI not found in Crossref: {doi}")
        fail("response is not Crossref JSON: " + text.strip()[:120])
    if not isinstance(payload, dict) or payload.get("status") != "ok" or not isinstance(payload.get("message"), dict):
        fail("Crossref response has no work (status is not ok)")
    work = payload["message"]
    authors = []
    for person in work.get("author") or []:
        entry = {}
        if person.get("family"):
            entry["family"] = person["family"]
        if person.get("given"):
            entry["given"] = person["given"]
        if person.get("name") and not entry:
            entry["literal"] = person["name"]
        if person.get("ORCID"):
            entry["ORCID"] = person["ORCID"]
        if entry:
            authors.append(entry)
    parts = date_parts(work)
    page = work.get("page")
    if isinstance(page, str):
        page = re.sub(r"\s*[-‐‑‒–—]\s*", "–", page.strip())
    title = clean(first(work.get("title")))
    subtitle = clean(first(work.get("subtitle")))
    if title and subtitle and subtitle.lower() not in title.lower():
        title = f"{title.rstrip('.')}: {subtitle}"
    csl = {
        "type": CSL_TYPES.get(work.get("type"), work.get("type")),
        "crossref_type": work.get("type"),
        "title": title,
        "author": authors,
        "container-title": clean(first(work.get("container-title"))),
        "issued": {"date-parts": [parts]} if parts else None,
        "volume": work.get("volume"),
        "issue": work.get("issue"),
        "page": page,
        "DOI": work.get("DOI", doi),
        "URL": work.get("URL") or f"https://doi.org/{doi}",
        "publisher": work.get("publisher"),
        "ISSN": work.get("ISSN") or [],
    }
    plain, html = apa(csl)
    return {"doi": csl["DOI"], "csl": csl, "apa": plain, "apa_html": html}


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    try:
        json.dumps(request, ensure_ascii=False).encode("utf-8")
    except UnicodeEncodeError:
        fail("input contains an unpaired surrogate (not valid Unicode text)")
    unknown = set(request) - {"doi", "mailto", "response"}
    if unknown:
        fail(f"unknown field(s): {', '.join(sorted(unknown))}")
    doi = normalise_doi(request.get("doi"))
    mailto = request.get("mailto")
    if mailto is not None and (not isinstance(mailto, str) or not MAIL_RE.match(mailto)):
        fail("mailto must be an e-mail address")
    response = request.get("response")
    if response is not None:
        if not isinstance(response, str) or not response.strip():
            fail("response must be the non-empty text Crossref returned")
        source = {"api": "Crossref REST API", "url": API + urllib.parse.quote(doi, safe="/"), "fetched": False}
        text = response
    else:
        url, text = fetch(doi, mailto)
        source = {"api": "Crossref REST API", "url": url, "fetched": True}
    result = parse(text, doi)
    result["source"] = source
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/doi_lookup/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/doi_lookup/1.0.0/doi_lookup.py