Skip to content

Commit 8651e5c

Browse files
committed
feat(enrichment): keep og-parser image OG-strict with dimensions
resolveImage() no longer falls back to apple-touch-icon / link icons. `image` now holds only a real og:image / twitter:image so link cards never render a square favicon into a wide image slot. og:image:width and og:image:height are parsed into image dimensions, and discovered icons move to `result.links`.
1 parent 935fb66 commit 8651e5c

2 files changed

Lines changed: 139 additions & 13 deletions

File tree

apps/core/src/modules/enrichment/providers/open-graph/og-parser.ts

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export function parseOpenGraph(
4747
pick(bag.named, 'description') ||
4848
undefined
4949

50-
const imageUrl = resolveImage(bag, requestUrl)
50+
const resolvedImage = resolveImage(bag, requestUrl)
5151
const imageAlt =
5252
pick(bag.og, 'image:alt') || pick(bag.twitter, 'image:alt') || undefined
5353

@@ -117,14 +117,22 @@ export function parseOpenGraph(
117117
const result: EnrichmentResult = {
118118
title,
119119
description,
120-
image: imageUrl ? { url: imageUrl, alt: imageAlt } : undefined,
120+
image: resolvedImage
121+
? {
122+
url: resolvedImage.url,
123+
alt: imageAlt,
124+
width: resolvedImage.width,
125+
height: resolvedImage.height,
126+
}
127+
: undefined,
121128
url: canonical,
122129
category: ENRICHMENT_CATEGORIES.WEB,
123130
subtype,
124131
fetchedAt: '',
125132
publishedAt,
126133
color: themeColor,
127134
attributes: attributes.length ? attributes : undefined,
135+
links: iconLinks(bag, requestUrl),
128136
}
129137

130138
return { result, oembedUrl: bag.oembedUrl }
@@ -193,29 +201,59 @@ function pick(map: Record<string, string>, key: string): string | undefined {
193201
return v && v.length > 0 ? v : undefined
194202
}
195203

196-
function resolveImage(bag: MetaBag, base: string): string | undefined {
204+
interface ResolvedImage {
205+
url: string
206+
width?: number
207+
height?: number
208+
}
209+
210+
// `image` is strictly the page's advertised OG / Twitter Card image. Icons
211+
// (favicon, apple-touch-icon) are deliberately excluded — a square favicon
212+
// must never land in a link card's wide image slot. Icons are surfaced
213+
// separately via `result.links`.
214+
function resolveImage(bag: MetaBag, base: string): ResolvedImage | undefined {
197215
const candidates = [
198216
pick(bag.og, 'image:secure_url'),
199217
pick(bag.og, 'image:url'),
200218
pick(bag.og, 'image'),
201219
pick(bag.twitter, 'image:src'),
202220
pick(bag.twitter, 'image'),
203221
]
222+
let url: string | undefined
204223
for (const c of candidates) {
205224
const abs = absolutize(c, base)
206-
if (abs) return abs
225+
if (abs) {
226+
url = abs
227+
break
228+
}
207229
}
208-
// Fallback: best-sized icon (apple-touch-icon prefers, else first).
209-
const apple = bag.icons.find((i) => i.rel.startsWith('apple-touch-icon'))
210-
if (apple) {
211-
const abs = absolutize(apple.href, base)
212-
if (abs) return abs
230+
if (!url) return undefined
231+
return {
232+
url,
233+
width: parseDimension(pick(bag.og, 'image:width')),
234+
height: parseDimension(pick(bag.og, 'image:height')),
213235
}
214-
if (bag.icons.length) {
215-
const abs = absolutize(bag.icons[0].href, base)
216-
if (abs) return abs
236+
}
237+
238+
function parseDimension(raw: string | undefined): number | undefined {
239+
if (!raw) return undefined
240+
const n = Number.parseInt(raw, 10)
241+
return Number.isFinite(n) && n > 0 ? n : undefined
242+
}
243+
244+
function iconLinks(
245+
bag: MetaBag,
246+
base: string,
247+
): EnrichmentResult['links'] | undefined {
248+
const seen = new Set<string>()
249+
const links: NonNullable<EnrichmentResult['links']> = []
250+
for (const icon of bag.icons) {
251+
const url = absolutize(icon.href, base)
252+
if (!url || seen.has(url)) continue
253+
seen.add(url)
254+
links.push({ rel: icon.rel, url })
217255
}
218-
return undefined
256+
return links.length ? links : undefined
219257
}
220258

221259
function absolutize(
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { parseOpenGraph } from '~/modules/enrichment/providers/open-graph/og-parser'
4+
5+
function parse(head: string, url = 'https://example.com/article') {
6+
return parseOpenGraph(
7+
`<html><head>${head}</head><body></body></html>`,
8+
url,
9+
url,
10+
)
11+
}
12+
13+
describe('parseOpenGraph — image', () => {
14+
it('resolves og:image and records advertised dimensions', () => {
15+
const { result } = parse(`
16+
<meta property="og:title" content="A Post" />
17+
<meta property="og:image" content="https://cdn.example.com/og.png" />
18+
<meta property="og:image:width" content="1200" />
19+
<meta property="og:image:height" content="630" />
20+
`)
21+
expect(result.image).toEqual({
22+
url: 'https://cdn.example.com/og.png',
23+
alt: undefined,
24+
width: 1200,
25+
height: 630,
26+
})
27+
})
28+
29+
it('keeps the image without dimensions when none are advertised', () => {
30+
const { result } = parse(`
31+
<meta property="og:image" content="https://cdn.example.com/og.png" />
32+
`)
33+
expect(result.image?.url).toBe('https://cdn.example.com/og.png')
34+
expect(result.image?.width).toBeUndefined()
35+
expect(result.image?.height).toBeUndefined()
36+
})
37+
38+
it('ignores non-numeric / non-positive image dimensions', () => {
39+
const { result } = parse(`
40+
<meta property="og:image" content="https://cdn.example.com/og.png" />
41+
<meta property="og:image:width" content="wide" />
42+
<meta property="og:image:height" content="0" />
43+
`)
44+
expect(result.image?.width).toBeUndefined()
45+
expect(result.image?.height).toBeUndefined()
46+
})
47+
48+
it('falls back to twitter:image when no og:image is present', () => {
49+
const { result } = parse(`
50+
<meta name="twitter:image" content="https://cdn.example.com/tw.png" />
51+
`)
52+
expect(result.image?.url).toBe('https://cdn.example.com/tw.png')
53+
})
54+
55+
it('absolutizes a relative image url', () => {
56+
const { result } = parse(
57+
`<meta property="og:image" content="/static/og.png" />`,
58+
'https://example.com/blog/post',
59+
)
60+
expect(result.image?.url).toBe('https://example.com/static/og.png')
61+
})
62+
63+
it('does NOT use an apple-touch-icon / favicon as the image', () => {
64+
const { result } = parse(`
65+
<link rel="apple-touch-icon" href="https://example.com/touch.png" />
66+
<link rel="icon" href="https://example.com/favicon.ico" />
67+
`)
68+
expect(result.image).toBeUndefined()
69+
})
70+
})
71+
72+
describe('parseOpenGraph — icon links', () => {
73+
it('surfaces discovered icons in result.links', () => {
74+
const { result } = parse(`
75+
<link rel="apple-touch-icon" href="https://example.com/touch.png" />
76+
<link rel="icon" href="/favicon.ico" />
77+
`)
78+
expect(result.links).toEqual([
79+
{ rel: 'apple-touch-icon', url: 'https://example.com/touch.png' },
80+
{ rel: 'icon', url: 'https://example.com/favicon.ico' },
81+
])
82+
})
83+
84+
it('leaves links undefined when the page advertises no icons', () => {
85+
const { result } = parse(`<meta property="og:title" content="A Post" />`)
86+
expect(result.links).toBeUndefined()
87+
})
88+
})

0 commit comments

Comments
 (0)