Adds Bluesky to the capabilities

This commit is contained in:
2025-07-29 18:01:00 -06:00
parent 01c8970e3a
commit 9fc635c1c7
3 changed files with 539 additions and 2 deletions

537
bluesky.py Normal file
View File

@@ -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()

View File

@@ -6,4 +6,4 @@ path=$(dirname $(realpath $0))
source "$path/venv/bin/activate" source "$path/venv/bin/activate"
"$path/xkcd.py" --output /mnt/MEDIA/Comics/xkcd -l "$path/xkcd.py" --output /mnt/MEDIA/Comics/xkcd -l
"$path/existential_comics.py" --output /mnt/MEDIA/Comics/existential_comics -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

View File

@@ -2,4 +2,4 @@ requests
beautifulsoup4 beautifulsoup4
lxml lxml
pillow pillow
libraqm atproto