File size: 2,655 Bytes
7c6808b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
"""
doi_utils.py — deterministic DOI extraction and ISSN validation.

No LLM. No guessing. If a DOI cannot be extracted, we say so and ask the user.
"""

from __future__ import annotations

import re
from urllib.parse import unquote, urlparse

# DOIs are 10.<registrant>/<suffix>. The suffix may contain almost anything,
# so we trim trailing punctuation and known URL cruft afterwards.
DOI_RE = re.compile(r"(10\.\d{4,9}/[^\s\"'<>&?#]+)", re.I)

_TRAILING = ".,;:)]}>'\"-"
_SUFFIX_JUNK = (
    "/full", "/abstract", "/pdf", "/epub", "/meta", "/html",
    ".full", ".pdf", ".abstract", ".long",
)

ISSN_RE = re.compile(r"^(\d{4})-?(\d{3}[\dXx])$")


def extract_doi(text: str) -> str:
    """
    Pull a DOI out of a URL, a citation, or raw text.

    Handles publisher URL shapes:
        https://www.tandfonline.com/doi/full/10.1080/19322909.2023.2221477
        https://link.springer.com/article/10.1007/s11192-023-04812-4
        https://doi.org/10.1016/j.jclepro.2023.136775
        https://onlinelibrary.wiley.com/doi/10.1002/adma.202301234
        10.1109/TCOMM.2023.1234567
    Returns "" when no DOI is present.
    """
    if not text:
        return ""
    s = unquote(str(text).strip())

    m = DOI_RE.search(s)
    if not m:
        return ""
    doi = m.group(1)

    # Strip publisher URL suffixes appended after the DOI.
    low = doi.lower()
    for junk in _SUFFIX_JUNK:
        if low.endswith(junk):
            doi = doi[: -len(junk)]
            low = doi.lower()

    doi = doi.rstrip(_TRAILING)
    return doi if "/" in doi and len(doi) > 7 else ""


def looks_like_url(text: str) -> bool:
    try:
        p = urlparse((text or "").strip())
        return p.scheme in ("http", "https") and bool(p.netloc)
    except ValueError:
        return False


def normalise_issn(raw: str) -> str:
    """Return a hyphenated, checksum-valid ISSN, or '' if invalid."""
    m = ISSN_RE.match((raw or "").strip())
    if not m:
        return ""
    digits = (m.group(1) + m.group(2)).upper()
    if not issn_checksum_ok(digits):
        return ""
    return f"{digits[:4]}-{digits[4:]}"


def issn_checksum_ok(eight: str) -> bool:
    """ISSN mod-11 check digit. Catches typos before we hit the network."""
    e = eight.replace("-", "").upper()
    if len(e) != 8:
        return False
    try:
        total = sum(int(e[i]) * (8 - i) for i in range(7))
    except ValueError:
        return False
    remainder = total % 11
    check = 0 if remainder == 0 else 11 - remainder
    expected = "X" if check == 10 else str(check)
    return e[7] == expected


def doi_url(doi: str) -> str:
    return f"https://doi.org/{doi}" if doi else ""