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.

at main 523 lines 16 kB view raw
1export const API_BASE = "https://public.api.bsky.app/xrpc"; 2const CACHE_FILE = "cache.json"; 3 4// Logging helper - all progress goes to stderr so stdout is clean for data 5const log = (...args: unknown[]) => console.error(...args); 6 7export interface CacheData { 8 profile: Profile; 9 pdsUrl: string; 10 follows: Record<string, Follow>; 11 followDates: Record<string, string>; // ISO date strings 12 posts: FeedItem[]; 13 cachedAt: string; 14} 15 16async function loadCache(): Promise<CacheData | null> { 17 try { 18 const file = Bun.file(CACHE_FILE); 19 if (await file.exists()) { 20 const data = await file.json(); 21 return data as CacheData; 22 } 23 } catch { 24 // Cache doesn't exist or is invalid 25 } 26 return null; 27} 28 29async function saveCache(data: CacheData): Promise<void> { 30 await Bun.write(CACHE_FILE, JSON.stringify(data, null, 2)); 31 log(`[cache] saved to ${CACHE_FILE}`); 32} 33 34// Allows tests to inject a mock fetch 35type FetchFn = (url: string) => Promise<Response>; 36let fetchImpl: FetchFn = fetch; 37 38export function setFetchImpl(fn: FetchFn) { 39 fetchImpl = fn; 40} 41 42export function resetFetchImpl() { 43 fetchImpl = fetch; 44} 45 46async function apiFetch(url: string): Promise<Response> { 47 return fetchImpl(url); 48} 49 50export interface DidDocument { 51 id: string; 52 service?: Array<{ 53 id: string; 54 type: string; 55 serviceEndpoint: string; 56 }>; 57} 58 59export async function resolvePds(did: string): Promise<string> { 60 const url = `https://plc.directory/${did}`; 61 const res = await apiFetch(url); 62 if (!res.ok) { 63 throw new Error(`Failed to resolve DID ${did}: ${res.status}`); 64 } 65 const doc: DidDocument = await res.json(); 66 const pdsService = doc.service?.find(s => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"); 67 if (!pdsService) { 68 throw new Error(`No PDS service found in DID document for ${did}`); 69 } 70 return pdsService.serviceEndpoint; 71} 72 73export interface Profile { 74 did: string; 75 handle: string; 76 displayName?: string; 77} 78 79export interface Follow { 80 did: string; 81 handle: string; 82 displayName?: string; 83} 84 85export interface FollowRecord { 86 uri: string; 87 value: { 88 subject: string; 89 createdAt: string; 90 }; 91} 92 93export interface PostRecord { 94 createdAt?: string; 95 reply?: { 96 parent: { uri: string; cid: string }; 97 root: { uri: string; cid: string }; 98 }; 99} 100 101export interface FeedItem { 102 post: { 103 uri: string; 104 author: { did: string; handle: string }; 105 record: PostRecord; 106 }; 107 reply?: { 108 parent?: { author: { did: string; handle: string } }; 109 root?: { author: { did: string; handle: string } }; 110 }; 111} 112 113export interface ScoredEntry { 114 handle: string; 115 score: number; 116 engagement: number; 117 freshness: number; 118 recency: number; 119} 120 121export function formatOutput(entries: ScoredEntry[]): string { 122 const header = "handle score engagement freshness recency"; 123 const rows = entries.map(({ handle, score, engagement, freshness, recency }) => 124 `${handle} ${score} ${engagement} ${freshness} ${recency}` 125 ); 126 return [header, ...rows].join("\n"); 127} 128 129export const SCORE_DIRECT_REPLY = 10; 130export const SCORE_THREAD_REPLY = 3; 131export const SCORE_FRESHNESS_MAX = 50; 132export const FRESHNESS_DECAY_DAYS = 25; 133export const SCORE_RECENCY_PENALTY_PER_DAY = 1; 134 135export async function getProfile(handle: string): Promise<Profile> { 136 const url = `${API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`; 137 const res = await apiFetch(url); 138 if (!res.ok) { 139 throw new Error(`Failed to get profile for ${handle}: ${res.status}`); 140 } 141 return res.json(); 142} 143 144export async function getFollows(actor: string, cursor?: string): Promise<{ follows: Follow[]; cursor?: string }> { 145 const params = new URLSearchParams({ actor, limit: "100" }); 146 if (cursor) params.set("cursor", cursor); 147 148 const url = `${API_BASE}/app.bsky.graph.getFollows?${params}`; 149 const res = await apiFetch(url); 150 if (!res.ok) { 151 throw new Error(`Failed to get follows: ${res.status}`); 152 } 153 return res.json(); 154} 155 156export async function getAuthorFeed(actor: string, cursor?: string): Promise<{ feed: FeedItem[]; cursor?: string }> { 157 const params = new URLSearchParams({ actor, limit: "100", filter: "posts_with_replies" }); 158 if (cursor) params.set("cursor", cursor); 159 160 const url = `${API_BASE}/app.bsky.feed.getAuthorFeed?${params}`; 161 const res = await apiFetch(url); 162 if (!res.ok) { 163 throw new Error(`Failed to get author feed: ${res.status}`); 164 } 165 return res.json(); 166} 167 168export async function getAllFollows(actor: string): Promise<Map<string, Follow>> { 169 const follows = new Map<string, Follow>(); 170 let cursor: string | undefined; 171 let page = 1; 172 173 log("[follows] fetching who you follow..."); 174 175 do { 176 const data = await getFollows(actor, cursor); 177 for (const follow of data.follows) { 178 follows.set(follow.did, follow); 179 } 180 log(`[follows] page ${page}: got ${data.follows.length} follows (total: ${follows.size})`); 181 cursor = data.cursor; 182 page++; 183 } while (cursor); 184 185 log(`[follows] done. you follow ${follows.size} accounts`); 186 return follows; 187} 188 189export async function getAllPosts(actor: string): Promise<FeedItem[]> { 190 const posts: FeedItem[] = []; 191 let cursor: string | undefined; 192 let page = 1; 193 194 log("[posts] fetching your posts and replies..."); 195 196 do { 197 const data = await getAuthorFeed(actor, cursor); 198 posts.push(...data.feed); 199 log(`[posts] page ${page}: got ${data.feed.length} posts (total: ${posts.length})`); 200 cursor = data.cursor; 201 page++; 202 } while (cursor); 203 204 log(`[posts] done. found ${posts.length} posts/replies`); 205 return posts; 206} 207 208export async function getFollowRecords(pdsUrl: string, actor: string, cursor?: string): Promise<{ records: FollowRecord[]; cursor?: string }> { 209 const params = new URLSearchParams({ repo: actor, collection: "app.bsky.graph.follow", limit: "100" }); 210 if (cursor) params.set("cursor", cursor); 211 212 const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`; 213 const res = await apiFetch(url); 214 if (!res.ok) { 215 throw new Error(`Failed to get follow records: ${res.status}`); 216 } 217 return res.json(); 218} 219 220export async function getAllFollowRecords(pdsUrl: string, actor: string): Promise<Map<string, Date>> { 221 const followDates = new Map<string, Date>(); 222 let cursor: string | undefined; 223 let page = 1; 224 225 log("[follow-dates] fetching follow timestamps..."); 226 227 do { 228 const data = await getFollowRecords(pdsUrl, actor, cursor); 229 for (const record of data.records) { 230 followDates.set(record.value.subject, new Date(record.value.createdAt)); 231 } 232 log(`[follow-dates] page ${page}: got ${data.records.length} records (total: ${followDates.size})`); 233 cursor = data.cursor; 234 page++; 235 } while (cursor); 236 237 log(`[follow-dates] done. got timestamps for ${followDates.size} follows`); 238 return followDates; 239} 240 241export function scoreFreshness(followDates: Map<string, Date>, follows: Map<string, Follow>): Map<string, number> { 242 const scores = new Map<string, number>(); 243 const now = new Date(); 244 245 for (const [did] of follows) { 246 const followDate = followDates.get(did); 247 if (!followDate) { 248 scores.set(did, 0); 249 continue; 250 } 251 252 const daysAgo = Math.floor((now.getTime() - followDate.getTime()) / (1000 * 60 * 60 * 24)); 253 const freshness = Math.max(0, SCORE_FRESHNESS_MAX - daysAgo * 2); 254 scores.set(did, freshness); 255 } 256 257 const freshCount = [...scores.values()].filter(s => s > 0).length; 258 log(`[scoring] ${freshCount} follows within last ${FRESHNESS_DECAY_DAYS} days get freshness bonus`); 259 return scores; 260} 261 262export function scoreEngagement(posts: FeedItem[], follows: Map<string, Follow>, myDid: string): Map<string, number> { 263 const scores = new Map<string, number>(); 264 265 // Initialize all follows with 0 266 for (const [did] of follows) { 267 scores.set(did, 0); 268 } 269 270 let directReplies = 0; 271 let threadReplies = 0; 272 273 for (const item of posts) { 274 const record = item.post.record; 275 276 // Skip if not a reply 277 if (!record.reply) continue; 278 279 const parentAuthorDid = item.reply?.parent?.author?.did; 280 const rootAuthorDid = item.reply?.root?.author?.did; 281 282 // Direct reply to someone we follow 283 if (parentAuthorDid && parentAuthorDid !== myDid && follows.has(parentAuthorDid)) { 284 scores.set(parentAuthorDid, (scores.get(parentAuthorDid) || 0) + SCORE_DIRECT_REPLY); 285 directReplies++; 286 } 287 288 // Reply in a thread started by someone we follow (but not a direct reply to them) 289 if (rootAuthorDid && rootAuthorDid !== myDid && rootAuthorDid !== parentAuthorDid && follows.has(rootAuthorDid)) { 290 scores.set(rootAuthorDid, (scores.get(rootAuthorDid) || 0) + SCORE_THREAD_REPLY); 291 threadReplies++; 292 } 293 } 294 295 log(`[scoring] found ${directReplies} direct replies and ${threadReplies} thread replies to people you follow`); 296 return scores; 297} 298 299export function combineScores( 300 follows: Map<string, Follow>, 301 engagementScores: Map<string, number>, 302 freshnessScores: Map<string, number>, 303 recencyScores: Map<string, number> 304): ScoredEntry[] { 305 const results: ScoredEntry[] = []; 306 307 for (const [did, follow] of follows) { 308 const engagement = engagementScores.get(did) || 0; 309 const freshness = freshnessScores.get(did) || 0; 310 // Skip recency penalty for fresh follows (freshness > 0) 311 const recency = freshness > 0 ? 0 : (recencyScores.get(did) || 0); 312 const score = engagement + freshness + recency; 313 314 results.push({ 315 handle: follow.handle, 316 score, 317 engagement, 318 freshness, 319 recency, 320 }); 321 } 322 323 return results.sort((a, b) => b.score - a.score); 324} 325 326export function scoreRecency(posts: FeedItem[], follows: Map<string, Follow>, myDid: string, followDates: Map<string, Date>, now?: Date): Map<string, number> { 327 const penalties = new Map<string, number>(); 328 const lastEngagement = new Map<string, Date>(); 329 const currentDate = now || new Date(); 330 331 // Initialize all follows with no engagement 332 for (const [did] of follows) { 333 penalties.set(did, 0); 334 } 335 336 // Find most recent engagement with each follow 337 for (const item of posts) { 338 const record = item.post.record; 339 340 // Skip if not a reply or no createdAt 341 if (!record.reply || !record.createdAt) continue; 342 343 const postDate = new Date(record.createdAt); 344 const parentAuthorDid = item.reply?.parent?.author?.did; 345 const rootAuthorDid = item.reply?.root?.author?.did; 346 347 // Check direct reply 348 if (parentAuthorDid && parentAuthorDid !== myDid && follows.has(parentAuthorDid)) { 349 const existing = lastEngagement.get(parentAuthorDid); 350 if (!existing || postDate > existing) { 351 lastEngagement.set(parentAuthorDid, postDate); 352 } 353 } 354 355 // Check thread participation 356 if (rootAuthorDid && rootAuthorDid !== myDid && rootAuthorDid !== parentAuthorDid && follows.has(rootAuthorDid)) { 357 const existing = lastEngagement.get(rootAuthorDid); 358 if (!existing || postDate > existing) { 359 lastEngagement.set(rootAuthorDid, postDate); 360 } 361 } 362 } 363 364 // Calculate penalties based on days since last engagement or follow date 365 let penalizedCount = 0; 366 let noEngagementCount = 0; 367 for (const [did] of follows) { 368 const lastDate = lastEngagement.get(did); 369 if (!lastDate) { 370 // No engagement found - penalize based on follow age 371 const followDate = followDates.get(did); 372 if (followDate) { 373 const daysSinceFollow = Math.floor((currentDate.getTime() - followDate.getTime()) / (1000 * 60 * 60 * 24)); 374 if (daysSinceFollow > 0) { 375 const penalty = daysSinceFollow * SCORE_RECENCY_PENALTY_PER_DAY; 376 penalties.set(did, -penalty); 377 noEngagementCount++; 378 } 379 } 380 continue; 381 } 382 383 const daysSinceEngagement = Math.floor((currentDate.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24)); 384 if (daysSinceEngagement > 0) { 385 const penalty = daysSinceEngagement * SCORE_RECENCY_PENALTY_PER_DAY; 386 penalties.set(did, -penalty); 387 penalizedCount++; 388 } 389 } 390 391 log(`[scoring] ${penalizedCount} follows have stale engagement penalties, ${noEngagementCount} have no-engagement penalties`); 392 return penalties; 393} 394 395async function main(handle: string, targetHandle?: string) { 396 log(`\nanalyzing engagement for @${handle}${targetHandle ? ` with @${targetHandle}` : ""}\n`); 397 398 let profile: Profile; 399 let pdsUrl: string; 400 let follows: Map<string, Follow>; 401 let followDates: Map<string, Date>; 402 let posts: FeedItem[]; 403 404 // Check for cache 405 const cache = await loadCache(); 406 407 if (cache) { 408 log(`[cache] using cached data from ${CACHE_FILE} (cached at ${cache.cachedAt})\n`); 409 410 profile = cache.profile; 411 pdsUrl = cache.pdsUrl; 412 followDates = new Map( 413 Object.entries(cache.followDates).map(([did, dateStr]) => [did, new Date(dateStr)]) 414 ); 415 posts = cache.posts; 416 417 if (targetHandle) { 418 // Single target mode - filter to one account from cache 419 log("[target] resolving target account..."); 420 const targetProfile = await getProfile(targetHandle); 421 log(`[target] found: ${targetProfile.displayName || targetProfile.handle} (${targetProfile.did})\n`); 422 423 follows = new Map([[targetProfile.did, { 424 did: targetProfile.did, 425 handle: targetProfile.handle, 426 displayName: targetProfile.displayName, 427 }]]); 428 } else { 429 follows = new Map(Object.entries(cache.follows)); 430 } 431 432 log(`[cache] loaded ${follows.size} follows, ${followDates.size} follow dates, ${posts.length} posts\n`); 433 } else { 434 // Validate handle 435 log("[profile] validating handle..."); 436 profile = await getProfile(handle); 437 log(`[profile] found: ${profile.displayName || profile.handle} (${profile.did})\n`); 438 439 // Resolve user's PDS 440 log("[pds] resolving user's PDS..."); 441 pdsUrl = await resolvePds(profile.did); 442 log(`[pds] found: ${pdsUrl}\n`); 443 444 if (targetHandle) { 445 // Single target mode - only analyze one account 446 log("[target] resolving target account..."); 447 const targetProfile = await getProfile(targetHandle); 448 log(`[target] found: ${targetProfile.displayName || targetProfile.handle} (${targetProfile.did})\n`); 449 450 follows = new Map([[targetProfile.did, { 451 did: targetProfile.did, 452 handle: targetProfile.handle, 453 displayName: targetProfile.displayName, 454 }]]); 455 } else { 456 // Get all follows 457 follows = await getAllFollows(profile.did); 458 log(); 459 } 460 461 // Get follow timestamps 462 followDates = await getAllFollowRecords(pdsUrl, profile.did); 463 log(); 464 465 // Get posts 466 posts = await getAllPosts(profile.did); 467 log(); 468 469 // Save to cache (only for full runs, not target mode) 470 if (!targetHandle) { 471 const cacheData: CacheData = { 472 profile, 473 pdsUrl, 474 follows: Object.fromEntries(follows), 475 followDates: Object.fromEntries( 476 [...followDates.entries()].map(([did, date]) => [did, date.toISOString()]) 477 ), 478 posts, 479 cachedAt: new Date().toISOString(), 480 }; 481 await saveCache(cacheData); 482 log(); 483 } 484 } 485 486 // Score engagement 487 log("[scoring] calculating engagement scores..."); 488 const engagementScores = scoreEngagement(posts, follows, profile.did); 489 490 // Score freshness 491 const freshnessScores = scoreFreshness(followDates, follows); 492 493 // Score recency (penalty for stale engagement) 494 const recencyScores = scoreRecency(posts, follows, profile.did, followDates); 495 496 // Combine scores and sort 497 const sorted = combineScores(follows, engagementScores, freshnessScores, recencyScores); 498 499 // Output to stdout 500 console.log(formatOutput(sorted)); 501 502 log(`\ndone. output ${sorted.length} entries.`); 503} 504 505// Only run CLI when this file is the main entry point 506if (import.meta.main) { 507 const handle = process.argv[2]; 508 const targetHandle = process.argv[3]; 509 if (!handle) { 510 console.error("Usage: follow-cleaner <handle> [target]"); 511 console.error(" handle: Bluesky handle to analyze (e.g. user.bsky.social)"); 512 console.error(" target: Optional - single account to check score against"); 513 console.error(""); 514 console.error("Output goes to stdout, progress to stderr."); 515 console.error("Caching: Data is saved to cache.json on first run. Delete to refresh."); 516 console.error("Examples:"); 517 console.error(" follow-cleaner user.bsky.social > results.txt"); 518 console.error(" follow-cleaner user.bsky.social someone.bsky.social"); 519 process.exit(1); 520 } 521 522 main(handle, targetHandle); 523}