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.

Add caching, fix bug with follow age

Now correctly giving strong negative based on zero activity and
long time ago following. This ensures accounts we followed but
literally never engaged with are at the bottom.

Woovie 6a23bfa7 48a23ad6

+165 -46
+165 -46
index.ts
··· 1 1 export const API_BASE = "https://public.api.bsky.app/xrpc"; 2 + const CACHE_FILE = "cache.json"; 2 3 3 4 // Logging helper - all progress goes to stderr so stdout is clean for data 4 5 const log = (...args: unknown[]) => console.error(...args); 6 + 7 + export 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 + 16 + async 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 + 29 + async 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 + } 5 33 6 34 // Allows tests to inject a mock fetch 7 35 type FetchFn = (url: string) => Promise<Response>; ··· 268 296 return scores; 269 297 } 270 298 271 - export function scoreRecency(posts: FeedItem[], follows: Map<string, Follow>, myDid: string, now?: Date): Map<string, number> { 299 + export 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 + 326 + export function scoreRecency(posts: FeedItem[], follows: Map<string, Follow>, myDid: string, followDates: Map<string, Date>, now?: Date): Map<string, number> { 272 327 const penalties = new Map<string, number>(); 273 328 const lastEngagement = new Map<string, Date>(); 274 329 const currentDate = now || new Date(); ··· 306 361 } 307 362 } 308 363 309 - // Calculate penalties based on days since last engagement 364 + // Calculate penalties based on days since last engagement or follow date 310 365 let penalizedCount = 0; 366 + let noEngagementCount = 0; 311 367 for (const [did] of follows) { 312 368 const lastDate = lastEngagement.get(did); 313 369 if (!lastDate) { 314 - // No engagement found - no additional penalty (they already have 0 engagement score) 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 + } 315 380 continue; 316 381 } 317 382 ··· 323 388 } 324 389 } 325 390 326 - log(`[scoring] ${penalizedCount} follows have recency penalties applied`); 391 + log(`[scoring] ${penalizedCount} follows have stale engagement penalties, ${noEngagementCount} have no-engagement penalties`); 327 392 return penalties; 328 393 } 329 394 330 - async function main(handle: string) { 331 - log(`\nanalyzing engagement for @${handle}\n`); 395 + async 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`); 332 443 333 - // Validate handle 334 - log("[profile] validating handle..."); 335 - const profile = await getProfile(handle); 336 - log(`[profile] found: ${profile.displayName || profile.handle} (${profile.did})\n`); 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`); 337 449 338 - // Resolve user's PDS 339 - log("[pds] resolving user's PDS..."); 340 - const pdsUrl = await resolvePds(profile.did); 341 - log(`[pds] found: ${pdsUrl}\n`); 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 + } 342 460 343 - // Get follows 344 - const follows = await getAllFollows(profile.did); 345 - log(); 461 + // Get follow timestamps 462 + followDates = await getAllFollowRecords(pdsUrl, profile.did); 463 + log(); 346 464 347 - // Get follow timestamps 348 - const followDates = await getAllFollowRecords(pdsUrl, profile.did); 349 - log(); 465 + // Get posts 466 + posts = await getAllPosts(profile.did); 467 + log(); 350 468 351 - // Get posts 352 - const posts = await getAllPosts(profile.did); 353 - log(); 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 + } 354 485 355 486 // Score engagement 356 487 log("[scoring] calculating engagement scores..."); ··· 360 491 const freshnessScores = scoreFreshness(followDates, follows); 361 492 362 493 // Score recency (penalty for stale engagement) 363 - const recencyScores = scoreRecency(posts, follows, profile.did); 494 + const recencyScores = scoreRecency(posts, follows, profile.did, followDates); 364 495 365 - // Combine scores 366 - const scores = new Map<string, number>(); 367 - for (const [did] of follows) { 368 - const engagement = engagementScores.get(did) || 0; 369 - const freshness = freshnessScores.get(did) || 0; 370 - const recency = recencyScores.get(did) || 0; 371 - scores.set(did, engagement + freshness + recency); 372 - } 373 - 374 - // Sort by score descending 375 - const sorted = [...scores.entries()] 376 - .map(([did, score]) => ({ 377 - handle: follows.get(did)!.handle, 378 - score, 379 - engagement: engagementScores.get(did) || 0, 380 - freshness: freshnessScores.get(did) || 0, 381 - recency: recencyScores.get(did) || 0, 382 - })) 383 - .sort((a, b) => b.score - a.score); 496 + // Combine scores and sort 497 + const sorted = combineScores(follows, engagementScores, freshnessScores, recencyScores); 384 498 385 499 // Output to stdout 386 500 console.log(formatOutput(sorted)); ··· 391 505 // Only run CLI when this file is the main entry point 392 506 if (import.meta.main) { 393 507 const handle = process.argv[2]; 508 + const targetHandle = process.argv[3]; 394 509 if (!handle) { 395 - console.error("Usage: follow-cleaner <handle>"); 510 + console.error("Usage: follow-cleaner <handle> [target]"); 396 511 console.error(" handle: Bluesky handle to analyze (e.g. user.bsky.social)"); 512 + console.error(" target: Optional - single account to check score against"); 397 513 console.error(""); 398 514 console.error("Output goes to stdout, progress to stderr."); 399 - console.error("Example: follow-cleaner user.bsky.social > results.txt"); 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"); 400 519 process.exit(1); 401 520 } 402 521 403 - main(handle); 522 + main(handle, targetHandle); 404 523 }