Formalizes the bluesky process
This commit is contained in:
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
from comic_download import Comic, find_comic_class
|
from comic_download import Comic, find_comic_class
|
||||||
import comic_download.xkcd
|
import comic_download.xkcd
|
||||||
import comic_download.existential_comics
|
import comic_download.existential_comics
|
||||||
|
import comic_download.bluesky
|
||||||
|
|
||||||
def setup_args():
|
def setup_args():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
@@ -85,9 +86,10 @@ if __name__ == "__main__":
|
|||||||
r = [comic.get_latest_strip()]
|
r = [comic.get_latest_strip()]
|
||||||
else:
|
else:
|
||||||
r = comic.get_all_strips()
|
r = comic.get_all_strips()
|
||||||
start = args.start or comic.get_first_strip()
|
if r and type(r[0]) == int:
|
||||||
end = args.end or comic.get_latest_strip()
|
start = args.start or comic.get_first_strip()
|
||||||
r = r[start - 1:end]
|
end = args.end or comic.get_latest_strip()
|
||||||
|
r = r[start - 1:end]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for index in reversed(r):
|
for index in reversed(r):
|
||||||
|
|||||||
537
bluesky.py
537
bluesky.py
@@ -1,537 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
# Copyright (c) 2025 Markil 3
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import logging.handlers
|
|
||||||
import argparse
|
|
||||||
from datetime import datetime
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import re
|
|
||||||
import textwrap
|
|
||||||
import json
|
|
||||||
from zipfile import ZipFile
|
|
||||||
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
|
|
||||||
|
|
||||||
class ComicStrip:
|
|
||||||
client = None
|
|
||||||
|
|
||||||
def __init__(self, instance_url, handle, post):
|
|
||||||
self.instance = instance_url
|
|
||||||
self.handle = handle
|
|
||||||
self.post = post
|
|
||||||
self.title = None
|
|
||||||
self.safe_title = None
|
|
||||||
self.image_url = []
|
|
||||||
self.caption = []
|
|
||||||
self.date = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_client(cls):
|
|
||||||
if not cls.client:
|
|
||||||
cls.client = Client()
|
|
||||||
return cls.client
|
|
||||||
|
|
||||||
def get_metadata_path(self, base_path: Path):
|
|
||||||
return Path(base_path, f"{self.handle}-{self.post}.json")
|
|
||||||
|
|
||||||
def get_image_path(self, base_path: Path, image_num: int = 1):
|
|
||||||
return Path(base_path, f"{self.handle}-{self.post}-{image_num}.png")
|
|
||||||
|
|
||||||
def get_package_path(self, base_path: Path):
|
|
||||||
return Path(base_path, f"{self.date.date()}-{self.safe_title}.cbz")
|
|
||||||
|
|
||||||
def load_data(self):
|
|
||||||
logging.info("Loading data for post %s by %s", self.post, self.handle)
|
|
||||||
post_data = self.get_client().get_post(self.post, self.handle)
|
|
||||||
|
|
||||||
url = urlparse(post_data.uri)
|
|
||||||
netloc = url.netloc
|
|
||||||
self.title = post_data.value.text
|
|
||||||
self.safe_title = re.sub('/', ' - ', self.title)
|
|
||||||
self.safe_title = re.sub('&', 'and', self.safe_title)
|
|
||||||
self.safe_title = re.sub(':', ' -', self.safe_title)
|
|
||||||
self.safe_title = re.sub(r'\?', '', self.safe_title)
|
|
||||||
self.safe_title = re.sub('\n', '', self.safe_title)
|
|
||||||
if len(self.safe_title) > 70:
|
|
||||||
self.safe_title = self.safe_title[0:70]
|
|
||||||
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.instance}/img/feed_thumbnail/plain/{netloc}/{image_link}@jpg"
|
|
||||||
self.image_url.append(image_link)
|
|
||||||
self.caption.append(image.alt)
|
|
||||||
self.date = datetime.fromisoformat(post_data.value.created_at)
|
|
||||||
|
|
||||||
|
|
||||||
def download_data(self, base_path: Path):
|
|
||||||
if self.image_url:
|
|
||||||
meta_path = self.get_metadata_path(base_path)
|
|
||||||
if not meta_path.exists():
|
|
||||||
with open(meta_path, 'w') as meta_stream:
|
|
||||||
json.dump({
|
|
||||||
"title": self.title,
|
|
||||||
"image_url": self.image_url,
|
|
||||||
"caption": self.caption,
|
|
||||||
"date": self.date.isoformat(),
|
|
||||||
}, meta_stream)
|
|
||||||
for i in range(len(self.image_url)):
|
|
||||||
logging.info(" Downloading %s-%s-%d", self.handle, self.post, i + 1)
|
|
||||||
image_path = self.get_image_path(base_path, i + 1)
|
|
||||||
if not image_path.exists():
|
|
||||||
result = requests.get(self.image_url[i])
|
|
||||||
result.raise_for_status()
|
|
||||||
with open(image_path, 'wb') as image_stream:
|
|
||||||
image_stream.write(result.content)
|
|
||||||
|
|
||||||
def package_data(self, base_path: Path):
|
|
||||||
"""
|
|
||||||
Compresses all the data into a cbz file and removes the source files
|
|
||||||
"""
|
|
||||||
with ZipFile(self.get_package_path(base_path), 'w') as comic_zip:
|
|
||||||
for i in range(len(self.image_url)):
|
|
||||||
image_path = self.get_image_path(base_path, i + 1)
|
|
||||||
comic_zip.write(image_path, str(i + 1) + image_path.suffix)
|
|
||||||
if self.get_metadata_path(base_path).exists():
|
|
||||||
comic_zip.write(self.get_metadata_path(base_path), "info.0.json")
|
|
||||||
for i in range(len(self.image_url)):
|
|
||||||
self.get_image_path(base_path, i + 1).unlink()
|
|
||||||
self.get_metadata_path(base_path).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_args():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog='xkcd',
|
|
||||||
description='Downloads comics from BlueSky')
|
|
||||||
|
|
||||||
parser.add_argument('-o', '--output', type=Path, default=Path(), help="The directory to dump the files to.")
|
|
||||||
parser.add_argument('-w', '--wait', type=int, default=0, help="How many seconds to wait between each request")
|
|
||||||
parser.add_argument('-t', '--threads', type=int, default=10, help="How many download threads will run at once")
|
|
||||||
parser.add_argument('--instance', type=str, default="bsky.app", help="The Bluesky instance to use (defaults to \"bsky.app\"")
|
|
||||||
parser.add_argument('--handle', type=str, help="The Bluesky handle to use")
|
|
||||||
parser.add_argument('-s', '--start', type=int, default=0, help="The comic index to start at. A zero will be interpreted as using up to the first comic.")
|
|
||||||
parser.add_argument('-e', '--end', type=int, default=0, help="The comic index to end at. A zero will be interpreted as using up to the last comic.")
|
|
||||||
parser.add_argument('-l', '--latest', action='store_true', help="If set, only the latest comic will be downloaded")
|
|
||||||
parser.add_argument('-p', '--plain', action='store_true', help="If set, only the raw image will be downloaded, and titles, caption, etc. will not be added.")
|
|
||||||
|
|
||||||
return parser
|
|
||||||
|
|
||||||
def setup_logging():
|
|
||||||
logger = logging.getLogger()
|
|
||||||
logger.setLevel(logging.DEBUG)
|
|
||||||
|
|
||||||
ch = logging.StreamHandler()
|
|
||||||
ch.setLevel(logging.INFO)
|
|
||||||
formatter = logging.Formatter('%(levelname)s - %(message)s')
|
|
||||||
ch.setFormatter(formatter)
|
|
||||||
logger.addHandler(ch)
|
|
||||||
|
|
||||||
ch = logging.handlers.RotatingFileHandler("bluesky_download.log", encoding='utf-8')
|
|
||||||
ch.setLevel(logging.DEBUG)
|
|
||||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
ch.setFormatter(formatter)
|
|
||||||
ch.doRollover()
|
|
||||||
logger.addHandler(ch)
|
|
||||||
|
|
||||||
def load_strip(base_path, instance, handle, post, plain, thread_limit):
|
|
||||||
"""
|
|
||||||
Threading function for downloading XKCD strips.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
|
|
||||||
"""
|
|
||||||
with thread_limit:
|
|
||||||
strip = ComicStrip(instance, handle, post)
|
|
||||||
strip.load_data()
|
|
||||||
if not strip.get_image_path(base_path).exists() and not strip.get_package_path(base_path).exists():
|
|
||||||
strip.download_data(base_path)
|
|
||||||
#if not plain:
|
|
||||||
# strip.transform_data(base_path)
|
|
||||||
strip.package_data(base_path)
|
|
||||||
|
|
||||||
def get_posts_from_rss(instance, handle):
|
|
||||||
rss_url = f"https://{instance}/profile/{handle}/rss"
|
|
||||||
result = requests.get(rss_url)
|
|
||||||
result.raise_for_status()
|
|
||||||
rss = BeautifulSoup(result.content, features="xml")
|
|
||||||
|
|
||||||
for item in rss.channel.find_all("item"):
|
|
||||||
logging.info(item)
|
|
||||||
post_url = item.link.string.strip()
|
|
||||||
post_url = urlparse(post_url)
|
|
||||||
post_id = post_url.path.split("/")[-1]
|
|
||||||
yield post_id
|
|
||||||
|
|
||||||
posts = [
|
|
||||||
"3le2rltt2m22r",
|
|
||||||
"3luzztwioxk2b",
|
|
||||||
"3lusiuzh2nc2k",
|
|
||||||
"3lungh2klv22y",
|
|
||||||
"3luihmiqtus2x",
|
|
||||||
"3luayf6sq5k2d",
|
|
||||||
"3lu3tbja3ss23",
|
|
||||||
"3ltwvnkf3g22b",
|
|
||||||
"3ltsuox74es2e",
|
|
||||||
"3ltsv6uplk227",
|
|
||||||
"3ltpbisgqqk2d",
|
|
||||||
"3ltk6o23wls2f",
|
|
||||||
"3ltf6raqyj22q",
|
|
||||||
"3lt6wgnn4kc2x",
|
|
||||||
"3lt5karh7ms2j",
|
|
||||||
"3lsyont4mk227",
|
|
||||||
"3lstmfh2d5s2l",
|
|
||||||
"3lsw7sos3uc2x",
|
|
||||||
"3lsmpck6ims2v",
|
|
||||||
"3lsm6yqgva22a",
|
|
||||||
"3lsgxaqaut22z",
|
|
||||||
"3lsbsgwppds2y",
|
|
||||||
"3ls2hhf2zac2g",
|
|
||||||
"3ls2hhjhgfk2g",
|
|
||||||
"3lrwrenc7ms2c",
|
|
||||||
"3lrvcuqr2d222",
|
|
||||||
"3lrqdiff3zs2i",
|
|
||||||
"3lriuf2gcoc2j",
|
|
||||||
"3lrixbhfi3c2n",
|
|
||||||
"3lrdtjetkoc2i",
|
|
||||||
"3lr6qyppq4c2a",
|
|
||||||
"3lqxainrmic2h",
|
|
||||||
"3lr4cdocqyk2g",
|
|
||||||
"3lqvc2io4722w",
|
|
||||||
"3lqs74rjq3k2e",
|
|
||||||
"3lqn5nrlqok2v",
|
|
||||||
"3lqflgiz7x22g",
|
|
||||||
"3lqaoqvftfk2h",
|
|
||||||
"3lq3lxr4l4224",
|
|
||||||
"3lpu3g25etk2x",
|
|
||||||
"3lpp3a2fqtk2l",
|
|
||||||
"3lpjz2at56k2a",
|
|
||||||
"3lpckqwgqhk2q",
|
|
||||||
"3lp5iihqikk2s",
|
|
||||||
"3loyehjmovc22",
|
|
||||||
"3loqrcugln22r",
|
|
||||||
"3lolqbcgbds2r",
|
|
||||||
"3logrbxvlis2m",
|
|
||||||
"3lo75vdpk3c2x",
|
|
||||||
"3lo27ziivr22q",
|
|
||||||
"3lnv5skkeyc2i",
|
|
||||||
"3lnnqzkknzs2r",
|
|
||||||
"3lnikchunsk22",
|
|
||||||
"3lndn7ur7ms2u",
|
|
||||||
"3ln45awbjts2y",
|
|
||||||
"3lmwywtb4e22s",
|
|
||||||
"3lmrywp5m6s27",
|
|
||||||
"3lmklt222ls27",
|
|
||||||
"3lmffhpl3fc2q",
|
|
||||||
"3lmaewixca22l",
|
|
||||||
"3llytfxgcks2z",
|
|
||||||
"3lltrv3fjuc2h",
|
|
||||||
"3llrg2guy4s2v",
|
|
||||||
"3lloujvsrbk2t",
|
|
||||||
"3llmdjqgchs2c",
|
|
||||||
"3llhc44fwkk2q",
|
|
||||||
"3llc7u5r5cs2e",
|
|
||||||
"3ll57ss2n7c25",
|
|
||||||
"3lkx5alofos2a",
|
|
||||||
"3lkvnh3if2k2a",
|
|
||||||
"3lks3qgjmsu2f",
|
|
||||||
"3lkqlcklufc2v",
|
|
||||||
"3lkll2vqzfc27",
|
|
||||||
"3lke5giatak2u",
|
|
||||||
"3lk6yttystc24",
|
|
||||||
"3lk4sw6af3222",
|
|
||||||
"3lk4t2zaxe22h",
|
|
||||||
"3lk2ke7x45k2w",
|
|
||||||
"3ljso424eo22o",
|
|
||||||
"3ljngjav23c2t",
|
|
||||||
"3ljilhdtyqc2x",
|
|
||||||
"3ljaz67awxs2b",
|
|
||||||
"3lj6i36enqc2x",
|
|
||||||
"3lj4bc5bag22d",
|
|
||||||
"3lj3w5facqk2e",
|
|
||||||
"3liwy6ljsqs26",
|
|
||||||
"3lipewnxlpc2i",
|
|
||||||
"3likfj5hrp22d",
|
|
||||||
"3lihvotejc22v",
|
|
||||||
"3lifde7c2lk2r",
|
|
||||||
"3li5oqjc7fs2q",
|
|
||||||
"3li3p4z2wdk2d",
|
|
||||||
"3lhypepqcdc2a",
|
|
||||||
"3lhtqmusffk2w",
|
|
||||||
"3lhn7wct53s2l",
|
|
||||||
"3lhma5544yc2g",
|
|
||||||
"3lhhjtwwbgk23",
|
|
||||||
"3lhh2vmmxtc2r",
|
|
||||||
"3lhc3mw35ns2g",
|
|
||||||
"3lh4zlga4xs23",
|
|
||||||
"3lh2l2enao22q",
|
|
||||||
"3lgxzc2zzps24",
|
|
||||||
"3lgvfjbb5y22q",
|
|
||||||
"3lgtkfpxdac2v",
|
|
||||||
"3lgqj6naiuk2n",
|
|
||||||
"3lgizbat2l22u",
|
|
||||||
"3lgdxq2w3pc2q",
|
|
||||||
"3lg74lxyb7k2t",
|
|
||||||
"3lg6tvpdyic2v",
|
|
||||||
"3lfxfbrmngs24",
|
|
||||||
"3lfsdkdlym22m",
|
|
||||||
"3lfq4ktiucc2l",
|
|
||||||
"3lfnawmi6i22n",
|
|
||||||
"3lffqudbdk22r",
|
|
||||||
"3lfapkgbkms2p",
|
|
||||||
"3lf3t22bh3c2y",
|
|
||||||
"3lf3qkvvctc2j",
|
|
||||||
"3leubskybes2f",
|
|
||||||
"3lepbfjudgk2w",
|
|
||||||
"3lemufveutk2w",
|
|
||||||
"3lejym26k222f",
|
|
||||||
"3lecttjatcs2g",
|
|
||||||
"3lecltbuam22c",
|
|
||||||
"3leaardk5uc2j",
|
|
||||||
"3le56jdfhoc2r",
|
|
||||||
"3le2rlwgijk2r",
|
|
||||||
"3ldykvgpc622y",
|
|
||||||
"3ldtcehgmzc2p",
|
|
||||||
"3ldr2pc2qm22z",
|
|
||||||
"3ldluyvf7jc2w",
|
|
||||||
"3ldguofbiqk2e",
|
|
||||||
"3ldfvg7irmc2j",
|
|
||||||
"3ld7h6smups22",
|
|
||||||
"3ld2g3qg32k2x",
|
|
||||||
"3lcvi4dshrs2o",
|
|
||||||
"3lcvb2p5asc2w",
|
|
||||||
"3lcnrgclrpc2f",
|
|
||||||
"3lcitywixr22q",
|
|
||||||
"3lcdrbly4us2m",
|
|
||||||
"3lc74s2lwhs2v",
|
|
||||||
"3lbx2mppygs2b",
|
|
||||||
"3lc4jsccz4225",
|
|
||||||
"3lc45ylqyi22w",
|
|
||||||
"3lbx2mppygs2b",
|
|
||||||
"3lbvt5g2xfc2z",
|
|
||||||
"3lbrynnzs4k26",
|
|
||||||
"3lbnnmitbxk2p",
|
|
||||||
"3lbkkx6luas22",
|
|
||||||
"3lbgqnx7wu22q",
|
|
||||||
"3lbfhgb4c522z",
|
|
||||||
"3lbaf2ylktc2z",
|
|
||||||
"3lb3yhibbjc25",
|
|
||||||
"3laywzibuzk2l",
|
|
||||||
"3latuve2bfc22",
|
|
||||||
"3laoueyhivc2t",
|
|
||||||
"3lahgfz7y6o2z",
|
|
||||||
"3lacbmhxrm72z",
|
|
||||||
"3la57pcazkc2d",
|
|
||||||
"3l7vlhkfaw32l",
|
|
||||||
"3l7tohca2wy2u",
|
|
||||||
"3l7qila4qyg2a",
|
|
||||||
"3l7o5grftsg2f",
|
|
||||||
"3l7lhap7y4u2d",
|
|
||||||
"3l7dzeyijg22d",
|
|
||||||
"3l76wtw5zfx2d",
|
|
||||||
"3l6zwmfshlh2h",
|
|
||||||
"3l6sgqhic5o2z",
|
|
||||||
"3l6nfwg5oqv2d",
|
|
||||||
"3l6iga4gwdz2x",
|
|
||||||
"3l6atb75y5223",
|
|
||||||
"3l63otj4ure24",
|
|
||||||
"3l5wsknqjlq24",
|
|
||||||
"3l5p7esy64r26",
|
|
||||||
"3l5kbkidgm32t",
|
|
||||||
"3l5f75eev2v2i",
|
|
||||||
"3l5cr4juokr23",
|
|
||||||
"3l55irjwezr24",
|
|
||||||
"3l4ymvvsuaq2y",
|
|
||||||
"3l4toybkjg227",
|
|
||||||
"3l4lyu6cfrs2b",
|
|
||||||
"3l4gvipojkg2x",
|
|
||||||
"3l4btvjgc6b2p",
|
|
||||||
"3l42gpapcf72y",
|
|
||||||
"3l3v74ouuhk2k",
|
|
||||||
"3l3q73wb7jg2i",
|
|
||||||
"3l3imu5jls622",
|
|
||||||
"3l3duuldfiw2x",
|
|
||||||
"3l36n3d67ss2m",
|
|
||||||
"3l2x3ii4xqs23",
|
|
||||||
"3l2s2fraxdj2z",
|
|
||||||
"3l2mxjqpigt2f",
|
|
||||||
"3l2fpbcq4ih2k",
|
|
||||||
"3l2alzbnhtl2u",
|
|
||||||
"3l23dane6eh2z",
|
|
||||||
"3kztqzul4dt2u",
|
|
||||||
"3kzp4j5vi3d2n",
|
|
||||||
"3kzjvr6czhw2w",
|
|
||||||
"3kzce6yxp5h2z",
|
|
||||||
"3kz5edvageh2g",
|
|
||||||
"3kyy7u47pca2a",
|
|
||||||
"3kyqsamtnfz2y",
|
|
||||||
"3kyloetc74k23",
|
|
||||||
"3kygne33n332v",
|
|
||||||
"3ky7c3z7ztp24",
|
|
||||||
"3ky2bahurnl27",
|
|
||||||
"3kxv4u74bhf2v",
|
|
||||||
"3kxnltrvlg32r",
|
|
||||||
"3kxijc74tm32u",
|
|
||||||
"3kxde3ujnq72i",
|
|
||||||
"3kx42evta732e",
|
|
||||||
"3kwx4c6l4vz23",
|
|
||||||
"3kwrytyspqd2t",
|
|
||||||
"3kwk7xmo4nc23",
|
|
||||||
"3kwffcat3sn2g",
|
|
||||||
"3kwagbkberv2n",
|
|
||||||
"3kvylvn6ebs2e",
|
|
||||||
"3kvtt37sk7s2y",
|
|
||||||
"3kvosflvzyk2k",
|
|
||||||
"3kvh4c2jmln2u",
|
|
||||||
"3kvc63r4v4d2b",
|
|
||||||
"3kv4zwdfzfe2p",
|
|
||||||
"3kv2i2qx7k22h",
|
|
||||||
"3kuvhqlavpr2e",
|
|
||||||
"3kuqecv6yzq2o",
|
|
||||||
"3kullo7jigu2y",
|
|
||||||
"3kudyfsy3bc2o",
|
|
||||||
"3ku6si64pbs2g",
|
|
||||||
"3ku6sozokgk2q",
|
|
||||||
"3ktzv4yk2mc2z",
|
|
||||||
"3ktuymwfthf2r",
|
|
||||||
"3ktsgyxbhmk25",
|
|
||||||
"3ktneffuh422p",
|
|
||||||
"3ktiehoosac2p",
|
|
||||||
"3ktapw575h22f",
|
|
||||||
"3kt3uqywyv227",
|
|
||||||
"3kswqis3lyd2u",
|
|
||||||
"3ksp3mphugs25",
|
|
||||||
"3kskcnww26c2g",
|
|
||||||
"3kscnot6t622q",
|
|
||||||
"3ks5tqnchuk2a",
|
|
||||||
"3kryi3bppmq2y",
|
|
||||||
"3krtjykszev22",
|
|
||||||
"3krm4t6f3hd2w",
|
|
||||||
"3krh2eg32ic23",
|
|
||||||
"3krbzz4jxel23",
|
|
||||||
"3kr26y757ol2f",
|
|
||||||
"3kqy37huvv223",
|
|
||||||
"3kqvajl2hkm2v",
|
|
||||||
"3kqq6dfzztt2c",
|
|
||||||
"3kqip35oxe22m",
|
|
||||||
"3kqdkpkxrmy24",
|
|
||||||
"3kq6kmhxtde2q",
|
|
||||||
"3kpx6mwbyzx2q",
|
|
||||||
"3kpsa2bztzw2e",
|
|
||||||
"3kpn5tsxpxz2n",
|
|
||||||
"3kpfolpzzbt27",
|
|
||||||
"3kpaf5gww672s",
|
|
||||||
"3kp6asc2k3h2w",
|
|
||||||
"3kp3mq3v5rf2m",
|
|
||||||
"3kou2xcx2m52k",
|
|
||||||
"3koozplzpmb2c",
|
|
||||||
"3kojvm6ehq42q",
|
|
||||||
"3koccjnwyfg2w",
|
|
||||||
"3ko5bc23nz224",
|
|
||||||
"3kny7xsb7dt2b",
|
|
||||||
"3knqofbpjju2g",
|
|
||||||
"3knog34ey4t2b",
|
|
||||||
"3knjbjipe5s2y",
|
|
||||||
"3kngq72xjz42l",
|
|
||||||
"3kn7bjwf2ol26",
|
|
||||||
"3kn4p5fwrmk2f",
|
|
||||||
"3kn2dr3vzwk2z",
|
|
||||||
"3kmvdpyngrw2w",
|
|
||||||
"3kmtcap2yxx2l",
|
|
||||||
"3kmnkjrxwcp2s",
|
|
||||||
"3kml7jfy2vw2i",
|
|
||||||
"3kmj4j7esez2f",
|
|
||||||
"3kmg474slz42d",
|
|
||||||
"3kmemdt2ruu2d",
|
|
||||||
"3kmdhojihqw22",
|
|
||||||
"3km5p7t7yls2v",
|
|
||||||
"3klzyhzyjvd2e",
|
|
||||||
"3klxcthnt6222",
|
|
||||||
"3kltk25g4l62k",
|
|
||||||
"3kls5rchqlf25",
|
|
||||||
"3kloifjxe4f2o",
|
|
||||||
"3kllyc2fsh32r",
|
|
||||||
"3klkx3kduwd25",
|
|
||||||
"3klighxbzgd2y",
|
|
||||||
"3klhz44tmw32l",
|
|
||||||
"3klfhk4rurt2j",
|
|
||||||
"3klcy3n6rak2u",
|
|
||||||
"3klahec7c662c",
|
|
||||||
"3kl66egnxn323",
|
|
||||||
"3kkz64a6vx224",
|
|
||||||
"3kkx2xdin472f",
|
|
||||||
"3kkszqjc4xs2k",
|
|
||||||
"3kg6mhlaxr52q",
|
|
||||||
"3kg3srdu3jv2h",
|
|
||||||
"3kfzhkxpciu2a",
|
|
||||||
"3kfgle4bkw42w",
|
|
||||||
"3kfeae6duti2r",
|
|
||||||
"3kfbvgmsjhg2r",
|
|
||||||
"3kf3ebk7tco2q",
|
|
||||||
"3keyuh3dmer2i",
|
|
||||||
"3ketpnc2xdt27",
|
|
||||||
"3keh6776rog2e",
|
|
||||||
"3keel4tcwks2b",
|
|
||||||
"3keel3kyvcu24",
|
|
||||||
"3ke7jhcpek427",
|
|
||||||
"3ke3dq2v7742n",
|
|
||||||
"3kdt5he6ctv2o",
|
|
||||||
"3kdnywpvncg2z",
|
|
||||||
"3kdl7nhx5qs2e",
|
|
||||||
"3kdiy4fhmp22o",
|
|
||||||
"3kddthn3jp225",
|
|
||||||
"3kdbekg2bvz2n",
|
|
||||||
"3kd6xewt3ic27",
|
|
||||||
"3kd6vdwfcl62r",
|
|
||||||
"3kd3rrah2yg26",
|
|
||||||
"3kd2k4olmak2n",
|
|
||||||
"3kckqafe5dy2n",
|
|
||||||
"3kbwnfoelqx2x",
|
|
||||||
"3kb7zr6olkp2e",
|
|
||||||
"3kb7yjluo4k2o",
|
|
||||||
"3kayfm2aois2l",
|
|
||||||
"3kayfkv2jnv23",
|
|
||||||
"3kavjsv6jxi2l",
|
|
||||||
"3kagrbgquwe25",
|
|
||||||
"3kacvtqswp72s",
|
|
||||||
"3kaaqsygump2b",
|
|
||||||
"3ka77otgehr2c",
|
|
||||||
"3ka5xqef4af2c",
|
|
||||||
"3ka3qeixvje25",
|
|
||||||
"3ka252ljngl2q",
|
|
||||||
"3ka24bi5em72c"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
setup_logging()
|
|
||||||
parser = setup_args()
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
base_path = args.output
|
|
||||||
|
|
||||||
if not base_path.is_dir():
|
|
||||||
base_path.mkdir(parents=True)
|
|
||||||
|
|
||||||
logging.info("Beginning parsing")
|
|
||||||
|
|
||||||
threads = []
|
|
||||||
thread_limit = threading.BoundedSemaphore(value=args.threads)
|
|
||||||
|
|
||||||
for post_id in get_posts_from_rss(args.instance, args.handle):
|
|
||||||
t = threading.Thread(name=f"{args.handle}-{post_id}", target=load_strip, args=(base_path, args.instance, args.handle, post_id, args.plain, thread_limit))
|
|
||||||
threads.append(t)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
for t in threads:
|
|
||||||
t.join()
|
|
||||||
|
|
||||||
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 logging.handlers
|
||||||
import argparse
|
import argparse
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
from threading import Lock, Condition
|
from threading import Lock, Condition
|
||||||
import bisect
|
import bisect
|
||||||
import time
|
import time
|
||||||
@@ -153,8 +153,8 @@ class ImageRepo(ABC):
|
|||||||
if not self._data_loaded:
|
if not self._data_loaded:
|
||||||
self._load_data()
|
self._load_data()
|
||||||
logging.info("Completed loading of %s", self.get_identifier_string())
|
logging.info("Completed loading of %s", self.get_identifier_string())
|
||||||
self._data_loaded = True
|
self._data_loaded = True
|
||||||
self._load_lock.notify_all()
|
self._load_lock.notify_all()
|
||||||
|
|
||||||
def await_load(self):
|
def await_load(self):
|
||||||
"""
|
"""
|
||||||
@@ -204,8 +204,8 @@ class ImageRepo(ABC):
|
|||||||
logging.info("Downloading %s", self.get_identifier_string())
|
logging.info("Downloading %s", self.get_identifier_string())
|
||||||
self._download_data()
|
self._download_data()
|
||||||
logging.info("Completed downloading of %s", self.get_identifier_string())
|
logging.info("Completed downloading of %s", self.get_identifier_string())
|
||||||
self._data_downloaded = True
|
self._data_downloaded = True
|
||||||
self._download_lock.notify_all()
|
self._download_lock.notify_all()
|
||||||
|
|
||||||
def await_download(self):
|
def await_download(self):
|
||||||
"""
|
"""
|
||||||
@@ -426,9 +426,9 @@ class ComicStrip(ImageRepo, ABC):
|
|||||||
prefix = prefix_num_form.format(self.identifier)
|
prefix = prefix_num_form.format(self.identifier)
|
||||||
elif (type(self.identifier) == list or type(self.identifier) == tuple) and type(self.identifier[-1]) == int:
|
elif (type(self.identifier) == list or type(self.identifier) == tuple) and type(self.identifier[-1]) == int:
|
||||||
prefix = prefix_num_form.format(self.identifier[-1])
|
prefix = prefix_num_form.format(self.identifier[-1])
|
||||||
elif type(self.date) == datetime.date:
|
elif type(self.date) == date:
|
||||||
prefix = self.date.isoformat()
|
prefix = self.date.isoformat()
|
||||||
elif type(self.date) == datetime.datetime:
|
elif type(self.date) == datetime:
|
||||||
prefix = self.date.date().isoformat()
|
prefix = self.date.date().isoformat()
|
||||||
else:
|
else:
|
||||||
prefix = self.get_identifier_string()
|
prefix = self.get_identifier_string()
|
||||||
@@ -447,21 +447,21 @@ class ComicStrip(ImageRepo, ABC):
|
|||||||
return Path(base_path, f"{self.get_filename()}.cbz")
|
return Path(base_path, f"{self.get_filename()}.cbz")
|
||||||
|
|
||||||
def load_data(self):
|
def load_data(self):
|
||||||
super().load_data()
|
|
||||||
self.comic.load_data()
|
self.comic.load_data()
|
||||||
|
super().load_data()
|
||||||
self.comic.index_strip(self)
|
self.comic.index_strip(self)
|
||||||
|
|
||||||
def download_data(self):
|
def download_data(self):
|
||||||
super().download_data()
|
|
||||||
self.comic.download_data()
|
self.comic.download_data()
|
||||||
|
super().download_data()
|
||||||
|
|
||||||
def await_load(self):
|
def await_load(self):
|
||||||
super().await_load()
|
|
||||||
self.comic.await_load()
|
self.comic.await_load()
|
||||||
|
super().await_load()
|
||||||
|
|
||||||
def await_download(self):
|
def await_download(self):
|
||||||
super().await_download()
|
|
||||||
self.comic.await_download()
|
self.comic.await_download()
|
||||||
|
super().await_download()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _load_data(self):
|
def _load_data(self):
|
||||||
@@ -519,7 +519,8 @@ class ComicStrip(ImageRepo, ABC):
|
|||||||
for i in range(len(self.transformed_images)):
|
for i in range(len(self.transformed_images)):
|
||||||
image = self.transformed_images[i]
|
image = self.transformed_images[i]
|
||||||
image_path = Path(image.name)
|
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())
|
zip_stream.write(image_stream.read())
|
||||||
# Delete the base images, as they are no longer needed.
|
# Delete the base images, as they are no longer needed.
|
||||||
# We cannot touch the transformed images, since some
|
# We cannot touch the transformed images, since some
|
||||||
|
|||||||
Reference in New Issue
Block a user