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