196 lines
7.2 KiB
Python
196 lines
7.2 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 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)
|