export const API_BASE = "https://public.api.bsky.app/xrpc"; const CACHE_FILE = "cache.json"; // Logging helper - all progress goes to stderr so stdout is clean for data const log = (...args: unknown[]) => console.error(...args); export interface CacheData { profile: Profile; pdsUrl: string; follows: Record; followDates: Record; // ISO date strings posts: FeedItem[]; cachedAt: string; } async function loadCache(): Promise { try { const file = Bun.file(CACHE_FILE); if (await file.exists()) { const data = await file.json(); return data as CacheData; } } catch { // Cache doesn't exist or is invalid } return null; } async function saveCache(data: CacheData): Promise { await Bun.write(CACHE_FILE, JSON.stringify(data, null, 2)); log(`[cache] saved to ${CACHE_FILE}`); } // Allows tests to inject a mock fetch type FetchFn = (url: string) => Promise; let fetchImpl: FetchFn = fetch; export function setFetchImpl(fn: FetchFn) { fetchImpl = fn; } export function resetFetchImpl() { fetchImpl = fetch; } async function apiFetch(url: string): Promise { return fetchImpl(url); } export interface DidDocument { id: string; service?: Array<{ id: string; type: string; serviceEndpoint: string; }>; } export async function resolvePds(did: string): Promise { const url = `https://plc.directory/${did}`; const res = await apiFetch(url); if (!res.ok) { throw new Error(`Failed to resolve DID ${did}: ${res.status}`); } const doc: DidDocument = await res.json(); const pdsService = doc.service?.find(s => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"); if (!pdsService) { throw new Error(`No PDS service found in DID document for ${did}`); } return pdsService.serviceEndpoint; } export interface Profile { did: string; handle: string; displayName?: string; } export interface Follow { did: string; handle: string; displayName?: string; } export interface FollowRecord { uri: string; value: { subject: string; createdAt: string; }; } export interface PostRecord { createdAt?: string; reply?: { parent: { uri: string; cid: string }; root: { uri: string; cid: string }; }; } export interface FeedItem { post: { uri: string; author: { did: string; handle: string }; record: PostRecord; }; reply?: { parent?: { author: { did: string; handle: string } }; root?: { author: { did: string; handle: string } }; }; } export interface ScoredEntry { handle: string; score: number; engagement: number; freshness: number; recency: number; } export function formatOutput(entries: ScoredEntry[]): string { const header = "handle score engagement freshness recency"; const rows = entries.map(({ handle, score, engagement, freshness, recency }) => `${handle} ${score} ${engagement} ${freshness} ${recency}` ); return [header, ...rows].join("\n"); } export const SCORE_DIRECT_REPLY = 10; export const SCORE_THREAD_REPLY = 3; export const SCORE_FRESHNESS_MAX = 50; export const FRESHNESS_DECAY_DAYS = 25; export const SCORE_RECENCY_PENALTY_PER_DAY = 1; export async function getProfile(handle: string): Promise { const url = `${API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`; const res = await apiFetch(url); if (!res.ok) { throw new Error(`Failed to get profile for ${handle}: ${res.status}`); } return res.json(); } export async function getFollows(actor: string, cursor?: string): Promise<{ follows: Follow[]; cursor?: string }> { const params = new URLSearchParams({ actor, limit: "100" }); if (cursor) params.set("cursor", cursor); const url = `${API_BASE}/app.bsky.graph.getFollows?${params}`; const res = await apiFetch(url); if (!res.ok) { throw new Error(`Failed to get follows: ${res.status}`); } return res.json(); } export async function getAuthorFeed(actor: string, cursor?: string): Promise<{ feed: FeedItem[]; cursor?: string }> { const params = new URLSearchParams({ actor, limit: "100", filter: "posts_with_replies" }); if (cursor) params.set("cursor", cursor); const url = `${API_BASE}/app.bsky.feed.getAuthorFeed?${params}`; const res = await apiFetch(url); if (!res.ok) { throw new Error(`Failed to get author feed: ${res.status}`); } return res.json(); } export async function getAllFollows(actor: string): Promise> { const follows = new Map(); let cursor: string | undefined; let page = 1; log("[follows] fetching who you follow..."); do { const data = await getFollows(actor, cursor); for (const follow of data.follows) { follows.set(follow.did, follow); } log(`[follows] page ${page}: got ${data.follows.length} follows (total: ${follows.size})`); cursor = data.cursor; page++; } while (cursor); log(`[follows] done. you follow ${follows.size} accounts`); return follows; } export async function getAllPosts(actor: string): Promise { const posts: FeedItem[] = []; let cursor: string | undefined; let page = 1; log("[posts] fetching your posts and replies..."); do { const data = await getAuthorFeed(actor, cursor); posts.push(...data.feed); log(`[posts] page ${page}: got ${data.feed.length} posts (total: ${posts.length})`); cursor = data.cursor; page++; } while (cursor); log(`[posts] done. found ${posts.length} posts/replies`); return posts; } export async function getFollowRecords(pdsUrl: string, actor: string, cursor?: string): Promise<{ records: FollowRecord[]; cursor?: string }> { const params = new URLSearchParams({ repo: actor, collection: "app.bsky.graph.follow", limit: "100" }); if (cursor) params.set("cursor", cursor); const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`; const res = await apiFetch(url); if (!res.ok) { throw new Error(`Failed to get follow records: ${res.status}`); } return res.json(); } export async function getAllFollowRecords(pdsUrl: string, actor: string): Promise> { const followDates = new Map(); let cursor: string | undefined; let page = 1; log("[follow-dates] fetching follow timestamps..."); do { const data = await getFollowRecords(pdsUrl, actor, cursor); for (const record of data.records) { followDates.set(record.value.subject, new Date(record.value.createdAt)); } log(`[follow-dates] page ${page}: got ${data.records.length} records (total: ${followDates.size})`); cursor = data.cursor; page++; } while (cursor); log(`[follow-dates] done. got timestamps for ${followDates.size} follows`); return followDates; } export function scoreFreshness(followDates: Map, follows: Map): Map { const scores = new Map(); const now = new Date(); for (const [did] of follows) { const followDate = followDates.get(did); if (!followDate) { scores.set(did, 0); continue; } const daysAgo = Math.floor((now.getTime() - followDate.getTime()) / (1000 * 60 * 60 * 24)); const freshness = Math.max(0, SCORE_FRESHNESS_MAX - daysAgo * 2); scores.set(did, freshness); } const freshCount = [...scores.values()].filter(s => s > 0).length; log(`[scoring] ${freshCount} follows within last ${FRESHNESS_DECAY_DAYS} days get freshness bonus`); return scores; } export function scoreEngagement(posts: FeedItem[], follows: Map, myDid: string): Map { const scores = new Map(); // Initialize all follows with 0 for (const [did] of follows) { scores.set(did, 0); } let directReplies = 0; let threadReplies = 0; for (const item of posts) { const record = item.post.record; // Skip if not a reply if (!record.reply) continue; const parentAuthorDid = item.reply?.parent?.author?.did; const rootAuthorDid = item.reply?.root?.author?.did; // Direct reply to someone we follow if (parentAuthorDid && parentAuthorDid !== myDid && follows.has(parentAuthorDid)) { scores.set(parentAuthorDid, (scores.get(parentAuthorDid) || 0) + SCORE_DIRECT_REPLY); directReplies++; } // Reply in a thread started by someone we follow (but not a direct reply to them) if (rootAuthorDid && rootAuthorDid !== myDid && rootAuthorDid !== parentAuthorDid && follows.has(rootAuthorDid)) { scores.set(rootAuthorDid, (scores.get(rootAuthorDid) || 0) + SCORE_THREAD_REPLY); threadReplies++; } } log(`[scoring] found ${directReplies} direct replies and ${threadReplies} thread replies to people you follow`); return scores; } export function combineScores( follows: Map, engagementScores: Map, freshnessScores: Map, recencyScores: Map ): ScoredEntry[] { const results: ScoredEntry[] = []; for (const [did, follow] of follows) { const engagement = engagementScores.get(did) || 0; const freshness = freshnessScores.get(did) || 0; // Skip recency penalty for fresh follows (freshness > 0) const recency = freshness > 0 ? 0 : (recencyScores.get(did) || 0); const score = engagement + freshness + recency; results.push({ handle: follow.handle, score, engagement, freshness, recency, }); } return results.sort((a, b) => b.score - a.score); } export function scoreRecency(posts: FeedItem[], follows: Map, myDid: string, followDates: Map, now?: Date): Map { const penalties = new Map(); const lastEngagement = new Map(); const currentDate = now || new Date(); // Initialize all follows with no engagement for (const [did] of follows) { penalties.set(did, 0); } // Find most recent engagement with each follow for (const item of posts) { const record = item.post.record; // Skip if not a reply or no createdAt if (!record.reply || !record.createdAt) continue; const postDate = new Date(record.createdAt); const parentAuthorDid = item.reply?.parent?.author?.did; const rootAuthorDid = item.reply?.root?.author?.did; // Check direct reply if (parentAuthorDid && parentAuthorDid !== myDid && follows.has(parentAuthorDid)) { const existing = lastEngagement.get(parentAuthorDid); if (!existing || postDate > existing) { lastEngagement.set(parentAuthorDid, postDate); } } // Check thread participation if (rootAuthorDid && rootAuthorDid !== myDid && rootAuthorDid !== parentAuthorDid && follows.has(rootAuthorDid)) { const existing = lastEngagement.get(rootAuthorDid); if (!existing || postDate > existing) { lastEngagement.set(rootAuthorDid, postDate); } } } // Calculate penalties based on days since last engagement or follow date let penalizedCount = 0; let noEngagementCount = 0; for (const [did] of follows) { const lastDate = lastEngagement.get(did); if (!lastDate) { // No engagement found - penalize based on follow age const followDate = followDates.get(did); if (followDate) { const daysSinceFollow = Math.floor((currentDate.getTime() - followDate.getTime()) / (1000 * 60 * 60 * 24)); if (daysSinceFollow > 0) { const penalty = daysSinceFollow * SCORE_RECENCY_PENALTY_PER_DAY; penalties.set(did, -penalty); noEngagementCount++; } } continue; } const daysSinceEngagement = Math.floor((currentDate.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24)); if (daysSinceEngagement > 0) { const penalty = daysSinceEngagement * SCORE_RECENCY_PENALTY_PER_DAY; penalties.set(did, -penalty); penalizedCount++; } } log(`[scoring] ${penalizedCount} follows have stale engagement penalties, ${noEngagementCount} have no-engagement penalties`); return penalties; } async function main(handle: string, targetHandle?: string) { log(`\nanalyzing engagement for @${handle}${targetHandle ? ` with @${targetHandle}` : ""}\n`); let profile: Profile; let pdsUrl: string; let follows: Map; let followDates: Map; let posts: FeedItem[]; // Check for cache const cache = await loadCache(); if (cache) { log(`[cache] using cached data from ${CACHE_FILE} (cached at ${cache.cachedAt})\n`); profile = cache.profile; pdsUrl = cache.pdsUrl; followDates = new Map( Object.entries(cache.followDates).map(([did, dateStr]) => [did, new Date(dateStr)]) ); posts = cache.posts; if (targetHandle) { // Single target mode - filter to one account from cache log("[target] resolving target account..."); const targetProfile = await getProfile(targetHandle); log(`[target] found: ${targetProfile.displayName || targetProfile.handle} (${targetProfile.did})\n`); follows = new Map([[targetProfile.did, { did: targetProfile.did, handle: targetProfile.handle, displayName: targetProfile.displayName, }]]); } else { follows = new Map(Object.entries(cache.follows)); } log(`[cache] loaded ${follows.size} follows, ${followDates.size} follow dates, ${posts.length} posts\n`); } else { // Validate handle log("[profile] validating handle..."); profile = await getProfile(handle); log(`[profile] found: ${profile.displayName || profile.handle} (${profile.did})\n`); // Resolve user's PDS log("[pds] resolving user's PDS..."); pdsUrl = await resolvePds(profile.did); log(`[pds] found: ${pdsUrl}\n`); if (targetHandle) { // Single target mode - only analyze one account log("[target] resolving target account..."); const targetProfile = await getProfile(targetHandle); log(`[target] found: ${targetProfile.displayName || targetProfile.handle} (${targetProfile.did})\n`); follows = new Map([[targetProfile.did, { did: targetProfile.did, handle: targetProfile.handle, displayName: targetProfile.displayName, }]]); } else { // Get all follows follows = await getAllFollows(profile.did); log(); } // Get follow timestamps followDates = await getAllFollowRecords(pdsUrl, profile.did); log(); // Get posts posts = await getAllPosts(profile.did); log(); // Save to cache (only for full runs, not target mode) if (!targetHandle) { const cacheData: CacheData = { profile, pdsUrl, follows: Object.fromEntries(follows), followDates: Object.fromEntries( [...followDates.entries()].map(([did, date]) => [did, date.toISOString()]) ), posts, cachedAt: new Date().toISOString(), }; await saveCache(cacheData); log(); } } // Score engagement log("[scoring] calculating engagement scores..."); const engagementScores = scoreEngagement(posts, follows, profile.did); // Score freshness const freshnessScores = scoreFreshness(followDates, follows); // Score recency (penalty for stale engagement) const recencyScores = scoreRecency(posts, follows, profile.did, followDates); // Combine scores and sort const sorted = combineScores(follows, engagementScores, freshnessScores, recencyScores); // Output to stdout console.log(formatOutput(sorted)); log(`\ndone. output ${sorted.length} entries.`); } // Only run CLI when this file is the main entry point if (import.meta.main) { const handle = process.argv[2]; const targetHandle = process.argv[3]; if (!handle) { console.error("Usage: follow-cleaner [target]"); console.error(" handle: Bluesky handle to analyze (e.g. user.bsky.social)"); console.error(" target: Optional - single account to check score against"); console.error(""); console.error("Output goes to stdout, progress to stderr."); console.error("Caching: Data is saved to cache.json on first run. Delete to refresh."); console.error("Examples:"); console.error(" follow-cleaner user.bsky.social > results.txt"); console.error(" follow-cleaner user.bsky.social someone.bsky.social"); process.exit(1); } main(handle, targetHandle); }