I have solid dates for the first comic and the 9th comic, but not those in between, and the first comic does not follow my algorithm. This change at least gives the first comic the correct date.
223 lines
9.9 KiB
Python
223 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Copyright (c) 2025 Markil 3
|
|
|
|
import logging
|
|
import datetime
|
|
import textwrap
|
|
import json
|
|
import re
|
|
from urllib.parse import urlparse
|
|
|
|
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 Comic, ComicStrip, register_comic_class
|
|
|
|
class ExistentialComic(Comic):
|
|
"""
|
|
The Existential Comics strip
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__("existential_comics")
|
|
|
|
@classmethod
|
|
def create_from_url(cls, url):
|
|
return cls()
|
|
|
|
def _load_data(self):
|
|
self.title = "Existential Comics"
|
|
self.author = "Corey Mohler"
|
|
self.image_urls = {
|
|
"header": "https://static.existentialcomics.com/title.jpg",
|
|
"safety": "https://static.existentialcomics.com/safety.png"
|
|
}
|
|
self.description = "A webcomic of romance, sarcasm, math, and language."
|
|
|
|
result = requests.get("https://existentialcomics.com/")
|
|
result.raise_for_status()
|
|
|
|
content = BeautifulSoup(result.content, features="lxml")
|
|
# The home page automatically fetches the latest comic, and we
|
|
# can grab the regular URL (and thus the index) from there.
|
|
index = content.find("meta", property="og:url")
|
|
if index and index["content"]:
|
|
self.latest_identifier = int(index["content"].split('/')[-1])
|
|
else:
|
|
raise ValueError("Invalid comic data")
|
|
|
|
def get_first_strip(self):
|
|
return 1
|
|
|
|
def get_latest_strip(self):
|
|
return self.latest_identifier
|
|
|
|
def get_all_strips(self) -> list:
|
|
return list(range(self.get_first_strip(), self.get_latest_strip() + 1))
|
|
|
|
def __new__(cls):
|
|
if not hasattr(cls, 'instance'):
|
|
cls.instance = super(ExistentialComic, cls).__new__(cls)
|
|
return cls.instance
|
|
|
|
|
|
class ExistentialComicStrip(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://existentialcomics.com/comic/{index}"
|
|
self.title = None
|
|
|
|
@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_="comicImg"):
|
|
self.image_urls.append(f'http:{img["src"]}')
|
|
if img.get("title"):
|
|
self.captions.append(img["title"])
|
|
else:
|
|
self.captions.append("")
|
|
|
|
final_caption = ""
|
|
explanation = content.find(id="explanation")
|
|
if explanation:
|
|
self.captions.extend(explanation.get_text().split("\n"))
|
|
philosophers = content.find(id="philosophers-comic")
|
|
if philosophers:
|
|
self.captions.extend(philosophers.get_text().split("\n"))
|
|
if final_caption:
|
|
self.captions.append(final_caption)
|
|
self.date = max(datetime.date(2013, 11, 11) + datetime.timedelta(weeks=self.index - 2), datetime.date(2013, 11, 12))
|
|
|
|
if self.date >= datetime.date(2023, 11, 13):
|
|
self.safe = (self.date - datetime.date(2023, 11, 13)).days
|
|
else:
|
|
self.safe = (self.date - datetime.date(2013, 11, 12)).days
|
|
|
|
|
|
def _transform_images(self, base_path: Path):
|
|
"""
|
|
Takes the raw image data from #download_data and transforms it to add a title, captions, etc. This function is not idempotent.
|
|
"""
|
|
|
|
# Marks out various bounding boxes of strip-global texts (i.e. how much space the title takes up)
|
|
title_size = 18
|
|
safety_size = 24
|
|
caption_size = 15
|
|
f_size = (1000, 1500)
|
|
title_font = ImageFont.truetype(Path(Path(__file__).parent, "Lucida Sans Bold.ttf"), title_size)
|
|
safety_font = ImageFont.truetype(Path(Path(__file__).parent, "Lucida Sans Bold.ttf"), safety_size)
|
|
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
|
|
|
|
safe_box = safety_font.getbbox(str(self.safe))
|
|
safe_box = (safe_box[2] - safe_box[0], safe_box[3] - safe_box[1])
|
|
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 = 10
|
|
|
|
img_size = (0, 0)
|
|
|
|
self.transformed_images = []
|
|
header_img = Image.open(self.comic.images["header"].name)
|
|
safety_img = Image.open(self.comic.images["safety"].name)
|
|
for i in range(len(self.images)):
|
|
img = Image.open(self.images[i].name)
|
|
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 = img.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:
|
|
f_offset = header_img.height + safety_img.height + max(date_box[1], url_box[1]) + text_margin
|
|
# 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 = (img.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(header_img, (0, 0))
|
|
f_img.paste(safety_img, (0, header_img.height))
|
|
|
|
# Draw the actual comic
|
|
f_img.paste(img, (0, f_offset))
|
|
|
|
draw = ImageDraw.Draw(f_img)
|
|
if i == 0:
|
|
# Draw the title
|
|
draw.text(((img.width - title_box[0]) // 2, header_img.height + 20), self.title, (0, 0, 0), font=title_font)
|
|
# Draw the safety text
|
|
draw.text(((safety_img.width - safe_box[0]) // 2, header_img.height + 20), str(self.safe), (0, 0, 0), font=safety_font)
|
|
# Draw the URL
|
|
draw.text((text_margin, header_img.height + safety_img.height + text_margin), self.url, (0, 0, 0), font=caption_font)
|
|
# Draw the date
|
|
draw.text((img.width - date_box[0] - text_margin, header_img.height + safety_img.height + text_margin), self.date.isoformat(), (0, 0, 0), align="right", font=caption_font)
|
|
# Draw the caption
|
|
if self.captions[i]:
|
|
draw.multiline_text(((img.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)
|
|
|
|
if len(self.captions) > len(self.image_urls):
|
|
# Add the explanation to a new page
|
|
explanation = []
|
|
# Pillow's bbox functions do not handle newline characters properly, so we have to
|
|
# split the text, find the widest line, and use that.
|
|
for paragraph in self.captions[len(self.image_urls):]:
|
|
explanation.extend(textwrap.wrap(paragraph.strip(), width = img_size[0] // caption_size))
|
|
explanation_box = [caption_font.getbbox(ex) for ex in explanation]
|
|
explanation_box = [(box[2] - box[0], box[3] - box[1]) for box in explanation_box]
|
|
# Using a comparison function (i.e. max) on a tuple just compares the first value of each
|
|
explanation_box = [max(explanation_box)[0], (explanation_box[0][1] + caption_spacing) * len(explanation_box)]
|
|
explanation = "\n".join(explanation)
|
|
|
|
f_img = Image.new("RGBA", (img_size[0], explanation_box[1] + text_margin * 4), (255, 255, 255, 255))
|
|
draw = ImageDraw.Draw(f_img)
|
|
# Adds a background rectangle, like on the website
|
|
draw.rounded_rectangle(((img_size[0] - explanation_box[0] - text_margin * 2) // 2, text_margin, (img_size[0] + explanation_box[0] + text_margin * 2) // 2, explanation_box[1] + text_margin * 3), 20, (200, 200, 200), (0, 0, 0), 2)
|
|
draw.multiline_text(((img_size[0] - explanation_box[0]) // 2, text_margin * 2), explanation, (0, 0, 0), align="center", font=caption_font, spacing=caption_spacing)
|
|
|
|
# Save the image
|
|
f_image_path = NamedTemporaryFile(mode='wb', suffix=f"f_ex.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)
|
|
|
|
ExistentialComic.strip_cls = ExistentialComicStrip
|
|
register_comic_class("existentialcomics.com", ExistentialComic)
|