Something went wrong. Try again.
visualize bsky quote trends sites.wisp.place/zzstoatzz.io/quotree
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133import { getBacklinkPages, getRecord, extractImages, getProfile, RateLimitError } from './api.js';import { isTextMatch } from './text.js';import { makeAtUri } from './parse.js';import { runWorkQueue } from './work-queue.js';
const SOURCES = [ 'app.bsky.feed.post:embed.record.uri', 'app.bsky.feed.post:embed.record.record.uri',];
export async function findRoot(uri, seedText, records = new Map(), onProgress) { const read = async (uri) => { if (!records.has(uri)) records.set(uri, await getRecord(uri)); return records.get(uri); }; onProgress?.(0); let current = uri; let record = await read(current); const visited = new Set([current]);
for (let i = 0; i < 50; i++) { const parentUri = record.value?.embed?.record?.record?.uri || record.value?.embed?.record?.uri; if (!parentUri || visited.has(parentUri)) break;
let parent; try { parent = await read(parentUri); } catch (error) { if (error instanceof RateLimitError) throw error; break; } if (!isTextMatch(parent.value?.text || '', seedText, 0.8)) break; visited.add(parentUri); current = parentUri; record = parent; onProgress?.(visited.size - 1); }
return { rootUri: current, rootRecord: record };}
export async function crawl(seedUri, seedText, { onNode, onStatus, records = new Map(), resolvedRoot }) { onStatus?.({ depth: 0, found: 0, phase: resolvedRoot ? 'finding quote posts…' : 'finding chain root…' }); const { rootUri, rootRecord } = resolvedRoot || await findRoot(seedUri, seedText, records, (followed) => { onStatus?.({ depth: 0, found: 0, phase: `finding chain root… ${followed} parent quotes followed` }); }); onStatus?.({ depth: 0, found: 0, phase: 'finding quote posts…' }); const seen = new Set([rootUri]); const profiles = new Map(); const profileFor = (did) => { if (!profiles.has(did)) profiles.set(did, getProfile(did)); return profiles.get(did); }; const makeNode = (uri, record, depth) => { const did = uri.split('/')[2]; return { uri, did, handle: did.slice(0, 20) + '…', avatar: null, text: record.value?.text || '', images: extractImages(did, record.value), children: [], depth, }; }; const root = makeNode(rootUri, rootRecord, 0); root.text ||= seedText; const requireImages = root.images.length > 0; let found = 0; let depth = 0;
const publish = async (node) => { const profile = await profileFor(node.did); node.handle = profile?.handle || node.handle; node.avatar = profile?.avatar || null; found++; depth = Math.max(depth, node.depth); onNode?.(node); onStatus?.({ depth, found }); };
const expand = (node, enqueue) => { for (const source of SOURCES) { enqueue(async (enqueue) => { for await (const backlinks of getBacklinkPages(node.uri, source)) { for (const bl of backlinks) { const uri = makeAtUri(bl.did, bl.collection, bl.rkey); if (seen.has(uri)) continue; seen.add(uri); enqueue(async (enqueue) => { let record; try { record = records.get(uri) || await getRecord(uri); } catch (error) { if (error instanceof RateLimitError) throw error; return; } const child = makeNode(uri, record, node.depth + 1); if (requireImages && child.images.length === 0) return; if (!isTextMatch(child.text, root.text)) return; node.children.push(child); expand(child, enqueue); await publish(child); }); } } }); } };
await runWorkQueue([ async (enqueue) => { expand(root, enqueue); }, () => publish(root), ]); return root;}
export function flattenTree(node) { const result = []; const pending = [node]; while (pending.length) { const current = pending.pop(); result.push(current); for (let i = current.children.length - 1; i >= 0; i--) { pending.push(current.children[i]); } } return result;}