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 testing, Claude's file from bun

Woovie bbfac9bb 6a23bfa7

+937
+2
.gitignore
··· 32 32 33 33 # Finder (MacOS) folder config 34 34 .DS_Store 35 + 36 + cache.json
+106
CLAUDE.md
··· 1 + 2 + Default to using Bun instead of Node.js. 3 + 4 + - Use `bun <file>` instead of `node <file>` or `ts-node <file>` 5 + - Use `bun test` instead of `jest` or `vitest` 6 + - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` 7 + - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` 8 + - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` 9 + - Use `bunx <package> <command>` instead of `npx <package> <command>` 10 + - Bun automatically loads .env, so don't use dotenv. 11 + 12 + ## APIs 13 + 14 + - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. 15 + - `bun:sqlite` for SQLite. Don't use `better-sqlite3`. 16 + - `Bun.redis` for Redis. Don't use `ioredis`. 17 + - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. 18 + - `WebSocket` is built-in. Don't use `ws`. 19 + - Prefer `Bun.file` over `node:fs`'s readFile/writeFile 20 + - Bun.$`ls` instead of execa. 21 + 22 + ## Testing 23 + 24 + Use `bun test` to run tests. 25 + 26 + ```ts#index.test.ts 27 + import { test, expect } from "bun:test"; 28 + 29 + test("hello world", () => { 30 + expect(1).toBe(1); 31 + }); 32 + ``` 33 + 34 + ## Frontend 35 + 36 + Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. 37 + 38 + Server: 39 + 40 + ```ts#index.ts 41 + import index from "./index.html" 42 + 43 + Bun.serve({ 44 + routes: { 45 + "/": index, 46 + "/api/users/:id": { 47 + GET: (req) => { 48 + return new Response(JSON.stringify({ id: req.params.id })); 49 + }, 50 + }, 51 + }, 52 + // optional websocket support 53 + websocket: { 54 + open: (ws) => { 55 + ws.send("Hello, world!"); 56 + }, 57 + message: (ws, message) => { 58 + ws.send(message); 59 + }, 60 + close: (ws) => { 61 + // handle close 62 + } 63 + }, 64 + development: { 65 + hmr: true, 66 + console: true, 67 + } 68 + }) 69 + ``` 70 + 71 + HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle. 72 + 73 + ```html#index.html 74 + <html> 75 + <body> 76 + <h1>Hello, world!</h1> 77 + <script type="module" src="./frontend.tsx"></script> 78 + </body> 79 + </html> 80 + ``` 81 + 82 + With the following `frontend.tsx`: 83 + 84 + ```tsx#frontend.tsx 85 + import React from "react"; 86 + import { createRoot } from "react-dom/client"; 87 + 88 + // import .css files directly and it works 89 + import './index.css'; 90 + 91 + const root = createRoot(document.body); 92 + 93 + export default function Frontend() { 94 + return <h1>Hello, world!</h1>; 95 + } 96 + 97 + root.render(<Frontend />); 98 + ``` 99 + 100 + Then, run index.ts 101 + 102 + ```sh 103 + bun --hot ./index.ts 104 + ``` 105 + 106 + For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+829
index.test.ts
··· 1 + import { test, expect, describe, beforeEach, afterEach } from "bun:test"; 2 + import { 3 + scoreFreshness, 4 + scoreEngagement, 5 + scoreRecency, 6 + combineScores, 7 + formatOutput, 8 + getProfile, 9 + getAllFollows, 10 + getAllPosts, 11 + getAllFollowRecords, 12 + resolvePds, 13 + setFetchImpl, 14 + resetFetchImpl, 15 + SCORE_DIRECT_REPLY, 16 + SCORE_THREAD_REPLY, 17 + SCORE_FRESHNESS_MAX, 18 + SCORE_RECENCY_PENALTY_PER_DAY, 19 + type Follow, 20 + type FeedItem, 21 + type ScoredEntry, 22 + } from "./index"; 23 + 24 + describe("scoreFreshness", () => { 25 + test("returns 0 for follows without a date", () => { 26 + const follows = new Map<string, Follow>([ 27 + ["did:plc:abc", { did: "did:plc:abc", handle: "alice.bsky.social" }], 28 + ]); 29 + const followDates = new Map<string, Date>(); 30 + 31 + const scores = scoreFreshness(followDates, follows); 32 + 33 + expect(scores.get("did:plc:abc")).toBe(0); 34 + }); 35 + 36 + test("returns max score for follows from today", () => { 37 + const follows = new Map<string, Follow>([ 38 + ["did:plc:abc", { did: "did:plc:abc", handle: "alice.bsky.social" }], 39 + ]); 40 + const followDates = new Map<string, Date>([ 41 + ["did:plc:abc", new Date()], 42 + ]); 43 + 44 + const scores = scoreFreshness(followDates, follows); 45 + 46 + expect(scores.get("did:plc:abc")).toBe(SCORE_FRESHNESS_MAX); 47 + }); 48 + 49 + test("decays score by 2 points per day", () => { 50 + const follows = new Map<string, Follow>([ 51 + ["did:plc:abc", { did: "did:plc:abc", handle: "alice.bsky.social" }], 52 + ]); 53 + const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); 54 + const followDates = new Map<string, Date>([ 55 + ["did:plc:abc", fiveDaysAgo], 56 + ]); 57 + 58 + const scores = scoreFreshness(followDates, follows); 59 + 60 + expect(scores.get("did:plc:abc")).toBe(SCORE_FRESHNESS_MAX - 10); 61 + }); 62 + 63 + test("returns 0 for very old follows", () => { 64 + const follows = new Map<string, Follow>([ 65 + ["did:plc:abc", { did: "did:plc:abc", handle: "alice.bsky.social" }], 66 + ]); 67 + const longAgo = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000); 68 + const followDates = new Map<string, Date>([ 69 + ["did:plc:abc", longAgo], 70 + ]); 71 + 72 + const scores = scoreFreshness(followDates, follows); 73 + 74 + expect(scores.get("did:plc:abc")).toBe(0); 75 + }); 76 + }); 77 + 78 + describe("scoreEngagement", () => { 79 + const myDid = "did:plc:me"; 80 + 81 + test("returns 0 for follows with no replies", () => { 82 + const follows = new Map<string, Follow>([ 83 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 84 + ]); 85 + const posts: FeedItem[] = []; 86 + 87 + const scores = scoreEngagement(posts, follows, myDid); 88 + 89 + expect(scores.get("did:plc:alice")).toBe(0); 90 + }); 91 + 92 + test("scores direct replies with SCORE_DIRECT_REPLY points", () => { 93 + const follows = new Map<string, Follow>([ 94 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 95 + ]); 96 + const posts: FeedItem[] = [ 97 + { 98 + post: { 99 + uri: "at://did:plc:me/app.bsky.feed.post/1", 100 + author: { did: myDid, handle: "me.bsky.social" }, 101 + record: { 102 + reply: { 103 + parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 104 + root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 105 + }, 106 + }, 107 + }, 108 + reply: { 109 + parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 110 + root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 111 + }, 112 + }, 113 + ]; 114 + 115 + const scores = scoreEngagement(posts, follows, myDid); 116 + 117 + expect(scores.get("did:plc:alice")).toBe(SCORE_DIRECT_REPLY); 118 + }); 119 + 120 + test("scores thread replies with SCORE_THREAD_REPLY points", () => { 121 + const follows = new Map<string, Follow>([ 122 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 123 + ["did:plc:bob", { did: "did:plc:bob", handle: "bob.bsky.social" }], 124 + ]); 125 + const posts: FeedItem[] = [ 126 + { 127 + post: { 128 + uri: "at://did:plc:me/app.bsky.feed.post/1", 129 + author: { did: myDid, handle: "me.bsky.social" }, 130 + record: { 131 + reply: { 132 + parent: { uri: "at://did:plc:bob/app.bsky.feed.post/2", cid: "cid2" }, 133 + root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 134 + }, 135 + }, 136 + }, 137 + reply: { 138 + parent: { author: { did: "did:plc:bob", handle: "bob.bsky.social" } }, 139 + root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 140 + }, 141 + }, 142 + ]; 143 + 144 + const scores = scoreEngagement(posts, follows, myDid); 145 + 146 + expect(scores.get("did:plc:alice")).toBe(SCORE_THREAD_REPLY); 147 + expect(scores.get("did:plc:bob")).toBe(SCORE_DIRECT_REPLY); 148 + }); 149 + 150 + test("accumulates scores for multiple replies", () => { 151 + const follows = new Map<string, Follow>([ 152 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 153 + ]); 154 + const posts: FeedItem[] = [ 155 + { 156 + post: { 157 + uri: "at://did:plc:me/app.bsky.feed.post/1", 158 + author: { did: myDid, handle: "me.bsky.social" }, 159 + record: { 160 + reply: { 161 + parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 162 + root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 163 + }, 164 + }, 165 + }, 166 + reply: { 167 + parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 168 + root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 169 + }, 170 + }, 171 + { 172 + post: { 173 + uri: "at://did:plc:me/app.bsky.feed.post/2", 174 + author: { did: myDid, handle: "me.bsky.social" }, 175 + record: { 176 + reply: { 177 + parent: { uri: "at://did:plc:alice/app.bsky.feed.post/2", cid: "cid2" }, 178 + root: { uri: "at://did:plc:alice/app.bsky.feed.post/2", cid: "cid2" }, 179 + }, 180 + }, 181 + }, 182 + reply: { 183 + parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 184 + root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 185 + }, 186 + }, 187 + ]; 188 + 189 + const scores = scoreEngagement(posts, follows, myDid); 190 + 191 + expect(scores.get("did:plc:alice")).toBe(SCORE_DIRECT_REPLY * 2); 192 + }); 193 + 194 + test("ignores replies to self", () => { 195 + const follows = new Map<string, Follow>([ 196 + [myDid, { did: myDid, handle: "me.bsky.social" }], 197 + ]); 198 + const posts: FeedItem[] = [ 199 + { 200 + post: { 201 + uri: "at://did:plc:me/app.bsky.feed.post/2", 202 + author: { did: myDid, handle: "me.bsky.social" }, 203 + record: { 204 + reply: { 205 + parent: { uri: "at://did:plc:me/app.bsky.feed.post/1", cid: "cid1" }, 206 + root: { uri: "at://did:plc:me/app.bsky.feed.post/1", cid: "cid1" }, 207 + }, 208 + }, 209 + }, 210 + reply: { 211 + parent: { author: { did: myDid, handle: "me.bsky.social" } }, 212 + root: { author: { did: myDid, handle: "me.bsky.social" } }, 213 + }, 214 + }, 215 + ]; 216 + 217 + const scores = scoreEngagement(posts, follows, myDid); 218 + 219 + expect(scores.get(myDid)).toBe(0); 220 + }); 221 + 222 + test("ignores replies to accounts not followed", () => { 223 + const follows = new Map<string, Follow>([ 224 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 225 + ]); 226 + const posts: FeedItem[] = [ 227 + { 228 + post: { 229 + uri: "at://did:plc:me/app.bsky.feed.post/1", 230 + author: { did: myDid, handle: "me.bsky.social" }, 231 + record: { 232 + reply: { 233 + parent: { uri: "at://did:plc:stranger/app.bsky.feed.post/1", cid: "cid1" }, 234 + root: { uri: "at://did:plc:stranger/app.bsky.feed.post/1", cid: "cid1" }, 235 + }, 236 + }, 237 + }, 238 + reply: { 239 + parent: { author: { did: "did:plc:stranger", handle: "stranger.bsky.social" } }, 240 + root: { author: { did: "did:plc:stranger", handle: "stranger.bsky.social" } }, 241 + }, 242 + }, 243 + ]; 244 + 245 + const scores = scoreEngagement(posts, follows, myDid); 246 + 247 + expect(scores.get("did:plc:alice")).toBe(0); 248 + }); 249 + 250 + test("skips posts without replies", () => { 251 + const follows = new Map<string, Follow>([ 252 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 253 + ]); 254 + const posts: FeedItem[] = [ 255 + { 256 + post: { 257 + uri: "at://did:plc:me/app.bsky.feed.post/1", 258 + author: { did: myDid, handle: "me.bsky.social" }, 259 + record: {}, 260 + }, 261 + }, 262 + ]; 263 + 264 + const scores = scoreEngagement(posts, follows, myDid); 265 + 266 + expect(scores.get("did:plc:alice")).toBe(0); 267 + }); 268 + }); 269 + 270 + describe("scoreRecency", () => { 271 + const myDid = "did:plc:me"; 272 + const now = new Date("2025-01-15T12:00:00Z"); 273 + 274 + test("penalizes follows with no engagement based on follow age", () => { 275 + const follows = new Map<string, Follow>([ 276 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 277 + ]); 278 + const followDates = new Map<string, Date>([ 279 + ["did:plc:alice", new Date("2025-01-05T12:00:00Z")], // 10 days ago 280 + ]); 281 + const posts: FeedItem[] = []; 282 + 283 + const scores = scoreRecency(posts, follows, myDid, followDates, now); 284 + 285 + expect(scores.get("did:plc:alice")).toBe(-10 * SCORE_RECENCY_PENALTY_PER_DAY); 286 + }); 287 + 288 + test("returns 0 for follows with no engagement and no follow date", () => { 289 + const follows = new Map<string, Follow>([ 290 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 291 + ]); 292 + const followDates = new Map<string, Date>(); 293 + const posts: FeedItem[] = []; 294 + 295 + const scores = scoreRecency(posts, follows, myDid, followDates, now); 296 + 297 + expect(scores.get("did:plc:alice")).toBe(0); 298 + }); 299 + 300 + test("returns 0 penalty for engagement from today", () => { 301 + const follows = new Map<string, Follow>([ 302 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 303 + ]); 304 + const followDates = new Map<string, Date>(); 305 + const posts: FeedItem[] = [ 306 + { 307 + post: { 308 + uri: "at://did:plc:me/app.bsky.feed.post/1", 309 + author: { did: myDid, handle: "me.bsky.social" }, 310 + record: { 311 + createdAt: "2025-01-15T10:00:00Z", 312 + reply: { 313 + parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 314 + root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 315 + }, 316 + }, 317 + }, 318 + reply: { 319 + parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 320 + root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 321 + }, 322 + }, 323 + ]; 324 + 325 + const scores = scoreRecency(posts, follows, myDid, followDates, now); 326 + 327 + expect(scores.get("did:plc:alice")).toBe(0); 328 + }); 329 + 330 + test("subtracts 1 point per day since last engagement", () => { 331 + const follows = new Map<string, Follow>([ 332 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 333 + ]); 334 + const followDates = new Map<string, Date>(); 335 + // Post from 10 days ago 336 + const posts: FeedItem[] = [ 337 + { 338 + post: { 339 + uri: "at://did:plc:me/app.bsky.feed.post/1", 340 + author: { did: myDid, handle: "me.bsky.social" }, 341 + record: { 342 + createdAt: "2025-01-05T10:00:00Z", 343 + reply: { 344 + parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 345 + root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 346 + }, 347 + }, 348 + }, 349 + reply: { 350 + parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 351 + root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 352 + }, 353 + }, 354 + ]; 355 + 356 + const scores = scoreRecency(posts, follows, myDid, followDates, now); 357 + 358 + expect(scores.get("did:plc:alice")).toBe(-10 * SCORE_RECENCY_PENALTY_PER_DAY); 359 + }); 360 + 361 + test("uses most recent engagement when multiple replies exist", () => { 362 + const follows = new Map<string, Follow>([ 363 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 364 + ]); 365 + const followDates = new Map<string, Date>(); 366 + const posts: FeedItem[] = [ 367 + // Older post from 20 days ago 368 + { 369 + post: { 370 + uri: "at://did:plc:me/app.bsky.feed.post/1", 371 + author: { did: myDid, handle: "me.bsky.social" }, 372 + record: { 373 + createdAt: "2024-12-26T10:00:00Z", 374 + reply: { 375 + parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 376 + root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 377 + }, 378 + }, 379 + }, 380 + reply: { 381 + parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 382 + root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 383 + }, 384 + }, 385 + // More recent post from 5 days ago 386 + { 387 + post: { 388 + uri: "at://did:plc:me/app.bsky.feed.post/2", 389 + author: { did: myDid, handle: "me.bsky.social" }, 390 + record: { 391 + createdAt: "2025-01-10T10:00:00Z", 392 + reply: { 393 + parent: { uri: "at://did:plc:alice/app.bsky.feed.post/2", cid: "cid2" }, 394 + root: { uri: "at://did:plc:alice/app.bsky.feed.post/2", cid: "cid2" }, 395 + }, 396 + }, 397 + }, 398 + reply: { 399 + parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 400 + root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 401 + }, 402 + }, 403 + ]; 404 + 405 + const scores = scoreRecency(posts, follows, myDid, followDates, now); 406 + 407 + // Should use the 5-day-old engagement, not the 20-day-old one 408 + expect(scores.get("did:plc:alice")).toBe(-5 * SCORE_RECENCY_PENALTY_PER_DAY); 409 + }); 410 + 411 + test("tracks thread participation for recency", () => { 412 + const follows = new Map<string, Follow>([ 413 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 414 + ["did:plc:bob", { did: "did:plc:bob", handle: "bob.bsky.social" }], 415 + ]); 416 + const followDates = new Map<string, Date>(); 417 + // Reply to bob in alice's thread from 7 days ago 418 + const posts: FeedItem[] = [ 419 + { 420 + post: { 421 + uri: "at://did:plc:me/app.bsky.feed.post/1", 422 + author: { did: myDid, handle: "me.bsky.social" }, 423 + record: { 424 + createdAt: "2025-01-08T10:00:00Z", 425 + reply: { 426 + parent: { uri: "at://did:plc:bob/app.bsky.feed.post/2", cid: "cid2" }, 427 + root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 428 + }, 429 + }, 430 + }, 431 + reply: { 432 + parent: { author: { did: "did:plc:bob", handle: "bob.bsky.social" } }, 433 + root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 434 + }, 435 + }, 436 + ]; 437 + 438 + const scores = scoreRecency(posts, follows, myDid, followDates, now); 439 + 440 + // Both should have recency tracked 441 + expect(scores.get("did:plc:alice")).toBe(-7 * SCORE_RECENCY_PENALTY_PER_DAY); 442 + expect(scores.get("did:plc:bob")).toBe(-7 * SCORE_RECENCY_PENALTY_PER_DAY); 443 + }); 444 + 445 + test("ignores replies to self", () => { 446 + const follows = new Map<string, Follow>([ 447 + [myDid, { did: myDid, handle: "me.bsky.social" }], 448 + ]); 449 + const followDates = new Map<string, Date>(); 450 + const posts: FeedItem[] = [ 451 + { 452 + post: { 453 + uri: "at://did:plc:me/app.bsky.feed.post/2", 454 + author: { did: myDid, handle: "me.bsky.social" }, 455 + record: { 456 + createdAt: "2025-01-10T10:00:00Z", 457 + reply: { 458 + parent: { uri: "at://did:plc:me/app.bsky.feed.post/1", cid: "cid1" }, 459 + root: { uri: "at://did:plc:me/app.bsky.feed.post/1", cid: "cid1" }, 460 + }, 461 + }, 462 + }, 463 + reply: { 464 + parent: { author: { did: myDid, handle: "me.bsky.social" } }, 465 + root: { author: { did: myDid, handle: "me.bsky.social" } }, 466 + }, 467 + }, 468 + ]; 469 + 470 + const scores = scoreRecency(posts, follows, myDid, followDates, now); 471 + 472 + expect(scores.get(myDid)).toBe(0); 473 + }); 474 + 475 + test("ignores posts without createdAt and uses follow date for penalty", () => { 476 + const follows = new Map<string, Follow>([ 477 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 478 + ]); 479 + const followDates = new Map<string, Date>([ 480 + ["did:plc:alice", new Date("2025-01-10T12:00:00Z")], // 5 days ago 481 + ]); 482 + const posts: FeedItem[] = [ 483 + { 484 + post: { 485 + uri: "at://did:plc:me/app.bsky.feed.post/1", 486 + author: { did: myDid, handle: "me.bsky.social" }, 487 + record: { 488 + // No createdAt 489 + reply: { 490 + parent: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 491 + root: { uri: "at://did:plc:alice/app.bsky.feed.post/1", cid: "cid1" }, 492 + }, 493 + }, 494 + }, 495 + reply: { 496 + parent: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 497 + root: { author: { did: "did:plc:alice", handle: "alice.bsky.social" } }, 498 + }, 499 + }, 500 + ]; 501 + 502 + const scores = scoreRecency(posts, follows, myDid, followDates, now); 503 + 504 + // No valid engagement due to missing createdAt, so penalty based on follow date (5 days) 505 + expect(scores.get("did:plc:alice")).toBe(-5 * SCORE_RECENCY_PENALTY_PER_DAY); 506 + }); 507 + }); 508 + 509 + describe("combineScores", () => { 510 + test("skips recency penalty when freshness is greater than zero", () => { 511 + const follows = new Map<string, Follow>([ 512 + ["did:plc:fresh", { did: "did:plc:fresh", handle: "fresh.bsky.social" }], 513 + ["did:plc:stale", { did: "did:plc:stale", handle: "stale.bsky.social" }], 514 + ]); 515 + const engagementScores = new Map<string, number>([ 516 + ["did:plc:fresh", 20], 517 + ["did:plc:stale", 20], 518 + ]); 519 + const freshnessScores = new Map<string, number>([ 520 + ["did:plc:fresh", 30], // Fresh follow (freshness > 0) 521 + ["did:plc:stale", 0], // Stale follow (freshness = 0) 522 + ]); 523 + const recencyScores = new Map<string, number>([ 524 + ["did:plc:fresh", -15], // Would be penalized, but freshness > 0 525 + ["did:plc:stale", -15], // Should be penalized 526 + ]); 527 + 528 + const results = combineScores(follows, engagementScores, freshnessScores, recencyScores); 529 + 530 + const freshEntry = results.find(e => e.handle === "fresh.bsky.social")!; 531 + const staleEntry = results.find(e => e.handle === "stale.bsky.social")!; 532 + 533 + // Fresh follow: recency should be 0 (skipped), score = 20 + 30 + 0 = 50 534 + expect(freshEntry.recency).toBe(0); 535 + expect(freshEntry.score).toBe(50); 536 + 537 + // Stale follow: recency should be applied, score = 20 + 0 + (-15) = 5 538 + expect(staleEntry.recency).toBe(-15); 539 + expect(staleEntry.score).toBe(5); 540 + }); 541 + 542 + test("applies recency penalty when freshness is zero", () => { 543 + const follows = new Map<string, Follow>([ 544 + ["did:plc:alice", { did: "did:plc:alice", handle: "alice.bsky.social" }], 545 + ]); 546 + const engagementScores = new Map<string, number>([["did:plc:alice", 10]]); 547 + const freshnessScores = new Map<string, number>([["did:plc:alice", 0]]); 548 + const recencyScores = new Map<string, number>([["did:plc:alice", -5]]); 549 + 550 + const results = combineScores(follows, engagementScores, freshnessScores, recencyScores); 551 + 552 + expect(results[0].recency).toBe(-5); 553 + expect(results[0].score).toBe(5); // 10 + 0 + (-5) 554 + }); 555 + 556 + test("sorts results by score descending", () => { 557 + const follows = new Map<string, Follow>([ 558 + ["did:plc:low", { did: "did:plc:low", handle: "low.bsky.social" }], 559 + ["did:plc:high", { did: "did:plc:high", handle: "high.bsky.social" }], 560 + ["did:plc:mid", { did: "did:plc:mid", handle: "mid.bsky.social" }], 561 + ]); 562 + const engagementScores = new Map<string, number>([ 563 + ["did:plc:low", 5], 564 + ["did:plc:high", 50], 565 + ["did:plc:mid", 25], 566 + ]); 567 + const freshnessScores = new Map<string, number>(); 568 + const recencyScores = new Map<string, number>(); 569 + 570 + const results = combineScores(follows, engagementScores, freshnessScores, recencyScores); 571 + 572 + expect(results[0].handle).toBe("high.bsky.social"); 573 + expect(results[1].handle).toBe("mid.bsky.social"); 574 + expect(results[2].handle).toBe("low.bsky.social"); 575 + }); 576 + }); 577 + 578 + describe("formatOutput", () => { 579 + test("outputs header as first line", () => { 580 + const entries: ScoredEntry[] = []; 581 + 582 + const output = formatOutput(entries); 583 + 584 + expect(output).toBe("handle score engagement freshness recency"); 585 + }); 586 + 587 + test("formats single entry correctly", () => { 588 + const entries: ScoredEntry[] = [ 589 + { handle: "alice.bsky.social", score: 50, engagement: 30, freshness: 40, recency: -20 }, 590 + ]; 591 + 592 + const output = formatOutput(entries); 593 + const lines = output.split("\n"); 594 + 595 + expect(lines).toHaveLength(2); 596 + expect(lines[0]).toBe("handle score engagement freshness recency"); 597 + expect(lines[1]).toBe("alice.bsky.social 50 30 40 -20"); 598 + }); 599 + 600 + test("formats multiple entries in order", () => { 601 + const entries: ScoredEntry[] = [ 602 + { handle: "alice.bsky.social", score: 50, engagement: 30, freshness: 40, recency: -20 }, 603 + { handle: "bob.bsky.social", score: 25, engagement: 10, freshness: 20, recency: -5 }, 604 + { handle: "carol.bsky.social", score: 0, engagement: 0, freshness: 0, recency: 0 }, 605 + ]; 606 + 607 + const output = formatOutput(entries); 608 + const lines = output.split("\n"); 609 + 610 + expect(lines).toHaveLength(4); 611 + expect(lines[0]).toBe("handle score engagement freshness recency"); 612 + expect(lines[1]).toBe("alice.bsky.social 50 30 40 -20"); 613 + expect(lines[2]).toBe("bob.bsky.social 25 10 20 -5"); 614 + expect(lines[3]).toBe("carol.bsky.social 0 0 0 0"); 615 + }); 616 + 617 + test("handles negative scores correctly", () => { 618 + const entries: ScoredEntry[] = [ 619 + { handle: "alice.bsky.social", score: -15, engagement: 10, freshness: 5, recency: -30 }, 620 + ]; 621 + 622 + const output = formatOutput(entries); 623 + const lines = output.split("\n"); 624 + 625 + expect(lines[1]).toBe("alice.bsky.social -15 10 5 -30"); 626 + }); 627 + 628 + test("output is space-separated and parseable", () => { 629 + const entries: ScoredEntry[] = [ 630 + { handle: "alice.bsky.social", score: 100, engagement: 50, freshness: 50, recency: 0 }, 631 + ]; 632 + 633 + const output = formatOutput(entries); 634 + const lines = output.split("\n"); 635 + const headerFields = lines[0].split(" "); 636 + const dataFields = lines[1].split(" "); 637 + 638 + expect(headerFields).toEqual(["handle", "score", "engagement", "freshness", "recency"]); 639 + expect(dataFields).toHaveLength(5); 640 + expect(dataFields[0]).toBe("alice.bsky.social"); 641 + expect(parseInt(dataFields[1])).toBe(100); 642 + expect(parseInt(dataFields[2])).toBe(50); 643 + expect(parseInt(dataFields[3])).toBe(50); 644 + expect(parseInt(dataFields[4])).toBe(0); 645 + }); 646 + }); 647 + 648 + // Integration tests with mock fetch 649 + describe("API functions with mock fetch", () => { 650 + afterEach(() => { 651 + resetFetchImpl(); 652 + }); 653 + 654 + test("getProfile returns profile data", async () => { 655 + const mockProfile = { 656 + did: "did:plc:alice", 657 + handle: "alice.bsky.social", 658 + displayName: "Alice", 659 + }; 660 + 661 + setFetchImpl(async () => new Response(JSON.stringify(mockProfile))); 662 + 663 + const profile = await getProfile("alice.bsky.social"); 664 + 665 + expect(profile.did).toBe("did:plc:alice"); 666 + expect(profile.handle).toBe("alice.bsky.social"); 667 + }); 668 + 669 + test("resolvePds extracts PDS URL from DID document", async () => { 670 + const mockDidDoc = { 671 + id: "did:plc:alice", 672 + service: [ 673 + { 674 + id: "#atproto_pds", 675 + type: "AtprotoPersonalDataServer", 676 + serviceEndpoint: "https://pds.example.com", 677 + }, 678 + ], 679 + }; 680 + 681 + setFetchImpl(async () => new Response(JSON.stringify(mockDidDoc))); 682 + 683 + const pdsUrl = await resolvePds("did:plc:alice"); 684 + 685 + expect(pdsUrl).toBe("https://pds.example.com"); 686 + }); 687 + 688 + test("getAllFollows paginates through all follows", async () => { 689 + let callCount = 0; 690 + setFetchImpl(async () => { 691 + callCount++; 692 + if (callCount === 1) { 693 + return new Response(JSON.stringify({ 694 + follows: [ 695 + { did: "did:plc:bob", handle: "bob.bsky.social" }, 696 + { did: "did:plc:carol", handle: "carol.bsky.social" }, 697 + ], 698 + cursor: "page2", 699 + })); 700 + } 701 + return new Response(JSON.stringify({ 702 + follows: [ 703 + { did: "did:plc:dave", handle: "dave.bsky.social" }, 704 + ], 705 + })); 706 + }); 707 + 708 + const follows = await getAllFollows("did:plc:alice"); 709 + 710 + expect(follows.size).toBe(3); 711 + expect(follows.has("did:plc:bob")).toBe(true); 712 + expect(follows.has("did:plc:carol")).toBe(true); 713 + expect(follows.has("did:plc:dave")).toBe(true); 714 + expect(callCount).toBe(2); 715 + }); 716 + 717 + test("getAllPosts paginates through all posts", async () => { 718 + let callCount = 0; 719 + setFetchImpl(async () => { 720 + callCount++; 721 + if (callCount === 1) { 722 + return new Response(JSON.stringify({ 723 + feed: [ 724 + { post: { uri: "at://did:plc:alice/post/1", author: { did: "did:plc:alice", handle: "alice.bsky.social" }, record: {} } }, 725 + ], 726 + cursor: "page2", 727 + })); 728 + } 729 + return new Response(JSON.stringify({ 730 + feed: [ 731 + { post: { uri: "at://did:plc:alice/post/2", author: { did: "did:plc:alice", handle: "alice.bsky.social" }, record: {} } }, 732 + ], 733 + })); 734 + }); 735 + 736 + const posts = await getAllPosts("did:plc:alice"); 737 + 738 + expect(posts.length).toBe(2); 739 + expect(callCount).toBe(2); 740 + }); 741 + }); 742 + 743 + // Live integration tests against the real Bluesky API 744 + describe("live integration with alice.bsky.social", () => { 745 + test("fetches profile for alice.bsky.social", async () => { 746 + const profile = await getProfile("alice.bsky.social"); 747 + 748 + expect(profile.did).toStartWith("did:plc:"); 749 + expect(profile.handle).toBe("alice.bsky.social"); 750 + }); 751 + 752 + test("resolves PDS for alice.bsky.social", async () => { 753 + const profile = await getProfile("alice.bsky.social"); 754 + const pdsUrl = await resolvePds(profile.did); 755 + 756 + expect(pdsUrl).toStartWith("https://"); 757 + }); 758 + 759 + test("fetches follows for alice.bsky.social", async () => { 760 + const profile = await getProfile("alice.bsky.social"); 761 + const follows = await getAllFollows(profile.did); 762 + 763 + // Alice follows people, so we should get some results 764 + expect(follows.size).toBeGreaterThan(0); 765 + 766 + // Verify the structure of a follow entry 767 + const firstFollow = follows.values().next().value; 768 + expect(firstFollow.did).toStartWith("did:"); 769 + expect(typeof firstFollow.handle).toBe("string"); 770 + }); 771 + 772 + test("fetches posts for alice.bsky.social", async () => { 773 + const profile = await getProfile("alice.bsky.social"); 774 + const posts = await getAllPosts(profile.did); 775 + 776 + // Alice has posted, so we should get some results 777 + expect(posts.length).toBeGreaterThan(0); 778 + 779 + // Verify the structure of a post entry 780 + const firstPost = posts[0]; 781 + expect(firstPost.post.uri).toStartWith("at://"); 782 + expect(firstPost.post.author.did).toBe(profile.did); 783 + }); 784 + 785 + test("fetches follow records with dates for alice.bsky.social", async () => { 786 + const profile = await getProfile("alice.bsky.social"); 787 + const pdsUrl = await resolvePds(profile.did); 788 + const followDates = await getAllFollowRecords(pdsUrl, profile.did); 789 + 790 + // Should have timestamps for follows 791 + expect(followDates.size).toBeGreaterThan(0); 792 + 793 + // Verify dates are valid 794 + const firstDate = followDates.values().next().value; 795 + expect(firstDate).toBeInstanceOf(Date); 796 + expect(firstDate.getTime()).toBeGreaterThan(0); 797 + }); 798 + 799 + test("runs full scoring pipeline for alice.bsky.social", async () => { 800 + const profile = await getProfile("alice.bsky.social"); 801 + const pdsUrl = await resolvePds(profile.did); 802 + 803 + const follows = await getAllFollows(profile.did); 804 + const followDates = await getAllFollowRecords(pdsUrl, profile.did); 805 + const posts = await getAllPosts(profile.did); 806 + 807 + // Run scoring 808 + const engagementScores = scoreEngagement(posts, follows, profile.did); 809 + const freshnessScores = scoreFreshness(followDates, follows); 810 + const recencyScores = scoreRecency(posts, follows, profile.did, followDates); 811 + 812 + // Combine and format 813 + const results = combineScores(follows, engagementScores, freshnessScores, recencyScores); 814 + const output = formatOutput(results); 815 + 816 + // Verify output structure 817 + const lines = output.split("\n"); 818 + expect(lines[0]).toBe("handle score engagement freshness recency"); 819 + expect(lines.length).toBeGreaterThan(1); 820 + 821 + // Verify each data line has 5 fields 822 + for (let i = 1; i < lines.length; i++) { 823 + const fields = lines[i].split(" "); 824 + expect(fields.length).toBe(5); 825 + expect(fields[0]).toContain("."); // handle has a dot 826 + expect(Number.isInteger(parseInt(fields[1]))).toBe(true); // score is a number 827 + } 828 + }); 829 + });