A tool for tailing a labelers' firehose, rehydrating, and storing records for future analysis of moderation decisions.

fix: support profile avatar/banner blob processing

- Add URI parsing for profile:// scheme (profile://did/avatar or profile://did/banner)
- Use correct CDN paths for avatars (img/avatar) and banners (img/banner)
- Keep existing feed_thumbnail/feed_fullsize paths for post blobs
- Add type tracking to blob processing logs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Changed files
+37 -12
src
blobs
+37 -12
src/blobs/processor.ts
··· 100 100 } 101 101 } 102 102 103 + private parseBlobUri(uri: string): { did: string; type: 'post' | 'avatar' | 'banner' } { 104 + if (uri.startsWith("profile://")) { 105 + const match = uri.match(/^profile:\/\/([^/]+)\/(avatar|banner)$/); 106 + if (match) { 107 + return { did: match[1], type: match[2] as 'avatar' | 'banner' }; 108 + } 109 + } 110 + 111 + const [, did] = uri.replace("at://", "").split("/"); 112 + return { did, type: 'post' }; 113 + } 114 + 115 + private getBlobUrls(did: string, cid: string, type: 'post' | 'avatar' | 'banner'): { thumbnail: string; fullsize: string } { 116 + if (type === 'avatar') { 117 + return { 118 + thumbnail: `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@jpeg`, 119 + fullsize: `https://cdn.bsky.app/img/avatar/plain/${did}/${cid}@jpeg`, 120 + }; 121 + } else if (type === 'banner') { 122 + return { 123 + thumbnail: `https://cdn.bsky.app/img/banner/plain/${did}/${cid}@jpeg`, 124 + fullsize: `https://cdn.bsky.app/img/banner/plain/${did}/${cid}@jpeg`, 125 + }; 126 + } else { 127 + return { 128 + thumbnail: `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@jpeg`, 129 + fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${cid}@jpeg`, 130 + }; 131 + } 132 + } 133 + 103 134 private async processBlob( 104 135 postUri: string, 105 136 ref: BlobReference ··· 113 144 return; 114 145 } 115 146 116 - const [, did] = postUri.replace("at://", "").split("/"); 147 + const { did, type } = this.parseBlobUri(postUri); 148 + const urls = this.getBlobUrls(did, ref.cid, type); 117 149 118 150 try { 119 - const response = await fetch( 120 - `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${ref.cid}@jpeg`, 121 - { method: "HEAD" } 122 - ); 151 + const response = await fetch(urls.thumbnail, { method: "HEAD" }); 123 152 124 153 if (!response.ok) { 125 154 logger.warn( ··· 133 162 let storagePath: string | undefined; 134 163 135 164 if (this.storage && config.blobs.hydrateBlobs) { 136 - const fullResponse = await fetch( 137 - `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${ref.cid}@jpeg` 138 - ); 165 + const fullResponse = await fetch(urls.fullsize); 139 166 140 167 if (fullResponse.ok) { 141 168 blobData = Buffer.from( ··· 148 175 ); 149 176 } 150 177 } else { 151 - const thumbResponse = await fetch( 152 - `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${ref.cid}@jpeg` 153 - ); 178 + const thumbResponse = await fetch(urls.thumbnail); 154 179 155 180 if (thumbResponse.ok) { 156 181 blobData = Buffer.from( ··· 182 207 }); 183 208 184 209 logger.info( 185 - { postUri, cid: ref.cid, sha256: hashes.sha256 }, 210 + { postUri, cid: ref.cid, sha256: hashes.sha256, type }, 186 211 "Blob processed successfully" 187 212 ); 188 213 } catch (error) {