A very simple CLI tool for scanning your followers and ranking by your reply engagement
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}