See the best posts from any Bluesky account
0
fork

Configure Feed

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

Parse image/video/external embeds in getAuthorFeed; skip quote posts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+466 -4
+135 -3
app/lib/atproto/parsers/get_author_feed.ts
··· 1 - import type { PostSnapshot } from '../types.js' 1 + import type { ExternalEmbed, ImagesEmbed, PostEmbed, PostSnapshot, VideoEmbed } from '../types.js' 2 2 3 3 function isObject(v: unknown): v is Record<string, unknown> { 4 4 return typeof v === 'object' && v !== null && !Array.isArray(v) ··· 21 21 return v 22 22 } 23 23 24 + function optionalObject( 25 + obj: Record<string, unknown>, 26 + key: string 27 + ): Record<string, unknown> | undefined { 28 + const v = obj[key] 29 + if (v === undefined || v === null) return undefined 30 + if (!isObject(v)) return undefined 31 + return v 32 + } 33 + 34 + function parseAspectRatio( 35 + obj: Record<string, unknown> 36 + ): { width: number; height: number } | undefined { 37 + const ar = optionalObject(obj, 'aspectRatio') 38 + if (!ar) return undefined 39 + const width = ar['width'] 40 + const height = ar['height'] 41 + if (typeof width !== 'number' || typeof height !== 'number') return undefined 42 + return { width, height } 43 + } 44 + 45 + /** 46 + * Sentinel used by parsePostEmbed to signal "this feed item is a quote post, 47 + * skip the whole snapshot". Distinct from `null` (which means "no embed"). 48 + */ 49 + const SKIP_QUOTE = Symbol('skip-quote') 50 + 51 + /** 52 + * Parse a hydrated `post.embed` view object into a PostEmbed. 53 + * 54 + * - Returns `null` when there is no embed, the embed type is unknown, or a 55 + * recognized embed shape is malformed. Warns via console.warn in the 56 + * unknown/malformed cases (with the post URI for triage). 57 + * - Returns `SKIP_QUOTE` for `app.bsky.embed.record#view` and 58 + * `app.bsky.embed.recordWithMedia#view` — the caller should drop the entire 59 + * snapshot in that case (spec: quote posts are excluded from storage because 60 + * we can't track deletion of quoted content). 61 + */ 62 + function parsePostEmbed(embed: unknown, postUri: string): PostEmbed | null | typeof SKIP_QUOTE { 63 + if (embed === undefined || embed === null) return null 64 + if (!isObject(embed)) { 65 + console.warn(`parseGetAuthorFeedResponse: post ${postUri} has non-object embed; skipping embed`) 66 + return null 67 + } 68 + const type = embed['$type'] 69 + if (typeof type !== 'string') { 70 + console.warn(`parseGetAuthorFeedResponse: post ${postUri} embed missing $type; skipping embed`) 71 + return null 72 + } 73 + 74 + switch (type) { 75 + case 'app.bsky.embed.record#view': 76 + case 'app.bsky.embed.recordWithMedia#view': 77 + return SKIP_QUOTE 78 + 79 + case 'app.bsky.embed.images#view': { 80 + const images = embed['images'] 81 + if (!Array.isArray(images)) { 82 + console.warn( 83 + `parseGetAuthorFeedResponse: post ${postUri} images embed has non-array images; skipping embed` 84 + ) 85 + return null 86 + } 87 + try { 88 + const items: ImagesEmbed['items'] = images.map((img: unknown) => { 89 + if (!isObject(img)) throw new Error('image item is not an object') 90 + return { 91 + thumb: getString(img, 'thumb'), 92 + fullsize: getString(img, 'fullsize'), 93 + alt: getString(img, 'alt'), 94 + aspectRatio: parseAspectRatio(img), 95 + } 96 + }) 97 + return { type: 'images', items } 98 + } catch (err) { 99 + console.warn( 100 + `parseGetAuthorFeedResponse: post ${postUri} malformed images embed (${(err as Error).message}); skipping embed` 101 + ) 102 + return null 103 + } 104 + } 105 + 106 + case 'app.bsky.embed.video#view': { 107 + try { 108 + const video: VideoEmbed = { 109 + type: 'video', 110 + thumbnail: getString(embed, 'thumbnail'), 111 + alt: getString(embed, 'alt'), 112 + aspectRatio: parseAspectRatio(embed), 113 + } 114 + return video 115 + } catch (err) { 116 + console.warn( 117 + `parseGetAuthorFeedResponse: post ${postUri} malformed video embed (${(err as Error).message}); skipping embed` 118 + ) 119 + return null 120 + } 121 + } 122 + 123 + case 'app.bsky.embed.external#view': { 124 + try { 125 + const external = getObject(embed, 'external') 126 + const thumb = external['thumb'] 127 + const result: ExternalEmbed = { 128 + type: 'external', 129 + uri: getString(external, 'uri'), 130 + title: getString(external, 'title'), 131 + description: getString(external, 'description'), 132 + thumb: typeof thumb === 'string' ? thumb : null, 133 + } 134 + return result 135 + } catch (err) { 136 + console.warn( 137 + `parseGetAuthorFeedResponse: post ${postUri} malformed external embed (${(err as Error).message}); skipping embed` 138 + ) 139 + return null 140 + } 141 + } 142 + 143 + default: 144 + console.warn( 145 + `parseGetAuthorFeedResponse: post ${postUri} has unknown embed $type "${type}"; skipping embed` 146 + ) 147 + return null 148 + } 149 + } 150 + 24 151 /** 25 152 * Parse the response body from `app.bsky.feed.getAuthorFeed` into an array of 26 153 * PostSnapshots ready for ClickHouse insertion. ··· 86 213 const snapshotReposts = getNumber(post, 'repostCount', 0) 87 214 const snapshotQuotes = getNumber(post, 'quoteCount', 0) 88 215 216 + const parsedEmbed = parsePostEmbed(post['embed'], postUri) 217 + if (parsedEmbed === SKIP_QUOTE) { 218 + // Quote posts are excluded from storage — see parsePostEmbed doc. 219 + return 220 + } 221 + 89 222 snapshots.push({ 90 223 postUri, 91 224 postAuthorDid, ··· 95 228 snapshotReposts, 96 229 snapshotQuotes, 97 230 snapshotTakenAt, 98 - // Embed parsing is wired up in a later task; default to null for now. 99 - embed: null, 231 + embed: parsedEmbed, 100 232 }) 101 233 }) 102 234
+331 -1
tests/unit/atproto/get_author_feed.spec.ts
··· 17 17 repostCount?: number 18 18 quoteCount?: number 19 19 reply?: unknown 20 + embed?: unknown 20 21 }) { 21 22 const authorDid = opts.authorDid ?? TARGET_DID 22 23 const record: Record<string, unknown> = { ··· 25 26 createdAt: opts.createdAt ?? '2024-01-15T12:00:00.000Z', 26 27 } 27 28 if (opts.reply !== undefined) record['reply'] = opts.reply 28 - return { 29 + const post: Record<string, unknown> = { 29 30 uri: opts.uri ?? `at://${authorDid}/app.bsky.feed.post/rkey001`, 30 31 cid: 'bafypostcid', 31 32 author: { did: authorDid, handle: 'someone.bsky.social' }, ··· 36 37 replyCount: 0, 37 38 indexedAt: '2024-01-15T12:01:00.000Z', 38 39 } 40 + if (opts.embed !== undefined) post['embed'] = opts.embed 41 + return post 42 + } 43 + 44 + function imagesEmbedView( 45 + count: number, 46 + opts: { omitAspectRatio?: boolean; omitAlt?: boolean } = {} 47 + ) { 48 + const images = Array.from({ length: count }, (_, i) => { 49 + const img: Record<string, unknown> = { 50 + thumb: `https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:x/cid${i}`, 51 + fullsize: `https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:x/cid${i}`, 52 + } 53 + if (!opts.omitAlt) img['alt'] = `alt text ${i}` 54 + if (!opts.omitAspectRatio) img['aspectRatio'] = { width: 1200, height: 675 } 55 + return img 56 + }) 57 + return { $type: 'app.bsky.embed.images#view', images } 58 + } 59 + 60 + function videoEmbedView() { 61 + return { 62 + $type: 'app.bsky.embed.video#view', 63 + cid: 'bafyvideocid', 64 + playlist: 'https://video.bsky.app/watch/did:plc:x/playlist.m3u8', 65 + thumbnail: 'https://video.bsky.app/watch/did:plc:x/thumbnail.jpg', 66 + alt: 'video alt', 67 + aspectRatio: { width: 440, height: 912 }, 68 + presentation: 'default', 69 + } 70 + } 71 + 72 + function externalEmbedView(withThumb: boolean) { 73 + const external: Record<string, unknown> = { 74 + uri: 'https://example.com', 75 + title: 'Example Title', 76 + description: 'Example description', 77 + } 78 + if (withThumb) { 79 + external['thumb'] = 'https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:x/extcid' 80 + } 81 + return { $type: 'app.bsky.embed.external#view', external } 39 82 } 40 83 41 84 function makeFeedItem(post: ReturnType<typeof makePostView>, reason?: unknown) { ··· 209 252 test('returns empty array for empty feed', ({ assert }) => { 210 253 const result = parseGetAuthorFeedResponse({ feed: [] }, TARGET_DID) 211 254 assert.deepEqual(result, []) 255 + }) 256 + 257 + // ------------------------------------------------------------------------- 258 + // Embed parsing 259 + // ------------------------------------------------------------------------- 260 + 261 + test('post with no embed → embed: null', ({ assert }) => { 262 + const response = { 263 + feed: [makeFeedItem(makePostView({ uri: `at://${TARGET_DID}/app.bsky.feed.post/plain` }))], 264 + } 265 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 266 + assert.equal(result.length, 1) 267 + assert.isNull(result[0].embed) 268 + }) 269 + 270 + test('post with single image embed is parsed', ({ assert }) => { 271 + const response = { 272 + feed: [ 273 + makeFeedItem( 274 + makePostView({ 275 + uri: `at://${TARGET_DID}/app.bsky.feed.post/img1`, 276 + embed: imagesEmbedView(1), 277 + }) 278 + ), 279 + ], 280 + } 281 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 282 + assert.equal(result.length, 1) 283 + const embed = result[0].embed 284 + if (!embed || embed.type !== 'images') throw new Error('expected images embed') 285 + assert.equal(embed.items.length, 1) 286 + assert.equal( 287 + embed.items[0].thumb, 288 + 'https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:x/cid0' 289 + ) 290 + assert.equal( 291 + embed.items[0].fullsize, 292 + 'https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:x/cid0' 293 + ) 294 + assert.equal(embed.items[0].alt, 'alt text 0') 295 + assert.deepEqual(embed.items[0].aspectRatio, { width: 1200, height: 675 }) 296 + }) 297 + 298 + test('post with 4-image embed parses all 4 items', ({ assert }) => { 299 + const response = { 300 + feed: [ 301 + makeFeedItem( 302 + makePostView({ 303 + uri: `at://${TARGET_DID}/app.bsky.feed.post/img4`, 304 + embed: imagesEmbedView(4), 305 + }) 306 + ), 307 + ], 308 + } 309 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 310 + const embed = result[0].embed 311 + if (!embed || embed.type !== 'images') throw new Error('expected images embed') 312 + assert.equal(embed.items.length, 4) 313 + }) 314 + 315 + test('image embed missing aspectRatio → item.aspectRatio is undefined', ({ assert }) => { 316 + const response = { 317 + feed: [ 318 + makeFeedItem( 319 + makePostView({ 320 + uri: `at://${TARGET_DID}/app.bsky.feed.post/imgnoar`, 321 + embed: imagesEmbedView(1, { omitAspectRatio: true }), 322 + }) 323 + ), 324 + ], 325 + } 326 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 327 + const embed = result[0].embed 328 + if (!embed || embed.type !== 'images') throw new Error('expected images embed') 329 + assert.isUndefined(embed.items[0].aspectRatio) 330 + }) 331 + 332 + test('image embed missing alt → logs warning and embed: null', ({ assert }) => { 333 + const origWarn = console.warn 334 + const warnings: string[] = [] 335 + console.warn = (msg: string) => warnings.push(msg) 336 + try { 337 + const response = { 338 + feed: [ 339 + makeFeedItem( 340 + makePostView({ 341 + uri: `at://${TARGET_DID}/app.bsky.feed.post/imgnoalt`, 342 + embed: imagesEmbedView(1, { omitAlt: true }), 343 + }) 344 + ), 345 + ], 346 + } 347 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 348 + assert.equal(result.length, 1) 349 + assert.isNull(result[0].embed) 350 + assert.equal(warnings.length, 1) 351 + assert.match(warnings[0], /malformed images embed/) 352 + } finally { 353 + console.warn = origWarn 354 + } 355 + }) 356 + 357 + test('video embed is parsed and playlist ignored', ({ assert }) => { 358 + const response = { 359 + feed: [ 360 + makeFeedItem( 361 + makePostView({ 362 + uri: `at://${TARGET_DID}/app.bsky.feed.post/vid`, 363 + embed: videoEmbedView(), 364 + }) 365 + ), 366 + ], 367 + } 368 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 369 + const embed = result[0].embed 370 + if (!embed || embed.type !== 'video') throw new Error('expected video embed') 371 + assert.equal(embed.thumbnail, 'https://video.bsky.app/watch/did:plc:x/thumbnail.jpg') 372 + assert.equal(embed.alt, 'video alt') 373 + assert.deepEqual(embed.aspectRatio, { width: 440, height: 912 }) 374 + // playlist must not leak into the parsed shape 375 + assert.notProperty(embed as unknown as Record<string, unknown>, 'playlist') 376 + }) 377 + 378 + test('external embed with thumb is parsed', ({ assert }) => { 379 + const response = { 380 + feed: [ 381 + makeFeedItem( 382 + makePostView({ 383 + uri: `at://${TARGET_DID}/app.bsky.feed.post/ext`, 384 + embed: externalEmbedView(true), 385 + }) 386 + ), 387 + ], 388 + } 389 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 390 + const embed = result[0].embed 391 + if (!embed || embed.type !== 'external') throw new Error('expected external embed') 392 + assert.equal(embed.uri, 'https://example.com') 393 + assert.equal(embed.title, 'Example Title') 394 + assert.equal(embed.description, 'Example description') 395 + assert.equal(embed.thumb, 'https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:x/extcid') 396 + }) 397 + 398 + test('external embed without thumb → thumb: null', ({ assert }) => { 399 + const response = { 400 + feed: [ 401 + makeFeedItem( 402 + makePostView({ 403 + uri: `at://${TARGET_DID}/app.bsky.feed.post/extnothumb`, 404 + embed: externalEmbedView(false), 405 + }) 406 + ), 407 + ], 408 + } 409 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 410 + const embed = result[0].embed 411 + if (!embed || embed.type !== 'external') throw new Error('expected external embed') 412 + assert.isNull(embed.thumb) 413 + }) 414 + 415 + test('record embed (quote post) → feed item dropped entirely', ({ assert }) => { 416 + const response = { 417 + feed: [ 418 + makeFeedItem( 419 + makePostView({ 420 + uri: `at://${TARGET_DID}/app.bsky.feed.post/quote`, 421 + embed: { $type: 'app.bsky.embed.record#view', record: { uri: 'at://whatever' } }, 422 + }) 423 + ), 424 + ], 425 + } 426 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 427 + assert.deepEqual(result, []) 428 + }) 429 + 430 + test('recordWithMedia embed → feed item dropped entirely', ({ assert }) => { 431 + const response = { 432 + feed: [ 433 + makeFeedItem( 434 + makePostView({ 435 + uri: `at://${TARGET_DID}/app.bsky.feed.post/qwm`, 436 + embed: { 437 + $type: 'app.bsky.embed.recordWithMedia#view', 438 + record: { record: { uri: 'at://whatever' } }, 439 + media: imagesEmbedView(1), 440 + }, 441 + }) 442 + ), 443 + ], 444 + } 445 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 446 + assert.deepEqual(result, []) 447 + }) 448 + 449 + test('unknown embed $type → included with embed: null and warning', ({ assert }) => { 450 + const origWarn = console.warn 451 + const warnings: string[] = [] 452 + console.warn = (msg: string) => warnings.push(msg) 453 + try { 454 + const response = { 455 + feed: [ 456 + makeFeedItem( 457 + makePostView({ 458 + uri: `at://${TARGET_DID}/app.bsky.feed.post/unknown`, 459 + embed: { $type: 'app.bsky.embed.somethingNew#view', foo: 'bar' }, 460 + }) 461 + ), 462 + ], 463 + } 464 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 465 + assert.equal(result.length, 1) 466 + assert.isNull(result[0].embed) 467 + assert.equal(warnings.length, 1) 468 + assert.match(warnings[0], /unknown embed \$type/) 469 + } finally { 470 + console.warn = origWarn 471 + } 472 + }) 473 + 474 + test('malformed images embed (images not an array) → embed: null with warning', ({ assert }) => { 475 + const origWarn = console.warn 476 + const warnings: string[] = [] 477 + console.warn = (msg: string) => warnings.push(msg) 478 + try { 479 + const response = { 480 + feed: [ 481 + makeFeedItem( 482 + makePostView({ 483 + uri: `at://${TARGET_DID}/app.bsky.feed.post/badimg`, 484 + embed: { $type: 'app.bsky.embed.images#view', images: 'not an array' }, 485 + }) 486 + ), 487 + ], 488 + } 489 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 490 + assert.equal(result.length, 1) 491 + assert.isNull(result[0].embed) 492 + assert.equal(warnings.length, 1) 493 + assert.match(warnings[0], /non-array images/) 494 + } finally { 495 + console.warn = origWarn 496 + } 497 + }) 498 + 499 + test('mixed feed: images + foreign repost + record quote + plain → 2 kept', ({ assert }) => { 500 + const response = { 501 + feed: [ 502 + makeFeedItem( 503 + makePostView({ 504 + uri: `at://${TARGET_DID}/app.bsky.feed.post/withimg`, 505 + embed: imagesEmbedView(1), 506 + }) 507 + ), 508 + // foreign repost — dropped by existing author filter 509 + makeFeedItem( 510 + makePostView({ 511 + authorDid: OTHER_DID, 512 + uri: `at://${OTHER_DID}/app.bsky.feed.post/foreign`, 513 + }) 514 + ), 515 + // quote post — dropped by new embed logic 516 + makeFeedItem( 517 + makePostView({ 518 + uri: `at://${TARGET_DID}/app.bsky.feed.post/quote`, 519 + embed: { $type: 'app.bsky.embed.record#view', record: { uri: 'at://somewhere' } }, 520 + }) 521 + ), 522 + // plain text — kept, embed null 523 + makeFeedItem( 524 + makePostView({ 525 + uri: `at://${TARGET_DID}/app.bsky.feed.post/plain`, 526 + text: 'just text', 527 + }) 528 + ), 529 + ], 530 + } 531 + const result = parseGetAuthorFeedResponse(response, TARGET_DID) 532 + assert.equal(result.length, 2) 533 + assert.deepEqual( 534 + result.map((s) => s.postUri), 535 + [ 536 + `at://${TARGET_DID}/app.bsky.feed.post/withimg`, 537 + `at://${TARGET_DID}/app.bsky.feed.post/plain`, 538 + ] 539 + ) 540 + assert.equal(result[0].embed?.type, 'images') 541 + assert.isNull(result[1].embed) 212 542 }) 213 543 })