This repository has no description
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}