#!/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 . import logging import datetime import os import time import re import textwrap import json from urllib.parse import urlparse from pathlib import Path import requests from PIL import Image, ImageDraw, ImageFont from bs4 import BeautifulSoup from atproto import Client from comic_download.comic_strip import Comic, ComicStrip, register_comic_class class BlueskyComic(Comic): """ A comic class for any Bluesky-posted comics. """ client = None """The single atproto client""" logged_in = False """A flag for keeping track of whether we logged in.""" def __init__(self, identifier: tuple): """ Creates a BlueSky comic. :param identifier: The comic identifier. This is a tuple containing the Bluesky instance ID (the base domain), and the bluesky user handle. """ super().__init__(identifier) @classmethod def create_from_url(cls, url): if type(url) == str: if not re.match(r'^\w+://', url): url = "https://" + url url = urlparse(url) instance = url.netloc handle = url.path.split('/')[2] return cls((instance, handle)) @property def instance(self): """The bluesky instance this comic is published on (i.e. "bsky.app.")""" return self.identifier[0] @property def handle(self): """The handle of the user that posts this comic.""" return self.identifier[1] @classmethod def get_client(cls): """ Obtains the ATProto client, creating it if needed. """ if not cls.client: logging.info("Creating AT client") cls.client = Client() # Handles logins if os.environ.get("BSKY_USER_PATH", None): with open(os.environ.get("BSKY_USER_PATH"), 'r') as env_file: os.environ["BSKY_USER"] = env_file.read().strip() if os.environ.get("BSKY_PASSWORD_PATH", None): with open(os.environ.get("BSKY_PASSWORD_PATH"), 'r') as env_file: os.environ["BSKY_PASSWORD"] = env_file.read().strip() if os.environ.get("BSKY_SESSION_PATH", None): with open(os.environ.get("BSKY_SESSION_PATH"), 'r') as env_file: os.environ["BSKY_SESSION"] = env_file.read().strip() if os.environ.get("BSKY_AUTH_TOKEN_PATH", None): with open(os.environ.get("BSKY_AUTH_TOKEN_PATH"), 'r') as env_file: os.environ["BSKY_AUTH_TOKEN"] = env_file.read().strip() if "BSKY_USER" in os.environ or "BSKY_PASSWORD" in os.environ or "BSKY_SESSION" in os.environ or "BSKY_AUTH_TOKEN" in os.environ: cls.client.login(login=os.environ.get("BSKY_USER", None), password=os.environ.get("BSKY_PASSWORD", None), session_string=os.environ.get("BSKY_SESSION", None), auth_factor_token=os.environ.get("BSKY_AUTH_TOKEN", None)) cls.logged_in = True else: logging.info("No Bluesky login details were provided. Going forward with the more limited, unauthenticated APIs.") return cls.client def _load_data(self): if self.logged_in: profile = self.get_client().get_profile(self.handle) self.author = profile.display_name self.title = profile.display_name self.image_urls = { "avatar": profile.avatar, "banner": profile.banner } self.description = profile.description self.posts = [] # TODO - Find post IDs with the API else: rss_url = f"https://{self.instance}/profile/{self.handle}/rss" result = requests.get(rss_url) result.raise_for_status() rss = BeautifulSoup(result.content, features="xml") self.author = rss.channel.title.string self.author = self.author[self.author.index(" - ") + 3:] self.title = self.author self.image_urls = {} self.description = rss.channel.description.string self.posts = [] for item in rss.channel.find_all("item"): post_url = item.link.string.strip() post_url = urlparse(post_url) post_id = post_url.path.split("/")[-1] self.posts.insert(0, post_id) self.did = self.get_client().resolve_handle(self.handle).did def get_first_strip(self): return self.posts[0] def get_latest_strip(self): return self.posts[-1] def get_all_strips(self) -> list: return self.posts class BlueskyComicStrip(ComicStrip): """ The Bluesky Comics Strip """ client = None def __init__(self, comic, post: str): """ Creates a Bluesky comic instance :param comic: The BlueSky comic instance that this strip belongs to. :param post: The ID of the post. """ super().__init__(comic, post) self.url = f"https://{self.comic.instance}/profile/{self.comic.handle}/post/{post}" self.title = None @property def post(self): # Alias return self.identifier def _load_data(self): logging.info("Loading data for post %s by %s", self.post, self.comic.handle) post_data = self.comic.get_client().get_post(self.post, self.comic.handle) url = urlparse(post_data.uri) self.title = post_data.value.text if post_data.value.embed and post_data.value.embed.py_type == "app.bsky.embed.images": for i in range(len(post_data.value.embed.images)): image = post_data.value.embed.images[i] if image.py_type == "app.bsky.embed.images#image": image_link = image.image.ref.link image_link = f"https://cdn.{self.comic.instance}/img/feed_fullsize/plain/{self.comic.did}/{image_link}@jpeg" self.image_urls.append(image_link) if image.alt: self.captions.append(image.alt) else: self.captions.append("") self.date = datetime.datetime.fromisoformat(post_data.value.created_at) def _transform_images(self): self.transformed_images = self.images BlueskyComic.strip_cls = BlueskyComicStrip register_comic_class("bsky.app", BlueskyComic)