bsky follow audit script

reorg and format

besaid.zone 02853e8f 803bbcf6

verified
+1
.gitignore
··· 32 32 33 33 # Finder (MacOS) folder config 34 34 .DS_Store 35 + *.json
+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
··· 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
··· 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
··· 8 8 }, 9 9 "peerDependencies": { 10 10 "typescript": "^5" 11 + }, 12 + "dependencies": { 13 + "@atproto/api": "^0.18.3" 11 14 } 12 15 }
+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
··· 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
··· 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 + }