+152
index.ts
+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
+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
+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
+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
}