This repository has no description
12 kB
442 lines
1import { IMetadataService } from '../domain/services/IMetadataService';
2import { UrlMetadata } from '../domain/value-objects/UrlMetadata';
3import { URL } from '../domain/value-objects/URL';
4import { Result, ok, err } from '../../../shared/core/Result';
5import {
6 mapCitoidUrlType,
7 CitoidUrlTypes,
8} from './mappers/CitoidUrlTypeMapper';
9
10interface CitoidCreator {
11 firstName?: string;
12 lastName?: string;
13 creatorType?: string;
14 name?: string; // For single-field names (organizations)
15}
16
17interface CitoidTag {
18 tag: string;
19 type?: number;
20}
21
22interface CitoidResponse {
23 key?: string;
24 version?: number;
25 itemType?: string;
26 creators?: CitoidCreator[];
27 tags?: CitoidTag[];
28 title?: string;
29 date?: string;
30 url?: string;
31 abstractNote?: string;
32 publicationTitle?: string;
33 language?: string;
34 libraryCatalog?: string;
35 accessDate?: string;
36 repository?: string;
37 archiveID?: string;
38 DOI?: string;
39 extra?: string;
40 websiteTitle?: string;
41 blogTitle?: string;
42 forumTitle?: string;
43 websiteType?: string;
44 medium?: string;
45 artworkSize?: string;
46 runningTime?: string;
47 ISBN?: string;
48 ISSN?: string;
49 volume?: string;
50 issue?: string;
51 pages?: string;
52 edition?: string;
53 series?: string;
54 seriesNumber?: string;
55 place?: string;
56 publisher?: string;
57 institution?: string;
58 university?: string;
59 conferenceName?: string;
60 proceedingsTitle?: string;
61 meetingName?: string;
62 reportType?: string;
63 reportNumber?: string;
64 type?: string;
65 // Additional fields from the schema
66 numPages?: string;
67 numberOfVolumes?: string;
68 archive?: string;
69 archiveLocation?: string;
70 callNumber?: string;
71 rights?: string;
72 shortTitle?: string;
73 artworkMedium?: string;
74 audioRecordingFormat?: string;
75 seriesTitle?: string;
76 label?: string;
77 videoRecordingFormat?: string;
78 genre?: string;
79 distributor?: string;
80 studio?: string;
81 network?: string;
82 episodeNumber?: string;
83 programTitle?: string;
84 billNumber?: string;
85 code?: string;
86 codeVolume?: string;
87 section?: string;
88 codePages?: string;
89 legislativeBody?: string;
90 session?: string;
91 history?: string;
92 bookTitle?: string;
93 caseName?: string;
94 court?: string;
95 dateDecided?: string;
96 docketNumber?: string;
97 reporter?: string;
98 reporterVolume?: string;
99 firstPage?: string;
100 dictionaryTitle?: string;
101 encyclopediaTitle?: string;
102 subject?: string;
103 letterType?: string;
104 interviewMedium?: string;
105 journalAbbreviation?: string;
106 seriesText?: string;
107 manuscriptType?: string;
108 mapType?: string;
109 scale?: string;
110 postType?: string;
111 committee?: string;
112 documentNumber?: string;
113 patentNumber?: string;
114 filingDate?: string;
115 applicationNumber?: string;
116 priorityNumbers?: string;
117 issueDate?: string;
118 references?: string;
119 legalStatus?: string;
120 assignee?: string;
121 issuingAuthority?: string;
122 country?: string;
123 presentationType?: string;
124 thesisType?: string;
125 identifier?: string;
126 versionNumber?: string;
127 repositoryLocation?: string;
128 format?: string;
129 citationKey?: string;
130 audioFileType?: string;
131 programmingLanguage?: string;
132 system?: string;
133 company?: string;
134 status?: string;
135 nameOfAct?: string;
136 publicLawNumber?: string;
137 codeNumber?: string;
138 dateEnacted?: string;
139 organization?: string;
140 number?: string;
141}
142
143interface CitoidErrorResponse {
144 Error: string;
145}
146
147export class CitoidMetadataService implements IMetadataService {
148 private readonly baseUrl =
149 'https://en.wikipedia.org/api/rest_v1/data/citation/zotero/';
150 private readonly headers = {
151 accept: 'application/json; charset=utf-8;',
152 };
153
154 async fetchMetadata(url: URL): Promise<Result<UrlMetadata>> {
155 try {
156 // URL-encode the target URL
157 const encodedUrl = encodeURIComponent(url.value);
158 const fullUrl = this.baseUrl + encodedUrl;
159
160 const response = await fetch(fullUrl, {
161 method: 'GET',
162 headers: this.headers,
163 });
164
165 if (!response.ok) {
166 return err(
167 new Error(
168 `Citoid API request failed with status ${response.status} and message: ${response.statusText}`,
169 ),
170 );
171 }
172
173 const data = (await response.json()) as any;
174
175 // Check if the response is an error
176 if (data && typeof data === 'object' && 'Error' in data) {
177 const errorResponse = data as CitoidErrorResponse;
178 return err(new Error(`Citoid service error: ${errorResponse.Error}`));
179 }
180
181 // Check if it's an array response
182 if (!Array.isArray(data) || data.length === 0) {
183 return err(new Error('No metadata found for the given URL'));
184 }
185
186 // Use the first result
187 const citoidData = data[0] as CitoidResponse;
188 if (!citoidData || !citoidData.itemType) {
189 return err(new Error('Invalid metadata format from Citoid'));
190 }
191
192 // Extract author from creators array - prioritize 'author' type creators
193 const author = this.extractPrimaryAuthor(citoidData.creators);
194
195 // Determine site name from various possible fields
196 const siteName = this.determineSiteName(citoidData);
197
198 // Parse published date - try specific date fields first based on item type
199 const publishedDate = this.extractPublishedDate(citoidData);
200
201 const metadataResult = UrlMetadata.create({
202 url: url.value,
203 title: citoidData.title,
204 description: citoidData.abstractNote,
205 author,
206 publishedDate,
207 siteName,
208 type: mapCitoidUrlType(citoidData.itemType),
209 doi: citoidData.DOI,
210 isbn: citoidData.ISBN,
211 });
212
213 return metadataResult;
214 } catch (error) {
215 return err(
216 new Error(
217 `Failed to fetch metadata from Citoid: ${error instanceof Error ? error.message : 'Unknown error'}`,
218 ),
219 );
220 }
221 }
222
223 async isAvailable(): Promise<boolean> {
224 try {
225 // Test with a simple URL to check if the service is available
226 const testUrl = this.baseUrl + encodeURIComponent('https://example.com');
227 const response = await fetch(testUrl, {
228 method: 'HEAD',
229 headers: this.headers,
230 });
231
232 // Service is available if we get any response (even 4xx errors are fine)
233 return response.status < 500;
234 } catch {
235 return false;
236 }
237 }
238
239 private extractPrimaryAuthor(creators?: CitoidCreator[]): string | undefined {
240 if (!creators || creators.length === 0) {
241 return undefined;
242 }
243
244 // First, try to find a creator with type 'author'
245 const authorCreator = creators.find(
246 (creator) => creator.creatorType === 'author',
247 );
248
249 if (authorCreator) {
250 return this.formatAuthor(authorCreator);
251 }
252
253 // If no 'author' type, try other primary creator types in order of preference
254 // Based on the Zotero schema, these are the most common primary creator types
255 const primaryTypes = [
256 'artist', // artwork
257 'performer', // audioRecording
258 'director', // film, tvBroadcast, radioBroadcast, videoRecording
259 'podcaster', // podcast
260 'cartographer', // map
261 'programmer', // computerProgram
262 'presenter', // presentation
263 'sponsor', // bill
264 'inventor', // patent
265 'interviewee', // interview
266 'bookAuthor', // bookSection
267 'editor', // various types
268 'contributor', // fallback for many types
269 ];
270
271 for (const type of primaryTypes) {
272 const creator = creators.find((c) => c.creatorType === type);
273 if (creator) {
274 return this.formatAuthor(creator);
275 }
276 }
277
278 // Fall back to the first creator
279 return this.formatAuthor(creators[0]!);
280 }
281
282 private formatAuthor(creator: CitoidCreator): string {
283 const { firstName, lastName, name } = creator;
284
285 // Handle single-field names (typically organizations)
286 if (name) {
287 return name;
288 }
289
290 // Handle two-field names
291 if (firstName && lastName) {
292 return `${firstName} ${lastName}`;
293 } else if (lastName) {
294 return lastName;
295 } else if (firstName) {
296 return firstName;
297 }
298
299 return '';
300 }
301
302 private determineSiteName(data: CitoidResponse): string | undefined {
303 // Determine site name based on item type for better accuracy
304 const itemType = data.itemType;
305
306 // Type-specific site name logic based on Zotero schema
307 switch (itemType) {
308 case CitoidUrlTypes.JOURNAL_ARTICLE:
309 case CitoidUrlTypes.MAGAZINE_ARTICLE:
310 case CitoidUrlTypes.NEWSPAPER_ARTICLE:
311 return data.publicationTitle || data.publisher;
312
313 case CitoidUrlTypes.BLOG_POST:
314 return data.blogTitle || data.websiteTitle;
315
316 case CitoidUrlTypes.FORUM_POST:
317 return data.forumTitle;
318
319 case CitoidUrlTypes.WEBPAGE:
320 return data.websiteTitle;
321
322 case CitoidUrlTypes.BOOK:
323 case CitoidUrlTypes.BOOK_SECTION:
324 return data.publisher || data.series;
325
326 case CitoidUrlTypes.CONFERENCE_PAPER:
327 return data.proceedingsTitle || data.conferenceName || data.publisher;
328
329 case CitoidUrlTypes.THESIS:
330 return data.university || data.institution;
331
332 case CitoidUrlTypes.REPORT:
333 return data.institution || data.publisher;
334
335 case CitoidUrlTypes.DATASET:
336 case CitoidUrlTypes.PREPRINT:
337 return data.repository || data.institution;
338
339 case CitoidUrlTypes.PODCAST:
340 return data.seriesTitle || data.network;
341
342 case CitoidUrlTypes.TV_BROADCAST:
343 case CitoidUrlTypes.RADIO_BROADCAST:
344 return data.network || data.programTitle;
345
346 case CitoidUrlTypes.FILM:
347 case CitoidUrlTypes.VIDEO_RECORDING:
348 return data.distributor || data.studio;
349
350 case CitoidUrlTypes.AUDIO_RECORDING:
351 return data.label || data.publisher;
352
353 default:
354 // Fallback to general logic for other types
355 return (
356 data.publicationTitle ||
357 data.websiteTitle ||
358 data.blogTitle ||
359 data.forumTitle ||
360 data.repository ||
361 data.libraryCatalog ||
362 data.institution ||
363 data.university ||
364 data.publisher
365 );
366 }
367 }
368
369 private extractPublishedDate(data: CitoidResponse): Date | undefined {
370 // Try type-specific date fields first, then fall back to general 'date' field
371 const itemType = data.itemType;
372
373 let dateString: string | undefined;
374
375 switch (itemType) {
376 case CitoidUrlTypes.CASE:
377 dateString = data.dateDecided || data.date;
378 break;
379 case CitoidUrlTypes.STATUTE:
380 dateString = data.dateEnacted || data.date;
381 break;
382 case CitoidUrlTypes.PATENT:
383 dateString = data.issueDate || data.filingDate || data.date;
384 break;
385 default:
386 dateString = data.date;
387 break;
388 }
389
390 return dateString ? this.parseDate(dateString) : undefined;
391 }
392
393 private parseDate(dateString: string): Date | undefined {
394 try {
395 // Handle various date formats common in Citoid responses
396
397 // ISO date format (YYYY-MM-DD)
398 if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
399 const parsed = new Date(dateString + 'T00:00:00.000Z');
400 if (!isNaN(parsed.getTime())) {
401 return parsed;
402 }
403 }
404
405 // ISO datetime format (YYYY-MM-DDTHH:mm:ss.sssZ)
406 if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(dateString)) {
407 const parsed = new Date(dateString);
408 if (!isNaN(parsed.getTime())) {
409 return parsed;
410 }
411 }
412
413 // Year only (YYYY)
414 if (/^\d{4}$/.test(dateString)) {
415 const parsed = new Date(`${dateString}-01-01T00:00:00.000Z`);
416 if (!isNaN(parsed.getTime())) {
417 return parsed;
418 }
419 }
420
421 // Month and year (YYYY-MM)
422 if (/^\d{4}-\d{2}$/.test(dateString)) {
423 const parsed = new Date(`${dateString}-01T00:00:00.000Z`);
424 if (!isNaN(parsed.getTime())) {
425 return parsed;
426 }
427 }
428
429 // Try to parse the date string as-is for other formats
430 const parsed = new Date(dateString);
431
432 // Check if the date is valid
433 if (isNaN(parsed.getTime())) {
434 return undefined;
435 }
436
437 return parsed;
438 } catch {
439 return undefined;
440 }
441 }
442}