Mass Block [bsky] Reposts [and more]
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add --no-block-target, quotes category, --output, and --unblock

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Winter 1fc55d4c b133bb39

+245 -41
+183 -40
index.js
··· 27 27 const PROFILE_BATCH_SIZE = 25; 28 28 const SCOPE = "atproto repo:app.bsky.graph.block?action=create"; 29 29 30 - const POST_CATEGORIES = /** @type {const} */ (["reposts", "replies", "likes", "followers"]); 30 + const POST_CATEGORIES = /** @type {const} */ (["reposts", "replies", "likes", "quotes", "followers"]); 31 31 const PROFILE_CATEGORIES = /** @type {const} */ (["followers", "following"]); 32 32 const DUPLICATE_PATTERNS = ["duplicate", "already exists"]; 33 33 ··· 52 52 flags.batch = parseInt(args[++i], 10); 53 53 } else if (args[i] === "--dry-run") { 54 54 flags.dryRun = true; 55 + } else if (args[i] === "--no-block-target") { 56 + flags.noBlockTarget = true; 57 + } else if (args[i] === "--output" && args[i + 1]) { 58 + flags.output = args[++i]; 59 + } else if (args[i] === "--unblock") { 60 + flags.unblock = true; 55 61 } else if (args[i] === "--help" || args[i] === "-h") { 56 62 flags.help = true; 57 63 } ··· 67 73 node index.js [options] 68 74 69 75 options: 70 - --delay <ms> delay between batches in ms (default: ${BLOCK_DELAY_MS}) 71 - --batch <n> writes per applyWrites call (default: ${BATCH_SIZE}) 72 - --dry-run show what would be blocked without actually blocking 73 - -h, --help show this help`); 76 + --delay <ms> delay between batches in ms (default: ${BLOCK_DELAY_MS}) 77 + --batch <n> writes per applyWrites call (default: ${BATCH_SIZE}) 78 + --dry-run show what would be blocked without actually blocking 79 + --no-block-target don't block the target account itself 80 + --output <file> write blocked DIDs to file (one per line) 81 + --unblock remove blocks instead of adding them 82 + -h, --help show this help`); 74 83 } 75 84 76 85 // ── shared helpers ────────────────────────────────────────────────── ··· 79 88 function exitIfCancelled(value) { 80 89 if (p.isCancel(value)) { p.cancel("cancelled."); process.exit(0); } 81 90 return value; 91 + } 92 + 93 + async function writeOutput(path, dids) { 94 + const { writeFile } = await import("node:fs/promises"); 95 + await writeFile(path, dids.join("\n") + "\n"); 82 96 } 83 97 84 98 async function resolveMiniDoc(identifier) { ··· 378 392 return blocked; 379 393 } 380 394 395 + async function fetchBlockMap(did, pdsUrl, onProgress) { 396 + const map = new Map(); 397 + let cursor; 398 + 399 + while (true) { 400 + const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`); 401 + url.searchParams.set("repo", did); 402 + url.searchParams.set("collection", "app.bsky.graph.block"); 403 + url.searchParams.set("limit", "100"); 404 + if (cursor) url.searchParams.set("cursor", cursor); 405 + 406 + const res = await fetch(url); 407 + if (!res.ok) throw new Error(`listRecords error: ${res.status}`); 408 + const data = await res.json(); 409 + 410 + for (const rec of data.records) { 411 + const rkey = rec.uri.split("/").pop(); 412 + map.set(rec.value.subject, rkey); 413 + } 414 + if (onProgress) onProgress(data.records.length); 415 + if (!data.cursor || data.records.length === 0) break; 416 + cursor = data.cursor; 417 + } 418 + 419 + return map; 420 + } 421 + 381 422 async function fetchSocialGraph(did, onProgress) { 382 423 const pdsUrl = await resolvePds(did); 383 424 let total = 0; ··· 493 534 } 494 535 495 536 // ── oauth ─────────────────────────────────────────────────────────── 496 - async function authenticate(handle) { 537 + async function authenticate(handle, scope) { 497 538 const oauthClient = new OAuthClient({ 498 539 metadata: { 499 540 redirect_uris: [REDIRECT_URI], 500 - scope: SCOPE, 541 + scope, 501 542 }, 502 543 actorResolver: new LocalActorResolver({ 503 544 handleResolver: new NodeDnsHandleResolver(), ··· 521 562 try { 522 563 const { url } = await oauthClient.authorize({ 523 564 target: { type: "account", identifier: handle }, 524 - scope: SCOPE, 565 + scope, 525 566 }); 526 567 authUrl = url.toString(); 527 568 ··· 718 759 719 760 // ── data fetching ─────────────────────────────────────────────────── 720 761 async function fetchEngagementData(target, categories, mode, onProgress) { 721 - const results = { reposts: [], likes: [], replies: [], followers: [], following: [], quotePosters: [] }; 762 + const results = { reposts: [], likes: [], replies: [], quotes: [], followers: [], following: [], quotePosters: [] }; 722 763 const fetchers = []; 723 764 let total = 0; 724 765 const tick = onProgress ? (delta) => { total += delta; onProgress(total); } : undefined; ··· 750 791 const authorDid = extractAuthorDid(target); 751 792 fetchers.push(fetchFollowers(authorDid, tick).then((d) => { results.followers = [...d]; })); 752 793 } 753 - fetchers.push(fetchQuotePosters(target, tick).then((d) => { results.quotePosters = d; })); 794 + fetchers.push(fetchQuotePosters(target, tick).then((d) => { 795 + results.quotePosters = d; 796 + if (categories.has("quotes")) results.quotes = d; 797 + })); 754 798 } 755 799 756 800 await Promise.all(fetchers); ··· 758 802 } 759 803 760 804 // ── filtering ─────────────────────────────────────────────────────── 761 - function filterCandidates({ results, did, targetDid, follows, followers, existingBlocks, blockFollowing }) { 805 + function filterCandidates({ results, did, targetDid, follows, followers, existingBlocks, blockFollowing, blockTarget = true, blockQuotes = false }) { 762 806 const allCandidates = new Set(); 763 - for (const src of [results.reposts, results.likes, results.replies, results.followers, results.following]) { 807 + for (const src of [results.reposts, results.likes, results.replies, results.quotes, results.followers, results.following]) { 764 808 for (const d of src) allCandidates.add(d); 765 809 } 766 810 767 - const quotePosters = new Set(results.quotePosters); 811 + const quotePosters = blockQuotes ? new Set() : new Set(results.quotePosters); 768 812 const followedInBlockList = []; 769 813 const toBlock = []; 770 814 let skippedSelf = 0, skippedQuote = 0, skippedFollow = 0, skippedFollower = 0, skippedAlreadyBlocked = 0; 771 815 772 - // Block the target themselves first, unless it's the logged-in user or already blocked 773 - if (targetDid && targetDid !== did && !existingBlocks.has(targetDid)) { 816 + // Block the target themselves first, unless skipped or already blocked 817 + if (blockTarget && targetDid && targetDid !== did && !existingBlocks.has(targetDid)) { 774 818 toBlock.push(targetDid); 775 819 } 776 820 ··· 803 847 if (categories.has("likes")) lines.push(` likers: ${results.likes.length}`); 804 848 if (categories.has("replies")) lines.push(` repliers: ${results.replies.length}`); 805 849 if (categories.has("followers")) lines.push(` author followers: ${results.followers.length}`); 806 - lines.push(` quote posters (excluded): ${results.quotePosters.length}`); 850 + if (categories.has("quotes")) lines.push(` quote posters: ${results.quotes.length}`); 851 + else lines.push(` quote posters (excluded): ${results.quotePosters.length}`); 807 852 } else { 808 853 if (categories.has("followers")) lines.push(` followers: ${results.followers.length}`); 809 854 if (categories.has("following")) lines.push(` following: ${results.following.length}`); ··· 900 945 901 946 const authSpinner = p.spinner(); 902 947 authSpinner.start("authenticating via oauth..."); 903 - const session = await authenticate(handle); 948 + const session = await authenticate(handle, "atproto repo:app.bsky.graph.block?action=create"); 904 949 authSpinner.stop(`authenticated as ${session.did}`); 905 950 906 951 const blockSpinner = p.spinner(); ··· 1005 1050 p.outro("done!"); 1006 1051 } 1007 1052 1053 + async function confirmAndUnblock({ toUnblock, blockMap, handle, did, delayMs, batchSize }) { 1054 + const proceed = exitIfCancelled(await p.confirm({ 1055 + message: `unblock ${toUnblock.length} accounts?`, 1056 + initialValue: false, 1057 + })); 1058 + if (!proceed) { 1059 + p.cancel("cancelled."); 1060 + process.exit(0); 1061 + } 1062 + 1063 + const authSpinner = p.spinner(); 1064 + authSpinner.start("authenticating via oauth..."); 1065 + const session = await authenticate(handle, "atproto repo:app.bsky.graph.block?action=delete"); 1066 + authSpinner.stop(`authenticated as ${session.did}`); 1067 + 1068 + const unblockSpinner = p.spinner(); 1069 + unblockSpinner.start(`unblocking 0/${toUnblock.length}...`); 1070 + 1071 + let unblocked = 0, notBlocked = 0, errors = 0; 1072 + let errorCount = 0; 1073 + function logError(msg) { errorCount++; if (errorCount <= 5) p.log.error(msg); } 1074 + 1075 + for (let i = 0; i < toUnblock.length; i += batchSize) { 1076 + const batch = toUnblock.slice(i, i + batchSize); 1077 + 1078 + const writes = batch.flatMap((targetDid) => { 1079 + const rkey = blockMap.get(targetDid); 1080 + if (!rkey) { notBlocked++; return []; } 1081 + return [{ $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.block", rkey }]; 1082 + }); 1083 + 1084 + if (writes.length === 0) continue; 1085 + 1086 + let lastRes = null; 1087 + try { 1088 + const res = await session.handle("/xrpc/com.atproto.repo.applyWrites", { 1089 + method: "POST", 1090 + headers: { "content-type": "application/json" }, 1091 + body: JSON.stringify({ repo: did, writes }), 1092 + }); 1093 + lastRes = res; 1094 + 1095 + if (await sleepForRateLimit(res, unblockSpinner)) { i -= batchSize; continue; } 1096 + 1097 + if (!res.ok) { 1098 + const msg = await parseApiError(res); 1099 + errors += writes.length; 1100 + logError(`batch error: ${msg}`); 1101 + } else { 1102 + unblocked += writes.length; 1103 + } 1104 + } catch (err) { 1105 + errors += writes.length; 1106 + logError(`batch error: ${err?.message ?? err}`); 1107 + } 1108 + 1109 + unblockSpinner.message(`unblocking ${unblocked + notBlocked + errors}/${toUnblock.length}...`); 1110 + if (i + batchSize < toUnblock.length) await sleep(getBackoffDelay(lastRes, delayMs)); 1111 + } 1112 + 1113 + unblockSpinner.stop("unblocking complete"); 1114 + p.note(` unblocked: ${unblocked}\n not blocked: ${notBlocked}\n errors: ${errors}`, "results"); 1115 + p.outro("done!"); 1116 + } 1117 + 1008 1118 // ── main ──────────────────────────────────────────────────────────── 1009 1119 async function main() { 1010 1120 const { flags } = parseArgs(); ··· 1027 1137 ]); 1028 1138 s2.stop("data fetched"); 1029 1139 1030 - const s3 = p.spinner(); 1031 - s3.start("fetching your social graph & existing blocks..."); 1032 - const { follows, followers, existingBlocks } = await fetchSocialGraph(did, (n) => { 1033 - s3.message(`fetching your social graph & existing blocks... (${n.toLocaleString()} records)`); 1034 - }); 1035 - s3.stop(`following: ${follows.size}, followers: ${followers.size}, existing blocks: ${existingBlocks.size}`); 1140 + let filterResult, blockMap; 1141 + 1142 + if (flags.unblock) { 1143 + const pdsUrl = await resolvePds(did); 1144 + const s3 = p.spinner(); 1145 + s3.start("fetching your existing blocks..."); 1146 + let blockCount = 0; 1147 + blockMap = await fetchBlockMap(did, pdsUrl, (n) => { 1148 + blockCount += n; 1149 + s3.message(`fetching your existing blocks... (${blockCount.toLocaleString()} records)`); 1150 + }); 1151 + s3.stop(`existing blocks: ${blockMap.size}`); 1152 + 1153 + const targetDid = config.mode === "post" ? extractAuthorDid(target) : target; 1154 + const allEngaged = new Set([ 1155 + ...results.reposts, ...results.likes, ...results.replies, 1156 + ...results.quotes, ...results.followers, ...results.following, 1157 + ]); 1158 + if (!flags.noBlockTarget && targetDid !== did) allEngaged.add(targetDid); 1159 + const toUnblock = [...allEngaged].filter((d) => blockMap.has(d)); 1160 + filterResult = { toBlock: toUnblock, followedInBlockList: [], skippedSelf: 0, skippedQuote: 0, skippedFollow: 0, skippedFollower: 0, skippedAlreadyBlocked: 0, total: allEngaged.size }; 1161 + } else { 1162 + const s3 = p.spinner(); 1163 + s3.start("fetching your social graph & existing blocks..."); 1164 + const { follows, followers, existingBlocks } = await fetchSocialGraph(did, (n) => { 1165 + s3.message(`fetching your social graph & existing blocks... (${n.toLocaleString()} records)`); 1166 + }); 1167 + s3.stop(`following: ${follows.size}, followers: ${followers.size}, existing blocks: ${existingBlocks.size}`); 1036 1168 1037 - const targetDid = config.mode === "post" ? extractAuthorDid(target) : target; 1038 - const filterResult = filterCandidates({ 1039 - results, did, targetDid, follows, followers, existingBlocks, blockFollowing: config.blockFollowing, 1040 - }); 1169 + const targetDid = config.mode === "post" ? extractAuthorDid(target) : target; 1170 + filterResult = filterCandidates({ 1171 + results, did, targetDid, follows, followers, existingBlocks, 1172 + blockFollowing: config.blockFollowing, 1173 + blockTarget: !flags.noBlockTarget, 1174 + blockQuotes: config.categories.has("quotes"), 1175 + }); 1176 + } 1041 1177 1042 - if (filterResult.toBlock.length === 0) { 1043 - p.outro("nothing to block after filtering."); 1044 - process.exit(0); 1178 + if (flags.unblock) { 1179 + p.note(` to unblock: ${color.bold(String(filterResult.toBlock.length))}`, "summary"); 1180 + } else { 1181 + await showSummary(results, filterResult, config.categories, config.mode); 1045 1182 } 1046 1183 1047 - await showSummary(results, filterResult, config.categories, config.mode); 1184 + if (flags.output) { 1185 + await writeOutput(flags.output, filterResult.toBlock); 1186 + p.log.info(`wrote ${filterResult.toBlock.length} DIDs to ${flags.output}`); 1187 + } 1048 1188 1049 1189 if (flags.dryRun) { 1050 - p.outro("dry run — nothing blocked."); 1190 + p.outro(flags.unblock ? "dry run — nothing unblocked." : "dry run — nothing blocked."); 1051 1191 process.exit(0); 1052 1192 } 1053 1193 1054 - await confirmAndBlock({ 1055 - toBlock: filterResult.toBlock, 1056 - handle: config.handle, 1057 - did, 1058 - delayMs, 1059 - batchSize, 1060 - }); 1194 + if (filterResult.toBlock.length === 0) { 1195 + p.outro(flags.unblock ? "nothing to unblock." : "nothing to block after filtering."); 1196 + process.exit(0); 1197 + } 1198 + 1199 + if (flags.unblock) { 1200 + await confirmAndUnblock({ toUnblock: filterResult.toBlock, blockMap, handle: config.handle, did, delayMs, batchSize }); 1201 + } else { 1202 + await confirmAndBlock({ toBlock: filterResult.toBlock, handle: config.handle, did, delayMs, batchSize }); 1203 + } 1061 1204 process.exit(0); 1062 1205 } 1063 1206 ··· 1073 1216 }); 1074 1217 } 1075 1218 1076 - export { isProfileUrl, resolveMiniDoc }; 1219 + export { isProfileUrl, resolveMiniDoc, filterCandidates, writeOutput };
+62 -1
test.js
··· 1 1 import { test } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 - import { isProfileUrl, resolveMiniDoc } from "./index.js"; 3 + import { readFile, unlink } from "node:fs/promises"; 4 + import { tmpdir } from "node:os"; 5 + import { join } from "node:path"; 6 + import { isProfileUrl, resolveMiniDoc, filterCandidates, writeOutput } from "./index.js"; 4 7 5 8 test("isProfileUrl: profile URLs", () => { 6 9 assert.equal(isProfileUrl("https://bsky.app/profile/alice.bsky.social"), true); ··· 35 38 assert.equal(typeof result.pds, "string"); 36 39 assert.equal(typeof result.handle, "string"); 37 40 }); 41 + 42 + const BASE_FILTER_ARGS = { 43 + results: { reposts: [], likes: [], replies: [], followers: [], following: [], quotes: [], quotePosters: [] }, 44 + did: "did:plc:me", 45 + follows: new Set(), 46 + followers: new Set(), 47 + existingBlocks: new Set(), 48 + blockFollowing: false, 49 + }; 50 + 51 + test("filterCandidates: blocks target by default", () => { 52 + const result = filterCandidates({ 53 + ...BASE_FILTER_ARGS, 54 + results: { ...BASE_FILTER_ARGS.results, reposts: ["did:plc:reposter"] }, 55 + targetDid: "did:plc:target", 56 + blockTarget: true, 57 + }); 58 + assert.ok(result.toBlock.includes("did:plc:target")); 59 + }); 60 + 61 + test("filterCandidates: skips target when blockTarget is false", () => { 62 + const result = filterCandidates({ 63 + ...BASE_FILTER_ARGS, 64 + results: { ...BASE_FILTER_ARGS.results, reposts: ["did:plc:reposter"] }, 65 + targetDid: "did:plc:target", 66 + blockTarget: false, 67 + }); 68 + assert.ok(!result.toBlock.includes("did:plc:target")); 69 + }); 70 + 71 + test("filterCandidates: excludes quote posters by default", () => { 72 + const result = filterCandidates({ 73 + ...BASE_FILTER_ARGS, 74 + results: { ...BASE_FILTER_ARGS.results, quotePosters: ["did:plc:quoter"] }, 75 + targetDid: null, 76 + blockQuotes: false, 77 + }); 78 + assert.ok(!result.toBlock.includes("did:plc:quoter")); 79 + }); 80 + 81 + test("filterCandidates: includes quote posters when blockQuotes is true", () => { 82 + const result = filterCandidates({ 83 + ...BASE_FILTER_ARGS, 84 + results: { ...BASE_FILTER_ARGS.results, quotes: ["did:plc:quoter"], quotePosters: ["did:plc:quoter"] }, 85 + targetDid: null, 86 + blockQuotes: true, 87 + }); 88 + assert.ok(result.toBlock.includes("did:plc:quoter")); 89 + }); 90 + 91 + test("writeOutput: writes one DID per line", async () => { 92 + const path = join(tmpdir(), `blocker-test-${Date.now()}.txt`); 93 + const dids = ["did:plc:aaa", "did:plc:bbb", "did:plc:ccc"]; 94 + await writeOutput(path, dids); 95 + const content = await readFile(path, "utf8"); 96 + assert.equal(content, "did:plc:aaa\ndid:plc:bbb\ndid:plc:ccc\n"); 97 + await unlink(path); 98 + });