Adds a comic format registry

There is the limitation that you still need to manually import all modules that register a new comic format, but this does give us a lot of flexibility
This commit is contained in:
2025-07-30 14:40:00 -06:00
parent a0d7638598
commit a35ca0dcab
4 changed files with 236 additions and 24 deletions

View File

@@ -6,6 +6,7 @@ import argparse
from abc import ABC, abstractmethod
from datetime import datetime
from threading import Lock
import bisect
import time
import re
import textwrap
@@ -252,6 +253,9 @@ class Comic(ImageRepo, ABC):
"""
A generic representation of a comic collection. Comic-wide resources are stored here.
"""
strip_cls = None
"""A reference to the class used for this comic's strips."""
def __init__(self, identifier):
"""
@@ -265,6 +269,14 @@ class Comic(ImageRepo, ABC):
self.author = None
"""The author(s) of the comic. Can be a string for a name, a list of names, or a list of objects containing the names under "name" and role under "role"."""
#@abstractmethod
@classmethod
def create_from_url(cls, url):
"""
Creates a Comic instance from a given URL
"""
pass
@abstractmethod
def _load_data(self):
"""
@@ -277,7 +289,35 @@ class Comic(ImageRepo, ABC):
relevant images, loaded into `image_urls`.
"""
pass
@abstractmethod
def get_first_strip(self):
"""
Obtains the identifier of the first strip.
"""
pass
@abstractmethod
def get_latest_strip(self):
"""
Obtains the identifier of the latest strip.
"""
pass
@abstractmethod
def get_all_strips(self) -> list:
"""
Obtains a list of all strips.
"""
pass
def create_strip(self, identifier):
"""
Creates a new ComicStrip object.
The class variable #strip_cls must be set for this to work.
"""
return self.strip_cls(self, identifier)
class ComicStrip(ImageRepo, ABC):
@@ -427,3 +467,149 @@ class ComicStrip(ImageRepo, ABC):
image_path = Path(image.name)
if image_path.exists():
image_path.unlink()
class URLFormat:
"""An internal class used to help sort URLs based on subdomains and paths"""
def __init__(self, url: str):
self.raw_url = url
if not re.match(r'^\w+://', url):
url = 'http://' + url
self.url = urlparse(url)
self.domains = self.url.netloc.split('.')
self.paths = self.url.path.split('/')
def __lt__(self, other):
if type(other) == str:
other = URLFormat(other)
if len(self.domains) != len(other.domains):
return len(self.domains) < len(other.domains)
if self.domains != other.domains:
return self.domains < other.domains
if len(self.paths) != len(other.paths):
return len(self.paths) < len(other.paths)
if self.paths != other.paths:
return self.paths < other.paths
return False
def __le__(self, other):
if type(other) == str:
other = URLFormat(other)
if self == other:
return True
return self < other
def __gt__(self, other):
if type(other) == str:
other = URLFormat(other)
if len(self.domains) != len(other.domains):
return len(self.domains) > len(other.domains)
if self.domains != other.domains:
return self.domains > other.domains
if len(self.paths) != len(other.paths):
return len(self.paths) > len(other.paths)
if self.paths != other.paths:
return self.paths > other.paths
return False
def __ge__(self, other):
if type(other) == str:
other = URLFormat(other)
if self == other:
return True
return self > other
def __eq__(self, other):
if type(other) == str:
other = URLFormat(other)
return self.domains == other.domains and self.paths == other.paths
def __ne__(self, other):
if type(other) == str:
other = URLFormat(other)
return self.domains != other.domains or self.paths != other.paths
def __str__(self):
return f"{self.url.netloc}{self.url.path}"
def __repr__(self):
return f"\"{self.__str__()}\""
def __hash__(self):
return hash(tuple(self.domains + self.paths))
comic_registry = {}
"""A registry that maps URL Domains to Comic classes."""
_registry_subdomains = []
"""An internal list keeping track of the order we should use to find the right parser (priority is last)"""
def register_comic_class(url: str, comic_cls: type[Comic]):
"""
Registers a comic type.
The URL format provided here is used to help determine
which class should be used for a given strip. There are
a few rules for the URL parameter provided here:
1. The url should not include the schema (i.e. "http://").
For example, "xkcd.com" is okay, but "https://xkcd.com" is
not.
2. The URL provided here should usually just express the
domain name. Paths should only be expressed if absolutely
necessary, such as in cases where different comic formats
are published to the same site (i.e. Tumblr). For example,
"bsky.app" works, but "bsky.app/profile" is discourages
(although "bsky.app/profile/strange_user.bsky.social"
is acceptable if strange_user does something unusual
with the way he publishes his comics and needs a custom
parser).
3. If the subdomain is "www", it should be ommited.
Use "tumblr.com" instead of "www.tumblr.com" (although
"john_doe.tumblr.com" is valid).
:param url: The URL format to register.
:param comic_cls: The class to use for parsing this comic.
"""
url_f = URLFormat(url)
if url_f in comic_registry:
raise ValueError("URL %s is already registered" % url)
comic_registry[url_f] = comic_cls
bisect.insort(_registry_subdomains, url_f)
def find_comic_class(url: str) -> type[Comic]:
"""
Finds the appropriate comic for a given URL.
:param url: The URL of the comic to parse. This must be a proper URL (including schema, etc.)
:returns: A parser
"""
url_f = URLFormat(url)
logging.info(_registry_subdomains)
while len(url_f.domains) > 0:
i = bisect.bisect(_registry_subdomains, url_f)
if i == 0:
raise ValueError("Could not find a parser for url \"%s\"" % url)
if url_f.domains == _registry_subdomains[i - 1].domains:
# We have found a series of parsers with a matching subdomain.
# Now we check the paths.
while len(url_f.paths) > 0:
i = bisect.bisect(_registry_subdomains, url_f)
if i == 0:
raise ValueError("Could not find a parser for url \"%s\"" % url)
if url_f.paths == _registry_subdomains[i - 1].paths:
# We have found a registered URL pattern that matches the URL
# provided.
return comic_registry[url_f]
else:
# Drill down to find an exact matching path
url_f = URLFormat('.'.join(url_f.domains) + '/'.join(url_f.paths[:-1]))
else:
# The result we found has a different subdomain. Look for the root subdomain
url_f = URLFormat('.'.join(url_f.domains[1:]) + '/'.join(url_f.paths))
raise ValueError("Could not find a parse for url \"%s\"" % url)