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 / CitoidMetadataService.ts
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}