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.

First commit

Woovie 7a9a1368

+430
+34
.gitignore
··· 1 + # dependencies (bun install) 2 + node_modules 3 + 4 + # output 5 + out 6 + dist 7 + *.tgz 8 + 9 + # code coverage 10 + coverage 11 + *.lcov 12 + 13 + # logs 14 + logs 15 + _.log 16 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 + 18 + # dotenv environment variable files 19 + .env 20 + .env.development.local 21 + .env.test.local 22 + .env.production.local 23 + .env.local 24 + 25 + # caches 26 + .eslintcache 27 + .cache 28 + *.tsbuildinfo 29 + 30 + # IntelliJ based IDEs 31 + .idea 32 + 33 + # Finder (MacOS) folder config 34 + .DS_Store
+34
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "follow-cleaner", 7 + "dependencies": { 8 + "@bomb.sh/tab": "^0.0.11", 9 + "commander": "^14.0.2", 10 + }, 11 + "devDependencies": { 12 + "@types/bun": "latest", 13 + }, 14 + "peerDependencies": { 15 + "typescript": "^5", 16 + }, 17 + }, 18 + }, 19 + "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 + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], 23 + 24 + "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], 25 + 26 + "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 + 30 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 31 + 32 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 33 + } 34 + }
+317
index.ts
··· 1 + import { Command } from "commander"; 2 + 3 + const API_BASE = "https://public.api.bsky.app/xrpc"; 4 + 5 + interface DidDocument { 6 + id: string; 7 + service?: Array<{ 8 + id: string; 9 + type: string; 10 + serviceEndpoint: string; 11 + }>; 12 + } 13 + 14 + async function resolvePds(did: string): Promise<string> { 15 + const url = `https://plc.directory/${did}`; 16 + const res = await fetch(url); 17 + if (!res.ok) { 18 + throw new Error(`Failed to resolve DID ${did}: ${res.status}`); 19 + } 20 + const doc: DidDocument = await res.json(); 21 + const pdsService = doc.service?.find(s => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"); 22 + if (!pdsService) { 23 + throw new Error(`No PDS service found in DID document for ${did}`); 24 + } 25 + return pdsService.serviceEndpoint; 26 + } 27 + 28 + interface Profile { 29 + did: string; 30 + handle: string; 31 + displayName?: string; 32 + } 33 + 34 + interface Follow { 35 + did: string; 36 + handle: string; 37 + displayName?: string; 38 + } 39 + 40 + interface FollowRecord { 41 + uri: string; 42 + value: { 43 + subject: string; 44 + createdAt: string; 45 + }; 46 + } 47 + 48 + interface PostRecord { 49 + reply?: { 50 + parent: { uri: string; cid: string }; 51 + root: { uri: string; cid: string }; 52 + }; 53 + } 54 + 55 + interface FeedItem { 56 + post: { 57 + uri: string; 58 + author: { did: string; handle: string }; 59 + record: PostRecord; 60 + }; 61 + reply?: { 62 + parent?: { author: { did: string; handle: string } }; 63 + root?: { author: { did: string; handle: string } }; 64 + }; 65 + } 66 + 67 + const SCORE_DIRECT_REPLY = 10; 68 + const SCORE_THREAD_REPLY = 3; 69 + const SCORE_FRESHNESS_MAX = 50; 70 + const FRESHNESS_DECAY_DAYS = 25; 71 + 72 + async function getProfile(handle: string): Promise<Profile> { 73 + const url = `${API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`; 74 + const res = await fetch(url); 75 + if (!res.ok) { 76 + throw new Error(`Failed to get profile for ${handle}: ${res.status}`); 77 + } 78 + return res.json(); 79 + } 80 + 81 + async function getFollows(actor: string, cursor?: string): Promise<{ follows: Follow[]; cursor?: string }> { 82 + const params = new URLSearchParams({ actor, limit: "100" }); 83 + if (cursor) params.set("cursor", cursor); 84 + 85 + const url = `${API_BASE}/app.bsky.graph.getFollows?${params}`; 86 + const res = await fetch(url); 87 + if (!res.ok) { 88 + throw new Error(`Failed to get follows: ${res.status}`); 89 + } 90 + return res.json(); 91 + } 92 + 93 + async function getAuthorFeed(actor: string, cursor?: string): Promise<{ feed: FeedItem[]; cursor?: string }> { 94 + const params = new URLSearchParams({ actor, limit: "100", filter: "posts_with_replies" }); 95 + if (cursor) params.set("cursor", cursor); 96 + 97 + const url = `${API_BASE}/app.bsky.feed.getAuthorFeed?${params}`; 98 + const res = await fetch(url); 99 + if (!res.ok) { 100 + throw new Error(`Failed to get author feed: ${res.status}`); 101 + } 102 + return res.json(); 103 + } 104 + 105 + async function getAllFollows(actor: string): Promise<Map<string, Follow>> { 106 + const follows = new Map<string, Follow>(); 107 + let cursor: string | undefined; 108 + let page = 1; 109 + 110 + console.log("[follows] fetching who you follow..."); 111 + 112 + do { 113 + const data = await getFollows(actor, cursor); 114 + for (const follow of data.follows) { 115 + follows.set(follow.did, follow); 116 + } 117 + console.log(`[follows] page ${page}: got ${data.follows.length} follows (total: ${follows.size})`); 118 + cursor = data.cursor; 119 + page++; 120 + } while (cursor); 121 + 122 + console.log(`[follows] done. you follow ${follows.size} accounts`); 123 + return follows; 124 + } 125 + 126 + async function getAllPosts(actor: string): Promise<FeedItem[]> { 127 + const posts: FeedItem[] = []; 128 + let cursor: string | undefined; 129 + let page = 1; 130 + 131 + console.log("[posts] fetching your posts and replies..."); 132 + 133 + do { 134 + const data = await getAuthorFeed(actor, cursor); 135 + posts.push(...data.feed); 136 + console.log(`[posts] page ${page}: got ${data.feed.length} posts (total: ${posts.length})`); 137 + cursor = data.cursor; 138 + page++; 139 + } while (cursor); 140 + 141 + console.log(`[posts] done. found ${posts.length} posts/replies`); 142 + return posts; 143 + } 144 + 145 + async function getFollowRecords(pdsUrl: string, actor: string, cursor?: string): Promise<{ records: FollowRecord[]; cursor?: string }> { 146 + const params = new URLSearchParams({ repo: actor, collection: "app.bsky.graph.follow", limit: "100" }); 147 + if (cursor) params.set("cursor", cursor); 148 + 149 + const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?${params}`; 150 + const res = await fetch(url); 151 + if (!res.ok) { 152 + throw new Error(`Failed to get follow records: ${res.status}`); 153 + } 154 + return res.json(); 155 + } 156 + 157 + async function getAllFollowRecords(pdsUrl: string, actor: string): Promise<Map<string, Date>> { 158 + const followDates = new Map<string, Date>(); 159 + let cursor: string | undefined; 160 + let page = 1; 161 + 162 + console.log("[follow-dates] fetching follow timestamps..."); 163 + 164 + do { 165 + const data = await getFollowRecords(pdsUrl, actor, cursor); 166 + for (const record of data.records) { 167 + followDates.set(record.value.subject, new Date(record.value.createdAt)); 168 + } 169 + console.log(`[follow-dates] page ${page}: got ${data.records.length} records (total: ${followDates.size})`); 170 + cursor = data.cursor; 171 + page++; 172 + } while (cursor); 173 + 174 + console.log(`[follow-dates] done. got timestamps for ${followDates.size} follows`); 175 + return followDates; 176 + } 177 + 178 + function scoreFreshness(followDates: Map<string, Date>, follows: Map<string, Follow>): Map<string, number> { 179 + const scores = new Map<string, number>(); 180 + const now = new Date(); 181 + 182 + for (const [did] of follows) { 183 + const followDate = followDates.get(did); 184 + if (!followDate) { 185 + scores.set(did, 0); 186 + continue; 187 + } 188 + 189 + const daysAgo = Math.floor((now.getTime() - followDate.getTime()) / (1000 * 60 * 60 * 24)); 190 + const freshness = Math.max(0, SCORE_FRESHNESS_MAX - daysAgo * 2); 191 + scores.set(did, freshness); 192 + } 193 + 194 + const freshCount = [...scores.values()].filter(s => s > 0).length; 195 + console.log(`[scoring] ${freshCount} follows within last ${FRESHNESS_DECAY_DAYS} days get freshness bonus`); 196 + return scores; 197 + } 198 + 199 + function scoreEngagement(posts: FeedItem[], follows: Map<string, Follow>, myDid: string): Map<string, number> { 200 + const scores = new Map<string, number>(); 201 + 202 + // Initialize all follows with 0 203 + for (const [did] of follows) { 204 + scores.set(did, 0); 205 + } 206 + 207 + let directReplies = 0; 208 + let threadReplies = 0; 209 + 210 + for (const item of posts) { 211 + const record = item.post.record; 212 + 213 + // Skip if not a reply 214 + if (!record.reply) continue; 215 + 216 + const parentAuthorDid = item.reply?.parent?.author?.did; 217 + const rootAuthorDid = item.reply?.root?.author?.did; 218 + 219 + // Direct reply to someone we follow 220 + if (parentAuthorDid && parentAuthorDid !== myDid && follows.has(parentAuthorDid)) { 221 + scores.set(parentAuthorDid, (scores.get(parentAuthorDid) || 0) + SCORE_DIRECT_REPLY); 222 + directReplies++; 223 + } 224 + 225 + // Reply in a thread started by someone we follow (but not a direct reply to them) 226 + if (rootAuthorDid && rootAuthorDid !== myDid && rootAuthorDid !== parentAuthorDid && follows.has(rootAuthorDid)) { 227 + scores.set(rootAuthorDid, (scores.get(rootAuthorDid) || 0) + SCORE_THREAD_REPLY); 228 + threadReplies++; 229 + } 230 + } 231 + 232 + console.log(`[scoring] found ${directReplies} direct replies and ${threadReplies} thread replies to people you follow`); 233 + return scores; 234 + } 235 + 236 + async function main(handle: string) { 237 + console.log(`\n🔍 analyzing engagement for @${handle}\n`); 238 + 239 + // Validate handle 240 + console.log("[profile] validating handle..."); 241 + const profile = await getProfile(handle); 242 + console.log(`[profile] found: ${profile.displayName || profile.handle} (${profile.did})\n`); 243 + 244 + // Resolve user's PDS 245 + console.log("[pds] resolving user's PDS..."); 246 + const pdsUrl = await resolvePds(profile.did); 247 + console.log(`[pds] found: ${pdsUrl}\n`); 248 + 249 + // Get follows 250 + const follows = await getAllFollows(profile.did); 251 + console.log(); 252 + 253 + // Get follow timestamps 254 + const followDates = await getAllFollowRecords(pdsUrl, profile.did); 255 + console.log(); 256 + 257 + // Get posts 258 + const posts = await getAllPosts(profile.did); 259 + console.log(); 260 + 261 + // Score engagement 262 + console.log("[scoring] calculating engagement scores..."); 263 + const engagementScores = scoreEngagement(posts, follows, profile.did); 264 + 265 + // Score freshness 266 + const freshnessScores = scoreFreshness(followDates, follows); 267 + 268 + // Combine scores 269 + const scores = new Map<string, number>(); 270 + for (const [did] of follows) { 271 + const engagement = engagementScores.get(did) || 0; 272 + const freshness = freshnessScores.get(did) || 0; 273 + scores.set(did, engagement + freshness); 274 + } 275 + 276 + // Sort by score descending 277 + const sorted = [...scores.entries()] 278 + .map(([did, score]) => ({ 279 + handle: follows.get(did)!.handle, 280 + score, 281 + engagement: engagementScores.get(did) || 0, 282 + freshness: freshnessScores.get(did) || 0, 283 + })) 284 + .sort((a, b) => b.score - a.score); 285 + 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); 292 + 293 + console.log(`\n✅ done! wrote ${sorted.length} entries to ${outputFile}`); 294 + 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})`); 299 + } 300 + 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 + } 309 + } 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();
+16
package.json
··· 1 + { 2 + "name": "follow-cleaner", 3 + "module": "index.ts", 4 + "type": "module", 5 + "private": true, 6 + "devDependencies": { 7 + "@types/bun": "latest" 8 + }, 9 + "peerDependencies": { 10 + "typescript": "^5" 11 + }, 12 + "dependencies": { 13 + "@bomb.sh/tab": "^0.0.11", 14 + "commander": "^14.0.2" 15 + } 16 + }
+29
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Environment setup & latest features 4 + "lib": ["ESNext"], 5 + "target": "ESNext", 6 + "module": "Preserve", 7 + "moduleDetection": "force", 8 + "jsx": "react-jsx", 9 + "allowJs": true, 10 + 11 + // Bundler mode 12 + "moduleResolution": "bundler", 13 + "allowImportingTsExtensions": true, 14 + "verbatimModuleSyntax": true, 15 + "noEmit": true, 16 + 17 + // Best practices 18 + "strict": true, 19 + "skipLibCheck": true, 20 + "noFallthroughCasesInSwitch": true, 21 + "noUncheckedIndexedAccess": true, 22 + "noImplicitOverride": true, 23 + 24 + // Some stricter flags (disabled by default) 25 + "noUnusedLocals": false, 26 + "noUnusedParameters": false, 27 + "noPropertyAccessFromIndexSignature": false 28 + } 29 + }