421 lines
14 KiB
Python
421 lines
14 KiB
Python
"""
|
|
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")
|