PageSourceSearch

https://www.hetphoenixproject.nl/kunst/api-louvre.js

js hetphoenixproject.nl collected 2026-10-02 16:07:14 UTC 17,161 bytes, 459 lines download raw bytes

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.