From 9fc635c1c7ef147c2766cdb2a0765b404a5b636a Mon Sep 17 00:00:00 2001 From: Markil 3 Date: Tue, 29 Jul 2025 18:01:00 -0600 Subject: [PATCH] Adds Bluesky to the capabilities --- bluesky.py | 537 +++++++++++++++++++++++++++++++++++++++++++++++ download.sh | 2 +- requirements.txt | 2 +- 3 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 bluesky.py diff --git a/bluesky.py b/bluesky.py new file mode 100644 index 0000000..7f43001 --- /dev/null +++ b/bluesky.py @@ -0,0 +1,537 @@ +#!/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() + diff --git a/download.sh b/download.sh index 7377fee..fe536f7 100755 --- a/download.sh +++ b/download.sh @@ -6,4 +6,4 @@ path=$(dirname $(realpath $0)) source "$path/venv/bin/activate" "$path/xkcd.py" --output /mnt/MEDIA/Comics/xkcd -l "$path/existential_comics.py" --output /mnt/MEDIA/Comics/existential_comics -l - +"$path/bluesky.py" --handle fieldexplores.bsky.social --output /mnt/MEDIA/Comics/field_explores diff --git a/requirements.txt b/requirements.txt index 3f00347..47d75e8 100755 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ requests beautifulsoup4 lxml pillow -libraqm +atproto