Skip to content

Commit fb87132

Browse files
committed
fix(enrichment): preserve open graph refresh url context
1 parent c2a709c commit fb87132

8 files changed

Lines changed: 238 additions & 8 deletions

File tree

apps/core/src/modules/enrichment/enrichment.service.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ interface EnrichmentRefreshPayload extends Record<string, unknown> {
3333
provider: string
3434
externalId: string
3535
locale: string
36+
url?: string
37+
}
38+
39+
interface EnrichmentRefInput {
40+
provider: string
41+
externalId: string
42+
url?: string
3643
}
3744

3845
/**
@@ -64,8 +71,11 @@ export class EnrichmentService implements OnModuleInit {
6471
type: ENRICHMENT_REFRESH_TASK_TYPE,
6572
execute: async (payload: EnrichmentRefreshPayload) => {
6673
const locale = payload.locale ?? ''
74+
const url = typeof payload.url === 'string' ? payload.url : undefined
6775
try {
68-
await this.refresh(payload.provider, payload.externalId, locale)
76+
await this.refresh(payload.provider, payload.externalId, locale, {
77+
url,
78+
})
6979
} catch (error) {
7080
// Record per-row failure so backoff kicks in on subsequent SWR
7181
// resolves; re-throw so the task queue marks the task failed.
@@ -217,6 +227,7 @@ export class EnrichmentService implements OnModuleInit {
217227
providerName: string,
218228
id: string,
219229
lang?: string,
230+
opts?: { url?: string },
220231
): Promise<EnrichmentResult> {
221232
const provider = this.providerRegistry.getByName(providerName)
222233
if (!provider) throw new Error(`Unknown provider: ${providerName}`)
@@ -226,6 +237,7 @@ export class EnrichmentService implements OnModuleInit {
226237

227238
const result = await this.fetchAndPersist(provider, id, {
228239
locale: cacheLocale,
240+
url: opts?.url,
229241
})
230242
await this.deleteFromRedis(result.url, cacheLocale)
231243
return result
@@ -294,7 +306,7 @@ export class EnrichmentService implements OnModuleInit {
294306
* should index via the same helper.
295307
*/
296308
async hydrateRefs(
297-
refs: ReadonlyArray<{ provider: string; externalId: string }>,
309+
refs: ReadonlyArray<EnrichmentRefInput>,
298310
lang?: string,
299311
): Promise<Record<string, EnrichmentResult>> {
300312
if (refs.length === 0) return {}
@@ -304,6 +316,7 @@ export class EnrichmentService implements OnModuleInit {
304316
provider: string
305317
externalId: string
306318
locale: string
319+
url?: string
307320
}
308321
const byKey = new Map<string, RefEntry>()
309322
for (const r of refs) {
@@ -312,17 +325,28 @@ export class EnrichmentService implements OnModuleInit {
312325
? this.resolveCacheLocale(provider, reqLocale)
313326
: ''
314327
const key = `${r.provider}\t${r.externalId}\t${locale}`
315-
if (byKey.has(key)) continue
328+
const existing = byKey.get(key)
329+
if (existing) {
330+
if (!existing.url && r.url) existing.url = r.url
331+
continue
332+
}
316333
byKey.set(key, {
317334
provider: r.provider,
318335
externalId: r.externalId,
319336
locale,
337+
url: r.url,
320338
})
321339
}
322340
if (byKey.size === 0) return {}
323341

324342
const refQueries = [...byKey.values()]
325-
const rows = await this.repository.findManyByRefs(refQueries)
343+
const rows = await this.repository.findManyByRefs(
344+
refQueries.map(({ provider, externalId, locale }) => ({
345+
provider,
346+
externalId,
347+
locale,
348+
})),
349+
)
326350
const now = new Date()
327351
const out: Record<string, EnrichmentResult> = {}
328352
const seenKeys = new Set<string>()
@@ -361,12 +385,18 @@ export class EnrichmentService implements OnModuleInit {
361385
if (!provider) continue
362386
config ??= await this.configsService.get('thirdPartyServiceIntegration')
363387
if (!this.isProviderReady(provider, config)) continue
388+
if (provider.requiresUrlContext && !entry.url) continue
364389
const k = refKey(entry.provider, entry.externalId)
365390
if (entry.locale !== '' && !out[k]) {
366391
const fb = fallbackByKey.get(k)
367392
if (fb) out[k] = fb
368393
}
369-
this.enqueueRefresh(entry.provider, entry.externalId, entry.locale)
394+
this.enqueueRefresh(
395+
entry.provider,
396+
entry.externalId,
397+
entry.locale,
398+
entry.url,
399+
)
370400
}
371401
}
372402
return out
@@ -400,6 +430,7 @@ export class EnrichmentService implements OnModuleInit {
400430
urlRefs.map((r) => ({
401431
provider: r.provider,
402432
externalId: r.externalId,
433+
url: r.url,
403434
})),
404435
lang,
405436
)
@@ -604,6 +635,7 @@ export class EnrichmentService implements OnModuleInit {
604635
providerName: string,
605636
externalId: string,
606637
locale: string,
638+
url?: string,
607639
): void {
608640
const dedupKey = locale
609641
? `${providerName}:${externalId}:${locale}`
@@ -617,6 +649,7 @@ export class EnrichmentService implements OnModuleInit {
617649
provider: providerName,
618650
externalId,
619651
locale,
652+
...(url ? { url } : {}),
620653
} satisfies EnrichmentRefreshPayload,
621654
})
622655
.catch((error) => {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export class OpenGraphProvider implements EnrichmentProvider {
4040
readonly priority = -100
4141
readonly defaultTtl = 86_400 * 7
4242
readonly featureGateConfigKey = 'openGraph'
43+
readonly requiresUrlContext = true
4344

4445
private readonly logger = new Logger(OpenGraphProvider.name)
4546

apps/core/src/modules/enrichment/providers/provider.interface.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export interface EnrichmentProvider<TRaw = unknown> {
4242

4343
readonly requiredConfigKeys?: string[]
4444
readonly featureGateConfigKey?: string
45+
/**
46+
* True when `externalId` alone is not enough to fetch upstream data.
47+
* Ref-driven cold hydration must provide `ctx.url` for these providers.
48+
*/
49+
readonly requiresUrlContext?: boolean
4550
/**
4651
* When true, the service layer maps the request `lang` into a per-locale
4752
* cache row and passes it to {@link fetch}. Default false.

apps/core/src/modules/recently/recently.service.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { RecentlyRepository } from './recently.repository'
2020
import { RecentlyAttitudeEnum } from './recently.schema'
2121
import { RecentlyModel, type RecentlyRow } from './recently.types'
2222

23+
const URL_REGEX = /https?:\/\/\S+/i
24+
const URL_TAIL_TRIM = /[!"'),.:;>?\]`}]+$/
25+
2326
/**
2427
* Minimal hydrated reference returned alongside a recently row when its
2528
* `refType`/`refId` point at a post/note/page/recently. Mirrors the small
@@ -355,12 +358,14 @@ export class RecentlyService {
355358
): Promise<Array<T & { enrichment?: EnrichmentResult | null }>> {
356359
if (rows.length === 0) return []
357360

358-
const refs: Array<{ provider: string; externalId: string }> = []
361+
const refs: Array<{ provider: string; externalId: string; url?: string }> =
362+
[]
359363
for (const row of rows) {
360364
if (row.enrichmentProvider && row.enrichmentExternalId) {
361365
refs.push({
362366
provider: row.enrichmentProvider,
363367
externalId: row.enrichmentExternalId,
368+
url: this.resolveEnrichmentUrl(row),
364369
})
365370
}
366371
}
@@ -384,4 +389,35 @@ export class RecentlyService {
384389
return { ...row, enrichment: map[key] ?? null }
385390
})
386391
}
392+
393+
private resolveEnrichmentUrl(row: RecentlyRow): string | undefined {
394+
if (!row.enrichmentProvider || !row.enrichmentExternalId) return undefined
395+
const metadataUrl = (row.metadata as { url?: unknown } | null)?.url
396+
const candidates = [
397+
typeof metadataUrl === 'string' ? metadataUrl : undefined,
398+
extractFirstUrl(row.content),
399+
].filter((url): url is string => !!url)
400+
401+
for (const url of candidates) {
402+
const ref = this.enrichmentService.matchUrlToRef(url)
403+
if (
404+
ref?.provider === row.enrichmentProvider &&
405+
ref.externalId === row.enrichmentExternalId
406+
) {
407+
return url
408+
}
409+
}
410+
return undefined
411+
}
412+
}
413+
414+
function extractFirstUrl(
415+
content: string | null | undefined,
416+
): string | undefined {
417+
if (!content) return undefined
418+
const match = content.match(URL_REGEX)
419+
if (!match) return undefined
420+
let url = match[0]
421+
while (URL_TAIL_TRIM.test(url)) url = url.replace(URL_TAIL_TRIM, '')
422+
return url || undefined
387423
}

apps/core/test/src/modules/enrichment/enrichment.service.hydrate-refs.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,26 @@ describe('EnrichmentService.hydrateRefs', () => {
187187
await service.hydrateRefs([{ provider: 'gh-repo', externalId: 'a/b' }])
188188
expect(repository.findManyByRefs).toHaveBeenCalledTimes(1)
189189
})
190+
191+
it('does not enqueue a cold refresh for URL-context providers without URL', async () => {
192+
const taskQueueService = {
193+
createTask: vi.fn(async () => ({ taskId: 't1', created: true })),
194+
}
195+
const { service } = makeService({
196+
rows: new Map(),
197+
taskQueueService,
198+
providerRegistry: {
199+
getByName: () => ({
200+
name: 'open-graph',
201+
requiresUrlContext: true,
202+
}),
203+
},
204+
})
205+
206+
await service.hydrateRefs([
207+
{ provider: 'open-graph', externalId: 'opaque-hash' },
208+
])
209+
await new Promise((r) => setImmediate(r))
210+
expect(taskQueueService.createTask).not.toHaveBeenCalled()
211+
})
190212
})

apps/core/test/src/modules/enrichment/enrichment.service.hydrate.spec.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,42 @@ describe('EnrichmentService.hydrateUrls', () => {
9292
expect(await svc.hydrateUrls(['https://github.com/a/b'])).toEqual({})
9393
})
9494

95+
it('passes URL context when a cache miss enqueues a cold refresh', async () => {
96+
const url = 'https://example.com/article'
97+
const taskQueueService = {
98+
createTask: vi.fn(async () => ({ taskId: 't1', created: true })),
99+
}
100+
const svc = makeService({
101+
matchUrlToRef: () => ({
102+
provider: 'open-graph',
103+
externalId: 'opaque-hash',
104+
}),
105+
rows: new Map(),
106+
taskQueueService,
107+
}) as any
108+
svc.providerRegistry = {
109+
getByName: () => ({
110+
name: 'open-graph',
111+
localeAware: false,
112+
}),
113+
}
114+
115+
expect(await (svc as EnrichmentService).hydrateUrls([url])).toEqual({})
116+
await new Promise((r) => setImmediate(r))
117+
expect(taskQueueService.createTask).toHaveBeenCalledWith(
118+
expect.objectContaining({
119+
type: 'enrichment:refresh',
120+
dedupKey: 'open-graph:opaque-hash',
121+
payload: {
122+
provider: 'open-graph',
123+
externalId: 'opaque-hash',
124+
locale: '',
125+
url,
126+
},
127+
}),
128+
)
129+
})
130+
95131
it('returns the cached normalized result keyed by original URL', async () => {
96132
const url = 'https://github.com/vercel/next.js'
97133
const row = makeRow({
@@ -250,7 +286,12 @@ describe('EnrichmentService.hydrateUrls', () => {
250286
expect(taskQueueService.createTask).toHaveBeenCalledWith(
251287
expect.objectContaining({
252288
dedupKey: 'tmdb:movie/1:zh',
253-
payload: { provider: 'tmdb', externalId: 'movie/1', locale: 'zh' },
289+
payload: {
290+
provider: 'tmdb',
291+
externalId: 'movie/1',
292+
locale: 'zh',
293+
url,
294+
},
254295
}),
255296
)
256297
})

apps/core/test/src/modules/enrichment/enrichment.service.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ function makeService(stubs: ServiceStubs = {}) {
7979
},
8080
}
8181
}),
82+
getByName: vi.fn((name: string) =>
83+
name === provider.name ? provider : undefined,
84+
),
8285
}
8386
const configsService = {
8487
get: vi.fn(async () => ({})),
@@ -252,6 +255,17 @@ describe('EnrichmentService.resolveCacheLocale', () => {
252255
})
253256
})
254257

258+
describe('EnrichmentService.refresh', () => {
259+
it('threads explicit URL context into provider fetch', async () => {
260+
const url = 'https://example.com/post'
261+
const { service, provider } = makeService({ dbRow: null })
262+
263+
await service.refresh('tmdb', 'movie/1', undefined, { url })
264+
265+
expect(provider.fetch).toHaveBeenCalledWith('movie/1', undefined, { url })
266+
})
267+
})
268+
255269
describe('EnrichmentService.resolve (locale)', () => {
256270
const url = 'https://www.themoviedb.org/movie/1'
257271

0 commit comments

Comments
 (0)