+33
bun.lock
+33
bun.lock
···
3
3
"workspaces": {
4
4
"": {
5
5
"name": "unfollower",
6
+
"dependencies": {
7
+
"@atproto/api": "^0.18.3",
8
+
},
6
9
"devDependencies": {
7
10
"@types/bun": "latest",
8
11
},
···
12
15
},
13
16
},
14
17
"packages": {
18
+
"@atproto/api": ["@atproto/api@0.18.3", "", { "dependencies": { "@atproto/common-web": "^0.4.5", "@atproto/lexicon": "^0.5.2", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.6", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-CBqyZfkcKYsr348KP4CKb9plMlZ5A96HwA/DnYscPBl6fvMZkAezAjniZX+xUILASHQJg5c+NaNw9xP8ZuyyDQ=="],
19
+
20
+
"@atproto/common-web": ["@atproto/common-web@0.4.5", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "@atproto/lex-json": "0.0.1", "zod": "^3.23.8" } }, "sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA=="],
21
+
22
+
"@atproto/lex-data": ["@atproto/lex-data@0.0.1", "", { "dependencies": { "@atproto/syntax": "0.4.1", "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-DrS/8cQcQs3s5t9ELAFNtyDZ8/PdiCx47ALtFEP2GnX2uCBHZRkqWG7xmu6ehjc787nsFzZBvlnz3T/gov5fGA=="],
23
+
24
+
"@atproto/lex-json": ["@atproto/lex-json@0.0.1", "", { "dependencies": { "@atproto/lex-data": "0.0.1", "tslib": "^2.8.1" } }, "sha512-ivcF7+pDRuD/P97IEKQ/9TruunXj0w58Khvwk3M6psaI5eZT6LRsRZ4cWcKaXiFX4SHnjy+x43g0f7pPtIsERg=="],
25
+
26
+
"@atproto/lexicon": ["@atproto/lexicon@0.5.2", "", { "dependencies": { "@atproto/common-web": "^0.4.4", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-lRmJgMA8f5j7VB5Iu5cp188ald5FuI4FlmZ7nn6EBrk1dgOstWVrI5Ft6K3z2vjyLZRG6nzknlsw+tDP63p7bQ=="],
27
+
28
+
"@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="],
29
+
30
+
"@atproto/xrpc": ["@atproto/xrpc@0.7.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.2", "zod": "^3.23.8" } }, "sha512-RvCf4j0JnKYWuz3QzsYCntJi3VuiAAybQsMIUw2wLWcHhchO9F7UaBZINLL2z0qc/cYWPv5NSwcVydMseoCZLA=="],
31
+
15
32
"@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
16
33
17
34
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
18
35
36
+
"await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="],
37
+
19
38
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
20
39
40
+
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
41
+
42
+
"multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
43
+
44
+
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
45
+
46
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
47
+
21
48
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
49
+
50
+
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="],
22
51
23
52
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
53
+
54
+
"unicode-segmenter": ["unicode-segmenter@0.14.0", "", {}, "sha512-AH4lhPCJANUnSLEKnM4byboctePJzltF4xj8b+NbNiYeAkAXGh7px2K/4NANFp7dnr6+zB3e6HLu8Jj8SKyvYg=="],
55
+
56
+
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
24
57
}
25
58
}
+7
constants.ts
+7
constants.ts
···
1
+
export const MONTHS_IN_DAYS = Number(Bun.env.DAYS_SINCE_LAST_POST);
2
+
export const USER_AGENT =
3
+
"swab/https://tangled.org/dane.is.extraordinarily.cool/swab";
4
+
export const IDENTIFIER = Bun.env.IDENTIFIER as string;
5
+
export const PDS_URL = Bun.env.PDS_URL as string;
6
+
export const BSKY_USERNAME = Bun.env.BSKY_USERNAME as string;
7
+
export const BSKY_APP_PASSWORD = Bun.env.BSKY_APP_PASSWORD as string;
+41
-144
index.ts
+41
-144
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
-
}
1
+
import { IDENTIFIER, MONTHS_IN_DAYS } from "./constants";
2
+
import {
3
+
getDateDifferenceInDays,
4
+
getFollowRecords,
5
+
getRecentPost,
6
+
resolveIdentity,
7
+
} from "./utils";
16
8
17
-
interface MiniDoc {
18
-
did: string;
19
-
handle: string;
20
-
pds: string;
21
-
signing_key: string;
22
-
}
9
+
let cursor: string | undefined;
23
10
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
-
}
11
+
const unfollows = [];
44
12
45
-
const headers = {
46
-
'User-Agent': 'swab/https://tangled.org/dane.is.extraordinarily.cool/swab'
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
-
console.error(`Failed to fetch minidoc for ${indentifier}. Status - ${response.status}`)
66
-
}
67
-
const data = await response.json() as MiniDoc
68
-
return data
69
-
} catch (error) {
70
-
if (error instanceof Error) {
71
-
console.error(error.message)
72
-
}
73
-
throw error;
74
-
}
75
-
}
76
-
77
-
async function getRecentPost(doc: MiniDoc) {
78
-
try {
79
-
const response = await fetch(`${doc.pds}/xrpc/com.atproto.repo.listRecords?repo=${doc.did}&collection=app.bsky.feed.post&limit=1`, {headers})
80
-
if (!response.ok) {
81
-
// throw new Error(`There was an error fetching posts for ${doc.handle}. Status - ${response.status}`)
82
-
console.error(`There was an error fetching posts for ${doc.handle}. Status - ${response.status}`)
83
-
}
84
-
const data = await response.json() as {records: Post[]}
85
-
return data;
86
-
} catch (error) {
87
-
if (error instanceof Error) {
88
-
console.error(error.message)
89
-
}
90
-
91
-
throw error;
92
-
}
93
-
}
94
-
95
-
async function getFollowsByUser(did: string, cursor?: string) {
96
-
try {
97
-
const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${did}&cursor=${cursor}&limit=100`)
98
-
if (!response.ok) {
99
-
// throw new Error(`There was a problem getting follows for ${did}. Status - ${response.status}`)
100
-
console.error(`There was a problem getting follows for ${did}. Status - ${response.status}`)
101
-
}
102
-
103
-
const data = await response.json() as {
104
-
cursor: string
105
-
follows: Follow[]
106
-
}
107
-
108
-
return {
109
-
cursor: data?.cursor,
110
-
follows: data?.follows
111
-
}
112
-
} catch (error) {
113
-
if (error instanceof Error) {
114
-
console.error(error.message)
115
-
}
116
-
throw error;
117
-
}
118
-
}
119
-
120
-
let cursor: string | undefined;
121
-
122
-
const unfollowMap = new Map<string, number>();
13
+
const doc = await resolveIdentity(IDENTIFIER);
123
14
124
15
do {
125
-
const {follows, cursor: followsCursor} = await getFollowsByUser("did:plc:qttsv4e7pu2jl3ilanfgc3zn", cursor)
126
-
for (const [index, follower] of follows.entries()) {
127
-
const doc = await resolveIdentity(follower.did)
128
-
const post = await getRecentPost(doc)
16
+
const { follows, cursor: followCursor } = await getFollowRecords(doc, cursor);
129
17
130
-
131
-
// it's possible that someone has never made a post i guess, we should add them to the list
132
-
if (!post?.records?.[0]) {
133
-
// neg 1 can represent never made post
134
-
unfollowMap.set(follower.handle, -1)
135
-
}
136
-
if (post?.records[0] && post?.records[0]?.value) {
137
-
const recentPostCreationDate = post?.records?.[0].value?.createdAt
138
-
// invalid date for some reason idk
139
-
const daysSinceLastPost = getDateDifferenceInDays(new Date(recentPostCreationDate), new Date())
140
-
if (daysSinceLastPost >= MONTHS_IN_DAYS) {
141
-
unfollowMap.set(follower.handle, daysSinceLastPost)
142
-
}
18
+
for (const [index, record] of follows.entries()) {
19
+
const doc = await resolveIdentity(record?.value?.subject);
20
+
const post = await getRecentPost(doc);
143
21
144
-
console.clear();
145
-
console.info(`Auditing user [${index + 1} / ${follows.length}]`)
146
-
}
22
+
// it's possible that someone has never made a post i guess, we should add them to the list
23
+
if (!post?.records) {
24
+
// neg 1 can represent never made post
25
+
unfollows.push({ did: record?.value?.subject, lastPost: -1, uri: "" });
26
+
continue;
27
+
}
28
+
if (post?.records?.[0]?.value) {
29
+
const recentPostCreationDate = post?.records?.[0].value?.createdAt;
30
+
const daysSinceLastPost = getDateDifferenceInDays(
31
+
new Date(recentPostCreationDate),
32
+
new Date(),
33
+
);
34
+
if (daysSinceLastPost >= MONTHS_IN_DAYS) {
35
+
unfollows.push({
36
+
did: record?.value?.subject,
37
+
lastPost: daysSinceLastPost,
38
+
uri: record?.uri,
39
+
});
40
+
}
41
+
}
147
42
148
-
// await new Promise((resolve) => setTimeout(resolve, 1000))
149
-
}
150
-
cursor = followsCursor
151
-
} while (cursor)
43
+
console.clear();
44
+
console.info(`Auditing user [${index + 1} / ${follows.length}]`);
45
+
}
152
46
47
+
cursor = followCursor;
48
+
} while (cursor);
153
49
154
-
console.log(unfollowMap)
50
+
await Bun.write("follows.json", JSON.stringify(unfollows));
51
+
console.info(`wrote ${unfollows.length} accounts to follows.json`);
+3
package.json
+3
package.json
+37
types.ts
+37
types.ts
···
1
+
export interface FollowRecord {
2
+
uri: string;
3
+
cid: string;
4
+
value: {
5
+
$type: "app.bsky.graph.follow";
6
+
subject: string;
7
+
createdAt: string;
8
+
};
9
+
}
10
+
11
+
export interface MiniDoc {
12
+
did: string;
13
+
handle: string;
14
+
pds: string;
15
+
signing_key: string;
16
+
}
17
+
18
+
export interface Post {
19
+
uri: string;
20
+
cid: string;
21
+
value: {
22
+
text: string;
23
+
$type: "app.bsky.feed.post";
24
+
langs: string[];
25
+
reply: {
26
+
root: {
27
+
cid: string;
28
+
uri: string;
29
+
};
30
+
parent: {
31
+
cid: string;
32
+
uri: string;
33
+
};
34
+
};
35
+
createdAt: string;
36
+
};
37
+
}
+32
unfollow.ts
+32
unfollow.ts
···
1
+
import { AtpAgent } from "@atproto/api";
2
+
import { BSKY_APP_PASSWORD, BSKY_USERNAME, PDS_URL } from "./constants";
3
+
4
+
const agent = new AtpAgent({
5
+
service: PDS_URL,
6
+
});
7
+
8
+
await agent.login({
9
+
identifier: BSKY_USERNAME,
10
+
password: BSKY_APP_PASSWORD,
11
+
});
12
+
13
+
const file = Bun.file("follows.json");
14
+
15
+
const unfollows = (await file.json()) as {
16
+
did: string;
17
+
lastPost: number;
18
+
uri: string;
19
+
}[];
20
+
21
+
for (const [index, unfollow] of unfollows.entries()) {
22
+
if (unfollow.lastPost === -1 || unfollow.uri === "") {
23
+
// will figure something out later for this
24
+
continue;
25
+
}
26
+
27
+
console.clear();
28
+
console.info(
29
+
`unfollowing ${unfollow.did} [${index + 1} / ${unfollows.length}]`,
30
+
);
31
+
await agent.deleteFollow(unfollow.uri);
32
+
}
+106
utils.ts
+106
utils.ts
···
1
+
import { USER_AGENT } from "./constants";
2
+
import type { FollowRecord, MiniDoc, Post } from "./types";
3
+
4
+
// https://stackoverflow.com/questions/3224834/get-difference-between-2-dates-in-javascript
5
+
export function getDateDifferenceInDays(start: Date, end: Date) {
6
+
const MS_PER_DAY = 1000 * 60 * 60 * 24;
7
+
const startUTCDate = Date.UTC(
8
+
start.getFullYear(),
9
+
start.getMonth(),
10
+
start.getDate(),
11
+
);
12
+
const endUTCDate = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate());
13
+
return Math.floor((endUTCDate - startUTCDate) / MS_PER_DAY);
14
+
}
15
+
16
+
export async function resolveIdentity(indentifier: string): Promise<MiniDoc> {
17
+
try {
18
+
const response = await fetch(
19
+
`https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${indentifier}`,
20
+
{
21
+
headers: {
22
+
"User-Agent": USER_AGENT,
23
+
},
24
+
},
25
+
);
26
+
if (!response.ok) {
27
+
// throw new Error(`Failed to fetch minidoc for ${indentifier}. Status - ${response.status}`)
28
+
console.error(
29
+
`Failed to fetch minidoc for ${indentifier}. Status - ${response.status}`,
30
+
);
31
+
}
32
+
const data = (await response.json()) as MiniDoc;
33
+
return data;
34
+
} catch (error) {
35
+
if (error instanceof Error) {
36
+
console.error(error.message);
37
+
}
38
+
throw error;
39
+
}
40
+
}
41
+
42
+
export async function getRecentPost(doc: MiniDoc) {
43
+
try {
44
+
console.info(
45
+
`${doc.pds}/xrpc/com.atproto.repo.listRecords?repo=${doc.did}&collection=app.bsky.feed.post&limit=1`,
46
+
);
47
+
if (typeof doc.pds === "undefined") {
48
+
console.info("could not get pds info, skipping");
49
+
return;
50
+
}
51
+
52
+
const response = await fetch(
53
+
`${doc.pds}/xrpc/com.atproto.repo.listRecords?repo=${doc.did}&collection=app.bsky.feed.post&limit=1`,
54
+
{
55
+
headers: {
56
+
"User-Agent": USER_AGENT,
57
+
},
58
+
},
59
+
);
60
+
if (!response.ok) {
61
+
console.error(
62
+
`There was an error fetching posts for ${doc.handle}. Status - ${response.status}`,
63
+
);
64
+
}
65
+
const data = (await response.json()) as { records: Post[] };
66
+
return data;
67
+
} catch (error) {
68
+
if (error instanceof Error) {
69
+
console.error(error.message);
70
+
}
71
+
72
+
throw error;
73
+
}
74
+
}
75
+
76
+
export async function getFollowRecords(doc: MiniDoc, cursor?: string) {
77
+
try {
78
+
const response = await fetch(
79
+
`${doc.pds}/xrpc/com.atproto.repo.listRecords?repo=${doc.did}&collection=app.bsky.graph.follow&limit=100&cursor=${cursor}`,
80
+
{
81
+
headers: {
82
+
"User-Agent": USER_AGENT,
83
+
},
84
+
},
85
+
);
86
+
if (!response.ok) {
87
+
console.error("There was an error fetching follow records");
88
+
}
89
+
90
+
const data = (await response.json()) as {
91
+
cursor: string;
92
+
records: FollowRecord[];
93
+
};
94
+
95
+
return {
96
+
cursor: data?.cursor,
97
+
follows: data?.records,
98
+
};
99
+
} catch (error) {
100
+
if (error instanceof Error) {
101
+
console.error(error.message);
102
+
}
103
+
104
+
throw error;
105
+
}
106
+
}