This repository has no description
0

Configure Feed

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

semble / src / modules / cards / infrastructure / CompositeMetadataService.ts
7.7 kB 232 lines
1import { IMetadataService } from '../domain/services/IMetadataService'; 2import { UrlMetadata } from '../domain/value-objects/UrlMetadata'; 3import { URL } from '../domain/value-objects/URL'; 4import { UrlType } from '../domain/value-objects/UrlType'; 5import { Result, ok, err } from '../../../shared/core/Result'; 6import { UrlClassifier } from './UrlClassifier'; 7 8export enum DefaultServicePreference { 9 IFRAMELY = 'iframely', 10 CITOID = 'citoid', 11} 12 13export interface CompositeMetadataServiceConfig { 14 defaultService: DefaultServicePreference; 15} 16 17export class CompositeMetadataService implements IMetadataService { 18 private readonly iframelyService: IMetadataService; 19 private readonly citoidService: IMetadataService; 20 private readonly config: CompositeMetadataServiceConfig; 21 22 constructor( 23 iframelyService: IMetadataService, 24 citoidService: IMetadataService, 25 config: CompositeMetadataServiceConfig = { 26 defaultService: DefaultServicePreference.IFRAMELY, 27 }, 28 ) { 29 this.iframelyService = iframelyService; 30 this.citoidService = citoidService; 31 this.config = config; 32 } 33 34 async fetchMetadata( 35 url: URL, 36 refetchStaleMetadata: boolean = false, 37 ): Promise<Result<UrlMetadata>> { 38 // Fetch metadata from both services concurrently 39 const [iframelyResult, citoidResult] = await Promise.allSettled([ 40 this.iframelyService.fetchMetadata(url, refetchStaleMetadata), 41 this.citoidService.fetchMetadata(url, refetchStaleMetadata), 42 ]); 43 44 // Extract successful results 45 const iframelySuccess = 46 iframelyResult.status === 'fulfilled' && iframelyResult.value.isOk() 47 ? iframelyResult.value.value 48 : null; 49 50 const citoidSuccess = 51 citoidResult.status === 'fulfilled' && citoidResult.value.isOk() 52 ? citoidResult.value.value 53 : null; 54 55 // If both failed, return an error 56 if (!iframelySuccess && !citoidSuccess) { 57 const iframelyError = 58 iframelyResult.status === 'fulfilled' 59 ? iframelyResult.value.isErr() 60 ? iframelyResult.value.error 61 : new Error('Iframely service failed') 62 : new Error('Iframely service failed'); 63 const citoidError = 64 citoidResult.status === 'fulfilled' 65 ? citoidResult.value.isErr() 66 ? citoidResult.value.error 67 : new Error('Citoid service failed') 68 : new Error('Citoid service failed'); 69 70 return err( 71 new Error( 72 `Both metadata services failed. Iframely: ${iframelyError?.message}. Citoid: ${citoidError?.message}`, 73 ), 74 ); 75 } 76 77 // If only one succeeded, use that one 78 if (iframelySuccess && !citoidSuccess) { 79 const finalMetadata = this.applyUrlClassification(iframelySuccess, url); 80 return ok(finalMetadata); 81 } 82 83 if (citoidSuccess && !iframelySuccess) { 84 const finalMetadata = this.applyUrlClassification(citoidSuccess, url); 85 return ok(finalMetadata); 86 } 87 88 // Both succeeded, apply selection logic and merge missing fields 89 if (iframelySuccess && citoidSuccess) { 90 const selectedMetadata = this.selectBestMetadata( 91 iframelySuccess, 92 citoidSuccess, 93 ); 94 const mergedMetadata = this.mergeMetadata( 95 selectedMetadata, 96 selectedMetadata === iframelySuccess ? citoidSuccess : iframelySuccess, 97 ); 98 const finalMetadata = this.applyUrlClassification(mergedMetadata, url); 99 return ok(finalMetadata); 100 } 101 102 // This should never happen, but just in case 103 return err(new Error('Unexpected error in metadata selection')); 104 } 105 106 async isAvailable(): Promise<boolean> { 107 // Service is available if at least one of the underlying services is available 108 const [iframelyAvailable, citoidAvailable] = await Promise.all([ 109 this.iframelyService.isAvailable(), 110 this.citoidService.isAvailable(), 111 ]); 112 113 return iframelyAvailable || citoidAvailable; 114 } 115 116 private selectBestMetadata( 117 iframelyMetadata: UrlMetadata, 118 citoidMetadata: UrlMetadata, 119 ): UrlMetadata { 120 const iframelyType = iframelyMetadata.type || UrlType.LINK; 121 const citoidType = citoidMetadata.type || UrlType.LINK; 122 123 // If one returns 'link' (generic) and the other returns something more specific 124 if (iframelyType === UrlType.LINK && citoidType !== UrlType.LINK) { 125 return citoidMetadata; 126 } 127 128 if (citoidType === UrlType.LINK && iframelyType !== UrlType.LINK) { 129 return iframelyMetadata; 130 } 131 132 // If both return different types, prefer Citoid (for scholarly content) 133 if (citoidType !== iframelyType) { 134 return citoidMetadata; 135 } 136 137 // Default to Iframely 138 return iframelyMetadata; 139 } 140 141 /** 142 * Update the default service preference 143 */ 144 public setDefaultService(defaultService: DefaultServicePreference): void { 145 this.config.defaultService = defaultService; 146 } 147 148 /** 149 * Get the current default service preference 150 */ 151 public getDefaultService(): DefaultServicePreference { 152 return this.config.defaultService; 153 } 154 155 /** 156 * Get metadata from a specific service for debugging/testing purposes 157 */ 158 public async fetchFromIframely( 159 url: URL, 160 refetchStaleMetadata: boolean = false, 161 ): Promise<Result<UrlMetadata>> { 162 return this.iframelyService.fetchMetadata(url, refetchStaleMetadata); 163 } 164 165 /** 166 * Get metadata from a specific service for debugging/testing purposes 167 */ 168 public async fetchFromCitoid( 169 url: URL, 170 refetchStaleMetadata: boolean = false, 171 ): Promise<Result<UrlMetadata>> { 172 return this.citoidService.fetchMetadata(url, refetchStaleMetadata); 173 } 174 175 /** 176 * Apply URL classification based on hardcoded regex patterns 177 * This overrides the type from metadata services if a pattern matches 178 */ 179 private applyUrlClassification(metadata: UrlMetadata, url: URL): UrlMetadata { 180 const classifiedType = UrlClassifier.classifyUrl(url.value); 181 182 if (classifiedType) { 183 // Override the type with the classified type 184 const updatedProps = { 185 url: metadata.url, 186 title: metadata.title, 187 description: metadata.description, 188 author: metadata.author, 189 publishedDate: metadata.publishedDate, 190 siteName: metadata.siteName, 191 imageUrl: metadata.imageUrl, 192 type: classifiedType, // Override with classified type 193 retrievedAt: metadata.retrievedAt, 194 doi: metadata.doi, 195 isbn: metadata.isbn, 196 }; 197 198 // Create new UrlMetadata with updated type 199 return UrlMetadata.create(updatedProps).unwrap(); 200 } 201 202 // No classification found, return original metadata 203 return metadata; 204 } 205 206 /** 207 * Merge metadata by taking missing fields from the fallback metadata 208 */ 209 private mergeMetadata( 210 primary: UrlMetadata, 211 fallback: UrlMetadata, 212 ): UrlMetadata { 213 // Create merged props by taking primary values first, then fallback for missing fields 214 const mergedProps = { 215 url: primary.url, // URL should always be the same 216 title: primary.title || fallback.title, 217 description: primary.description || fallback.description, 218 author: primary.author || fallback.author, 219 publishedDate: primary.publishedDate || fallback.publishedDate, 220 siteName: primary.siteName || fallback.siteName, 221 imageUrl: primary.imageUrl || fallback.imageUrl, 222 type: primary.type || fallback.type, 223 retrievedAt: primary.retrievedAt || fallback.retrievedAt, 224 doi: primary.doi || fallback.doi, 225 isbn: primary.isbn || fallback.isbn, 226 }; 227 228 // Create new UrlMetadata with merged props 229 // We know this will succeed since both primary and fallback are valid 230 return UrlMetadata.create(mergedProps).unwrap(); 231 } 232}