""" ComicVine API Information & Documentation: https://comicvine.gamespot.com/api/ https://comicvine.gamespot.com/api/documentation """ import logging from django.conf import settings import requests logger = logging.getLogger(__name__) class ComicVineClient(object): """ Interacts with the ``search`` resource of the ComicVine API. Requires an account on https://comicvine.gamespot.com/ in order to obtain an API key. """ # All API requests made by this client will be made to these URLs. API_URL = "https://comicvine.gamespot.com/api/search/" ISSUE_API_URL = "https://comicvine.gamespot.com/api/issue/4000-{issue_id}/" VOLUME_API_URL = "https://comicvine.gamespot.com/api/volume/4050-{volume_id}/" # A valid User-Agent header must be set in order for our API requests to # be accepted, otherwise our request will be rejected with a # **403 - Forbidden** error. HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:7.0) " "Gecko/20130825 Firefox/36.0" } # A set of valid resource types to return in results. RESOURCE_TYPES = { "character", "issue", "location", "object", "person", "publisher", "story_arc", "team", "volume", } def __init__(self, api_key): """ Store the API key in a class variable. :param api_key: Your personal ComicVine API key. :type api_key: str """ self.api_key = api_key def search(self, query, offset=0, limit=10, resources=None): """ Perform a search against the API, using the provided query term. If required, a list of resource types to filter search results to can be included. Take the JSON contained in the response and provide it to the custom ``Response`` object's constructor. Return the ``Response`` object. :param query: The search query with which to make the request. :type query: str :param offset: The index of the first record returned. :type offset: int or None :param limit: How many records to return **(max 10)** :type limit: int or None :param resources: A list of resources to include in the search results. :type resources: list or None :type use_cache: bool :return: The response object containing the results of the search query. :rtype: comicvine_search.response.Response """ params = self._request_params(query, offset, limit, resources) json_data = self._query_api(params) return json_data def _request_params(self, query, offset, limit, resources): """ Construct a dict containing the required key-value pairs of parameters required in order to make the API request. The documentation for the ``search`` resource can be found at https://comicvine.gamespot.com/api/documentation#toc-0-30. Regarding 'limit', as per the documentation: The number of results to display per page. This value defaults to 10 and can not exceed this number. :param query: The search query with which to make the request. :type query: str :param offset: The index of the first record returned. :type offset: int :param limit: How many records to return **(max 10)** :type limit: int :param resources: A list of resources to include in the search results. :type resources: list or None :return: A dictionary of request parameters. :rtype: dict """ params = { "api_key": self.api_key, "format": "json", "limit": min(10, limit), # hard limit of 10 "offset": max(0, offset), # cannot provide negative offset "query": query, } validated = self._validate_resources(resources) if validated: params["resources"] = validated return params def _validate_resources(self, resources): """ Provided a list of resources, first convert it to a set and perform an intersection with the set of valid resource types, ``RESOURCE_TYPES``. Return a comma-separted string of the remaining valid resources, or None if the set is empty. :param resources: A list of resources to include in the search results. :type resources: list or None :return: A comma-separated string of valid resources. :rtype: str or None """ if not resources: return None valid_resources = self.RESOURCE_TYPES & set(resources) return ",".join(valid_resources) if valid_resources else None def _query_api(self, params): """ Query the ComicVine API's ``search`` resource, providing the required headers and parameters with the request. If an error occurs during the request, handle it accordingly. Upon success, return the JSON from the response. :param params: Parameters to include with the request. :type params: dict :return: The JSON contained in the response. :rtype: dict """ response = requests.get(self.API_URL, headers=self.HEADERS, params=params) if not response.ok: self._handle_http_error(response) json_data = response.json() if json_data.get("status_code") != 1: error_msg = json_data.get("error", "Unknown ComicVine API error") logger.error( "ComicVine API returned status_code %s: %s", json_data.get("status_code"), error_msg, ) return {} return json_data def _handle_http_error(self, response): """ Provided a ``requests.Response`` object, if the status code is anything other than **200**, we will treat it as an error. Using the response's status code, determine which type of exception to raise. Construct an exception message from the response's status code and reason properties before raising the exception. :param response: The requests.Response object returned by the HTTP request. :type response: requests.Response :raises ComicVineUnauthorizedException: if no API key provided. :raises ComicVineForbiddenException: if no User-Agent header provided. :raises ComicVineApiException: if an unidentified error occurs. """ exception = { 401: Exception, 403: Exception, }.get(response.status_code, Exception) message = f"{response.status_code} {response.reason}" raise exception(message) def get_issue(self, issue_id: str) -> dict: """ Fetch a single issue by its ComicVine ID directly from the issue detail endpoint, which returns richer data than the search endpoint. :param issue_id: The ComicVine numeric ID for the issue (e.g. "538480") :type issue_id: str :return: The full JSON response for the issue, or empty dict on failure. :rtype: dict """ params = { "api_key": self.api_key, "format": "json", } url = self.ISSUE_API_URL.format(issue_id=issue_id) response = requests.get(url, headers=self.HEADERS, params=params) if not response.ok: self._handle_http_error(response) json_data = response.json() if json_data.get("status_code") != 1: error_msg = json_data.get("error", "Unknown ComicVine API error") logger.error( "ComicVine API returned status_code %s: %s", json_data.get("status_code"), error_msg, ) return {} return json_data.get("results", {}) def get_volume(self, volume_id: str) -> dict: """ Fetch a single volume by its ComicVine ID from the volume detail endpoint. Used to get publisher info and other volume-level metadata. :param volume_id: The ComicVine numeric ID for the volume (e.g. "91273") :type volume_id: str :return: The full JSON response for the volume, or empty dict on failure. :rtype: dict """ params = { "api_key": self.api_key, "format": "json", } url = self.VOLUME_API_URL.format(volume_id=volume_id) response = requests.get(url, headers=self.HEADERS, params=params) if not response.ok: self._handle_http_error(response) json_data = response.json() if json_data.get("status_code") != 1: error_msg = json_data.get("error", "Unknown ComicVine API error") logger.error( "ComicVine API returned status_code %s: %s", json_data.get("status_code"), error_msg, ) return {} return json_data.get("results", {}) def lookup_comic_from_comicvine(title: str) -> dict: original_title = title issue_number = None resource_type = "issue" if "Issue " in title: issue_number = title.split("Issue ")[1] volume_number = None if "Volume " in title: resource_type = "volume" volume_number = title.split("Volume ")[1] api_key = getattr(settings, "COMICVINE_API_KEY", "") if not api_key: logger.warning("No ComicVine API key configured, not looking anything up") return {} client = ComicVineClient(api_key=api_key) raw_results = client.search(title) if not raw_results: return {} results = raw_results.get("results", []) results = [r for r in results if r.get("resource_type") == resource_type] if not results: logger.warning("No comic found on ComicVine") return {} found_result = None for result in results: if issue_number is not None and result.get("issue_number") == str(issue_number): found_result = result break if volume_number is not None and result.get("volume_number") == str(volume_number): found_result = result break if not found_result: found_result = results[0] data_dict = _build_data_dict_from_issue(found_result, original_title) _enrich_with_volume_data(client, data_dict) return data_dict def lookup_issue_by_comicvine_id(comicvine_id: str) -> dict: """ Look up an issue directly by its ComicVine ID using the issue detail endpoint. Returns richer data than the search-based lookup. :param comicvine_id: The ComicVine numeric ID for the issue (e.g. "538480") :type comicvine_id: str :return: A dict of extracted book metadata, or empty dict on failure. :rtype: dict """ if not comicvine_id: return {} api_key = getattr(settings, "COMICVINE_API_KEY", "") if not api_key: logger.warning("No ComicVine API key configured, not looking anything up") return {} client = ComicVineClient(api_key=api_key) issue_data = client.get_issue(comicvine_id) if not issue_data: logger.warning("No issue found on ComicVine for ID %s", comicvine_id) return {} data_dict = _build_data_dict_from_issue(issue_data, issue_data.get("name", "")) _enrich_with_volume_data(client, data_dict) return data_dict def _build_data_dict_from_issue(issue_data: dict, original_title: str = "") -> dict: """ Build a book metadata dict from a ComicVine issue resource (either from search results or issue detail endpoint). Both return the same shape of issue data. :param issue_data: The issue resource dict from ComicVine. :param original_title: The original search term, if any. :return: A dict of extracted book metadata. :rtype: dict """ title = issue_data.get("name") if issue_data.get("volume"): title = issue_data.get("volume").get("name") cover_url = None if issue_data.get("image"): cover_url = issue_data["image"].get("original_url") volume_name = None volume_cv_id = None publisher_name = None volume_data = issue_data.get("volume") if volume_data: volume_name = volume_data.get("name") volume_cv_id = volume_data.get("id") publisher_data = volume_data.get("publisher") if publisher_data: publisher_name = publisher_data.get("name") data_dict = { "title": title, "original_title": original_title, "issue_number": issue_data.get("issue_number"), "volume_number": issue_data.get("volume_number"), "volume": volume_name, "volume_comicvine_id": volume_cv_id, "publisher": publisher_name, "cover_url": cover_url, "comicvine_id": issue_data.get("id"), "summary": issue_data.get("description"), "publish_date": issue_data.get("cover_date"), "first_publish_year": (issue_data.get("cover_date") or "")[:4], "tags": ["comicbook"], } return data_dict def _enrich_with_volume_data(client: ComicVineClient, data_dict: dict) -> None: """ Follow-up a successful issue lookup by fetching the volume detail and filling in publisher and other volume-level metadata that the issue endpoint doesn't provide. :param client: An initialised ComicVineClient instance. :param data_dict: The data dict from an issue lookup (mutated in place). """ volume_cv_id = data_dict.get("volume_comicvine_id") if not volume_cv_id: return volume_data = client.get_volume(str(volume_cv_id)) if not volume_data: return publisher_data = volume_data.get("publisher") if publisher_data: publisher_name = publisher_data.get("name") if publisher_name and not data_dict.get("publisher"): data_dict["publisher"] = publisher_name if not data_dict.get("volume"): data_dict["volume"] = volume_data.get("name")