#!/usr/bin/env python3 import argparse import json from html import escape from bscon import connect def get_posts(client, handle, limit=100): """Download all public posts of a profile with images.""" posts = [] cursor = None while True: resp = client.app.bsky.feed.get_author_feed({ "actor": handle, "limit": limit, "cursor": cursor, }) for item in resp.feed: post = item.post record = post.record # Extract images if any images = [] embed = getattr(record, "embed", None) if embed and hasattr(embed, "images"): images = [{"thumb": i.thumb, "full": i.fullsize} for i in embed.images] posts.append({ "uri": post.uri, "cid": post.cid, "createdAt": getattr(record, "createdAt", None) or getattr(record, "created_at", None), "text": getattr(record, "text", ""), "images": images }) cursor = getattr(resp, "cursor", None) if not cursor: break return posts def save_posts(posts, handle): """Save posts to JSON.""" out_file = f"{handle.replace('.', '_')}_posts.json" with open(out_file, "w", encoding="utf-8") as f: json.dump(posts, f, indent=2, ensure_ascii=False) print(f"Saved {len(posts)} posts to {out_file}") def uri_to_url(uri): """Convert at://did/... URIs to clickable Bluesky URLs.""" parts = uri.split("/") if len(parts) >= 5 and parts[3] == "app.bsky.feed.post": did = parts[2] rkey = parts[4] return f"https://bsky.app/profile/{did}/post/{rkey}" return uri def posts_to_html(posts, out_html): """Generate HTML with text and image thumbnails.""" with open(out_html, "w", encoding="utf-8") as f: f.write("\n") for post in posts: text = escape(post.get("text", "")) url = uri_to_url(post.get("uri", "")) imgs_html = "".join( f'' for img in post.get("images", []) ) f.write(f"

{text}

{imgs_html}
" f"View post
\n") f.write("\n") print(f"HTML saved to {out_html}") def main(): parser = argparse.ArgumentParser(description="Download Bluesky profile posts and generate HTML.") parser.add_argument("handle", help="Bluesky handle (example: alice.bsky.social)") parser.add_argument("--limit", type=int, default=100, help="Posts per request") args = parser.parse_args() client = connect() posts = get_posts(client, args.handle, args.limit) save_posts(posts, args.handle) out_html = f"{args.handle.replace('.', '_')}_posts.html" posts_to_html(posts, out_html) if __name__ == "__main__": main()