Script that has 50% chance of unfollowing a non-mutual

Finish script

modamo 35f1eea9 65c56bb6

+2 -1
.gitignore
··· 1 - node_modules/ 1 + node_modules/ 2 + .env
+152
index.ts
··· 1 + import { AtpAgent } from "@atproto/api"; 2 + import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs.js"; 3 + import "dotenv/config"; 4 + 5 + const getFollows = async (agent: AtpAgent, actor: string) => { 6 + let cursor: string | undefined; 7 + 8 + const follows = []; 9 + 10 + while (true) { 11 + const response = await agent.getFollows({ 12 + actor, 13 + limit: 100, 14 + ...(cursor && { cursor }) 15 + }); 16 + 17 + follows.push(...response.data.follows); 18 + 19 + cursor = response.data.cursor; 20 + 21 + if (!cursor) { 22 + break; 23 + } 24 + } 25 + 26 + return follows; 27 + }; 28 + 29 + const getNonMutuals = async (agent: AtpAgent, actor: string) => { 30 + console.log("Fetching all accounts that you follow..."); 31 + 32 + const follows = await getFollows(agent, actor); 33 + 34 + console.log(`Fetched ${follows.length} follows to evaluate`); 35 + 36 + const dids = follows.map((follow) => follow.did); 37 + const BATCH_SIZE = 25; 38 + const allRelationships = []; 39 + 40 + for (let i = 0; i < dids.length; i += BATCH_SIZE) { 41 + const batch = dids.slice(i, i + BATCH_SIZE); 42 + 43 + const response = await agent.api.app.bsky.graph.getRelationships({ 44 + actor, 45 + others: batch 46 + }); 47 + 48 + const relationships = response.data.relationships.filter( 49 + (relationship) => relationship.did !== actor 50 + ); 51 + 52 + allRelationships.push(...relationships); 53 + 54 + if (i + BATCH_SIZE < dids.length) { 55 + console.log( 56 + ` -> Checked ${i + BATCH_SIZE} / ${dids.length} targets...` 57 + ); 58 + } 59 + } 60 + 61 + const mutuals = new Set( 62 + allRelationships 63 + .filter((relationship) => relationship.followedBy) 64 + .map((relationship) => relationship.did) 65 + ); 66 + 67 + console.log(`${mutuals.size} mutuals`); 68 + 69 + const nonMutuals = follows.filter((follow) => !mutuals.has(follow.did)); 70 + 71 + console.log(`${nonMutuals.length} non-mutuals`); 72 + 73 + return nonMutuals; 74 + }; 75 + 76 + const getProfile = async (agent: AtpAgent, actor: string) => { 77 + const response = await agent.getProfile({ actor }); 78 + 79 + return { 80 + followers: response.data.followersCount, 81 + follows: response.data.followsCount 82 + }; 83 + }; 84 + 85 + const snap = async (agent: AtpAgent, nms: ProfileView[]) => { 86 + const unfollows: ProfileView[] = []; 87 + 88 + nms.forEach((nm) => { 89 + const unfollow = Math.random() < 0.5; 90 + 91 + if (unfollow) { 92 + unfollows.push(nm); 93 + } 94 + }); 95 + 96 + console.log(`${unfollows.length} / ${nms.length} to snap`); 97 + 98 + const unfollowPromises = unfollows.map((u) => 99 + agent.deleteFollow(u.viewer?.following!) 100 + ); 101 + 102 + const CONCURRENCY_LIMIT = 5; 103 + 104 + for (let i = 0; i < unfollowPromises.length; i += CONCURRENCY_LIMIT) { 105 + await Promise.all(unfollowPromises.slice(i, i + CONCURRENCY_LIMIT)); 106 + 107 + console.log( 108 + `Snapped ${i + CONCURRENCY_LIMIT} / ${ 109 + unfollowPromises.length 110 + } non-mutuals` 111 + ); 112 + 113 + await new Promise((resolve) => setTimeout(resolve, 500)); 114 + } 115 + 116 + console.log("Thanos Snap operation finished."); 117 + }; 118 + 119 + const main = async () => { 120 + console.log("Begin Thanos Snap 🪙"); 121 + console.log("Authenticating ATProto account"); 122 + 123 + const agent = new AtpAgent({ service: "https://blacksky.app" }); 124 + 125 + await agent.login({ 126 + identifier: process.env.HANDLE!, 127 + password: process.env.PASSWORD! 128 + }); 129 + 130 + console.log(`Logged in as ${agent.session?.handle}`); 131 + 132 + const did = agent.session?.did; 133 + 134 + if (!did) { 135 + throw new Error("Failed to get DID"); 136 + } 137 + 138 + console.log("Fetching profile ..."); 139 + 140 + const profile = await getProfile(agent, did); 141 + 142 + console.log(`You have ${profile.followers} followers!`); 143 + console.log(`You follow ${profile.follows} accounts!`); 144 + console.log(`That's ${profile.follows - profile.followers} non-mutuals`); 145 + console.log(`Time to halve 🫰. Compiling non-mutuals...`); 146 + 147 + const nonMutuals = await getNonMutuals(agent, did); 148 + 149 + snap(agent, nonMutuals); 150 + }; 151 + 152 + main();
+16 -4
package-lock.json
··· 9 9 "version": "1.0.0", 10 10 "license": "ISC", 11 11 "dependencies": { 12 - "@atproto/api": "^0.17.7" 12 + "@atproto/api": "^0.17.7", 13 + "dotenv": "^17.2.3" 13 14 }, 14 15 "devDependencies": { 16 + "@types/node": "^24.9.2", 15 17 "ts-node": "^10.9.2", 16 18 "typescript": "^5.9.3" 17 19 } ··· 148 150 "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", 149 151 "dev": true, 150 152 "license": "MIT", 151 - "peer": true, 152 153 "dependencies": { 153 154 "undici-types": "~7.16.0" 154 155 } ··· 209 210 "node": ">=0.3.1" 210 211 } 211 212 }, 213 + "node_modules/dotenv": { 214 + "version": "17.2.3", 215 + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", 216 + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", 217 + "license": "BSD-2-Clause", 218 + "engines": { 219 + "node": ">=12" 220 + }, 221 + "funding": { 222 + "url": "https://dotenvx.com" 223 + } 224 + }, 212 225 "node_modules/graphemer": { 213 226 "version": "1.4.0", 214 227 "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", ··· 315 328 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", 316 329 "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", 317 330 "dev": true, 318 - "license": "MIT", 319 - "peer": true 331 + "license": "MIT" 320 332 }, 321 333 "node_modules/v8-compile-cache-lib": { 322 334 "version": "3.0.1",
+4 -2
package.json
··· 8 8 }, 9 9 "license": "ISC", 10 10 "author": "", 11 - "type": "commonjs", 11 + "type": "module", 12 12 "main": "index.ts", 13 13 "scripts": { 14 14 "test": "echo \"Error: no test specified\" && exit 1" 15 15 }, 16 16 "dependencies": { 17 - "@atproto/api": "^0.17.7" 17 + "@atproto/api": "^0.17.7", 18 + "dotenv": "^17.2.3" 18 19 }, 19 20 "devDependencies": { 21 + "@types/node": "^24.9.2", 20 22 "ts-node": "^10.9.2", 21 23 "typescript": "^5.9.3" 22 24 }
+37 -37
tsconfig.json
··· 1 1 { 2 - // Visit https://aka.ms/tsconfig to read more about this file 3 - "compilerOptions": { 4 - // File Layout 5 - // "rootDir": "./src", 6 - // "outDir": "./dist", 2 + // Visit https://aka.ms/tsconfig to read more about this file 3 + "compilerOptions": { 4 + // File Layout 5 + // "rootDir": "./src", 6 + // "outDir": "./dist", 7 7 8 - // Environment Settings 9 - // See also https://aka.ms/tsconfig/module 10 - "module": "nodenext", 11 - "target": "esnext", 12 - "types": [], 13 - // For nodejs: 14 - // "lib": ["esnext"], 15 - // "types": ["node"], 16 - // and npm install -D @types/node 8 + // Environment Settings 9 + // See also https://aka.ms/tsconfig/module 10 + "module": "nodenext", 11 + "target": "esnext", 12 + "types": ["node"], 13 + // For nodejs: 14 + // "lib": ["esnext"], 15 + // "types": ["node"], 16 + // and npm install -D @types/node 17 17 18 - // Other Outputs 19 - "sourceMap": true, 20 - "declaration": true, 21 - "declarationMap": true, 18 + // Other Outputs 19 + "sourceMap": true, 20 + "declaration": true, 21 + "declarationMap": true, 22 22 23 - // Stricter Typechecking Options 24 - "noUncheckedIndexedAccess": true, 25 - "exactOptionalPropertyTypes": true, 23 + // Stricter Typechecking Options 24 + "noUncheckedIndexedAccess": true, 25 + "exactOptionalPropertyTypes": true, 26 26 27 - // Style Options 28 - // "noImplicitReturns": true, 29 - // "noImplicitOverride": true, 30 - // "noUnusedLocals": true, 31 - // "noUnusedParameters": true, 32 - // "noFallthroughCasesInSwitch": true, 33 - // "noPropertyAccessFromIndexSignature": true, 27 + // Style Options 28 + // "noImplicitReturns": true, 29 + // "noImplicitOverride": true, 30 + // "noUnusedLocals": true, 31 + // "noUnusedParameters": true, 32 + // "noFallthroughCasesInSwitch": true, 33 + // "noPropertyAccessFromIndexSignature": true, 34 34 35 - // Recommended Options 36 - "strict": true, 37 - "jsx": "react-jsx", 38 - "verbatimModuleSyntax": true, 39 - "isolatedModules": true, 40 - "noUncheckedSideEffectImports": true, 41 - "moduleDetection": "force", 42 - "skipLibCheck": true, 43 - } 35 + // Recommended Options 36 + "strict": true, 37 + "jsx": "react-jsx", 38 + "verbatimModuleSyntax": true, 39 + "isolatedModules": true, 40 + "noUncheckedSideEffectImports": true, 41 + "moduleDetection": "force", 42 + "skipLibCheck": true 43 + } 44 44 }