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