1// Louvre Museum API Module 2// JSON API for 500,000+ artworks from the Musée du Louvre, Paris 3// Documentation: https://collections.louvre.fr/en/page/documentationJSON 4 5const LouvreAPI = { 6 config: { 7 name: 'Musée du Louvre', 8 baseUrl: 'https://collections.louvre.fr', 9 searchUrl: 'https://collections.louvre.fr/en/recherche', 10 proxyEndpoints: [ 11 'louvre-proxy.php', 12 'https://hetphoenixproject.nl/kunst/louvre-proxy.php' 13 ], // We need a proxy for HTML parsing 14 imageProxyEndpoints: [ 15 'louvre-image-proxy.php', 16 'https://hetphoenixproject.nl/kunst/louvre-image-proxy.php' 17 ], // Proxy for images (CORS) 18 type: 'json', 19 enabled: true 20 }, 21 22 cache: new Map(), 23 workingProxyEndpoint: null, 24 workingImageProxyEndpoint: 'https://hetphoenixproject.nl/kunst/louvre-image-proxy.php', 25 26 // Domain that requires image proxy for canvas/Cytoscape rendering 27 corsBlockedImageDomain: 'collections.louvre.fr', 28 29 /** 30 * Check if an image URL needs to be proxied for CORS 31 */ 32 needsImageProxy(url) { 33 if (!url) return false; 34 return url.includes(this.corsBlockedImageDomain); 35 }, 36 37 /** 38 * Get proxied image URL for CORS-blocked domains 39 */ 40 getProxiedImageUrl(url) { 41 if (!url || !this.needsImageProxy(url)) return url; 42 return `${this.getImageProxyEndpoint()}?url=${encodeURIComponent(url)}`; 43 }, 44 45 getProxyCandidates() { 46 const endpoints = this.config.proxyEndpoints || []; 47 if (!this.workingProxyEndpoint) { 48 return [...endpoints]; 49 } 50 return [this.workingProxyEndpoint, ...endpoints.filter(endpoint => endpoint !== this.workingProxyEndpoint)]; 51 }, 52 53 getImageProxyEndpoint() { 54 return this.workingImageProxyEndpoint || this.config.imageProxyEndpoints?.[0] || 'louvre-image-proxy.php'; 55 }, 56 57 // Object type mapping (typology IDs from Louvre) 58 objectTypes: { 59 '': 'Alle objecten', 60 '9': 'Architectuur & decoratie', 61 '10': 'Wapens & wapenrustingen', 62 '11': 'Boekkunst', 63 '12': 'Sieraden', 64 '13': 'Tekeningen & prenten', 65 '14': 'Schrift & inscripties', 66 '15': 'Meubels', 67 '16': 'Munten & medailles', 68 '17': 'Grafmonumenten', 69 '18': 'Religieuze objecten', 70 '19': 'Huishoudelijke voorwerpen', 71 '20': 'Persoonlijke voorwerpen', 72 '21': 'Gereedschap & instrumenten', 73 '22': 'Schilderijen', 74 '23': 'Zegels & glyptiek', 75 '24': 'Sculpturen', 76 '25': 'Stèles', 77 '26': 'Textiel & kleding', 78 '27': 'Vaatwerk' 79 }, 80 81 /** 82 * Search the Louvre collection 83 * Since Louvre doesn't have a direct search API returning JSON, 84 * we need to: 1) fetch HTML search results, 2) extract ARK IDs, 3) fetch JSON per item 85 */ 86 async search(searchTerm = '', imgOnly = true, objectType = '', page = 1) { 87 if (!searchTerm) { 88 searchTerm = '*'; // Browse all 89 } 90 91 const cacheKey = `louvre_${searchTerm.toLowerCase()}_type${objectType}_img${imgOnly}_page${page}`; 92 if (this.cache.has(cacheKey)) { 93 return this.cache.get(cacheKey); 94 } 95 96 try { 97 const endpointCandidates = this.getProxyCandidates(); 98 let data = null; 99 100 for (const endpoint of endpointCandidates) { 101 let searchUrl = `${endpoint}?action=search&q=${encodeURIComponent(searchTerm)}`; 102 if (objectType) { 103 searchUrl += `&typology=${objectType}`; 104 } 105 if (page > 1) { 106 searchUrl += `&page=${page}`; 107 } 108 109 try { 110 const response = await fetch(searchUrl); 111 if (!response.ok) { 112 continue; 113 } 114 115 data = await response.json(); 116 this.workingProxyEndpoint = endpoint; 117 this.workingImageProxyEndpoint = endpoint.includes('hetphoenixproject.
117nl') 118 ? (this.config.imageProxyEndpoints.find(url => url.includes('hetphoenixproject.nl')) || this.workingImageProxyEndpoint) 119 : (this.config.imageProxyEndpoints.find(url => !url.startsWith('http')) || this.workingImageProxyEndpoint); 120 break; 121 } catch (proxyError) { 122 continue; 123 } 124 } 125 126 if (!data) { 127 throw new Error('Louvre search failed on all proxy endpoints'); 128 } 129 130 if (!data.success || !data.items) { 131 return { items: [], total: 0, hasMore: false }; 132 } 133 134 // Convert to our standard format 135 const artworks = data.items.map(item => this.normalizeArtwork(item)) 136 .filter(artwork => !imgOnly || artwork.webImage); 137 138 const result = { 139 items: artworks, 140 total: data.total || artworks.length, 141 page: data.page || page, 142 hasMore: data.hasMore || false, 143 nextPage: data.nextPage || page + 1 144 }; 145 146 this.cache.set(cacheKey, result); 147 return result; 148 149 } catch (error) { 150 console.error('Louvre search error:', error); 151 return { items: [], total: 0, hasMore: false }; 152 } 153 }, 154 155 /** 156 * Load more results for the current search 157 */ 158 async loadMore(searchTerm = '', imgOnly = true, objectType = '', page = 1) { 159 return this.search(searchTerm, imgOnly, objectType, page); 160 }, 161 162 /** 163 * Get a single artwork by ARK ID 164 */ 165 async getArtwork(arkId) { 166 if (!arkId) return null; 167 168 const cacheKey = `louvre_item_${arkId}`; 169 if (this.cache.has(cacheKey)) { 170 return this.cache.get(cacheKey); 171 } 172 173 try { 174 // Fetch JSON directly from Louvre (no CORS issues for JSON) 175 const url = `${this.config.baseUrl}/ark:/53355/${arkId}.json`; 176 const response = await fetch(url); 177 178 if (!response.ok) { 179 throw new Error(`Failed to fetch artwork: ${response.status}`); 180 } 181 182 const data = await response.json(); 183 const artwork = this.normalizeArtwork(data); 184 185 this.cache.set(cacheKey, artwork); 186 return artwork; 187 188 } catch (error) { 189 console.error('Louvre getArtwork error:', error); 190 return null; 191 } 192 }, 193 194 /** 195 * Normalize Louvre JSON to our standard artwork format 196 */ 197 normalizeArtwork(item) { 198 // Handle both raw JSON from Louvre and pre-processed proxy data 199 const arkId = item.arkId || item.id || ''; 200 201 // Get all images 202 let imageUrl = null; 203 let thumbnailUrl = null; 204 let allImages = []; 205 206 if (item.image && Array.isArray(item.image) && item.image.length > 0) { 207 imageUrl = item.image[0].urlImage || null; 208 thumbnailUrl = item.image[0].urlThumbnail || null; 209 allImages = item.image.map(img => ({ 210 url: img.urlImage, 211 thumbnail: img.urlThumbnail, 212 copyright: img.copyright || '', 213 type: img.type || '' 214 })); 215 } else if (item.imageUrl) { 216 imageUrl = item.imageUrl; 217 thumbnailUrl = item.thumbnailUrl || item.imageUrl; 218 } 219 220 // Get creator info with full details 221 let creator = 'Onbekende kunstenaar'; 222 let creatorDates = ''; 223 let creatorInfo = null; 224 225 if (item.creator && Array.isArray(item.creator) && item.creator.length > 0) { 226 const primaryCreator = item.creator[0];
227 creator = primaryCreator.label || creator; 228 229 // Extract additional creator info 230 creatorInfo = { 231 name: primaryCreator.label, 232 attribution: primaryCreator.attributionLevel || '', 233 linkType: primaryCreator.linkType || '', 234 wikidata: primaryCreator.wikidata || '' 235 }; 236 237 if (primaryCreator.dates && Array.isArray(primaryCreator.dates)) { 238 const birthDate = primaryCreator.dates.find(d => d.type === 'date de naissance'); 239 const deathDate = primaryCreator.dates.find(d => d.type === 'date de mort'); 240 if (birthDate || deathDate) { 241 creatorDates = `(${birthDate?.date || '?'} - ${deathDate?.date || '?'})`; 242 creatorInfo.birthDate = birthDate?.date || ''; 243 creatorInfo.birthPlace = birthDate?.place || ''; 244 creatorInfo.deathDate = deathDate?.date || ''; 245 creatorInfo.deathPlace = deathDate?.place || ''; 246 } 247 } 248 } else if (item.creator && typeof item.creator === 'string') { 249 creator = item.creator; 250 } 251 252 // Get all titles 253 let title = item.title || 'Zonder titel'; 254 let allTitles = []; 255 if (item.denominationTitle && Array.isArray(item.denominationTitle)) { 256 allTitles = item.denominationTitle.map(t => ({ 257 type: t.type, 258 value: t.value 259 })); 260 // Prefer "Titre d'usage" (common title) if available 261 const usualTitle = item.denominationTitle.find(t => t.type === "Titre d'usage"); 262 if (usualTitle) { 263 title = usualTitle.value; 264 } 265 } 266 267 // Get date with full info 268 let dateCreated = ''; 269 let dateInfo = null; 270 if (item.dateCreated && Array.isArray(item.dateCreated) && item.dateCreated.length > 0) { 271 dateInfo = item.dateCreated[0]; 272 if (dateInfo.startYear && dateInfo.endYear) { 273 if (dateInfo.startYear === dateInfo.endYear) { 274 dateCreated = String(dateInfo.startYear); 275 } else { 276 dateCreated = `${dateInfo.startYear} - ${dateInfo.endYear}`; 277 } 278 } else if (dateInfo.text) { 279 dateCreated = dateInfo.text; 280 } 281 } else if (item.displayDateCreated) { 282 dateCreated = item.displayDateCreated; 283 } 284 285 // Get all dimensions 286 let dimensions = ''; 287 let dimensionDetails = []; 288 if (item.dimension && Array.isArray(item.dimension)) { 289 dimensionDetails = item.dimension.map(d => ({ 290 type: d.type, 291 value: d.value, 292 unit: d.unit, 293 display: d.displayDimension || `${d.value} ${d.unit}` 294 })); 295 // Format main dimensions for display 296 const mainDims = item.dimension 297 .filter(d => ['Hauteur', 'Largeur', 'Profondeur', 'Diamètre'].includes(d.type)) 298 .map(d => `${this.translateDimensionType(d.type)}: ${d.displayDimension || d.value + ' ' + d.unit}`) 299 .join(', '); 300 dimensions = mainDims; 301 } 302 303 // Get collection/department 304 const collection = item.collection || item.heldBy || ''; 305 const department = item.collection || ''; 306 const ownedBy = item.ownedBy || ''; 307 const longTermLoanTo = item.longTermLoanTo || ''; 308 309 // Get description and history 310 let description = item.description || ''; 311 const objectHistory = item.objectHistory || ''; 312 if (!description && objectHistory) { 313 // Use first paragraph of object history as description 314 description = objectHistory.split('\r\n')[0]; 315 } 316 317 // Get inscriptions 318 const inscriptions = item.inscriptions || ''; 319 320 // Get provenance info 321 const provenance = item.provenance || ''; 322 323 // Get acquisition details 324 let acquisitionInfo = null;
325 if (item.acquisitionDetails && Array.isArray(item.acquisitionDetails) && item.acquisitionDetails.length > 0) { 326 const acq = item.acquisitionDetails[0]; 327 acquisitionInfo = { 328 mode: acq.mode || '', 329 dates: acq.dates || [] 330 }; 331 } 332 333 // Get exhibitions 334 let exhibitions = []; 335 if (item.exhibition && Array.isArray(item.exhibition)) { 336 exhibitions = item.exhibition.map(e => e.value || e); 337 } 338 339 // Get bibliography 340 let bibliography = []; 341 if (item.bibliography && Array.isArray(item.bibliography)) { 342 bibliography = item.bibliography.slice(0, 5).map(b => ({ 343 ref: b.bibliographyRef || '', 344 date: b.bibliographyDate || '', 345 detail: b.bibliographyDetail || '' 346 })); 347 } 348 349 // Get related works 350 let relatedWorks = []; 351 if (item.relatedWork && Array.isArray(item.relatedWork)) { 352 relatedWorks = item.relatedWork.map(r => ({ 353 url: r.objectUrl || '', 354 type: r.type || '' 355 })); 356 } 357 358 // Get place of creation 359 const placeOfCreation = item.placeOfCreation || ''; 360 361 // Get current location 362 const currentLocation = item.currentLocation || item.room || ''; 363 const room = item.room || ''; 364 365 // Get MNR info (if applicable) 366 const isMNR = item.isMuseesNationauxRecuperation || false; 367 368 return { 369 id: arkId, 370 title: title, 371 allTitles: allTitles, 372 creator: creator, 373 creatorDates: creatorDates, 374 creatorInfo: creatorInfo, 375 principalOrFirstMaker: creator, 376 creationDate: dateCreated, 377 dateInfo: dateInfo, 378 displayDateCreated: item.displayDateCreated || '', 379 description: description, 380 objectHistory: objectHistory, 381 inscriptions: inscriptions, 382 imageUrl: this.getProxiedImageUrl(imageUrl), 383 webImage: imageUrl ? { url: this.getProxiedImageUrl(imageUrl) } : null, 384 image: thumbnailUrl ? { url: this.getProxiedImageUrl(thumbnailUrl) } : null, 385 allImages: allImages.map(img => ({ 386 ...img, 387 url: this.getProxiedImageUrl(img.url), 388 thumbnail: this.getProxiedImageUrl(img.thumbnail) 389 })), 390 objectNumber: item.objectNumber?.[0]?.value || arkId, 391 objectNumberType: item.objectNumber?.[0]?.type || '', 392 type: item.objectType || '', 393 technique: item.materialsAndTechniques || '', 394 material: item.materialsAndTechniques || '', 395 dimensions: dimensions, 396 dimensionDetails: dimensionDetails, 397 currentLocation: currentLocation, 398 room: room, 399 placeOfCreation: placeOfCreation, 400 externalUrl: item.url || `${this.config.baseUrl}/ark:/53355/${arkId}`, 401 collection: collection, 402 department: department, 403 ownedBy: ownedBy, 404 longTermLoanTo: longTermLoanTo, 405 provenance: provenance, 406 acquisitionInfo: acquisitionInfo, 407 exhibitions: exhibitions, 408 bibliography: bibliography, 409 relatedWorks: relatedWorks, 410 isMNR: isMNR, 411 source: 'louvre', 412 sourceLabel: 'Musée du Louvre', 413 originalData: item 414 }; 415 }, 416 417 /** 418 * Translate French dimension types to Dutch 419 */ 420 translateDimensionType(type) { 421 const translations = { 422 'Hauteur': 'Hoogte', 423 'Largeur': 'Breedte', 424 'Profondeur': 'Diepte', 425 'Diamètre': 'Diameter', 426 'Hauteur avec accessoire': 'Hoogte met lijst', 427 'Largeur avec accessoire': 'Breedte met lijst', 428 'Poids': 'Gewicht' 429 }; 430 return translations[type] || type; 431 }, 432 433 /** 434 * Search by creator name 435 */ 436 async searchByCreator(creatorName, imgOnly = true) { 437 if (!creatorName) { 438 return { items: [], total: 0 }; 439 } 440 441 // Use the general search with creator name 442 return this.search(creatorName, imgOnly, ''); 443 }, 444 445 /** 446 * Get object types for filter dropdown 447 */ 448 getObjectTypes() { 449 return Object.entries(this.objectTypes).map(([value, label]) => ({ 450 value, 451 label 452 })); 453 } 454}; 455 456// Export for use in main script 457if (typeof window !== 'undefined') { 458 window.LouvreAPI = LouvreAPI; 459}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.