+34
.gitignore
+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
+3
README.md
+25
bun.lock
+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
+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
+12
package.json
+29
tsconfig.json
+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
+
}