Skip to content

tot_agent.covers

Book cover fetching with the Strategy design pattern.

Class diagram

classDiagram
    class CoverSource {
        <<abstract>>
        +search(query: str, limit: int) list~BookCover~
    }
    class OpenLibrarySource {
        -_timeout: int
        +search(query, limit) list~BookCover~
    }
    class GoogleBooksSource {
        -_timeout: int
        +search(query, limit) list~BookCover~
    }
    class CoverFetcher {
        -_sources: list~CoverSource~
        +fetch(query, count) list~BookCover~
        +fetch_random_pairs(pair_count) list
        -_deduplicate(covers) list~BookCover~
    }
    class BookCover {
        +title: str
        +author: str
        +cover_url: str
        +source: str
        +isbn: str
    }
    CoverSource <|-- OpenLibrarySource
    CoverSource <|-- GoogleBooksSource
    CoverFetcher --> CoverSource : uses
    CoverFetcher ..> BookCover : produces

Fetch flow

flowchart TD
    A[fetch query, count] --> B{OpenLibrary has enough?}
    B -- yes --> D[deduplicate]
    B -- no --> C[GoogleBooks fallback]
    C --> D
    D --> E[truncate to count]
    E --> F[return BookCover list]

Image download

download_cover_image(url) downloads a cover image to a local temp file and returns the path. The caller is responsible for deleting the file after use.

flowchart LR
    A["download_cover_image(url)"] --> B["httpx.get(url)"]
    B --> C{status ok?}
    C -- no --> D["raise HTTPStatusError"]
    C -- yes --> E["inspect Content-Type"]
    E --> F["tempfile.mkstemp(suffix=ext)"]
    F --> G["write bytes"]
    G --> H["return path"]

The extension is derived from the Content-Type response header:

Content-Type Extension
image/jpeg .jpg
image/png .png
image/gif .gif
image/webp .webp
image/bmp .bmp
(other) .jpg

Module reference

tot_agent.covers

covers.py — Book cover fetching via the Strategy design pattern.

Two concrete sources are provided out of the box:

  • :class:OpenLibrarySource — primary source, free, no API key required.
  • :class:GoogleBooksSource — fallback source, no API key required for low-volume requests.

The :class:CoverFetcher orchestrator accepts any list of :class:CoverSource implementations, making it straightforward to add new sources (e.g. Amazon, Goodreads) without touching existing code.

Example::

from tot_agent.covers import CoverFetcher

fetcher = CoverFetcher()
covers = fetcher.fetch("fantasy epic", count=4)
for cover in covers:
    print(cover)

BookCover dataclass

Metadata and image URL for a single book cover.

Parameters:

Name Type Description Default
title str

Book title.

required
author str

Primary author name.

required
cover_url str

Direct URL to the cover image (HTTPS).

required
source str

Originating data source identifier (e.g. "openlibrary").

required
isbn str | None

ISBN-10 or ISBN-13, if available.

None
Source code in src/tot_agent/covers.py
@dataclass
class BookCover:
    """Metadata and image URL for a single book cover.

    :param str title: Book title.
    :param str author: Primary author name.
    :param str cover_url: Direct URL to the cover image (HTTPS).
    :param str source: Originating data source identifier (e.g. ``"openlibrary"``).
    :param str or None isbn: ISBN-10 or ISBN-13, if available.
    """

    title: str
    author: str
    cover_url: str
    source: str
    isbn: str | None = None

    def __str__(self) -> str:
        return f'"{self.title}" by {self.author}{self.cover_url}'

    def __repr__(self) -> str:
        return (
            f"BookCover(title={self.title!r}, author={self.author!r}, "
            f"source={self.source!r})"
        )

CoverSource

Bases: ABC

Abstract base class for book cover data sources (Strategy pattern).

Subclasses implement :meth:search to query a specific upstream API and return a normalised list of :class:BookCover objects.

Source code in src/tot_agent/covers.py
class CoverSource(ABC):
    """Abstract base class for book cover data sources (Strategy pattern).

    Subclasses implement :meth:`search` to query a specific upstream API and
    return a normalised list of :class:`BookCover` objects.
    """

    @abstractmethod
    def search(self, query: str, limit: int) -> list[BookCover]:
        """Search for book covers matching *query*.

        :param str query: Free-text search query (title, author, or genre).
        :param int limit: Maximum number of results to return.
        :returns: List of matching book covers (may be empty).
        :rtype: list[BookCover]
        """

search(query, limit) abstractmethod

Search for book covers matching query.

Parameters:

Name Type Description Default
query str

Free-text search query (title, author, or genre).

required
limit int

Maximum number of results to return.

required

Returns:

Type Description
list[BookCover]

List of matching book covers (may be empty).

Source code in src/tot_agent/covers.py
@abstractmethod
def search(self, query: str, limit: int) -> list[BookCover]:
    """Search for book covers matching *query*.

    :param str query: Free-text search query (title, author, or genre).
    :param int limit: Maximum number of results to return.
    :returns: List of matching book covers (may be empty).
    :rtype: list[BookCover]
    """

OpenLibrarySource

Bases: CoverSource

Fetch book covers from the Open Library search API.

This is the primary source. No API key is required.

Parameters:

Name Type Description Default
timeout int

HTTP request timeout in seconds. Defaults to 10.

10
Source code in src/tot_agent/covers.py
class OpenLibrarySource(CoverSource):
    """Fetch book covers from the Open Library search API.

    This is the primary source.  No API key is required.

    :param int timeout: HTTP request timeout in seconds.  Defaults to ``10``.
    """

    def __init__(self, timeout: int = 10) -> None:
        self._timeout = timeout

    def search(self, query: str, limit: int) -> list[BookCover]:
        """Search Open Library and return books that have cover images.

        :param str query: Search query string.
        :param int limit: Maximum number of covers to return.
        :returns: List of :class:`BookCover` objects with Open Library image URLs.
        :rtype: list[BookCover]
        """
        covers: list[BookCover] = []
        try:
            resp = httpx.get(
                OPEN_LIBRARY_SEARCH_URL,
                params={
                    "q": query,
                    "limit": limit,
                    "fields": "title,author_name,cover_i,isbn",
                },
                timeout=self._timeout,
            )
            resp.raise_for_status()
            docs = resp.json().get("docs", [])

            for doc in docs:
                cover_id = doc.get("cover_i")
                if not cover_id:
                    continue
                title = doc.get("title", "Unknown Title")
                authors = doc.get("author_name", ["Unknown Author"])
                author = authors[0] if authors else "Unknown Author"
                isbn_list = doc.get("isbn", [])
                isbn: str | None = isbn_list[0] if isbn_list else None
                url = OPEN_LIBRARY_COVER_URL.format(cover_id=cover_id)
                covers.append(
                    BookCover(
                        title=title,
                        author=author,
                        cover_url=url,
                        source="openlibrary",
                        isbn=isbn,
                    )
                )
            logger.debug(
                "OpenLibrary returned %d covers for query %r", len(covers), query
            )
        except httpx.HTTPError as exc:
            logger.warning("Open Library search failed: %s", exc)
        return covers

search(query, limit)

Search Open Library and return books that have cover images.

Parameters:

Name Type Description Default
query str

Search query string.

required
limit int

Maximum number of covers to return.

required

Returns:

Type Description
list[BookCover]

List of :class:BookCover objects with Open Library image URLs.

Source code in src/tot_agent/covers.py
def search(self, query: str, limit: int) -> list[BookCover]:
    """Search Open Library and return books that have cover images.

    :param str query: Search query string.
    :param int limit: Maximum number of covers to return.
    :returns: List of :class:`BookCover` objects with Open Library image URLs.
    :rtype: list[BookCover]
    """
    covers: list[BookCover] = []
    try:
        resp = httpx.get(
            OPEN_LIBRARY_SEARCH_URL,
            params={
                "q": query,
                "limit": limit,
                "fields": "title,author_name,cover_i,isbn",
            },
            timeout=self._timeout,
        )
        resp.raise_for_status()
        docs = resp.json().get("docs", [])

        for doc in docs:
            cover_id = doc.get("cover_i")
            if not cover_id:
                continue
            title = doc.get("title", "Unknown Title")
            authors = doc.get("author_name", ["Unknown Author"])
            author = authors[0] if authors else "Unknown Author"
            isbn_list = doc.get("isbn", [])
            isbn: str | None = isbn_list[0] if isbn_list else None
            url = OPEN_LIBRARY_COVER_URL.format(cover_id=cover_id)
            covers.append(
                BookCover(
                    title=title,
                    author=author,
                    cover_url=url,
                    source="openlibrary",
                    isbn=isbn,
                )
            )
        logger.debug(
            "OpenLibrary returned %d covers for query %r", len(covers), query
        )
    except httpx.HTTPError as exc:
        logger.warning("Open Library search failed: %s", exc)
    return covers

GoogleBooksSource

Bases: CoverSource

Fetch book covers from the Google Books API (fallback source).

No API key is required for low-volume requests.

Parameters:

Name Type Description Default
timeout int

HTTP request timeout in seconds. Defaults to 10.

10
Source code in src/tot_agent/covers.py
class GoogleBooksSource(CoverSource):
    """Fetch book covers from the Google Books API (fallback source).

    No API key is required for low-volume requests.

    :param int timeout: HTTP request timeout in seconds.  Defaults to ``10``.
    """

    def __init__(self, timeout: int = 10) -> None:
        self._timeout = timeout

    def search(self, query: str, limit: int) -> list[BookCover]:
        """Search the Google Books API and return books that have cover images.

        :param str query: Search query string.
        :param int limit: Maximum number of covers to return.
        :returns: List of :class:`BookCover` objects with Google Books image URLs.
        :rtype: list[BookCover]
        """
        covers: list[BookCover] = []
        try:
            resp = httpx.get(
                GOOGLE_BOOKS_URL,
                params={
                    "q": query,
                    "maxResults": min(limit, 40),
                    "printType": "books",
                },
                timeout=self._timeout,
            )
            resp.raise_for_status()
            items = resp.json().get("items", [])

            for item in items:
                info = item.get("volumeInfo", {})
                image_links = info.get("imageLinks", {})
                cover_url: str | None = (
                    image_links.get("extraLarge")
                    or image_links.get("large")
                    or image_links.get("medium")
                    or image_links.get("thumbnail")
                )
                if not cover_url:
                    continue
                cover_url = cover_url.replace("http://", "https://")
                title = info.get("title", "Unknown Title")
                authors = info.get("authors", ["Unknown Author"])
                author = authors[0] if authors else "Unknown Author"
                covers.append(
                    BookCover(
                        title=title,
                        author=author,
                        cover_url=cover_url,
                        source="googlebooks",
                    )
                )
            logger.debug(
                "GoogleBooks returned %d covers for query %r", len(covers), query
            )
        except httpx.HTTPError as exc:
            logger.warning("Google Books search failed: %s", exc)
        return covers

search(query, limit)

Search the Google Books API and return books that have cover images.

Parameters:

Name Type Description Default
query str

Search query string.

required
limit int

Maximum number of covers to return.

required

Returns:

Type Description
list[BookCover]

List of :class:BookCover objects with Google Books image URLs.

Source code in src/tot_agent/covers.py
def search(self, query: str, limit: int) -> list[BookCover]:
    """Search the Google Books API and return books that have cover images.

    :param str query: Search query string.
    :param int limit: Maximum number of covers to return.
    :returns: List of :class:`BookCover` objects with Google Books image URLs.
    :rtype: list[BookCover]
    """
    covers: list[BookCover] = []
    try:
        resp = httpx.get(
            GOOGLE_BOOKS_URL,
            params={
                "q": query,
                "maxResults": min(limit, 40),
                "printType": "books",
            },
            timeout=self._timeout,
        )
        resp.raise_for_status()
        items = resp.json().get("items", [])

        for item in items:
            info = item.get("volumeInfo", {})
            image_links = info.get("imageLinks", {})
            cover_url: str | None = (
                image_links.get("extraLarge")
                or image_links.get("large")
                or image_links.get("medium")
                or image_links.get("thumbnail")
            )
            if not cover_url:
                continue
            cover_url = cover_url.replace("http://", "https://")
            title = info.get("title", "Unknown Title")
            authors = info.get("authors", ["Unknown Author"])
            author = authors[0] if authors else "Unknown Author"
            covers.append(
                BookCover(
                    title=title,
                    author=author,
                    cover_url=cover_url,
                    source="googlebooks",
                )
            )
        logger.debug(
            "GoogleBooks returned %d covers for query %r", len(covers), query
        )
    except httpx.HTTPError as exc:
        logger.warning("Google Books search failed: %s", exc)
    return covers

CoverFetcher

Orchestrates cover fetching across one or more :class:CoverSource strategies.

Sources are queried in order until count unique covers have been gathered. Results are deduplicated by normalised title.

Parameters:

Name Type Description Default
sources list[CoverSource] | None

Ordered list of sources to query. Defaults to [OpenLibrarySource(), GoogleBooksSource()].

Example::

fetcher = CoverFetcher()
covers = fetcher.fetch("mystery thriller", count=3)
None
Source code in src/tot_agent/covers.py
class CoverFetcher:
    """Orchestrates cover fetching across one or more :class:`CoverSource` strategies.

    Sources are queried in order until *count* unique covers have been gathered.
    Results are deduplicated by normalised title.

    :param list[CoverSource] or None sources: Ordered list of sources to query.  Defaults to
        ``[OpenLibrarySource(), GoogleBooksSource()]``.

    Example::

        fetcher = CoverFetcher()
        covers = fetcher.fetch("mystery thriller", count=3)
    """

    def __init__(self, sources: list[CoverSource] | None = None) -> None:
        self._sources: list[CoverSource] = sources or [
            OpenLibrarySource(),
            GoogleBooksSource(),
        ]

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def fetch(self, query: str, count: int = 5) -> list[BookCover]:
        """Fetch *count* book covers matching *query*.

        Tries each configured source in order, deduplicates by title, and
        returns up to *count* results.

        :param str query: Free-text search query.
        :param int count: Number of covers to return.
        :returns: List of unique :class:`BookCover` objects (length <= *count*).
        :rtype: list[BookCover]
        """
        logger.info("Fetching %d covers for query %r", count, query)
        pool: list[BookCover] = []

        for source in self._sources:
            if len(pool) >= count:
                break
            needed = (count - len(pool)) * 3
            pool.extend(source.search(query, limit=needed))

        unique = self._deduplicate(pool)
        selected = unique[:count]

        if not selected:
            logger.warning("No covers found for query %r", query)
        else:
            logger.info("Selected %d cover(s) for %r", len(selected), query)

        return selected

    def fetch_random_pairs(
        self, pair_count: int = 5
    ) -> list[tuple[BookCover, BookCover]]:
        """Return *pair_count* pairs of ``(cover_a, cover_b)`` from random genres.

        Useful for seeding A/B tests without repetition within a pair.

        :param int pair_count: Number of cover pairs to generate.
        :returns: List of 2-tuples, each containing two distinct
            :class:`BookCover` objects.
        :rtype: list[tuple[BookCover, BookCover]]
        """
        queries = random.sample(
            COVER_SEARCH_QUERIES, k=min(pair_count, len(COVER_SEARCH_QUERIES))
        )
        pairs: list[tuple[BookCover, BookCover]] = []
        for query in queries:
            covers = self.fetch(query, count=2)
            if len(covers) >= 2:
                pairs.append((covers[0], covers[1]))
            if len(pairs) >= pair_count:
                break
        logger.info("Generated %d cover pair(s)", len(pairs))
        return pairs

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _deduplicate(covers: list[BookCover]) -> list[BookCover]:
        """Remove covers with duplicate normalised titles.

        :param list[BookCover] covers: Raw list possibly containing duplicates.
        :returns: Deduplicated list preserving original order.
        :rtype: list[BookCover]
        """
        seen: set[str] = set()
        unique: list[BookCover] = []
        for cover in covers:
            key = cover.title.lower().strip()
            if key not in seen:
                seen.add(key)
                unique.append(cover)
        return unique

fetch(query, count=5)

Fetch count book covers matching query.

Tries each configured source in order, deduplicates by title, and returns up to count results.

Parameters:

Name Type Description Default
query str

Free-text search query.

required
count int

Number of covers to return.

5

Returns:

Type Description
list[BookCover]

List of unique :class:BookCover objects (length <= count).

Source code in src/tot_agent/covers.py
def fetch(self, query: str, count: int = 5) -> list[BookCover]:
    """Fetch *count* book covers matching *query*.

    Tries each configured source in order, deduplicates by title, and
    returns up to *count* results.

    :param str query: Free-text search query.
    :param int count: Number of covers to return.
    :returns: List of unique :class:`BookCover` objects (length <= *count*).
    :rtype: list[BookCover]
    """
    logger.info("Fetching %d covers for query %r", count, query)
    pool: list[BookCover] = []

    for source in self._sources:
        if len(pool) >= count:
            break
        needed = (count - len(pool)) * 3
        pool.extend(source.search(query, limit=needed))

    unique = self._deduplicate(pool)
    selected = unique[:count]

    if not selected:
        logger.warning("No covers found for query %r", query)
    else:
        logger.info("Selected %d cover(s) for %r", len(selected), query)

    return selected

fetch_random_pairs(pair_count=5)

Return pair_count pairs of (cover_a, cover_b) from random genres.

Useful for seeding A/B tests without repetition within a pair.

Parameters:

Name Type Description Default
pair_count int

Number of cover pairs to generate.

5

Returns:

Type Description
list[tuple[BookCover, BookCover]]

List of 2-tuples, each containing two distinct :class:BookCover objects.

Source code in src/tot_agent/covers.py
def fetch_random_pairs(
    self, pair_count: int = 5
) -> list[tuple[BookCover, BookCover]]:
    """Return *pair_count* pairs of ``(cover_a, cover_b)`` from random genres.

    Useful for seeding A/B tests without repetition within a pair.

    :param int pair_count: Number of cover pairs to generate.
    :returns: List of 2-tuples, each containing two distinct
        :class:`BookCover` objects.
    :rtype: list[tuple[BookCover, BookCover]]
    """
    queries = random.sample(
        COVER_SEARCH_QUERIES, k=min(pair_count, len(COVER_SEARCH_QUERIES))
    )
    pairs: list[tuple[BookCover, BookCover]] = []
    for query in queries:
        covers = self.fetch(query, count=2)
        if len(covers) >= 2:
            pairs.append((covers[0], covers[1]))
        if len(pairs) >= pair_count:
            break
    logger.info("Generated %d cover pair(s)", len(pairs))
    return pairs

download_cover_image(url, timeout=10)

Download a cover image from url to a temporary local file.

The caller is responsible for deleting the file when it is no longer needed (e.g. after a browser file-upload completes).

Parameters:

Name Type Description Default
url str

Direct URL of the image to download.

required
timeout int

HTTP timeout in seconds. Defaults to 10.

10

Returns:

Type Description
str

Absolute path to the downloaded temporary file.

Raises:

Type Description
httpx.HTTPError

If the download fails or returns a non-2xx status.

Source code in src/tot_agent/covers.py
def download_cover_image(url: str, timeout: int = 10) -> str:
    """Download a cover image from *url* to a temporary local file.

    The caller is responsible for deleting the file when it is no longer needed
    (e.g. after a browser file-upload completes).

    :param str url: Direct URL of the image to download.
    :param int timeout: HTTP timeout in seconds.  Defaults to ``10``.
    :returns: Absolute path to the downloaded temporary file.
    :rtype: str
    :raises httpx.HTTPError: If the download fails or returns a non-2xx status.
    """
    resp = httpx.get(url, timeout=timeout, follow_redirects=True)
    resp.raise_for_status()

    content_type = resp.headers.get("content-type", "image/jpeg").split(";")[0].strip()
    ext = _IMAGE_CONTENT_TYPE_EXT.get(content_type, ".jpg")

    fd, path = tempfile.mkstemp(suffix=ext, prefix="cover_")
    try:
        os.write(fd, resp.content)
    finally:
        os.close(fd)

    logger.debug("Downloaded %r to %r (%d bytes)", url, path, len(resp.content))
    return path

verify_cover_url(url, timeout=5)

Perform a HEAD request to verify that url resolves to a live image.

Parameters:

Name Type Description Default
url str

The image URL to check.

required
timeout int

HTTP timeout in seconds. Defaults to 5.

5

Returns:

Type Description
bool

True if the URL returns HTTP 200, False otherwise.

Source code in src/tot_agent/covers.py
def verify_cover_url(url: str, timeout: int = 5) -> bool:
    """Perform a HEAD request to verify that *url* resolves to a live image.

    :param str url: The image URL to check.
    :param int timeout: HTTP timeout in seconds.  Defaults to ``5``.
    :returns: ``True`` if the URL returns HTTP 200, ``False`` otherwise.
    :rtype: bool
    """
    try:
        resp = httpx.head(url, timeout=timeout, follow_redirects=True)
        return resp.status_code == 200
    except httpx.HTTPError as exc:
        logger.debug("Cover URL check failed for %s: %s", url, exc)
        return False

fetch_book_covers(query, count=5)

Module-level convenience wrapper around :class:CoverFetcher.

Parameters:

Name Type Description Default
query str

Search query.

required
count int

Number of covers to fetch.

5

Returns:

Type Description
list[BookCover]

List of :class:BookCover objects.

Source code in src/tot_agent/covers.py
def fetch_book_covers(query: str, count: int = 5) -> list[BookCover]:
    """Module-level convenience wrapper around :class:`CoverFetcher`.

    :param str query: Search query.
    :param int count: Number of covers to fetch.
    :returns: List of :class:`BookCover` objects.
    :rtype: list[BookCover]
    """
    return _default_fetcher.fetch(query, count=count)

fetch_random_cover_pairs(pair_count=5)

Module-level convenience wrapper for random cover pairs.

Parameters:

Name Type Description Default
pair_count int

Number of pairs to generate.

5

Returns:

Type Description
list[tuple[BookCover, BookCover]]

List of (cover_a, cover_b) tuples.

Source code in src/tot_agent/covers.py
def fetch_random_cover_pairs(pair_count: int = 5) -> list[tuple[BookCover, BookCover]]:
    """Module-level convenience wrapper for random cover pairs.

    :param int pair_count: Number of pairs to generate.
    :returns: List of ``(cover_a, cover_b)`` tuples.
    :rtype: list[tuple[BookCover, BookCover]]
    """
    return _default_fetcher.fetch_random_pairs(pair_count=pair_count)