bsky follow audit script

it works somewhat

+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
+3
README.md
···
··· 1 + # Swab 2 + 3 + tiny script to audit who you're following based on when they recently posted. currently it's set to build a list of people you're following that haven't posted in 6 months. still wip.
+25
bun.lock
···
··· 1 + { 2 + "lockfileVersion": 1, 3 + "workspaces": { 4 + "": { 5 + "name": "unfollower", 6 + "devDependencies": { 7 + "@types/bun": "latest", 8 + }, 9 + "peerDependencies": { 10 + "typescript": "^5", 11 + }, 12 + }, 13 + }, 14 + "packages": { 15 + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], 16 + 17 + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], 18 + 19 + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], 20 + 21 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 22 + 23 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 24 + } 25 + }
+151
index.ts
···
··· 1 + interface Follow { 2 + did: string; 3 + handle: string; 4 + displayName: string; 5 + avatar: string; 6 + associated: { 7 + activitySubsription:{ 8 + allowSubscriptions: string; 9 + } 10 + } 11 + labels: unknown[] 12 + createdAt: string; 13 + description: string; 14 + indexedAt: string; 15 + } 16 + 17 + interface MiniDoc { 18 + did: string; 19 + handle: string; 20 + pds: string; 21 + signing_key: string; 22 + } 23 + 24 + interface Post { 25 + uri: string; 26 + cid: string; 27 + value: { 28 + text: string; 29 + $type: "app.bsky.feed.post" 30 + langs: string[] 31 + reply: { 32 + root: { 33 + cid: string; 34 + uri: string; 35 + }, 36 + parent: { 37 + cid: string; 38 + uri: string; 39 + } 40 + } 41 + createdAt: string; 42 + } 43 + } 44 + 45 + const headers = { 46 + 'User-Agent': 'unfollower-script/did:plc:qttsv4e7pu2jl3ilanfgc3zn' 47 + } 48 + 49 + const MONTHS_IN_DAYS = 183 // 6 months, seems reasonable 50 + 51 + 52 + // https://stackoverflow.com/questions/3224834/get-difference-between-2-dates-in-javascript 53 + function getDateDifferenceInDays(start: Date, end: Date) { 54 + const MS_PER_DAY = 1000 * 60 * 60 * 24; 55 + const startUTCDate = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate()); 56 + const endUTCDate = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate()); 57 + return Math.floor((endUTCDate - startUTCDate) / MS_PER_DAY); 58 + } 59 + 60 + async function resolveIdentity(indentifier: string): Promise<MiniDoc> { 61 + try { 62 + const response = await fetch(`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${indentifier}`, {headers}) 63 + if (!response.ok) { 64 + throw new Error(`Failed to fetch minidoc for ${indentifier}. Status - ${response.status}`) 65 + } 66 + const data = await response.json() as MiniDoc 67 + return data 68 + } catch (error) { 69 + if (error instanceof Error) { 70 + console.error(error.message) 71 + } 72 + throw error; 73 + } 74 + } 75 + 76 + async function getRecentPost(doc: MiniDoc) { 77 + try { 78 + const response = await fetch(`${doc.pds}/xrpc/com.atproto.repo.listRecords?repo=${doc.did}&collection=app.bsky.feed.post&limit=1`, {headers}) 79 + if (!response.ok) { 80 + throw new Error(`There was an error fetching posts for ${doc.handle}. Status - ${response.status}`) 81 + } 82 + const data = await response.json() as {records: Post[]} 83 + return data; 84 + } catch (error) { 85 + if (error instanceof Error) { 86 + console.error(error.message) 87 + } 88 + 89 + throw error; 90 + } 91 + } 92 + 93 + async function getFollowsByUser(did: string, cursor?: string) { 94 + try { 95 + const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${did}&cursor=${cursor}&limit=100`) 96 + if (!response.ok) { 97 + throw new Error(`There was a problem getting follows for ${did}. Status - ${response.status}`) 98 + } 99 + 100 + const data = await response.json() as { 101 + cursor: string 102 + follows: Follow[] 103 + } 104 + 105 + return { 106 + cursor: data?.cursor, 107 + follows: data?.follows 108 + } 109 + } catch (error) { 110 + if (error instanceof Error) { 111 + console.error(error.message) 112 + } 113 + throw error; 114 + } 115 + } 116 + 117 + let cursor: string | undefined; 118 + 119 + const unfollowMap = new Map<string, number>(); 120 + 121 + do { 122 + const {follows, cursor: followsCursor} = await getFollowsByUser("did:plc:qttsv4e7pu2jl3ilanfgc3zn", cursor) 123 + for (const [index, follower] of follows.entries()) { 124 + const doc = await resolveIdentity(follower.did) 125 + const post = await getRecentPost(doc) 126 + const recentPostCreationDate = post?.records?.[0]!.value?.createdAt 127 + 128 + 129 + // it's possible that someone has never made a post i guess, we should add them to the list 130 + if (!post?.records?.[0]) { 131 + // neg 1 can represent never made post 132 + unfollowMap.set(follower.handle, -1) 133 + } 134 + if (post?.records[0] && post?.records[0]?.value && !isNaN(Date.parse(recentPostCreationDate))) { 135 + // invalid date for some reason idk 136 + const daysSinceLastPost = getDateDifferenceInDays(new Date(recentPostCreationDate), new Date()) 137 + if (daysSinceLastPost >= MONTHS_IN_DAYS) { 138 + unfollowMap.set(follower.handle, daysSinceLastPost) 139 + } 140 + 141 + console.clear(); 142 + console.info(`Auditing user [${index + 1} / ${follows.length}]`) 143 + } 144 + 145 + await new Promise((resolve) => setTimeout(resolve, 1000)) 146 + } 147 + cursor = followsCursor 148 + } while (cursor) 149 + 150 + 151 + console.log(unfollowMap)
+12
package.json
···
··· 1 + { 2 + "name": "unfollower", 3 + "module": "index.ts", 4 + "type": "module", 5 + "private": true, 6 + "devDependencies": { 7 + "@types/bun": "latest" 8 + }, 9 + "peerDependencies": { 10 + "typescript": "^5" 11 + } 12 + }
+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 + }