219 lines
9.5 KiB
Python
219 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# This CLI tool allows for the download and packaging of various internet comics into .cbz files.
|
|
# Copyright (C) 2025 Markil 3
|
|
# http://www.singlepilot.net
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
import logging
|
|
import datetime
|
|
import textwrap
|
|
import json
|
|
import re
|
|
from urllib.parse import urlparse, parse_qs
|
|
|
|
from importlib import resources
|
|
from pathlib import Path
|
|
from tempfile import NamedTemporaryFile
|
|
|
|
import requests
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from bs4 import BeautifulSoup
|
|
|
|
from comic_download.comic_strip import ComicCollection as Comic, ComicStrip, register_comic_class
|
|
|
|
class DinosaurComic(Comic):
|
|
"""
|
|
The Dinosaur Comics strip
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__("dinosaur_comics")
|
|
|
|
@classmethod
|
|
def create_from_url(cls, url):
|
|
return cls()
|
|
|
|
def _load_data(self):
|
|
self.title = "Dinosaur Comics!"
|
|
self.author = "Ryan North"
|
|
self.image_urls = {
|
|
"header": "https://www.qwantz.com/logo.png",
|
|
"background": "https://www.qwantz.com/sky.png"
|
|
}
|
|
self.description = "this comic... might be the best comic?"
|
|
|
|
result = requests.get("https://www.qwantz.com/archive.php")
|
|
result.raise_for_status()
|
|
|
|
content = BeautifulSoup(result.content, features="lxml")
|
|
archives = content.find_all("ul", class_="archive")
|
|
self.strips = []
|
|
self.strip_index = {}
|
|
date_pat = re.compile(r'(\d+)(st|nd|rd|th)')
|
|
for archive_month in archives:
|
|
link_el = archive_month.find("a")
|
|
if link_el:
|
|
parsed_index = urlparse(link_el["href"])
|
|
identifier = int(parse_qs(parsed_index.query)["comic"][0])
|
|
date_str = link_el.get_text()
|
|
date = datetime.datetime.strptime(date_pat.sub(r'\1', date_str), '%B %d, %Y').date()
|
|
self.strips.append({
|
|
"identifier": identifier,
|
|
"date": date,
|
|
"description": link_el.next_sibling.get_text()[2:]
|
|
})
|
|
self.strips.sort(key=lambda x: x["date"])
|
|
for i in range(len(self.strips)):
|
|
self.strip_index[self.strips[i]["identifier"]] = i
|
|
|
|
def get_first_strip(self):
|
|
return self.strips[0]["identifier"]
|
|
|
|
def get_latest_strip(self):
|
|
return self.strips[-1]["identifier"]
|
|
|
|
def get_all_strips(self) -> list:
|
|
return [strip["identifier"] for strip in self.strips]
|
|
|
|
def __new__(cls):
|
|
if not hasattr(cls, 'instance'):
|
|
cls.instance = super(DinosaurComic, cls).__new__(cls)
|
|
return cls.instance
|
|
|
|
|
|
class DinosaurComicStrip(ComicStrip):
|
|
"""
|
|
A comic strip object that represents a single Existential Comic strip.
|
|
"""
|
|
|
|
def __init__(self, comic, index: int):
|
|
super().__init__(comic, index)
|
|
self.url = f"https://qwantz.com/index.php?comic={index}"
|
|
self.title = None
|
|
strip_data = comic.strips[comic.strip_index[index]]
|
|
self.date = strip_data["date"]
|
|
self.description = strip_data["description"]
|
|
|
|
@property
|
|
def index(self):
|
|
# Alias
|
|
return self.identifier
|
|
|
|
def _load_data(self):
|
|
logging.info("Loading data for comic %s from %s", self.index, self.url)
|
|
result = requests.get(self.url)
|
|
result.raise_for_status()
|
|
|
|
content = BeautifulSoup(result.content, features="lxml")
|
|
title = content.find("meta", property="og:title")
|
|
if title and title["content"]:
|
|
self.title = title["content"]
|
|
for img in content.find_all("img", class_="comic"):
|
|
self.image_urls.append(f'https://qwantz.com/{img["src"]}')
|
|
if img.get("title"):
|
|
self.captions.append(img["title"])
|
|
else:
|
|
self.captions.append("")
|
|
|
|
|
|
def _transform_images(self):
|
|
"""
|
|
Takes the raw image data from #download_data and transforms it to add a title, captions, etc. This function is not idempotent.
|
|
"""
|
|
f_width = 780
|
|
title_size = 21
|
|
description_spacing = 10
|
|
description_size = 18
|
|
caption_size = 12
|
|
|
|
with resources.path("comic_download", "Lucida Sans Bold.ttf") as luc_sans_bold, resources.path("comic_download", "Lucida Sans.ttf") as luc_sans:
|
|
title_font = ImageFont.truetype(Path(Path(__file__).parent, "Lucida Sans Bold.ttf"), title_size, layout_engine=ImageFont.Layout.RAQM)
|
|
description_font = ImageFont.truetype(Path(Path(__file__).parent, "Lucida Sans Bold.ttf"), description_size, layout_engine=ImageFont.Layout.RAQM)
|
|
caption_font = ImageFont.truetype(Path(Path(__file__).parent, "Lucida Sans.ttf"), caption_size)
|
|
|
|
title_box = title_font.getbbox(self.title)
|
|
title_box = (title_box[2] - title_box[0], title_box[3] - title_box[1])
|
|
caption_spacing = 4
|
|
|
|
url_box = caption_font.getbbox(self.url)
|
|
url_box = (url_box[2] - url_box[0], url_box[3] - url_box[1])
|
|
date_box = caption_font.getbbox(self.date.isoformat())
|
|
date_box = (date_box[2] - date_box[0], date_box[3] - date_box[1])
|
|
text_margin = 20
|
|
|
|
self.transformed_images = []
|
|
header_img = Image.open(self.comic.images["header"].name)
|
|
background_img = Image.open(self.comic.images["background"].name)
|
|
for i in range(len(self.images)):
|
|
img = Image.open(self.images[i].name)
|
|
comic_width = max(img.width, header_img.width)
|
|
if self.captions[i]:
|
|
# Take measurements of the page caption, if one is present.
|
|
# Pillow's bbox functions do not handle newline characters properly, so we have to
|
|
# split the text, find the widest line, and use that.
|
|
caption = textwrap.wrap(self.captions[i], width = comic_width // caption_size)
|
|
caption_lines = len(caption)
|
|
caption_box = caption_font.getbbox(max(caption, key=len))
|
|
caption_box = (caption_box[2] - caption_box[0], (caption_box[3] - caption_box[1] + caption_spacing) * len(caption))
|
|
caption = "\n".join(caption)
|
|
else:
|
|
caption_box = (0, 0)
|
|
# The first page requires extra vertical space for the headers
|
|
if i == 0:
|
|
description = textwrap.wrap(self.description, width=comic_width // description_size)
|
|
description_lines = len(description)
|
|
description_box = description_font.getbbox(max(description, key=len))
|
|
description_box = (description_box[2] - description_box[0], (description_box[3] - description_box[1] + description_spacing) * (len(description) + 1))
|
|
description = "\n".join(description)
|
|
|
|
f_offset = header_img.height + description_box[1] + max(date_box[1], url_box[1]) + text_margin * 2
|
|
# Grab the image size while we are at it, for use outside of the loop
|
|
img_size = img.size
|
|
else:
|
|
f_offset = 0
|
|
f_img_size = (comic_width, f_offset + img.height + caption_box[1] + text_margin)
|
|
f_img = Image.new("RGBA", f_img_size, (255, 255, 255, 255))
|
|
# Draw the headers as needed
|
|
if i == 0:
|
|
#f_img.paste(background_img, (0, 0))
|
|
f_img.paste(header_img, ((comic_width - header_img.width) // 2, 0))
|
|
|
|
# Draw the actual comic
|
|
f_img.paste(img, ((comic_width - img.width) // 2, f_offset))
|
|
|
|
draw = ImageDraw.Draw(f_img)
|
|
if i == 0:
|
|
# Draw the description
|
|
draw.multiline_text(((comic_width - description_box[0]) // 2, header_img.height + 20), description, (0, 0, 0), spacing=description_spacing, font=description_font, align="center")
|
|
# Draw the URL
|
|
draw.text((text_margin, header_img.height + description_box[1] + text_margin), self.url, (0, 0, 0), font=caption_font)
|
|
# Draw the date
|
|
draw.text((comic_width - date_box[0] - text_margin, header_img.height + description_box[1] + text_margin), self.date.isoformat(), (0, 0, 0), align="right", font=caption_font)
|
|
# Draw the caption
|
|
if self.captions[i]:
|
|
draw.multiline_text(((comic_width - caption_box[0]) // 2, f_offset + img.height), caption, (0, 0, 0), spacing=caption_spacing, font=caption_font, align="center")
|
|
# Save the image
|
|
f_image_path = NamedTemporaryFile(mode='wb', suffix=f"f_{i}.png", prefix=self.get_identifier_string(), delete = False)
|
|
f_image_path.close()
|
|
f_img.save(f_image_path.name)
|
|
self.transformed_images.append(f_image_path)
|
|
|
|
DinosaurComicStrip.collection_cls = DinosaurComic
|
|
register_comic_class("qwantz.com", DinosaurComicStrip)
|
|
register_comic_class("dinosaurcomics.com", DinosaurComicStrip)
|
|
register_comic_class("exotica.ca", DinosaurComicStrip)
|
|
register_comic_class("chewbac.ca", DinosaurComicStrip)
|