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

Configure Feed

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

feat: 1.0 - progress, getRepo blocks, counts, dry-run, target self-block

- fetch progress: spinners now show running record counts as constellation
pages accumulate
- fetchExistingBlocks: replaced listRecords pagination with a single
getRepo CAR download + inline dag-cbor fast-path scanner (~170x faster
parse, one http round-trip instead of N paginated requests)
- category selector: resolve target upfront and prefetch appview counts
so each option shows e.g. "their followers (12,847)" before committing
to a full fetch
- profile mode labels: "their followers" / "their following" instead of
ambiguous raw category names
- dry-run: --dry-run flag runs the full flow through summary then exits
without blocking
- target self-block: always prepend the target account (post author or
profile) to the block list
- user-agent: dynamic per-runner handle + author contact; CONSTELLATION_BASE
and SLINGSHOT_BASE configurable via env vars
- batch default raised to 200 (applyWrites ceiling)
- process.exit(0) after completion to avoid event-loop hang from undici
connection pools
- appview resilience: resolveProfiles failures now degrade gracefully
instead of crashing

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

Winter b133bb39 e530590a

+239 -58
+238 -57
index.js
··· 15 15 const IS_BUN = typeof Bun !== "undefined"; 16 16 17 17 // ── config ────────────────────────────────────────────────────────── 18 - const USER_AGENT = "block-reposters/1.1 (did:plc:7exy3k53z33dvghn6edyayxt; winter@madoka.systems)"; 19 - const CONSTELLATION_BASE = "https://constellation.microcosm.blue"; 18 + const AUTHOR_CONTACT = "did:plc:7exy3k53z33dvghn6edyayxt; winter@madoka.systems"; 19 + let USER_AGENT = "block-reposters/1.0"; 20 + const CONSTELLATION_BASE = process.env.CONSTELLATION_BASE ?? "https://constellation.microcosm.blue"; 21 + const SLINGSHOT_BASE = process.env.SLINGSHOT_BASE ?? "https://slingshot.microcosm.blue"; 20 22 const PORT = 22891; 21 23 const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`; 22 24 const BLOCK_DELAY_MS = 150; 23 - const BATCH_SIZE = 10; 25 + const BATCH_SIZE = 200; 24 26 const PAGE_SIZE = 100; 25 27 const PROFILE_BATCH_SIZE = 25; 26 28 const SCOPE = "atproto repo:app.bsky.graph.block?action=create"; ··· 48 50 flags.delay = parseInt(args[++i], 10); 49 51 } else if (args[i] === "--batch" && args[i + 1]) { 50 52 flags.batch = parseInt(args[++i], 10); 53 + } else if (args[i] === "--dry-run") { 54 + flags.dryRun = true; 51 55 } else if (args[i] === "--help" || args[i] === "-h") { 52 56 flags.help = true; 53 57 } ··· 63 67 node index.js [options] 64 68 65 69 options: 66 - --delay <ms> delay between batches in ms (default: 150) 67 - --batch <n> writes per applyWrites call (default: 10) 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 68 73 -h, --help show this help`); 69 74 } 70 75 ··· 78 83 79 84 async function resolveMiniDoc(identifier) { 80 85 const res = await fetch( 81 - `https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`, 86 + `${SLINGSHOT_BASE}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`, 82 87 { headers: { "user-agent": USER_AGENT } } 83 88 ); 84 89 if (!res.ok) throw new Error(`slingshot identity error for ${identifier}: ${res.status}`); ··· 122 127 return out; 123 128 } 124 129 130 + // ── CAR / CBOR (inline, no deps) ──────────────────────────────────── 131 + const TEXT_DECODER = new TextDecoder(); 132 + 133 + function readVarint(bytes, pos) { 134 + let val = 0, mul = 1; 135 + for (;;) { 136 + const b = bytes[pos++]; 137 + val += (b & 0x7f) * mul; 138 + if (!(b & 0x80)) break; 139 + mul *= 128; 140 + } 141 + return [val, pos]; 142 + } 143 + 144 + // Minimal dag-cbor decoder — handles the subset used in ATProto records. 145 + function decodeCbor(bytes, pos) { 146 + const initial = bytes[pos++]; 147 + const major = initial >> 5; 148 + const info = initial & 0x1f; 149 + 150 + let arg = info; 151 + if (info === 24) { arg = bytes[pos++]; } 152 + else if (info === 25) { arg = (bytes[pos] << 8) | bytes[pos + 1]; pos += 2; } 153 + else if (info === 26) { arg = ((bytes[pos] << 24) | (bytes[pos+1] << 16) | (bytes[pos+2] << 8) | bytes[pos+3]) >>> 0; pos += 4; } 154 + else if (info === 27) { pos += 8; arg = 0; } // 8-byte ints won't appear in block records 155 + 156 + switch (major) { 157 + case 0: return [arg, pos]; 158 + case 1: return [-(arg + 1), pos]; 159 + case 2: { const end = pos + arg; return [bytes.slice(pos, end), end]; } 160 + case 3: { const end = pos + arg; return [TEXT_DECODER.decode(bytes.slice(pos, end)), end]; } 161 + case 4: { 162 + const arr = []; 163 + for (let i = 0; i < arg; i++) { let v; [v, pos] = decodeCbor(bytes, pos); arr.push(v); } 164 + return [arr, pos]; 165 + } 166 + case 5: { 167 + const obj = Object.create(null); 168 + for (let i = 0; i < arg; i++) { 169 + let k, v; 170 + [k, pos] = decodeCbor(bytes, pos); 171 + [v, pos] = decodeCbor(bytes, pos); 172 + obj[k] = v; 173 + } 174 + return [obj, pos]; 175 + } 176 + case 6: return decodeCbor(bytes, pos); // tag — unwrap (tag 42 = CID link, we don't need the value) 177 + case 7: 178 + if (info === 20) return [false, pos]; 179 + if (info === 21) return [true, pos]; 180 + if (info === 22) return [null, pos]; 181 + return [undefined, pos]; // floats: bytes already consumed above 182 + default: return [undefined, pos]; 183 + } 184 + } 185 + 125 186 // ── URL type detection ────────────────────────────────────────────── 126 187 function isProfileUrl(input) { 127 188 if (input.startsWith("at://")) return false; ··· 233 294 return new Set(dids); 234 295 } 235 296 236 - async function fetchExistingBlocks(did, pdsUrl, onProgress) { 237 - const allDids = []; 238 - let cursor; 297 + // Fast-path extractor for app.bsky.graph.block records from raw dag-cbor bytes. 298 + // dag-cbor uses deterministic key ordering (length-first, then lex), so a block 299 + // record's keys always appear in order: "$type"(5) < "subject"(7) < "createdAt"(9). 300 + // That gives a fixed byte prefix we can check cheaply before touching the decoder. 301 + // 302 + // Layout from pos: 303 + // a3 map(3) 304 + // 65 tstr(5) 305 + // 24 74 79 70 65 "$type" 306 + // 74 tstr(20) 307 + // [20 bytes] "app.bsky.graph.block" 308 + // 67 tstr(7) 309 + // [7 bytes] "subject" 310 + // [tstr hdr] subject DID string 311 + const BLOCK_TYPE_BYTES = new TextEncoder().encode("app.bsky.graph.block"); 312 + const SUBJECT_KEY_BYTES = new TextEncoder().encode("subject"); 239 313 240 - while (true) { 241 - const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.listRecords`); 242 - url.searchParams.set("repo", did); 243 - url.searchParams.set("collection", "app.bsky.graph.block"); 244 - url.searchParams.set("limit", "100"); 245 - if (cursor) url.searchParams.set("cursor", cursor); 314 + function extractBlockSubject(bytes, pos) { 315 + // map(3), tstr(5), "$type" 316 + if (bytes[pos] !== 0xa3 || bytes[pos+1] !== 0x65) return null; 317 + if (bytes[pos+2] !== 0x24 || bytes[pos+3] !== 0x74 || 318 + bytes[pos+4] !== 0x79 || bytes[pos+5] !== 0x70 || bytes[pos+6] !== 0x65) return null; 319 + // tstr(20) + "app.bsky.graph.block" 320 + if (bytes[pos+7] !== 0x74) return null; 321 + for (let i = 0; i < 20; i++) { 322 + if (bytes[pos + 8 + i] !== BLOCK_TYPE_BYTES[i]) return null; 323 + } 324 + // tstr(7) + "subject" 325 + let p = pos + 28; 326 + if (bytes[p] !== 0x67) return null; 327 + p++; 328 + for (let i = 0; i < 7; i++) { 329 + if (bytes[p + i] !== SUBJECT_KEY_BYTES[i]) return null; 330 + } 331 + p += 7; 332 + // read the subject DID string 333 + const hdr = bytes[p++]; 334 + if ((hdr >> 5) !== 3) return null; // not a tstr 335 + const info = hdr & 0x1f; 336 + let len; 337 + if (info <= 23) { len = info; } 338 + else if (info === 24) { len = bytes[p++]; } 339 + else if (info === 25) { len = (bytes[p] << 8) | bytes[p + 1]; p += 2; } 340 + else return null; 341 + return TEXT_DECODER.decode(bytes.slice(p, p + len)); 342 + } 343 + 344 + async function fetchExistingBlocks(did, pdsUrl) { 345 + const res = await fetch( 346 + `${pdsUrl}/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`, 347 + { headers: { "user-agent": USER_AGENT } } 348 + ); 349 + if (!res.ok) throw new Error(`getRepo error: ${res.status}`); 350 + const bytes = new Uint8Array(await res.arrayBuffer()); 351 + 352 + // Parse CARv1: skip header, then scan every dag-cbor block for block records 353 + let pos = 0; 354 + let headerLen; [headerLen, pos] = readVarint(bytes, pos); 355 + pos += headerLen; 356 + 357 + const blocked = new Set(); 358 + while (pos < bytes.length) { 359 + let sectionLen; [sectionLen, pos] = readVarint(bytes, pos); 360 + const sectionEnd = pos + sectionLen; 361 + 362 + // CIDv1: version + codec + multihash (fn + digestLen + digest) 363 + let codec, digestLen; 364 + [, pos] = readVarint(bytes, pos); // version 365 + [codec, pos] = readVarint(bytes, pos); 366 + [, pos] = readVarint(bytes, pos); // hash fn 367 + [digestLen, pos] = readVarint(bytes, pos); 368 + pos += digestLen; 246 369 247 - const res = await fetch(url); 248 - if (!res.ok) throw new Error(`listRecords error: ${res.status}`); 249 - const data = await res.json(); 370 + if (codec === 0x71) { // dag-cbor 371 + const subject = extractBlockSubject(bytes, pos); 372 + if (subject !== null) blocked.add(subject); 373 + } 250 374 251 - for (const rec of data.records) allDids.push(rec.value.subject); 252 - if (onProgress) onProgress(data.records.length); 253 - if (!data.cursor || data.records.length === 0) break; 254 - cursor = data.cursor; 375 + pos = sectionEnd; 255 376 } 256 377 257 - return new Set(allDids); 378 + return blocked; 258 379 } 259 380 260 381 async function fetchSocialGraph(did, onProgress) { ··· 264 385 const [follows, followers, existingBlocks] = await Promise.all([ 265 386 fetchFollowing(did, pdsUrl, tick), 266 387 fetchFollowers(did, tick), 267 - fetchExistingBlocks(did, pdsUrl, tick), 388 + fetchExistingBlocks(did, pdsUrl), 268 389 ]); 269 390 return { follows, followers, existingBlocks }; 270 391 } 271 392 393 + // ── count prefetch ────────────────────────────────────────────────── 394 + async function fetchProfile(actor) { 395 + const res = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`); 396 + if (!res.ok) return null; 397 + return res.json(); 398 + } 399 + 400 + async function fetchPostCounts(atUri) { 401 + const [postRes, profile] = await Promise.all([ 402 + fetch(`https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=${encodeURIComponent(atUri)}`), 403 + fetchProfile(extractAuthorDid(atUri)), 404 + ]); 405 + const counts = {}; 406 + if (postRes.ok) { 407 + const data = await postRes.json(); 408 + const post = data.posts?.[0]; 409 + if (post) { 410 + counts.reposts = post.repostCount ?? 0; 411 + counts.likes = post.likeCount ?? 0; 412 + counts.replies = post.replyCount ?? 0; 413 + } 414 + } 415 + if (profile) counts.followers = profile.followersCount ?? 0; 416 + return counts; 417 + } 418 + 419 + async function fetchProfileCounts(did) { 420 + const profile = await fetchProfile(did); 421 + if (!profile) return {}; 422 + return { 423 + followers: profile.followersCount ?? 0, 424 + following: profile.followsCount ?? 0, 425 + }; 426 + } 427 + 272 428 // ── profile resolution ────────────────────────────────────────────── 273 429 async function resolveProfiles(dids) { 274 - const profiles = []; 430 + const batches = []; 275 431 for (let i = 0; i < dids.length; i += PROFILE_BATCH_SIZE) { 276 - const batch = dids.slice(i, i + PROFILE_BATCH_SIZE); 277 - const params = batch.map((d) => `actors=${encodeURIComponent(d)}`).join("&"); 278 - const res = await fetch( 279 - `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${params}` 432 + const params = dids.slice(i, i + PROFILE_BATCH_SIZE).map((d) => `actors=${encodeURIComponent(d)}`).join("&"); 433 + batches.push( 434 + fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${params}`) 435 + .then((r) => r.ok ? r.json() : null) 436 + .then((d) => d?.profiles ?? []) 437 + .catch(() => []) 280 438 ); 281 - if (!res.ok) throw new Error(`getProfiles error: ${res.status}`); 282 - const data = await res.json(); 283 - profiles.push(...data.profiles); 284 439 } 285 - return profiles; 440 + return (await Promise.all(batches)).flat(); 286 441 } 287 442 288 443 // ── callback server ───────────────────────────────────────────────── ··· 505 660 } 506 661 507 662 // ── interactive flow ──────────────────────────────────────────────── 663 + const PROFILE_LABELS = { followers: "their followers", following: "their following" }; 664 + 508 665 async function runInteractiveFlow() { 509 666 p.intro(color.inverse(" bluesky blocker ")); 510 667 ··· 521 678 })); 522 679 523 680 const mode = isProfileUrl(url) ? "profile" : "post"; 681 + 682 + // Resolve target and prefetch counts before showing category selector 683 + const s = p.spinner(); 684 + s.start(mode === "post" ? "resolving post..." : "resolving profile..."); 685 + let target, counts; 686 + try { 687 + target = mode === "post" ? await resolvePostUri(url) : await resolveProfileTarget(url); 688 + counts = mode === "post" ? await fetchPostCounts(target) : await fetchProfileCounts(target); 689 + s.stop(`target: ${target}`); 690 + } catch (err) { 691 + s.stop("failed to resolve"); 692 + throw err; 693 + } 694 + 524 695 const categoryList = mode === "post" ? POST_CATEGORIES : PROFILE_CATEGORIES; 525 - 526 696 const categories = exitIfCancelled(await inlineMultiSelect({ 527 697 message: "what do you want to block?", 528 - options: categoryList.map((c) => ({ value: c, label: c })), 698 + options: categoryList.map((c) => { 699 + const base = mode === "profile" ? (PROFILE_LABELS[c] ?? c) : c; 700 + const n = counts?.[c]; 701 + return { value: c, label: n !== undefined ? `${base} (${n.toLocaleString()})` : base }; 702 + }), 529 703 })); 530 704 531 705 const blockFollowing = exitIfCancelled(await p.confirm({ ··· 539 713 validate: (v) => { if (!v) return "handle is required"; }, 540 714 })); 541 715 542 - return { url, mode, categories, blockFollowing, handle }; 716 + return { url, mode, target, categories, blockFollowing, handle }; 543 717 } 544 718 545 719 // ── data fetching ─────────────────────────────────────────────────── ··· 584 758 } 585 759 586 760 // ── filtering ─────────────────────────────────────────────────────── 587 - function filterCandidates({ results, did, follows, followers, existingBlocks, blockFollowing }) { 761 + function filterCandidates({ results, did, targetDid, follows, followers, existingBlocks, blockFollowing }) { 588 762 const allCandidates = new Set(); 589 763 for (const src of [results.reposts, results.likes, results.replies, results.followers, results.following]) { 590 764 for (const d of src) allCandidates.add(d); ··· 595 769 const toBlock = []; 596 770 let skippedSelf = 0, skippedQuote = 0, skippedFollow = 0, skippedFollower = 0, skippedAlreadyBlocked = 0; 597 771 772 + // Block the target themselves first, unless it's the logged-in user or already blocked 773 + if (targetDid && targetDid !== did && !existingBlocks.has(targetDid)) { 774 + toBlock.push(targetDid); 775 + } 776 + 598 777 for (const d of allCandidates) { 599 778 if (d === did) { skippedSelf++; continue; } 779 + if (d === targetDid) continue; // already added above 600 780 if (existingBlocks.has(d)) { skippedAlreadyBlocked++; continue; } 601 781 if (quotePosters.has(d)) { skippedQuote++; continue; } 602 782 if (follows.has(d)) { ··· 646 826 const profiles = await resolveProfiles(filterResult.followedInBlockList); 647 827 s.stop("profiles resolved"); 648 828 649 - const warningLines = profiles.map((pr) => 650 - ` ${color.cyan(`@${pr.handle}`)} ${color.dim(`(${pr.displayName || "no display name"})`)}` 651 - ); 652 - p.log.warn( 653 - `${color.yellow("you follow these accounts and they will be blocked:")}\n${warningLines.join("\n")}` 654 - ); 829 + if (profiles.length === 0) { 830 + p.log.warn(`${color.yellow("you follow")} ${filterResult.followedInBlockList.length} ${color.yellow("accounts that will be blocked")} ${color.dim("(profile resolution unavailable)")}`); 831 + } else { 832 + const warningLines = profiles.map((pr) => 833 + ` ${color.cyan(`@${pr.handle}`)} ${color.dim(`(${pr.displayName || "no display name"})`)}` 834 + ); 835 + p.log.warn( 836 + `${color.yellow("you follow these accounts and they will be blocked:")}\n${warningLines.join("\n")}` 837 + ); 838 + } 655 839 } 656 840 } 657 841 ··· 808 992 } 809 993 810 994 const total = blocked + alreadyBlocked + errors; 811 - const adaptiveDelay = getBackoffDelay(lastRes, delayMs); 812 995 blockSpinner.message(`blocking ${total}/${toBlock.length}...`); 813 - await sleep(adaptiveDelay); 996 + if (i + batchSize < toBlock.length) await sleep(getBackoffDelay(lastRes, delayMs)); 814 997 } 815 998 816 999 blockSpinner.stop("blocking complete"); ··· 830 1013 const batchSize = flags.batch ?? BATCH_SIZE; 831 1014 832 1015 const config = await runInteractiveFlow(); 1016 + USER_AGENT = `block-reposters/1.0 (@${config.handle}; ${AUTHOR_CONTACT})`; 833 1017 834 - const s1 = p.spinner(); 835 - let target; 836 - if (config.mode === "post") { 837 - s1.start("resolving post..."); 838 - target = await resolvePostUri(config.url); 839 - s1.stop(`target: ${target}`); 840 - } else { 841 - s1.start("resolving profile..."); 842 - target = await resolveProfileTarget(config.url); 843 - s1.stop(`target: ${target}`); 844 - } 1018 + const { target } = config; 845 1019 846 1020 const s2 = p.spinner(); 847 1021 s2.start("fetching data & resolving identity..."); ··· 860 1034 }); 861 1035 s3.stop(`following: ${follows.size}, followers: ${followers.size}, existing blocks: ${existingBlocks.size}`); 862 1036 1037 + const targetDid = config.mode === "post" ? extractAuthorDid(target) : target; 863 1038 const filterResult = filterCandidates({ 864 - results, did, follows, followers, existingBlocks, blockFollowing: config.blockFollowing, 1039 + results, did, targetDid, follows, followers, existingBlocks, blockFollowing: config.blockFollowing, 865 1040 }); 866 1041 867 1042 if (filterResult.toBlock.length === 0) { ··· 871 1046 872 1047 await showSummary(results, filterResult, config.categories, config.mode); 873 1048 1049 + if (flags.dryRun) { 1050 + p.outro("dry run — nothing blocked."); 1051 + process.exit(0); 1052 + } 1053 + 874 1054 await confirmAndBlock({ 875 1055 toBlock: filterResult.toBlock, 876 1056 handle: config.handle, ··· 878 1058 delayMs, 879 1059 batchSize, 880 1060 }); 1061 + process.exit(0); 881 1062 } 882 1063 883 1064 // Only run when executed directly (not imported by tests)
+1 -1
package.json
··· 1 1 { 2 2 "name": "block-reposters", 3 - "version": "1.1.0", 3 + "version": "1.0.0", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "node index.js"