fix: keep the identifiers an EPUB declares
DC:identifier was validated verbatim, so hyphenated and urn:isbn: forms never reached the checksum and every non-ISBN identifier was discarded. Normalise first, and name whatever survives.
This commit is contained in:
@@ -31,6 +31,76 @@ from chitai.services.utils import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Identifier schemes an EPUB can declare, mapped onto the names the rest of the app
|
||||||
|
# uses. A scheme arrives either as an `opf:scheme` attribute or as a prefix on the
|
||||||
|
# value itself (`urn:isbn:…`, `calibre:…`), and the two say the same thing.
|
||||||
|
_IDENTIFIER_SCHEMES = {
|
||||||
|
"isbn": "isbn",
|
||||||
|
"isbn10": "isbn-10",
|
||||||
|
"isbn-10": "isbn-10",
|
||||||
|
"isbn13": "isbn-13",
|
||||||
|
"isbn-13": "isbn-13",
|
||||||
|
"uuid": "uuid",
|
||||||
|
"calibre": "calibre",
|
||||||
|
"doi": "doi",
|
||||||
|
"asin": "asin",
|
||||||
|
"amazon": "asin",
|
||||||
|
"mobi-asin": "asin",
|
||||||
|
"google": "google",
|
||||||
|
"goodreads": "goodreads",
|
||||||
|
}
|
||||||
|
|
||||||
|
# `scheme:rest`, with an optional `urn:` in front of it.
|
||||||
|
_SCHEME_PREFIX = re.compile(r"^(?:urn:)?([A-Za-z][A-Za-z0-9.-]*):(.+)$")
|
||||||
|
|
||||||
|
_UUID = re.compile(r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_identifier(value: str, scheme: str | None = None) -> tuple[str, str] | None:
|
||||||
|
"""
|
||||||
|
Work out what one raw identifier is, and what it is worth storing as.
|
||||||
|
|
||||||
|
EPUBs write the same ISBN as `9780486282114`, `978-0-486-28211-4` and
|
||||||
|
`urn:isbn:978-0-486-28211-4`, and carry plenty of identifiers that are not ISBNs
|
||||||
|
at all. Validating the string verbatim keeps only the first form and throws the
|
||||||
|
rest away, so normalise first and name whatever survives.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The identifier as the file wrote it.
|
||||||
|
scheme: What the file said it is, if it said anything — an `opf:scheme`
|
||||||
|
attribute. A prefix on the value takes precedence over this.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The `(name, value)` to store, or `None` when there is nothing usable: an
|
||||||
|
empty value, or one declared to be an ISBN that fails its own checksum.
|
||||||
|
"""
|
||||||
|
value = (value or "").strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = _IDENTIFIER_SCHEMES.get((scheme or "").strip().casefold())
|
||||||
|
|
||||||
|
# An unrecognised prefix is part of the value rather than a scheme —
|
||||||
|
# "http://example.com/book" is not an identifier called "http".
|
||||||
|
if (match := _SCHEME_PREFIX.match(value)) and (
|
||||||
|
prefixed := _IDENTIFIER_SCHEMES.get(match.group(1).casefold())
|
||||||
|
):
|
||||||
|
name = prefixed
|
||||||
|
value = match.group(2).strip()
|
||||||
|
|
||||||
|
if name is None or name.startswith("isbn"):
|
||||||
|
digits = re.sub(r"[^0-9Xx]", "", value).upper()
|
||||||
|
if is_valid_isbn(digits):
|
||||||
|
return f"isbn-{len(digits)}", digits
|
||||||
|
|
||||||
|
# Something that announced itself as an ISBN and is not one carries no
|
||||||
|
# information: storing it would link the reader to a page that does not exist.
|
||||||
|
if name is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return name or ("uuid" if _UUID.match(value) else "id"), value
|
||||||
|
|
||||||
|
|
||||||
class FileExtractor(Protocol):
|
class FileExtractor(Protocol):
|
||||||
@classmethod
|
@classmethod
|
||||||
async def extract_metadata(
|
async def extract_metadata(
|
||||||
@@ -363,15 +433,23 @@ class EpubExtractor(FileExtractor):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _extract_identifiers(cls, epub: epub.EpubBook) -> dict[str, str]:
|
def _extract_identifiers(cls, epub: epub.EpubBook) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Every `DC:identifier` the file carries, keyed by what kind of thing it is.
|
||||||
|
|
||||||
|
Non-ISBN identifiers are kept: `Identifier` is a free-form name/value pair, so
|
||||||
|
a Calibre id or an ASIN costs nothing to store and is one more thing two copies
|
||||||
|
of a book can be recognised by.
|
||||||
|
"""
|
||||||
identifiers = {}
|
identifiers = {}
|
||||||
|
|
||||||
for id in epub.get_metadata("DC", "identifier"):
|
for value, attributes in epub.get_metadata("DC", "identifier"):
|
||||||
if is_valid_isbn(id[0]):
|
scheme = None
|
||||||
if len(id[0]) == 13:
|
if isinstance(attributes, dict):
|
||||||
identifiers.update({"isbn-13": id[0]})
|
scheme = attributes.get("opf:scheme") or attributes.get("scheme")
|
||||||
|
|
||||||
elif len(id[0]) == 10:
|
if (parsed := parse_identifier(value, scheme)) is not None:
|
||||||
identifiers.update({"isbn-10": id[0]})
|
name, parsed_value = parsed
|
||||||
|
identifiers[name] = parsed_value
|
||||||
|
|
||||||
return identifiers
|
return identifiers
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from chitai.services.matching import (
|
|||||||
normalize_identifier,
|
normalize_identifier,
|
||||||
normalize_title,
|
normalize_title,
|
||||||
)
|
)
|
||||||
|
from chitai.services.metadata_extractor import parse_identifier
|
||||||
from chitai.services.utils import isbn10_to_isbn13
|
from chitai.services.utils import isbn10_to_isbn13
|
||||||
|
|
||||||
|
|
||||||
@@ -123,3 +124,45 @@ class TestIsbnConversion:
|
|||||||
@pytest.mark.parametrize("isbn", ["0486282113", "9780486282114", "nonsense"])
|
@pytest.mark.parametrize("isbn", ["0486282113", "9780486282114", "nonsense"])
|
||||||
def test_anything_that_is_not_an_isbn_10_converts_to_nothing(self, isbn: str) -> None:
|
def test_anything_that_is_not_an_isbn_10_converts_to_nothing(self, isbn: str) -> None:
|
||||||
assert isbn10_to_isbn13(isbn) is None
|
assert isbn10_to_isbn13(isbn) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseIdentifier:
|
||||||
|
"""What an EPUB writes, and what is worth storing for it."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"written",
|
||||||
|
[
|
||||||
|
"9780486282114",
|
||||||
|
"978-0-486-28211-4",
|
||||||
|
"urn:isbn:9780486282114",
|
||||||
|
"urn:isbn:978-0-486-28211-4",
|
||||||
|
"ISBN:978-0-486-28211-4",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_isbns_survive_however_they_are_written(self, written: str) -> None:
|
||||||
|
assert parse_identifier(written) == ("isbn-13", "9780486282114")
|
||||||
|
|
||||||
|
def test_the_scheme_attribute_is_read_too(self) -> None:
|
||||||
|
assert parse_identifier("0-486-28211-2", "ISBN") == ("isbn-10", "0486282112")
|
||||||
|
|
||||||
|
def test_non_isbn_identifiers_are_kept(self) -> None:
|
||||||
|
assert parse_identifier("urn:uuid:3f2b1c4e-1111-2222-3333-444455556666") == (
|
||||||
|
"uuid",
|
||||||
|
"3f2b1c4e-1111-2222-3333-444455556666",
|
||||||
|
)
|
||||||
|
assert parse_identifier("calibre:1234") == ("calibre", "1234")
|
||||||
|
assert parse_identifier("B000FC0PDA", "mobi-asin") == ("asin", "B000FC0PDA")
|
||||||
|
|
||||||
|
def test_an_unrecognised_prefix_is_part_of_the_value(self) -> None:
|
||||||
|
""""http://example.com/book" is not an identifier called "http"."""
|
||||||
|
assert parse_identifier("http://www.gutenberg.org/5200") == (
|
||||||
|
"id",
|
||||||
|
"http://www.gutenberg.org/5200",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_declared_isbn_that_is_not_one_is_dropped(self) -> None:
|
||||||
|
assert parse_identifier("urn:isbn:not-an-isbn") is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("written", ["", " ", None])
|
||||||
|
def test_nothing_yields_nothing(self, written: str | None) -> None:
|
||||||
|
assert parse_identifier(written) is None
|
||||||
|
|||||||
Reference in New Issue
Block a user