|
#!/usr/bin/env python3 |
|
# /// script |
|
# requires-python = ">=3.13" |
|
# dependencies = [ |
|
# "requests", |
|
# "rich" |
|
# ] |
|
# /// |
|
""" |
|
🧹 Threadgate Janitor |
|
Finds and removes abandoned threadgates — reply restrictions left behind |
|
after their parent post was deleted. |
|
""" |
|
|
|
import sys |
|
import time |
|
import getpass |
|
from dataclasses import dataclass, field |
|
|
|
import requests |
|
from rich.console import Console |
|
from rich.live import Live |
|
from rich.panel import Panel |
|
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, MofNCompleteColumn |
|
from rich.table import Table |
|
from rich.text import Text |
|
from rich import box |
|
|
|
console = Console() |
|
|
|
BASE = "https://bsky.social/xrpc" |
|
# Be a polite citizen: small pause between API calls |
|
REQUEST_DELAY = 0.05 |
|
|
|
|
|
@dataclass |
|
class Stats: |
|
threadgates_found: int = 0 |
|
posts_alive: int = 0 |
|
posts_missing: int = 0 |
|
deleted: int = 0 |
|
errors: int = 0 |
|
error_details: list = field(default_factory=list) |
|
|
|
|
|
def authenticate(session: requests.Session, handle: str, password: str) -> tuple[str, str]: |
|
"""Authenticate and return (access_jwt, did).""" |
|
resp = session.post(f"{BASE}/com.atproto.server.createSession", json={ |
|
"identifier": handle, |
|
"password": password, |
|
}) |
|
resp.raise_for_status() |
|
data = resp.json() |
|
return data["accessJwt"], data["did"] |
|
|
|
|
|
def fetch_all_threadgates(session: requests.Session, did: str) -> list[dict]: |
|
"""Paginate through all threadgate records.""" |
|
records = [] |
|
cursor = None |
|
|
|
with Progress( |
|
SpinnerColumn("dots"), |
|
TextColumn("[bold cyan]Fetching threadgates…"), |
|
BarColumn(bar_width=30), |
|
MofNCompleteColumn(), |
|
console=console, |
|
transient=True, |
|
) as progress: |
|
task = progress.add_task("fetch", total=None) |
|
|
|
while True: |
|
params = { |
|
"repo": did, |
|
"collection": "app.bsky.feed.threadgate", |
|
"limit": 100, |
|
} |
|
if cursor: |
|
params["cursor"] = cursor |
|
|
|
resp = session.get(f"{BASE}/com.atproto.repo.listRecords", params=params) |
|
resp.raise_for_status() |
|
data = resp.json() |
|
|
|
batch = data.get("records", []) |
|
records.extend(batch) |
|
progress.update(task, completed=len(records)) |
|
|
|
cursor = data.get("cursor") |
|
if not cursor or not batch: |
|
break |
|
|
|
time.sleep(REQUEST_DELAY) |
|
|
|
return records |
|
|
|
|
|
def rkey_from_uri(uri: str) -> str: |
|
"""Extract the record key (last segment) from an at:// URI.""" |
|
return uri.rsplit("/", 1)[-1] |
|
|
|
|
|
def post_exists(session: requests.Session, did: str, rkey: str, token: str) -> bool: |
|
"""Check whether a post record still exists. Returns True if alive.""" |
|
resp = session.get(f"{BASE}/com.atproto.repo.getRecord", params={ |
|
"repo": did, |
|
"collection": "app.bsky.feed.post", |
|
"rkey": rkey, |
|
}, headers={"Authorization": f"Bearer {token}"}) |
|
return resp.status_code == 200 |
|
|
|
|
|
def delete_threadgate(session: requests.Session, did: str, rkey: str, token: str) -> bool: |
|
"""Delete a threadgate record. Returns True on success.""" |
|
resp = session.post(f"{BASE}/com.atproto.repo.deleteRecord", json={ |
|
"repo": did, |
|
"collection": "app.bsky.feed.threadgate", |
|
"rkey": rkey, |
|
}, headers={"Authorization": f"Bearer {token}"}) |
|
return resp.status_code == 200 |
|
|
|
|
|
def build_summary(stats: Stats, elapsed: float) -> Panel: |
|
"""Build a rich summary panel.""" |
|
table = Table(show_header=False, box=None, padding=(0, 2)) |
|
table.add_column(style="dim") |
|
table.add_column(style="bold") |
|
|
|
table.add_row("Threadgates scanned", str(stats.threadgates_found)) |
|
table.add_row("Posts still alive", f"[green]{stats.posts_alive}[/]") |
|
table.add_row("Orphaned & deleted", f"[red]{stats.deleted}[/]") |
|
if stats.errors: |
|
table.add_row("Errors", f"[yellow]{stats.errors}[/]") |
|
table.add_row("Time elapsed", f"{elapsed:.1f}s") |
|
|
|
return Panel(table, title="[bold]Summary[/]", border_style="bright_blue", box=box.ROUNDED) |
|
|
|
|
|
def main(): |
|
console.print() |
|
console.print( |
|
Panel( |
|
"[bold]Threadgate Janitor[/]\n" |
|
"[dim]Sweeps away reply restrictions orphaned by deleted posts[/]", |
|
box=box.DOUBLE, |
|
border_style="bright_magenta", |
|
padding=(1, 4), |
|
) |
|
) |
|
console.print() |
|
|
|
# --- Credentials --- |
|
handle = console.input("[bold cyan]Handle:[/] ") |
|
password = getpass.getpass("App password: ") |
|
console.print() |
|
|
|
# --- Session setup --- |
|
session = requests.Session() |
|
session.headers["Accept"] = "application/json" |
|
|
|
# --- Auth --- |
|
with console.status("[bold cyan]Authenticating…[/]", spinner="dots"): |
|
try: |
|
token, did = authenticate(session, handle, password) |
|
except requests.HTTPError as e: |
|
console.print(f"[bold red]Authentication failed:[/] {e.response.status_code} — double-check your handle and app password.") |
|
sys.exit(1) |
|
|
|
console.print(f" [green]✓[/] Logged in as [bold]{handle}[/] [dim]({did})[/]") |
|
console.print() |
|
|
|
# --- Fetch threadgates --- |
|
records = fetch_all_threadgates(session, did) |
|
|
|
if not records: |
|
console.print(" [green]✓[/] No threadgates found — nothing to clean up!") |
|
return |
|
|
|
console.print(f" [cyan]Found {len(records)} threadgate(s) to inspect.[/]") |
|
console.print() |
|
|
|
# --- Inspect & clean --- |
|
stats = Stats(threadgates_found=len(records)) |
|
start = time.monotonic() |
|
|
|
with Progress( |
|
SpinnerColumn("dots"), |
|
TextColumn("[bold]{task.description}"), |
|
BarColumn(bar_width=30), |
|
MofNCompleteColumn(), |
|
TextColumn("[dim]{task.fields[status]}[/]"), |
|
console=console, |
|
) as progress: |
|
task = progress.add_task("Inspecting", total=len(records), status="") |
|
|
|
for rec in records: |
|
tg_uri = rec["uri"] |
|
tg_rkey = rkey_from_uri(tg_uri) |
|
post_uri = rec.get("value", {}).get("post", "") |
|
post_rkey = rkey_from_uri(post_uri) if post_uri else None |
|
|
|
short_id = tg_rkey[:12] |
|
|
|
if not post_rkey: |
|
# Malformed threadgate with no post reference — treat as orphan |
|
stats.posts_missing += 1 |
|
progress.update(task, status=f"[yellow]⚠ {short_id} — no post ref[/]") |
|
elif post_exists(session, did, post_rkey, token): |
|
stats.posts_alive += 1 |
|
progress.update(task, status=f"[green]✓ {short_id}[/]") |
|
progress.advance(task) |
|
time.sleep(REQUEST_DELAY) |
|
continue |
|
else: |
|
stats.posts_missing += 1 |
|
progress.update(task, status=f"[red]✗ {short_id} — orphaned[/]") |
|
|
|
# Delete the orphan |
|
time.sleep(REQUEST_DELAY) |
|
if delete_threadgate(session, did, tg_rkey, token): |
|
stats.deleted += 1 |
|
progress.update(task, status=f"[red]🗑 {short_id} — removed[/]") |
|
else: |
|
stats.errors += 1 |
|
stats.error_details.append(tg_rkey) |
|
progress.update(task, status=f"[yellow]⚠ {short_id} — delete failed[/]") |
|
|
|
progress.advance(task) |
|
time.sleep(REQUEST_DELAY) |
|
|
|
elapsed = time.monotonic() - start |
|
console.print() |
|
console.print(build_summary(stats, elapsed)) |
|
|
|
if stats.errors: |
|
console.print(f"\n [yellow]Could not delete:[/] {', '.join(stats.error_details)}") |
|
|
|
if stats.deleted: |
|
console.print(f"\n [bright_magenta]Swept away {stats.deleted} orphan(s). Tidy! 🧹[/]") |
|
else: |
|
console.print(f"\n [green]All threadgates are accounted for — nothing to clean.[/]") |
|
|
|
console.print() |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |