search for standard sites pub-search.waow.tech
search zig blog atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

pub-search / scripts / backfill-recommends
12 kB 349 lines
1#!/usr/bin/env -S uv run --script --quiet 2# /// script 3# requires-python = ">=3.12" 4# dependencies = [ 5# "httpx", 6# "pydantic-settings", 7# "atproto", 8# ] 9# /// 10""" 11Backfill site.standard.graph.recommend records via CAR walk. 12 13Pattern (cribbed from prefect-pack/collection_creators): 14 lightrail listReposByCollection -> enumerate DIDs holding the collection 15 slingshot resolveMiniDoc -> map DID -> PDS endpoint 16 com.atproto.sync.getRepo -> fetch full repo as CAR 17 walk MST locally -> read each key+value with prefix 18 UPSERT into recommends -> idempotent on (uri) 19 20Why not tap? Tap is for streaming new records going forward. For a one-shot 21backfill, a CAR walk is one HTTP call per DID and gives ground truth. 22 23Usage: 24 TURSO_URL=libsql://... TURSO_TOKEN=... ./scripts/backfill-recommends 25 ./scripts/backfill-recommends --dry-run 26""" 27 28from __future__ import annotations 29 30import argparse 31import os 32import sys 33import time 34from concurrent.futures import ThreadPoolExecutor, as_completed 35from dataclasses import dataclass 36 37import httpx 38from atproto_core.car import CAR 39from atproto_core.cid import CID 40from pydantic_settings import BaseSettings, SettingsConfigDict 41 42SLINGSHOT = "https://slingshot.microcosm.blue" 43 44# Recommend-shaped lexicons in the wild, surveyed via constellation 2026-05-21. 45# Each entry: (collection NSID, field in record holding the document at-uri). 46DEFAULT_LEXICONS = [ 47 ("site.standard.graph.recommend", "document"), 48 ("pub.leaflet.interactions.recommend", "subject"), 49] 50 51# Union DIDs across multiple relays — lightrail's index lags the Bluesky 52# reference relay by ~15% for niche collections (216 vs 254 at last check). 53RELAY_ENDPOINTS = [ 54 "https://relay1.us-east.bsky.network", 55 "https://lightrail.microcosm.blue", 56 "https://relay.waow.tech", 57] 58 59 60class Settings(BaseSettings): 61 model_config = SettingsConfigDict( 62 env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" 63 ) 64 turso_url: str 65 turso_token: str 66 67 @property 68 def turso_host(self) -> str: 69 url = self.turso_url 70 if url.startswith("libsql://"): 71 url = url[len("libsql://") :] 72 return url 73 74 75# ---- Turso ---------------------------------------------------------------- 76 77 78def turso_pipeline(settings: Settings, requests: list[dict]) -> list[dict]: 79 last_err: Exception | None = None 80 for attempt in range(3): 81 try: 82 resp = httpx.post( 83 f"https://{settings.turso_host}/v2/pipeline", 84 headers={ 85 "Authorization": f"Bearer {settings.turso_token}", 86 "Content-Type": "application/json", 87 }, 88 json={"requests": [*requests, {"type": "close"}]}, 89 timeout=180, 90 ) 91 resp.raise_for_status() 92 return resp.json()["results"] 93 except (httpx.ReadTimeout, httpx.ConnectError, httpx.RemoteProtocolError) as e: 94 last_err = e 95 print( 96 f" turso pipeline attempt {attempt + 1} failed: {type(e).__name__}; retrying", 97 file=sys.stderr, 98 ) 99 time.sleep(2 * (attempt + 1)) 100 assert last_err is not None 101 raise last_err 102 103 104def turso_upsert_recommends(settings: Settings, rows: list[tuple[str, str, str, str, str]]) -> int: 105 """rows: (uri, did, rkey, document_uri, created_at).""" 106 BATCH = 50 107 written = 0 108 for i in range(0, len(rows), BATCH): 109 chunk = rows[i : i + BATCH] 110 reqs = [ 111 { 112 "type": "execute", 113 "stmt": { 114 "sql": ( 115 "INSERT INTO recommends (uri, did, rkey, document_uri, created_at, indexed_at) " 116 "VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%S','now')) " 117 "ON CONFLICT(uri) DO UPDATE SET " 118 " did = excluded.did, " 119 " rkey = excluded.rkey, " 120 " document_uri = excluded.document_uri, " 121 " created_at = excluded.created_at, " 122 " indexed_at = strftime('%Y-%m-%dT%H:%M:%S','now')" 123 ), 124 "args": [ 125 {"type": "text", "value": uri}, 126 {"type": "text", "value": did}, 127 {"type": "text", "value": rkey}, 128 {"type": "text", "value": doc_uri}, 129 {"type": "text", "value": created_at}, 130 ], 131 }, 132 } 133 for (uri, did, rkey, doc_uri, created_at) in chunk 134 ] 135 results = turso_pipeline(settings, reqs) 136 for r in results: 137 if r["type"] == "error": 138 print(f" upsert error: {r['error']}", file=sys.stderr) 139 continue 140 response = r.get("response") or {} 141 if response.get("type") != "execute": 142 continue 143 written += int(response.get("result", {}).get("affected_row_count", 0)) 144 return written 145 146 147# ---- Enumeration + resolution ------------------------------------------- 148 149 150def _enumerate_from_relay(client: httpx.Client, base: str, collection: str) -> list[str]: 151 dids: list[str] = [] 152 cursor: str | None = None 153 while True: 154 params: dict = {"collection": collection, "limit": 1000} 155 if cursor: 156 params["cursor"] = cursor 157 try: 158 r = client.get( 159 f"{base}/xrpc/com.atproto.sync.listReposByCollection", 160 params=params, 161 timeout=30, 162 ) 163 r.raise_for_status() 164 except Exception as e: 165 print(f" {base}: relay error {type(e).__name__}", file=sys.stderr) 166 return dids 167 body = r.json() 168 for repo in body.get("repos", []): 169 dids.append(repo["did"]) 170 cursor = body.get("cursor") 171 if not cursor: 172 return dids 173 174 175def enumerate_creator_dids(collection: str) -> list[str]: 176 all_dids: set[str] = set() 177 with httpx.Client() as client: 178 for base in RELAY_ENDPOINTS: 179 from_relay = _enumerate_from_relay(client, base, collection) 180 print(f" {base}: {len(from_relay)} DIDs") 181 all_dids.update(from_relay) 182 return sorted(all_dids) 183 184 185def resolve_pds(client: httpx.Client, did: str) -> str | None: 186 try: 187 r = client.get( 188 f"{SLINGSHOT}/xrpc/blue.microcosm.identity.resolveMiniDoc", 189 params={"identifier": did}, 190 timeout=15, 191 ) 192 r.raise_for_status() 193 return r.json().get("pds") 194 except Exception: 195 return None 196 197 198# ---- CAR walk ------------------------------------------------------------ 199 200 201def _walk_mst(car: CAR, collection: str) -> list[tuple[str, dict]]: 202 """Return [(rkey, decoded_value_dict), ...] for every entry whose key 203 starts with f"{collection}/". 204 205 MST nodes are nested via CIDs in `l` (left subtree) and `e[i].t` (right 206 subtree after entry i). Keys are byte-encoded with prefix compression 207 (`p` = chars shared with previous key, `k` = unique suffix). 208 """ 209 blocks = car.blocks 210 root = blocks[car.root] 211 data_cid = CID.decode(root["data"]) 212 213 prefix_bytes = f"{collection}/".encode() 214 out: list[tuple[str, dict]] = [] 215 stack = [data_cid] 216 while stack: 217 node = blocks[stack.pop()] 218 if (left := node.get("l")) is not None: 219 stack.append(CID.decode(left)) 220 prev_key = b"" 221 for entry in node["e"]: 222 key = prev_key[: entry["p"]] + entry["k"] 223 prev_key = key 224 if key.startswith(prefix_bytes): 225 value_cid = CID.decode(entry["v"]) 226 value = blocks[value_cid] 227 rkey = key[len(prefix_bytes) :].decode("utf-8", errors="replace") 228 out.append((rkey, value)) 229 if (t := entry.get("t")) is not None: 230 stack.append(CID.decode(t)) 231 return out 232 233 234# ---- Fetch one DID's recommends ------------------------------------------ 235 236 237@dataclass 238class FetchResult: 239 did: str 240 rows: list[tuple[str, str, str, str, str]] 241 status: str # "ok", "unresolved", "no-pds", "fetch-err:<type>", "parse-err" 242 243 244def fetch_recommends_for_did(client: httpx.Client, did: str, collection: str, doc_field: str) -> FetchResult: 245 pds = resolve_pds(client, did) 246 if not pds: 247 return FetchResult(did, [], "no-pds") 248 try: 249 r = client.get( 250 f"{pds}/xrpc/com.atproto.sync.getRepo", 251 params={"did": did}, 252 timeout=60, 253 ) 254 r.raise_for_status() 255 car = CAR.from_bytes(r.content) 256 except httpx.HTTPStatusError as e: 257 return FetchResult(did, [], f"fetch-err:{e.response.status_code}") 258 except Exception as e: 259 return FetchResult(did, [], f"fetch-err:{type(e).__name__}") 260 try: 261 entries = _walk_mst(car, collection) 262 except Exception: 263 return FetchResult(did, [], "parse-err") 264 265 rows: list[tuple[str, str, str, str, str]] = [] 266 for rkey, value in entries: 267 uri = f"at://{did}/{collection}/{rkey}" 268 document_uri = value.get(doc_field) if isinstance(value, dict) else None 269 created_at = value.get("createdAt") if isinstance(value, dict) else None 270 if not isinstance(document_uri, str): 271 continue # malformed; skip 272 rows.append((uri, did, rkey, document_uri, str(created_at) if created_at else "")) 273 return FetchResult(did, rows, "ok") 274 275 276# ---- Main --------------------------------------------------------------- 277 278 279def run_one_lexicon(settings: Settings, collection: str, doc_field: str, args) -> int: 280 print(f"\n=== {collection} (.{doc_field}) ===") 281 print("enumerating creator DIDs...") 282 dids = enumerate_creator_dids(collection) 283 if args.limit: 284 dids = dids[: args.limit] 285 print(f" {len(dids)} DIDs") 286 if not dids: 287 return 0 288 289 started = time.time() 290 done = 0 291 statuses: dict[str, int] = {} 292 rows_acc: list[tuple[str, str, str, str, str]] = [] 293 294 with httpx.Client() as client, ThreadPoolExecutor(max_workers=args.workers) as pool: 295 futures = { 296 pool.submit(fetch_recommends_for_did, client, did, collection, doc_field): did 297 for did in dids 298 } 299 for fut in as_completed(futures): 300 res = fut.result() 301 statuses[res.status] = statuses.get(res.status, 0) + 1 302 rows_acc.extend(res.rows) 303 done += 1 304 if done % 25 == 0 or done == len(dids): 305 elapsed = time.time() - started 306 rate = done / elapsed if elapsed > 0 else 0 307 print(f" [{done}/{len(dids)}] {rate:.1f} dids/s, {len(rows_acc)} records, statuses={statuses}") 308 309 print(f"collected {len(rows_acc)} records from {statuses.get('ok', 0)} DIDs") 310 if args.dry_run: 311 print("dry-run: skipping writes") 312 return 0 313 print("upserting...") 314 written = turso_upsert_recommends(settings, rows_acc) 315 print(f" wrote {written} rows") 316 return written 317 318 319def main() -> int: 320 parser = argparse.ArgumentParser(description="Backfill recommend-shaped records via CAR walk") 321 parser.add_argument("--dry-run", action="store_true", help="Don't write to Turso") 322 parser.add_argument("--workers", type=int, default=8, help="Concurrent PDS fetches") 323 parser.add_argument("--limit", type=int, default=None, help="Cap DIDs per lexicon (debug)") 324 parser.add_argument("--collection", default=None, help="Override: only process this NSID") 325 parser.add_argument("--doc-field", default=None, help="Field holding the document at-uri (paired with --collection)") 326 args = parser.parse_args() 327 328 try: 329 settings = Settings() # type: ignore 330 except Exception as e: 331 print(f"error loading settings: {e}", file=sys.stderr) 332 print("required env vars: TURSO_URL, TURSO_TOKEN", file=sys.stderr) 333 return 1 334 335 if args.collection: 336 field = args.doc_field or "document" 337 lexicons = [(args.collection, field)] 338 else: 339 lexicons = DEFAULT_LEXICONS 340 341 total = 0 342 for collection, doc_field in lexicons: 343 total += run_one_lexicon(settings, collection, doc_field, args) 344 print(f"\ngrand total: {total} rows written across {len(lexicons)} lexicon(s)") 345 return 0 346 347 348if __name__ == "__main__": 349 sys.exit(main())