#!/usr/bin/env python3 # Copyright (c) 2025 Markil 3 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)