A very simple CLI tool for scanning your followers and ranking by your reply engagement
7
fork

Configure Feed

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

Remove dependencies, add negative trait

We now reduce the score if it's been a long time since
the last interaction.

Woovie 48a23ad6 22504a56

+164 -89
-8
bun.lock
··· 4 4 "workspaces": { 5 5 "": { 6 6 "name": "follow-cleaner", 7 - "dependencies": { 8 - "@bomb.sh/tab": "^0.0.11", 9 - "commander": "^14.0.2", 10 - }, 11 7 "devDependencies": { 12 8 "@types/bun": "latest", 13 9 }, ··· 17 13 }, 18 14 }, 19 15 "packages": { 20 - "@bomb.sh/tab": ["@bomb.sh/tab@0.0.11", "", { "peerDependencies": { "cac": "^6.7.14", "citty": "^0.1.6", "commander": "^13.1.0" }, "optionalPeers": ["cac", "citty", "commander"], "bin": { "tab": "dist/bin/cli.js" } }, "sha512-RSqyreeicYBALcMaNxIUJTBknftXsyW45VRq5gKDNwKroh0Re5SDoWwXZaphb+OTEzVdpm/BA8Uq6y0P+AtVYw=="], 21 - 22 16 "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], 23 17 24 18 "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], 25 19 26 20 "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], 27 - 28 - "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], 29 21 30 22 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 31 23
+162 -75
index.ts
··· 1 - import { Command } from "commander"; 1 + export const API_BASE = "https://public.api.bsky.app/xrpc"; 2 + 3 + // Logging helper - all progress goes to stderr so stdout is clean for data 4 + const log = (...args: unknown[]) => console.error(...args); 5 + 6 + // Allows tests to inject a mock fetch 7 + type FetchFn = (url: string) => Promise<Response>; 8 + let fetchImpl: FetchFn = fetch; 2 9 3 - const API_BASE = "https://public.api.bsky.app/xrpc"; 10 + export function setFetchImpl(fn: FetchFn) { 11 + fetchImpl = fn; 12 + } 4 13 5 - interface DidDocument { 14 + export function resetFetchImpl() { 15 + fetchImpl = fetch; 16 + } 17 + 18 + async function apiFetch(url: string): Promise<Response> { 19 + return fetchImpl(url); 20 + } 21 + 22 + export interface DidDocument { 6 23 id: string; 7 24 service?: Array<{ 8 25 id: string; ··· 11 28 }>; 12 29 } 13 30 14 - async function resolvePds(did: string): Promise<string> { 31 + export async function resolvePds(did: string): Promise<string> { 15 32 const url = `https://plc.directory/${did}`; 16 - const res = await fetch(url); 33 + const res = await apiFetch(url); 17 34 if (!res.ok) { 18 35 throw new Error(`Failed to resolve DID ${did}: ${res.status}`); 19 36 } ··· 25 42 return pdsService.serviceEndpoint; 26 43 } 27 44 28 - interface Profile { 45 + export interface Profile { 29 46 did: string; 30 47 handle: string; 31 48 displayName?: string; 32 49 } 33 50 34 - interface Follow { 51 + export interface Follow { 35 52 did: string; 36 53 handle: string; 37 54 displayName?: string; 38 55 } 39 56 40 - interface FollowRecord { 57 + export interface FollowRecord { 41 58 uri: string; 42 59 value: { 43 60 subject: string; ··· 45 62 }; 46 63 } 47 64 48 - interface PostRecord { 65 + export interface PostRecord { 66 + createdAt?: string; 49 67 reply?: { 50 68 parent: { uri: string; cid: string }; 51 69 root: { uri: string; cid: string }; 52 70 }; 53 71 } 54 72 55 - interface FeedItem { 73 + export interface FeedItem { 56 74 post: { 57 75 uri: string; 58 76 author: { did: string; handle: string }; ··· 64 82 }; 65 83 } 66 84 67 - const SCORE_DIRECT_REPLY = 10; 68 - const SCORE_THREAD_REPLY = 3; 69 - const SCORE_FRESHNESS_MAX = 50; 70 - const FRESHNESS_DECAY_DAYS = 25; 85 + export interface ScoredEntry { 86 + handle: string; 87 + score: number; 88 + engagement: number; 89 + freshness: number; 90 + recency: number; 91 + } 92 + 93 + export function formatOutput(entries: ScoredEntry[]): string { 94 + const header = "handle score engagement freshness recency"; 95 + const rows = entries.map(({ handle, score, engagement, freshness, recency }) => 96 + `${handle} ${score} ${engagement} ${freshness} ${recency}` 97 + ); 98 + return [header, ...rows].join("\n"); 99 + } 100 + 101 + export const SCORE_DIRECT_REPLY = 10; 102 + export const SCORE_THREAD_REPLY = 3; 103 + export const SCORE_FRESHNESS_MAX = 50; 104 + export const FRESHNESS_DECAY_DAYS = 25; 105 + export const SCORE_RECENCY_PENALTY_PER_DAY = 1; 71 106 72 - async function getProfile(handle: string): Promise<Profile> { 107 + export async function getProfile(handle: string): Promise<Profile> { 73 108 const url = `${API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`; 74 - const res = await fetch(url); 109 + const res = await apiFetch(url); 75 110 if (!res.ok) { 76 111 throw new Error(`Failed to get profile for ${handle}: ${res.status}`); 77 112 } 78 113 return res.json(); 79 114 } 80 115 81 - async function getFollows(actor: string, cursor?: string): Promise<{ follows: Follow[]; cursor?: string }> { 116 + export async function getFollows(actor: string, cursor?: string): Promise<{ follows: Follow[]; cursor?: string }> { 82 117 const params = new URLSearchParams({ actor, limit: "100" }); 83 118 if (cursor) params.set("cursor", cursor); 84 119 85 120 const url = `${API_BASE}/app.bsky.graph.getFollows?${params}`; 86 - const res = await fetch(url); 121 + const res = await apiFetch(url); 87 122 if (!res.ok) { 88 123 throw new Error(`Failed to get follows: ${res.status}`); 89 124 } 90 125 return res.json(); 91 126 } 92 127 93 - async function getAuthorFeed(actor: string, cursor?: string): Promise<{ feed: FeedItem[]; cursor?: string }> { 128 + export async function getAuthorFeed(actor: string, cursor?: string): Promise<{ feed: FeedItem[]; cursor?: string }> { 94 129 const params = new URLSearchParams({ actor, limit: "100", filter: "posts_with_replies" }); 95 130 if (cursor) params.set("cursor", cursor); 96 131 97 132 const url = `${API_BASE}/app.bsky.feed.getAuthorFeed?${params}`; 98 - const res = await fetch(url); 133 + const res = await apiFetch(url); 99 134 if (!res.ok) { 100 135 throw new Error(`Failed to get author feed: ${res.status}`); 101 136 } 102 137 return res.json(); 103 138 } 104 139 105 - async function getAllFollows(actor: string): Promise<Map<string, Follow>> { 140 + export async function getAllFollows(actor: string): Promise<Map<string, Follow>> { 106 141 const follows = new Map<string, Follow>(); 107 142 let cursor: string | undefined; 108 143 let page = 1; 109 144 110 - console.log("[follows] fetching who you follow..."); 145 + log("[follows] fetching who you follow..."); 111 146 112 147 do { 113 148 const data = await getFollows(actor, cursor); 114 149 for (const follow of data.follows) { 115 150 follows.set(follow.did, follow); 116 151 } 117 - console.log(`[follows] page ${page}: got ${data.follows.length} follows (total: ${follows.size})`); 152 + log(`[follows] page ${page}: got ${data.follows.length} follows (total: ${follows.size})`); 118 153 cursor = data.cursor; 119 154 page++; 120 155 } while (cursor); 121 156 122 - console.log(`[follows] done. you follow ${follows.size} accounts`); 157 + log(`[follows] done. you follow ${follows.size} accounts`); 123 158 return follows; 124 159 } 125 160 126 - async function getAllPosts(actor: string): Promise<FeedItem[]> { 161 + export async function getAllPosts(actor: string): Promise<FeedItem[]> { 127 162 const posts: FeedItem[] = []; 128 163 let cursor: string | undefined; 129 164 let page = 1; 130 165 131 - console.log("[posts] fetching your posts and replies..."); 166 + log("[posts] fetching your posts and replies..."); 132 167 133 168 do { 134 169 const data = await getAuthorFeed(actor, cursor); 135 170 posts.push(...data.feed); 136 - console.log(`[posts] page ${page}: got ${data.feed.length} posts (total: ${posts.length})`); 171 + log(`[posts] page ${page}: got ${data.feed.length} posts (total: ${posts.length})`); 137 172 cursor = data.cursor; 138 173 page++; 139 174 } while (cursor); 140 175 141 - console.log(`[posts] done. found ${posts.length} posts/replies`); 176 + log(`[posts] done. found ${posts.length} posts/replies`); 142 177 return posts; 143 178 } 144 179 145 - async function getFollowRecords(pdsUrl: string, actor: string, cursor?: string): Promise<{ records: FollowRecord[]; cursor?: string }> { 180 + export async function getFollowRecords(pdsUrl: string, actor: string, cursor?: string): Promise<{ records: FollowRecord[]; cursor?: string }> { 146 181 const params = new URLSearchParams({ repo: actor, collection: "app.bsky.graph.follow", limit: "100" }); 147 182 if (cursor) params.set("cursor", cursor); 148 183 149 184 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`; 150 - const res = await fetch(url); 185 + const res = await apiFetch(url); 151 186 if (!res.ok) { 152 187 throw new Error(`Failed to get follow records: ${res.status}`); 153 188 } 154 189 return res.json(); 155 190 } 156 191 157 - async function getAllFollowRecords(pdsUrl: string, actor: string): Promise<Map<string, Date>> { 192 + export async function getAllFollowRecords(pdsUrl: string, actor: string): Promise<Map<string, Date>> { 158 193 const followDates = new Map<string, Date>(); 159 194 let cursor: string | undefined; 160 195 let page = 1; 161 196 162 - console.log("[follow-dates] fetching follow timestamps..."); 197 + log("[follow-dates] fetching follow timestamps..."); 163 198 164 199 do { 165 200 const data = await getFollowRecords(pdsUrl, actor, cursor); 166 201 for (const record of data.records) { 167 202 followDates.set(record.value.subject, new Date(record.value.createdAt)); 168 203 } 169 - console.log(`[follow-dates] page ${page}: got ${data.records.length} records (total: ${followDates.size})`); 204 + log(`[follow-dates] page ${page}: got ${data.records.length} records (total: ${followDates.size})`); 170 205 cursor = data.cursor; 171 206 page++; 172 207 } while (cursor); 173 208 174 - console.log(`[follow-dates] done. got timestamps for ${followDates.size} follows`); 209 + log(`[follow-dates] done. got timestamps for ${followDates.size} follows`); 175 210 return followDates; 176 211 } 177 212 178 - function scoreFreshness(followDates: Map<string, Date>, follows: Map<string, Follow>): Map<string, number> { 213 + export function scoreFreshness(followDates: Map<string, Date>, follows: Map<string, Follow>): Map<string, number> { 179 214 const scores = new Map<string, number>(); 180 215 const now = new Date(); 181 216 ··· 192 227 } 193 228 194 229 const freshCount = [...scores.values()].filter(s => s > 0).length; 195 - console.log(`[scoring] ${freshCount} follows within last ${FRESHNESS_DECAY_DAYS} days get freshness bonus`); 230 + log(`[scoring] ${freshCount} follows within last ${FRESHNESS_DECAY_DAYS} days get freshness bonus`); 196 231 return scores; 197 232 } 198 233 199 - function scoreEngagement(posts: FeedItem[], follows: Map<string, Follow>, myDid: string): Map<string, number> { 234 + export function scoreEngagement(posts: FeedItem[], follows: Map<string, Follow>, myDid: string): Map<string, number> { 200 235 const scores = new Map<string, number>(); 201 236 202 237 // Initialize all follows with 0 ··· 229 264 } 230 265 } 231 266 232 - console.log(`[scoring] found ${directReplies} direct replies and ${threadReplies} thread replies to people you follow`); 267 + log(`[scoring] found ${directReplies} direct replies and ${threadReplies} thread replies to people you follow`); 233 268 return scores; 234 269 } 235 270 271 + export function scoreRecency(posts: FeedItem[], follows: Map<string, Follow>, myDid: string, now?: Date): Map<string, number> { 272 + const penalties = new Map<string, number>(); 273 + const lastEngagement = new Map<string, Date>(); 274 + const currentDate = now || new Date(); 275 + 276 + // Initialize all follows with no engagement 277 + for (const [did] of follows) { 278 + penalties.set(did, 0); 279 + } 280 + 281 + // Find most recent engagement with each follow 282 + for (const item of posts) { 283 + const record = item.post.record; 284 + 285 + // Skip if not a reply or no createdAt 286 + if (!record.reply || !record.createdAt) continue; 287 + 288 + const postDate = new Date(record.createdAt); 289 + const parentAuthorDid = item.reply?.parent?.author?.did; 290 + const rootAuthorDid = item.reply?.root?.author?.did; 291 + 292 + // Check direct reply 293 + if (parentAuthorDid && parentAuthorDid !== myDid && follows.has(parentAuthorDid)) { 294 + const existing = lastEngagement.get(parentAuthorDid); 295 + if (!existing || postDate > existing) { 296 + lastEngagement.set(parentAuthorDid, postDate); 297 + } 298 + } 299 + 300 + // Check thread participation 301 + if (rootAuthorDid && rootAuthorDid !== myDid && rootAuthorDid !== parentAuthorDid && follows.has(rootAuthorDid)) { 302 + const existing = lastEngagement.get(rootAuthorDid); 303 + if (!existing || postDate > existing) { 304 + lastEngagement.set(rootAuthorDid, postDate); 305 + } 306 + } 307 + } 308 + 309 + // Calculate penalties based on days since last engagement 310 + let penalizedCount = 0; 311 + for (const [did] of follows) { 312 + const lastDate = lastEngagement.get(did); 313 + if (!lastDate) { 314 + // No engagement found - no additional penalty (they already have 0 engagement score) 315 + continue; 316 + } 317 + 318 + const daysSinceEngagement = Math.floor((currentDate.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24)); 319 + if (daysSinceEngagement > 0) { 320 + const penalty = daysSinceEngagement * SCORE_RECENCY_PENALTY_PER_DAY; 321 + penalties.set(did, -penalty); 322 + penalizedCount++; 323 + } 324 + } 325 + 326 + log(`[scoring] ${penalizedCount} follows have recency penalties applied`); 327 + return penalties; 328 + } 329 + 236 330 async function main(handle: string) { 237 - console.log(`\n🔍 analyzing engagement for @${handle}\n`); 331 + log(`\nanalyzing engagement for @${handle}\n`); 238 332 239 333 // Validate handle 240 - console.log("[profile] validating handle..."); 334 + log("[profile] validating handle..."); 241 335 const profile = await getProfile(handle); 242 - console.log(`[profile] found: ${profile.displayName || profile.handle} (${profile.did})\n`); 336 + log(`[profile] found: ${profile.displayName || profile.handle} (${profile.did})\n`); 243 337 244 338 // Resolve user's PDS 245 - console.log("[pds] resolving user's PDS..."); 339 + log("[pds] resolving user's PDS..."); 246 340 const pdsUrl = await resolvePds(profile.did); 247 - console.log(`[pds] found: ${pdsUrl}\n`); 341 + log(`[pds] found: ${pdsUrl}\n`); 248 342 249 343 // Get follows 250 344 const follows = await getAllFollows(profile.did); 251 - console.log(); 345 + log(); 252 346 253 347 // Get follow timestamps 254 348 const followDates = await getAllFollowRecords(pdsUrl, profile.did); 255 - console.log(); 349 + log(); 256 350 257 351 // Get posts 258 352 const posts = await getAllPosts(profile.did); 259 - console.log(); 353 + log(); 260 354 261 355 // Score engagement 262 - console.log("[scoring] calculating engagement scores..."); 356 + log("[scoring] calculating engagement scores..."); 263 357 const engagementScores = scoreEngagement(posts, follows, profile.did); 264 358 265 359 // Score freshness 266 360 const freshnessScores = scoreFreshness(followDates, follows); 267 361 362 + // Score recency (penalty for stale engagement) 363 + const recencyScores = scoreRecency(posts, follows, profile.did); 364 + 268 365 // Combine scores 269 366 const scores = new Map<string, number>(); 270 367 for (const [did] of follows) { 271 368 const engagement = engagementScores.get(did) || 0; 272 369 const freshness = freshnessScores.get(did) || 0; 273 - scores.set(did, engagement + freshness); 370 + const recency = recencyScores.get(did) || 0; 371 + scores.set(did, engagement + freshness + recency); 274 372 } 275 373 276 374 // Sort by score descending ··· 280 378 score, 281 379 engagement: engagementScores.get(did) || 0, 282 380 freshness: freshnessScores.get(did) || 0, 381 + recency: recencyScores.get(did) || 0, 283 382 })) 284 383 .sort((a, b) => b.score - a.score); 285 384 286 - // Write to file 287 - const outputFile = `${handle.replace(/[^a-zA-Z0-9]/g, "_")}_engagement.txt`; 288 - const output = sorted.map(({ handle, score, engagement, freshness }) => 289 - `${handle} ${score} (engagement: ${engagement}, freshness: ${freshness})` 290 - ).join("\n"); 291 - await Bun.file(outputFile).writer().write(output); 385 + // Output to stdout 386 + console.log(formatOutput(sorted)); 292 387 293 - console.log(`\n✅ done! wrote ${sorted.length} entries to ${outputFile}`); 388 + log(`\ndone. output ${sorted.length} entries.`); 389 + } 294 390 295 - // Show top/bottom 5 296 - console.log("\n--- top 5 highest scores ---"); 297 - for (const { handle, score, engagement, freshness } of sorted.slice(0, 5)) { 298 - console.log(` @${handle}: ${score} (engagement: ${engagement}, freshness: ${freshness})`); 391 + // Only run CLI when this file is the main entry point 392 + if (import.meta.main) { 393 + const handle = process.argv[2]; 394 + if (!handle) { 395 + console.error("Usage: follow-cleaner <handle>"); 396 + console.error(" handle: Bluesky handle to analyze (e.g. user.bsky.social)"); 397 + console.error(""); 398 + console.error("Output goes to stdout, progress to stderr."); 399 + console.error("Example: follow-cleaner user.bsky.social > results.txt"); 400 + process.exit(1); 299 401 } 300 402 301 - const zeroScore = sorted.filter(s => s.score === 0); 302 - console.log(`\n--- ${zeroScore.length} accounts with zero score ---`); 303 - for (const { handle } of zeroScore.slice(0, 5)) { 304 - console.log(` @${handle}`); 305 - } 306 - if (zeroScore.length > 5) { 307 - console.log(` ... and ${zeroScore.length - 5} more`); 308 - } 403 + main(handle); 309 404 } 310 - 311 - const program = new Command("follow-cleaner"); 312 - program 313 - .version("0.0.1") 314 - .argument("<handle>", "Bluesky handle to analyze (e.g. user.bsky.social)") 315 - .action(main); 316 - 317 - program.parse();
+2 -6
package.json
··· 1 1 { 2 2 "name": "follow-cleaner", 3 3 "module": "index.ts", 4 - "type": "module", 5 - "private": true, 6 4 "devDependencies": { 7 5 "@types/bun": "latest" 8 6 }, 9 7 "peerDependencies": { 10 8 "typescript": "^5" 11 9 }, 12 - "dependencies": { 13 - "@bomb.sh/tab": "^0.0.11", 14 - "commander": "^14.0.2" 15 - } 10 + "private": true, 11 + "type": "module" 16 12 }