Formalizes the bluesky process
This commit is contained in:
180
comic_download/bluesky.py
Normal file
180
comic_download/bluesky.py
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/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)
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import logging.handlers
|
||||
import argparse
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date
|
||||
from threading import Lock, Condition
|
||||
import bisect
|
||||
import time
|
||||
@@ -153,8 +153,8 @@ class ImageRepo(ABC):
|
||||
if not self._data_loaded:
|
||||
self._load_data()
|
||||
logging.info("Completed loading of %s", self.get_identifier_string())
|
||||
self._data_loaded = True
|
||||
self._load_lock.notify_all()
|
||||
self._data_loaded = True
|
||||
self._load_lock.notify_all()
|
||||
|
||||
def await_load(self):
|
||||
"""
|
||||
@@ -204,8 +204,8 @@ class ImageRepo(ABC):
|
||||
logging.info("Downloading %s", self.get_identifier_string())
|
||||
self._download_data()
|
||||
logging.info("Completed downloading of %s", self.get_identifier_string())
|
||||
self._data_downloaded = True
|
||||
self._download_lock.notify_all()
|
||||
self._data_downloaded = True
|
||||
self._download_lock.notify_all()
|
||||
|
||||
def await_download(self):
|
||||
"""
|
||||
@@ -426,9 +426,9 @@ class ComicStrip(ImageRepo, ABC):
|
||||
prefix = prefix_num_form.format(self.identifier)
|
||||
elif (type(self.identifier) == list or type(self.identifier) == tuple) and type(self.identifier[-1]) == int:
|
||||
prefix = prefix_num_form.format(self.identifier[-1])
|
||||
elif type(self.date) == datetime.date:
|
||||
elif type(self.date) == date:
|
||||
prefix = self.date.isoformat()
|
||||
elif type(self.date) == datetime.datetime:
|
||||
elif type(self.date) == datetime:
|
||||
prefix = self.date.date().isoformat()
|
||||
else:
|
||||
prefix = self.get_identifier_string()
|
||||
@@ -447,21 +447,21 @@ class ComicStrip(ImageRepo, ABC):
|
||||
return Path(base_path, f"{self.get_filename()}.cbz")
|
||||
|
||||
def load_data(self):
|
||||
super().load_data()
|
||||
self.comic.load_data()
|
||||
super().load_data()
|
||||
self.comic.index_strip(self)
|
||||
|
||||
def download_data(self):
|
||||
super().download_data()
|
||||
self.comic.download_data()
|
||||
super().download_data()
|
||||
|
||||
def await_load(self):
|
||||
super().await_load()
|
||||
self.comic.await_load()
|
||||
super().await_load()
|
||||
|
||||
def await_download(self):
|
||||
super().await_download()
|
||||
self.comic.await_download()
|
||||
super().await_download()
|
||||
|
||||
@abstractmethod
|
||||
def _load_data(self):
|
||||
@@ -519,7 +519,8 @@ class ComicStrip(ImageRepo, ABC):
|
||||
for i in range(len(self.transformed_images)):
|
||||
image = self.transformed_images[i]
|
||||
image_path = Path(image.name)
|
||||
with open(image.name, 'rb') as image_stream, comic_zip.open(str(i + 1) + image_path.suffix, 'w') as zip_stream:
|
||||
logging.info("Packaging %s" % image_path.suffix)
|
||||
with open(image.name, 'rb') as image_stream, comic_zip.open(str(i + 1) + (image_path.suffix or '.png'), 'w') as zip_stream:
|
||||
zip_stream.write(image_stream.read())
|
||||
# Delete the base images, as they are no longer needed.
|
||||
# We cannot touch the transformed images, since some
|
||||
|
||||
Reference in New Issue
Block a user