AXLR / Catalog / research

doi_isbn_validate

Validate and normalise bibliographic identifiers: DOI syntax (bare, doi: or doi.org URL, with the resolver URL and Crossref-pattern warning), ISBN-10 and ISBN-13 check digits with conversion both ways, ISSN check digit with its EAN-13, and ISMN (979-0 and legacy M- forms). Hyphens, spaces and labels are accepted; the type is detected automatically or given. One value or a list.

Install

/tools install doi_isbn_validate
/tools install doi_isbn_validate@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_isbn_validate 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_isbn_validate; each run is approved as a local_exec.

Arguments

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

nametyperequireddefaultmeaning
valuestringnolength ≤ 2000
identifiersarray of stringnoitems ≥ 1; items ≤ 1000
typestringno"auto"one of "auto", "doi", "isbn", "issn", "ismn"
issuestringno"00"Issue/variant digits of the ISSN's EAN-13. pattern ^\d{2}$
Input schema (JSON)
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "value": {
      "type": "string",
      "maxLength": 2000
    },
    "identifiers": {
      "type": "array",
      "items": {
        "type": "string",
        "maxLength": 2000
      },
      "minItems": 1,
      "maxItems": 1000
    },
    "type": {
      "type": "string",
      "enum": [
        "auto",
        "doi",
        "isbn",
        "issn",
        "ismn"
      ],
      "default": "auto"
    },
    "issue": {
      "type": "string",
      "pattern": "^\\d{2}$",
      "default": "00",
      "description": "Issue/variant digits of the ISSN's EAN-13"
    }
  }
}

Example

Input

{
  "value": "0-306-40615-2"
}

Output (stdout)

{
  "type": "isbn",
  "format": "ISBN-10",
  "valid": true,
  "isbn10": "0306406152",
  "isbn13": "9780306406157"
}

Files

The command the install card registers is python3 doi_isbn_validate.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_isbn_validate.py · sha256 e7e73c3e9a727bcc4d0eab5c05910a2b30064e12bc2227587c9eed64d33ea400
#!/usr/bin/env python3
"""Validate and normalise DOIs, ISBN-10, ISBN-13, ISSN and ISMN identifiers; convert ISBN-10 <->
ISBN-13, ISSN -> EAN-13 and legacy ISMN M-... <-> 979-0-...

Input (JSON on stdin):
  value        one identifier, or
  identifiers  a list of up to 1000
  type         "auto" (default) | "doi" | "isbn" | "issn" | "ismn"
  issue        two-digit issue (sequence variant) for the ISSN's EAN-13, default "00"

Hyphens and spaces in ISBN/ISSN/ISMN are accepted anywhere; labels such as "ISBN-13:" are
stripped. DOIs may be given bare, with "doi:" or as a doi.org / dx.doi.org URL.
"""
import json
import re
import sys

MAX_ITEMS = 1000
TYPES = ("auto", "doi", "isbn", "issn", "ismn")
DOI_URL = re.compile(r"^(?:https?://)?(?:dx\.)?doi\.org/", re.I)
DOI_SYNTAX = re.compile(r"^10\.(\d+(?:\.\d+)*)/(.+)$", re.S)
CROSSREF = re.compile(r"^10\.\d{4,9}/[-._;()/:A-Z0-9]+$", re.I)
LABEL = re.compile(r"^\s*(?:ISBN|ISSN|ISMN|EAN)(?:-1[03])?\s*:?\s*", re.I)
# characters percent-encoded when a DOI is written as a URL (DOI Handbook, encoding rules for URLs)
URL_ENCODE = set('%"#? <>{}^[]`|\\')


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


def percent_decode(text):
    """Decode %XX sequences as UTF-8 (a hand-rolled urllib.parse.unquote)."""
    raw, i = bytearray(), 0
    while i < len(text):
        if text[i] == "%" and re.fullmatch(r"[0-9A-Fa-f]{2}", text[i + 1:i + 3] or ""):
            raw.append(int(text[i + 1:i + 3], 16))
            i += 3
        else:
            raw.extend(text[i].encode("utf-8"))
            i += 1
    return raw.decode("utf-8", errors="replace")


def percent_encode(text):
    out = []
    for ch in text:
        if ch in URL_ENCODE or ord(ch) < 33 or ord(ch) > 126:
            out.extend(f"%{b:02X}" for b in ch.encode("utf-8"))
        else:
            out.append(ch)
    return "".join(out)


def ean_check(digits12):
    total = sum(int(d) * (1 if i % 2 == 0 else 3) for i, d in enumerate(digits12))
    return str((10 - total % 10) % 10)


def isbn10_check(digits9):
    total = sum(int(d) * (10 - i) for i, d in enumerate(digits9))
    c = (11 - total % 11) % 11
    return "X" if c == 10 else str(c)


def issn_check(digits7):
    total = sum(int(d) * (8 - i) for i, d in enumerate(digits7))
    c = (11 - total % 11) % 11
    return "X" if c == 10 else str(c)


def compact(value):
    return re.sub(r"[\s\-‐‑‒–]", "", LABEL.sub("", value)).upper()


def check_doi(value):
    text = value.strip()
    out = {"type": "doi", "input": value}
    form = "bare"
    if DOI_URL.match(text):
        text, form = percent_decode(DOI_URL.sub("", text)), "url"
    elif text.lower().startswith("doi:"):
        text, form = text[4:].strip(), "doi:"
    out["form"] = form
    m = DOI_SYNTAX.match(text)
    errors = []
    if not text.startswith("10."):
        errors.append("a DOI starts with the directory indicator 10.")
    elif not m:
        errors.append("a DOI is 10.<registrant>/<suffix>: the registrant code is digits (optionally dot-separated) and a non-empty suffix follows the first '/'")
    elif re.search(r"\s", text):
        errors.append("a DOI contains no whitespace")
    if errors:
        out.update(valid=False, errors=errors)
        return out
    out.update(valid=True, doi=text, doi_lower=text.lower(), prefix=f"10.{m.group(1)}", registrant=m.group(1),
               suffix=m.group(2), url="https://doi.org/" + percent_encode(text),
               crossref_pattern=bool(CROSSREF.match(text)))
    if not out["crossref_pattern"]:
        out["warnings"] = ["valid syntax, but outside Crossref's recommended pattern 10.\\d{4,9}/[-._;()/:A-Z0-9]+ (check for a stray character)"]
    return out


def check_isbn(value, digits):
    out = {"type": "isbn", "input": value, "compact": digits}
    if re.fullmatch(r"\d{9}[\dX]", digits):
        expected = isbn10_check(digits[:9])
        out.update(format="ISBN-10", check_digit=digits[9], expected_check_digit=expected, valid=digits[9] == expected)
        if out["valid"]:
            body = "978" + digits[:9]
            out.update(isbn10=digits, isbn13=body + ean_check(body))
    elif re.fullmatch(r"\d{13}", digits):
        expected = ean_check(digits[:12])
        out.update(format="ISBN-13", check_digit=digits[12], expected_check_digit=expected, valid=digits[12] == expected)
        if not digits.startswith(("978", "979")):
            out["valid"] = False
            out["errors"] = ["an ISBN-13 starts with the GS1 prefix 978 or 979"]
            return out
        if out["valid"]:
            out["isbn13"] = digits
            if digits.startswith("978"):
                out["isbn10"] = digits[3:12] + isbn10_check(digits[3:12])
            else:
                out["isbn10"] = None
                out["note"] = "979 ISBNs have no ISBN-10 form"
    else:
        out.update(valid=False, errors=["an ISBN has 10 characters (digits, last may be X) or 13 digits"])
        return out
    if not out["valid"] and "errors" not in out:
        out["errors"] = [f"check digit is {out['check_digit']}, expected {out['expected_check_digit']}"]
    return out


def check_issn(value, digits, issue):
    out = {"type": "issn", "input": value, "compact": digits}
    if not re.fullmatch(r"\d{7}[\dX]", digits):
        out.update(valid=False, errors=["an ISSN has 8 characters: 7 digits and a check digit (0-9 or X)"])
        return out
    expected = issn_check(digits[:7])
    out.update(check_digit=digits[7], expected_check_digit=expected, valid=digits[7] == expected)
    if out["valid"]:
        body = "977" + digits[:7] + issue
        out.update(issn=f"{digits[:4]}-{digits[4:]}", ean13=body + ean_check(body))
    else:
        out["errors"] = [f"check digit is {digits[7]}, expected {expected}"]
    return out


def check_ismn(value, digits):
    out = {"type": "ismn", "input": value, "compact": digits}
    if re.fullmatch(r"M\d{9}", digits):
        out["format"] = "ISMN-10 (legacy M-)"
        body = "9790" + digits[1:9]
        given = digits[9]
    elif re.fullmatch(r"9790\d{9}", digits):
        out["format"] = "ISMN-13"
        body, given = digits[:12], digits[12]
    else:
        out.update(valid=False, errors=["an ISMN is 979-0 followed by 9 digits, or the legacy M followed by 9 digits"])
        return out
    expected = ean_check(body)
    out.update(check_digit=given, expected_check_digit=expected, valid=given == expected)
    if out["valid"]:
        out.update(ismn13=body + expected, ismn10="M" + body[4:] + expected)
    else:
        out["errors"] = [f"check digit is {given}, expected {expected}"]
    return out


def detect(value):
    text = value.strip()
    if DOI_URL.match(text) or text.lower().startswith("doi:") or text.startswith("10.") or "/" in text:
        return "doi"
    label = LABEL.match(text)
    if label:
        word = label.group().strip().upper()[:4]
        if word in ("ISBN", "ISSN", "ISMN"):
            return word.lower()
    digits = compact(text)
    if digits.startswith("M") or digits.startswith("9790"):
        return "ismn"
    if len(digits) == 8:
        return "issn"
    return "isbn"


def validate(value, kind, issue):
    if kind == "auto":
        kind = detect(value)
    if kind == "doi":
        return check_doi(value)
    digits = compact(value)
    if kind == "isbn":
        return check_isbn(value, digits)
    if kind == "issn":
        return check_issn(value, digits, issue)
    return check_ismn(value, digits)


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")
    extra = set(request) - {"value", "identifiers", "type", "issue"}
    if extra:
        fail(f"unknown field(s): {', '.join(sorted(extra))}")
    kind = request.get("type", "auto")
    if kind not in TYPES:
        fail("type must be auto, doi, isbn, issn or ismn")
    issue = request.get("issue", "00")
    if not isinstance(issue, str) or not re.fullmatch(r"\d{2}", issue):
        fail("issue must be two digits, such as \"00\"")
    if ("value" in request) == ("identifiers" in request):
        fail("give value or identifiers")
    if "value" in request:
        value = request["value"]
        if not isinstance(value, str) or not value.strip() or len(value) > 2000:
            fail("value must be a non-empty string of at most 2000 characters")
        print(json.dumps(validate(value, kind, issue), ensure_ascii=False))
        return
    items = request["identifiers"]
    if not isinstance(items, list) or not 1 <= len(items) <= MAX_ITEMS:
        fail(f"identifiers must be a list of 1 to {MAX_ITEMS} strings")
    results = []
    for i, value in enumerate(items):
        if not isinstance(value, str) or not value.strip() or len(value) > 2000:
            fail(f"identifiers[{i}] must be a non-empty string of at most 2000 characters")
        results.append(validate(value, kind, issue))
    print(json.dumps({"count": len(results), "valid": sum(r["valid"] for r in results), "results": results},
                     ensure_ascii=False))


if __name__ == "__main__":
    try:
        main()
    except SystemExit:
        raise
    except Exception as error:  # never a traceback
        fail(f"{type(error).__name__}: {error}")

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_isbn_validate/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/doi_isbn_validate/1.0.0/doi_isbn_validate.py