···1111## scripts
12121313- [`check-files-for-bad-links`](#check-files-for-bad-links)
1414+- [`find-longest-bsky-thread`](#find-longest-bsky-thread)
1415- [`kill-processes`](#kill-processes)
1516- [`update-lights`](#update-lights)
1617- [`update-readme`](#update-readme)
···3435- pass exclude globs to skip (e.g. `*.md`)
3536- pass ignore-url prefixes to ignore (e.g. `http://localhost` or `https://localhost`)
3637- pass concurrency to run the checks concurrently (default is 50)
3838+3939+---
4040+4141+### `find-longest-bsky-thread`
4242+4343+Find the longest reply thread from a Bluesky post.
4444+4545+Usage:
4646+4747+```bash
4848+./find-longest-bsky-thread https://bsky.app/profile/nerditry.bsky.social/post/3lnofix5nlc23
4949+```
5050+5151+Details:
5252+- uses [`atproto`](https://github.com/MarshalX/atproto) to fetch the thread
5353+- uses [`jinja2`](https://github.com/pallets/jinja) to render the thread
37543855---
3956
+1-1
check-files-for-bad-links
···6060async def _probe(client: httpx.AsyncClient, url: str) -> LinkResult:
6161 try:
6262 r = await client.head(url, follow_redirects=True)
6363- if r.status_code in {405, 403}:
6363+ if r.status_code in {405, 403, 404}:
6464 r = await client.get(url, follow_redirects=True)
6565 return LinkResult(url, r.status_code, 200 <= r.status_code < 400, frozenset())
6666 except Exception as exc:
+236
find-longest-bsky-thread
···11+#!/usr/bin/env -S uv run --script --quiet
22+# /// script
33+# requires-python = ">=3.12"
44+# dependencies = ["atproto", "jinja2", "pydantic-settings"]
55+# ///
66+"""
77+Find the longest reply thread from a Bluesky post.
88+99+Usage:
1010+1111+```bash
1212+./find-longest-bsky-thread https://bsky.app/profile/nerditry.bsky.social/post/3lnofix5nlc23
1313+```
1414+1515+Details:
1616+- uses [`atproto`](https://github.com/MarshalX/atproto) to fetch the thread
1717+- uses [`jinja2`](https://github.com/pallets/jinja) to render the thread
1818+"""
1919+2020+import argparse
2121+import os
2222+from datetime import datetime
2323+from typing import Any
2424+2525+from atproto import Client
2626+from atproto.exceptions import BadRequestError
2727+from atproto_client.models.app.bsky.feed.defs import ThreadViewPost
2828+from jinja2 import Environment
2929+from pydantic_settings import BaseSettings, SettingsConfigDict
3030+3131+3232+class Settings(BaseSettings):
3333+ """App settings loaded from environment variables"""
3434+3535+ model_config = SettingsConfigDict(
3636+ env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore"
3737+ )
3838+3939+ bsky_handle: str
4040+ bsky_password: str
4141+4242+4343+def extract_post_uri(bluesky_url: str) -> str:
4444+ """Extract the AT URI from a Bluesky post URL"""
4545+ import re
4646+4747+ pattern = r"https?://bsky\.app/profile/([^/]+)/post/([a-zA-Z0-9]+)"
4848+ match = re.match(pattern, bluesky_url)
4949+ if not match:
5050+ raise ValueError(f"Invalid Bluesky URL format: {bluesky_url}")
5151+ profile_did_or_handle = match.group(1)
5252+ post_id = match.group(2)
5353+5454+ # We need the DID, not necessarily the handle, for the URI
5555+ # However, getPostThread seems to work with handles too, but let's be robust
5656+ # For now, we construct the URI assuming the input might be a handle or DID
5757+ # A more robust solution would resolve the handle to a DID if needed.
5858+ # Let's try constructing a basic URI first. `get_post_thread` might handle resolution.
5959+ return f"at://{profile_did_or_handle}/app.bsky.feed.post/{post_id}"
6060+6161+6262+def get_thread(client: Client, post_uri: str) -> ThreadViewPost | None:
6363+ """Fetch the full thread view for a given post URI."""
6464+ # Slightly reduced depth, as we might fetch sub-threads explicitly
6565+ depth = 50
6666+ # Parent height arguably less crucial for finding the *longest child* path
6767+ parent_height = 2
6868+ try:
6969+ response = client.app.bsky.feed.get_post_thread(
7070+ {"uri": post_uri, "depth": depth, "parent_height": parent_height}
7171+ )
7272+ if isinstance(response.thread, ThreadViewPost):
7373+ return response.thread
7474+ else:
7575+ # Handle cases where the post is not found, blocked, or deleted
7676+ # Suppress print for non-root calls later if needed
7777+ print(
7878+ f"Could not fetch thread or it's not a standard post thread: {post_uri}"
7979+ )
8080+ return None
8181+ except BadRequestError as e:
8282+ print(f"Error fetching thread {post_uri}: {e}")
8383+ return None
8484+ except Exception as e:
8585+ print(f"An unexpected error occurred fetching thread {post_uri}: {e}")
8686+ return None
8787+8888+8989+def find_longest_thread_path(
9090+ client: Client, thread: ThreadViewPost | None
9191+) -> list[ThreadViewPost]:
9292+ """Find the longest path of replies starting from the given thread view."""
9393+ if not thread or not isinstance(thread, ThreadViewPost) or not thread.post:
9494+ # Base case: Invalid or deleted/blocked post in the middle of a thread
9595+ return []
9696+9797+ longest_reply_extension: list[ThreadViewPost] = []
9898+ max_len = 0
9999+100100+ # Use replies from the current view, but potentially refresh if they seem incomplete
101101+ replies_to_check = thread.replies if thread.replies else []
102102+103103+ for reply_view in replies_to_check:
104104+ # Recurse only on valid ThreadViewPost replies
105105+ if isinstance(reply_view, ThreadViewPost) and reply_view.post:
106106+ current_reply_view = reply_view
107107+108108+ # If this reply has no children loaded, try fetching its thread directly
109109+ if not current_reply_view.replies:
110110+ # Check if the post *claims* to have replies (optional optimization, needs PostView check)
111111+ # For simplicity now, just always try fetching if replies are empty.
112112+ fetched_reply_view = get_thread(client, current_reply_view.post.uri)
113113+ if fetched_reply_view and fetched_reply_view.replies:
114114+ current_reply_view = fetched_reply_view # Use the richer view
115115+116116+ # Now recurse with the potentially updated view
117117+ recursive_path = find_longest_thread_path(client, current_reply_view)
118118+ if len(recursive_path) > max_len:
119119+ max_len = len(recursive_path)
120120+ longest_reply_extension = recursive_path
121121+122122+ # The full path includes the current post + the longest path found among its replies
123123+ return [thread] + longest_reply_extension
124124+125125+126126+def format_post_for_template(post_view: ThreadViewPost) -> dict[str, Any] | None:
127127+ """Extract relevant data from a ThreadViewPost for template rendering."""
128128+ if not post_view or not post_view.post:
129129+ return None
130130+131131+ post = post_view.post
132132+ record = post.record
133133+134134+ # Attempt to parse the timestamp
135135+ timestamp_str = getattr(record, "created_at", None)
136136+ timestamp_dt = None
137137+ if timestamp_str:
138138+ try:
139139+ # Handle different possible ISO 8601 formats from Bluesky
140140+ if "." in timestamp_str and "Z" in timestamp_str:
141141+ # Format like 2024-07-26T15:07:19.123Z
142142+ timestamp_dt = datetime.fromisoformat(
143143+ timestamp_str.replace("Z", "+00:00")
144144+ )
145145+ else:
146146+ # Potentially other formats, add more parsing if needed
147147+ print(f"Warning: Unrecognized timestamp format {timestamp_str}")
148148+ timestamp_dt = None # Or handle error appropriately
149149+ except ValueError:
150150+ print(f"Warning: Could not parse timestamp {timestamp_str}")
151151+ timestamp_dt = None
152152+153153+ return {
154154+ "author": post.author.handle,
155155+ "text": getattr(record, "text", "[No text content]"),
156156+ "timestamp": timestamp_dt.strftime("%Y-%m-%d %H:%M:%S UTC")
157157+ if timestamp_dt
158158+ else "[Unknown time]",
159159+ "uri": post.uri,
160160+ "cid": post.cid,
161161+ }
162162+163163+164164+def main(post_url: str, template_str: str):
165165+ """Main function to find and render the longest thread."""
166166+ try:
167167+ settings = Settings() # type: ignore
168168+ except Exception as e:
169169+ print(
170170+ f"Error loading settings (ensure .env file exists with BSKY_HANDLE and BSKY_PASSWORD): {e}"
171171+ )
172172+ return
173173+174174+ client = Client()
175175+ try:
176176+ client.login(settings.bsky_handle, settings.bsky_password)
177177+ except Exception as e:
178178+ print(f"Error logging into Bluesky: {e}")
179179+ return
180180+181181+ try:
182182+ post_uri = extract_post_uri(post_url)
183183+ except ValueError as e:
184184+ print(e)
185185+ return
186186+187187+ print(f"Fetching thread for: {post_uri}")
188188+ root_thread_view = get_thread(client, post_uri)
189189+190190+ if not root_thread_view:
191191+ print("Failed to fetch the root post thread.")
192192+ return
193193+194194+ # --- Finding the longest path ---
195195+ print("Finding the longest thread path...")
196196+ longest_path_views = find_longest_thread_path(client, root_thread_view)
197197+ print(f"Found {len(longest_path_views)} post(s) in the longest path.")
198198+ # --- End Finding ---
199199+200200+ thread_data = [
201201+ data
202202+ for view in longest_path_views
203203+ if (data := format_post_for_template(view)) is not None
204204+ ]
205205+206206+ if not thread_data:
207207+ print("No valid posts found in the path to render.")
208208+ return
209209+210210+ # Render using Jinja
211211+ environment = Environment()
212212+ template = environment.from_string(template_str)
213213+ output = template.render(posts=thread_data)
214214+215215+ print("\\n--- Rendered Thread ---")
216216+ print(output)
217217+ print("--- End Rendered Thread ---")
218218+219219+220220+if __name__ == "__main__":
221221+ parser = argparse.ArgumentParser(
222222+ description="Find and render the longest reply thread from a Bluesky post."
223223+ )
224224+ parser.add_argument("post_url", help="The URL of the starting Bluesky post.")
225225+ args = parser.parse_args()
226226+227227+ # Default Jinja Template
228228+ default_template = """
229229+{% for post in posts %}
230230+{{ loop.index }}. {{ post.author }} at {{ post.timestamp }}
231231+ URI: {{ post.uri }}
232232+ Text: {{ post.text | indent(width=4, first=false) }}
233233+{% endfor %}
234234+"""
235235+236236+ main(args.post_url, default_template)