mirror of
https://github.com/mue/mue.git
synced 2026-07-22 08:17:28 +02:00
feat: integrate Wikidata API for fetching author information, including images and occupations
This commit is contained in:
@@ -20,17 +20,22 @@ export default function AuthorInfo({
|
||||
const t = useT();
|
||||
const showAuthorImg = localStorage.getItem('authorImg') !== 'false';
|
||||
const hasLink = authorOccupation !== 'Unknown' && authorlink !== null;
|
||||
const trimmedLicense = authorimglicense?.substring(0, 40) +
|
||||
(authorimglicense?.length > 40 ? '…' : '');
|
||||
|
||||
return (
|
||||
<div className="author-holder">
|
||||
<div className="author">
|
||||
{showAuthorImg && (
|
||||
<div className="author-img" style={{ backgroundImage: `url(${authorimg})` }}>
|
||||
{!authorimg && authorimg !== undefined && <MdPerson />}
|
||||
</div>
|
||||
)}
|
||||
{showAuthorImg &&
|
||||
(authorimglicense && authorimg ? (
|
||||
<Tooltip title={authorimglicense}>
|
||||
<div className="author-img" style={{ backgroundImage: `url(${authorimg})` }}>
|
||||
{!authorimg && authorimg !== undefined && <MdPerson />}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="author-img" style={{ backgroundImage: `url(${authorimg})` }}>
|
||||
{!authorimg && authorimg !== undefined && <MdPerson />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{author ? (
|
||||
<div className="author-content">
|
||||
@@ -38,11 +43,6 @@ export default function AuthorInfo({
|
||||
{authorOccupation && authorOccupation !== 'Unknown' && (
|
||||
<span className="subtitle">{authorOccupation}</span>
|
||||
)}
|
||||
{authorimglicense && (
|
||||
<span className="author-license" title={authorimglicense}>
|
||||
{trimmedLicense}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="author-content whileLoading">
|
||||
|
||||
@@ -2,21 +2,13 @@ import { useCallback } from 'react';
|
||||
import variables from 'config/variables';
|
||||
import offline_quotes from '../offline_quotes.json';
|
||||
import { shouldUpdateByFrequency, resetStartTime } from 'utils/frequencyManager';
|
||||
import { safeParseJSON } from 'utils/jsonStorage';
|
||||
import { useCachedFetch } from 'hooks/useCachedFetch';
|
||||
import { QueueManager } from 'utils/backgroundQueue';
|
||||
import { fetchAuthorFromWikidata } from 'utils/wikidata/wikidataAuthorFetcher';
|
||||
|
||||
/**
|
||||
* Custom hook for loading quote data from various sources
|
||||
*/
|
||||
export function useQuoteLoader(updateQuote) {
|
||||
// Initialize cache hook for author images
|
||||
const { fetchWithCache: fetchAuthorImage } = useCachedFetch({
|
||||
cacheKey: 'authorImageCache',
|
||||
timestampKey: 'authorImageCacheTimestamp',
|
||||
expiryDays: 7,
|
||||
});
|
||||
|
||||
const getAuthorLink = useCallback((author) => {
|
||||
return localStorage.getItem('authorLink') === 'false' || author === 'Unknown'
|
||||
? null
|
||||
@@ -25,78 +17,75 @@ export function useQuoteLoader(updateQuote) {
|
||||
.join('_')}`;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch author data from Wikidata (image, occupation, description, etc.)
|
||||
* @param {string} author - Author name
|
||||
* @returns {Promise<object>} Author data including image, occupation, and license info
|
||||
*/
|
||||
const getAuthorDataFromWikidata = useCallback(
|
||||
async (author) => {
|
||||
if (localStorage.getItem('authorImg') === 'false' || author === 'Unknown') {
|
||||
return {
|
||||
authorimg: null,
|
||||
authorimglicense: null,
|
||||
authorOccupation: null,
|
||||
authorlink: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const lang = variables.languagecode.split('_')[0];
|
||||
const wikidataResult = await fetchAuthorFromWikidata(author, lang);
|
||||
|
||||
if (!wikidataResult) {
|
||||
return {
|
||||
authorimg: null,
|
||||
authorimglicense: null,
|
||||
authorOccupation: null,
|
||||
authorlink: getAuthorLink(author), // Fallback to Wikipedia link
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authorimg: wikidataResult.imageUrl || null,
|
||||
authorimglicense: wikidataResult.imageLicense || null,
|
||||
authorOccupation: wikidataResult.occupation || null,
|
||||
authorlink: wikidataResult.wikipediaLink || getAuthorLink(author),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Error fetching author data from Wikidata:', e);
|
||||
return {
|
||||
authorimg: null,
|
||||
authorimglicense: null,
|
||||
authorOccupation: null,
|
||||
authorlink: getAuthorLink(author),
|
||||
};
|
||||
}
|
||||
},
|
||||
[getAuthorLink],
|
||||
);
|
||||
|
||||
const stripHTML = useCallback((html) => {
|
||||
const tmpdoc = new DOMParser().parseFromString(html, 'text/html');
|
||||
return tmpdoc.body.textContent || '';
|
||||
}, []);
|
||||
|
||||
const getAuthorImg = useCallback(
|
||||
// Get cached author data or fetch from Wikidata and cache it
|
||||
const getCachedAuthorData = useCallback(
|
||||
async (author) => {
|
||||
if (localStorage.getItem('authorImg') === 'false' || author === 'Unknown') {
|
||||
return { authorimg: null, authorimglicense: null };
|
||||
return {
|
||||
authorimg: null,
|
||||
authorimglicense: null,
|
||||
authorOccupation: null,
|
||||
authorlink: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const lang = variables.languagecode.split('_')[0];
|
||||
const pageData = await fetch(
|
||||
`https://${lang}.wikipedia.org/w/api.php?action=query&titles=${author}&origin=*&prop=pageimages&format=json&pithumbsize=100`,
|
||||
).then((res) => res.json());
|
||||
|
||||
const authorPage = Object.values(pageData.query.pages)[0];
|
||||
const authorimg = authorPage?.thumbnail?.source;
|
||||
|
||||
if (!authorimg) {
|
||||
return { authorimg: null, authorimglicense: null };
|
||||
}
|
||||
|
||||
const licenseData = await fetch(
|
||||
`https://${lang}.wikipedia.org/w/api.php?action=query&prop=imageinfo&iiprop=extmetadata&titles=File:${authorPage.pageimage}&origin=*&format=json`,
|
||||
).then((res) => res.json());
|
||||
|
||||
const licensePage = Object.values(licenseData.query.pages)[0];
|
||||
const metadata = licensePage?.imageinfo?.[0]?.extmetadata;
|
||||
const license = metadata?.LicenseShortName;
|
||||
const photographer = stripHTML(
|
||||
metadata?.Attribution?.value || metadata?.Artist?.value || '',
|
||||
)
|
||||
.replace(/©\s/, '')
|
||||
.replace(/ \(talk\)/, '');
|
||||
|
||||
if (!photographer) {
|
||||
return { authorimg: null, authorimglicense: null };
|
||||
}
|
||||
|
||||
if (license?.value === 'Public domain') {
|
||||
return { authorimg, authorimglicense: null };
|
||||
}
|
||||
|
||||
const authorimglicense = photographer
|
||||
? `© ${photographer}${license ? `. ${license.value}` : ''}`.replace(/copyright\s/i, '')
|
||||
: license?.value || null;
|
||||
|
||||
return { authorimg, authorimglicense };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return { authorimg: null, authorimglicense: null };
|
||||
}
|
||||
// Wikidata fetcher has its own caching mechanism
|
||||
return await getAuthorDataFromWikidata(author);
|
||||
},
|
||||
[stripHTML],
|
||||
);
|
||||
|
||||
// Get cached author image or fetch and cache it
|
||||
const getCachedAuthorImg = useCallback(
|
||||
async (author) => {
|
||||
if (localStorage.getItem('authorImg') === 'false' || author === 'Unknown') {
|
||||
return { authorimg: null, authorimglicense: null };
|
||||
}
|
||||
|
||||
return await fetchAuthorImage(author, async () => {
|
||||
const result = await getAuthorImg(author);
|
||||
// Return only if we have actual data
|
||||
return result.authorimg || result.authorimglicense ? result : null;
|
||||
});
|
||||
},
|
||||
[fetchAuthorImage, getAuthorImg],
|
||||
[getAuthorDataFromWikidata],
|
||||
);
|
||||
|
||||
// Select raw quote data without author images (non-blocking)
|
||||
@@ -133,7 +122,7 @@ export function useQuoteLoader(updateQuote) {
|
||||
quote: `"${selected.quote}"`,
|
||||
author: selected.author,
|
||||
authorlink: getAuthorLink(selected.author),
|
||||
needsAuthorImg: true,
|
||||
needsAuthorData: true,
|
||||
noQuote: false,
|
||||
};
|
||||
}
|
||||
@@ -145,7 +134,7 @@ export function useQuoteLoader(updateQuote) {
|
||||
quote: '"' + quote.quote + '"',
|
||||
author: quote.author,
|
||||
authorlink: getAuthorLink(quote.author),
|
||||
needsAuthorImg: false, // Offline quotes don't get author images
|
||||
needsAuthorData: false, // Offline quotes don't get author data
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,7 +156,7 @@ export function useQuoteLoader(updateQuote) {
|
||||
quote: '"' + quote.quote + '"',
|
||||
author: quote.author,
|
||||
authorlink: getAuthorLink(quote.author),
|
||||
needsAuthorImg: false,
|
||||
needsAuthorData: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,51 +167,54 @@ export function useQuoteLoader(updateQuote) {
|
||||
return {
|
||||
quote: `"${data.quote}"`,
|
||||
author: displayAuthor,
|
||||
realAuthor: hasAuthor ? data.author : null, // For Wikipedia lookup
|
||||
realAuthor: hasAuthor ? data.author : null, // For Wikidata lookup
|
||||
authorlink: hasAuthor ? getAuthorLink(data.author) : null,
|
||||
fallbackauthorimg: data.fallbackauthorimg,
|
||||
needsAuthorImg: hasAuthor && !data.noAuthorImg,
|
||||
needsAuthorData: hasAuthor && !data.noAuthorImg,
|
||||
};
|
||||
}, [getAuthorLink]);
|
||||
|
||||
// Fetch complete quote data including author image (for prefetching)
|
||||
// Fetch complete quote data including author data (for prefetching)
|
||||
const fetchCompleteQuote = useCallback(
|
||||
async (quoteData) => {
|
||||
if (!quoteData || quoteData.noQuote) return quoteData;
|
||||
|
||||
// If author image is needed, fetch it
|
||||
if (quoteData.needsAuthorImg) {
|
||||
// If author data is needed, fetch it
|
||||
if (quoteData.needsAuthorData) {
|
||||
const authorToLookup = quoteData.realAuthor || quoteData.author;
|
||||
try {
|
||||
const authorImgData = await getCachedAuthorImg(authorToLookup);
|
||||
const authorData = await getCachedAuthorData(authorToLookup);
|
||||
|
||||
// For quote packs, use fallback if Wikipedia fails
|
||||
if (!authorImgData.authorimg && quoteData.fallbackauthorimg) {
|
||||
// For quote packs, use fallback if Wikidata fails
|
||||
if (!authorData.authorimg && quoteData.fallbackauthorimg) {
|
||||
return {
|
||||
...quoteData,
|
||||
authorimg: quoteData.fallbackauthorimg,
|
||||
authorimglicense: null,
|
||||
authorOccupation: authorData.authorOccupation || null,
|
||||
authorlink: authorData.authorlink || quoteData.authorlink,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...quoteData,
|
||||
...authorImgData,
|
||||
...authorData,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch author image:', e);
|
||||
// Return quote data with fallback or no image
|
||||
console.error('Failed to fetch author data:', e);
|
||||
// Return quote data with fallback or no data
|
||||
return {
|
||||
...quoteData,
|
||||
authorimg: quoteData.fallbackauthorimg || null,
|
||||
authorimglicense: null,
|
||||
authorOccupation: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return quoteData;
|
||||
},
|
||||
[getCachedAuthorImg],
|
||||
[getCachedAuthorData],
|
||||
);
|
||||
|
||||
const doOffline = useCallback(() => {
|
||||
@@ -233,6 +225,7 @@ export function useQuoteLoader(updateQuote) {
|
||||
author: quote.author,
|
||||
authorlink: getAuthorLink(quote.author),
|
||||
authorimg: '',
|
||||
authorOccupation: null,
|
||||
});
|
||||
}, [updateQuote, getAuthorLink]);
|
||||
|
||||
@@ -243,10 +236,6 @@ export function useQuoteLoader(updateQuote) {
|
||||
if (localStorage.getItem('quoteQueue') === null) {
|
||||
localStorage.setItem('quoteQueue', JSON.stringify([]));
|
||||
}
|
||||
if (localStorage.getItem('authorImageCache') === null) {
|
||||
localStorage.setItem('authorImageCache', JSON.stringify({}));
|
||||
localStorage.setItem('authorImageCacheTimestamp', Date.now().toString());
|
||||
}
|
||||
if (localStorage.getItem('quotePrefetchEnabled') === null) {
|
||||
localStorage.setItem('quotePrefetchEnabled', 'true');
|
||||
}
|
||||
@@ -278,15 +267,16 @@ export function useQuoteLoader(updateQuote) {
|
||||
authorlink: getAuthorLink(author),
|
||||
authorimg: null, // Will be updated asynchronously
|
||||
authorimglicense: null,
|
||||
authorOccupation: null,
|
||||
});
|
||||
|
||||
// Fetch author image asynchronously (non-blocking)
|
||||
getCachedAuthorImg(author).then((authorimgdata) => {
|
||||
// Fetch author data asynchronously (non-blocking)
|
||||
getCachedAuthorData(author).then((authorData) => {
|
||||
updateQuote({
|
||||
quote,
|
||||
author,
|
||||
authorlink: getAuthorLink(author),
|
||||
...authorimgdata,
|
||||
authorlink: authorData.authorlink || getAuthorLink(author),
|
||||
...authorData,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -308,28 +298,33 @@ export function useQuoteLoader(updateQuote) {
|
||||
return updateQuote({ noQuote: true });
|
||||
}
|
||||
|
||||
// For non-queue fetch, display immediately then enhance with author image
|
||||
if (rawQuote.needsAuthorImg) {
|
||||
// For non-queue fetch, display immediately then enhance with author data
|
||||
if (rawQuote.needsAuthorData) {
|
||||
// Display quote text immediately
|
||||
updateQuote({
|
||||
...rawQuote,
|
||||
authorimg: rawQuote.fallbackauthorimg || null,
|
||||
authorimglicense: null,
|
||||
authorOccupation: null,
|
||||
});
|
||||
|
||||
// Fetch author image asynchronously
|
||||
const authorToLookup = rawQuote.realAuthor || rawQuote.author;
|
||||
getCachedAuthorImg(authorToLookup).then((authorImgData) => {
|
||||
updateQuote({
|
||||
...rawQuote,
|
||||
...(authorImgData.authorimg
|
||||
? authorImgData
|
||||
: {
|
||||
authorimg: rawQuote.fallbackauthorimg || null,
|
||||
authorimglicense: null,
|
||||
}),
|
||||
// Fetch author data asynchronously (after a brief delay to ensure snappy initial render)
|
||||
setTimeout(() => {
|
||||
const authorToLookup = rawQuote.realAuthor || rawQuote.author;
|
||||
getCachedAuthorData(authorToLookup).then((authorData) => {
|
||||
updateQuote({
|
||||
...rawQuote,
|
||||
...(authorData.authorimg
|
||||
? authorData
|
||||
: {
|
||||
authorimg: rawQuote.fallbackauthorimg || null,
|
||||
authorimglicense: null,
|
||||
authorOccupation: authorData.authorOccupation || null,
|
||||
authorlink: authorData.authorlink || rawQuote.authorlink,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}, 0);
|
||||
|
||||
quoteData = rawQuote; // Use for prefetch reference
|
||||
} else {
|
||||
@@ -339,7 +334,7 @@ export function useQuoteLoader(updateQuote) {
|
||||
}
|
||||
|
||||
// Step 3: Display current quote (if from queue, it's already complete)
|
||||
if (cachedQueue.length > 0 || !quoteData.needsAuthorImg) {
|
||||
if (cachedQueue.length > 0 || !quoteData.needsAuthorData) {
|
||||
updateQuote(quoteData);
|
||||
}
|
||||
|
||||
@@ -351,28 +346,31 @@ export function useQuoteLoader(updateQuote) {
|
||||
console.warn('Could not save currentQuote to localStorage:', e);
|
||||
}
|
||||
|
||||
// Step 5: Prefetch next 3 quotes asynchronously (non-blocking)
|
||||
// Step 5: Prefetch next 3 quotes asynchronously (non-blocking, deferred)
|
||||
if (queueManager.needsPrefetch() && !offline) {
|
||||
const spaceNeeded = queueManager.getSpaceNeeded();
|
||||
|
||||
Promise.all(
|
||||
Array.from({ length: spaceNeeded }, async () => {
|
||||
const rawQuote = selectQuoteData();
|
||||
if (rawQuote.noQuote) return null;
|
||||
return await fetchCompleteQuote(rawQuote);
|
||||
}),
|
||||
)
|
||||
.then((newQuotes) => {
|
||||
const validQuotes = newQuotes.filter(Boolean);
|
||||
if (validQuotes.length > 0) {
|
||||
queueManager.push(validQuotes);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to prefetch quotes:', error);
|
||||
});
|
||||
// Defer prefetch to avoid blocking initial quote display
|
||||
setTimeout(() => {
|
||||
Promise.all(
|
||||
Array.from({ length: spaceNeeded }, async () => {
|
||||
const rawQuote = selectQuoteData();
|
||||
if (rawQuote.noQuote) return null;
|
||||
return await fetchCompleteQuote(rawQuote);
|
||||
}),
|
||||
)
|
||||
.then((newQuotes) => {
|
||||
const validQuotes = newQuotes.filter(Boolean);
|
||||
if (validQuotes.length > 0) {
|
||||
queueManager.push(validQuotes);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to prefetch quotes:', error);
|
||||
});
|
||||
}, 100); // Small delay to ensure current quote renders first
|
||||
}
|
||||
}, [updateQuote, getAuthorLink, getCachedAuthorImg, selectQuoteData, fetchCompleteQuote]);
|
||||
}, [updateQuote, getAuthorLink, getCachedAuthorData, selectQuoteData, fetchCompleteQuote]);
|
||||
|
||||
return {
|
||||
getQuote,
|
||||
|
||||
286
src/utils/wikidata/wikidataAuthorFetcher.js
Normal file
286
src/utils/wikidata/wikidataAuthorFetcher.js
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Wikidata API integration for fetching author information
|
||||
* Provides author images, occupations, and metadata with multilingual support
|
||||
*/
|
||||
|
||||
/* global URLSearchParams */
|
||||
|
||||
import { safeParseJSON } from '../jsonStorage';
|
||||
|
||||
const WIKIDATA_API = 'https://www.wikidata.org/w/api.php';
|
||||
const CACHE_EXPIRY = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
/**
|
||||
* Get cached author data from localStorage
|
||||
* @param {string} authorName - Author name
|
||||
* @param {string} language - Language code (e.g., 'en', 'fr')
|
||||
* @returns {object|null} Cached author data or null
|
||||
*/
|
||||
function getCachedAuthorData(authorName, language) {
|
||||
const cacheKey = `wikidataAuthor_${authorName.toLowerCase()}_${language}`;
|
||||
const cached = safeParseJSON(cacheKey);
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if cache has expired
|
||||
const now = Date.now();
|
||||
if (cached.timestamp && now - cached.timestamp > CACHE_EXPIRY) {
|
||||
localStorage.removeItem(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache author data in localStorage
|
||||
* @param {string} authorName - Author name
|
||||
* @param {string} language - Language code
|
||||
* @param {object} data - Author data to cache
|
||||
*/
|
||||
function cacheAuthorData(authorName, language, data) {
|
||||
const cacheKey = `wikidataAuthor_${authorName.toLowerCase()}_${language}`;
|
||||
localStorage.setItem(
|
||||
cacheKey,
|
||||
JSON.stringify({
|
||||
timestamp: Date.now(),
|
||||
data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for an author entity in Wikidata by name
|
||||
* @param {string} authorName - Author name to search
|
||||
* @param {string} language - Language code for search (default: 'en')
|
||||
* @returns {Promise<string|null>} Wikidata entity ID (e.g., 'Q937') or null
|
||||
*/
|
||||
async function searchAuthorEntity(authorName, language = 'en') {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
action: 'wbsearchentities',
|
||||
search: authorName,
|
||||
language: language,
|
||||
limit: '1',
|
||||
format: 'json',
|
||||
origin: '*',
|
||||
type: 'item',
|
||||
});
|
||||
|
||||
const response = await fetch(`${WIKIDATA_API}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.search && data.search.length > 0) {
|
||||
return data.search[0].id;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error searching Wikidata author:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract occupation labels from Wikidata claims
|
||||
* @param {object} claims - Wikidata claims object
|
||||
* @param {string} language - Preferred language
|
||||
* @returns {Promise<string|null>} Formatted occupation string or null
|
||||
*/
|
||||
async function extractOccupations(claims, language) {
|
||||
if (!claims || !claims.P106) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const occupationIds = claims.P106.filter((claim) => claim.mainsnak?.datavalue?.value?.id)
|
||||
.map((claim) => claim.mainsnak.datavalue.value.id)
|
||||
.slice(0, 1); // Only take the first/primary occupation
|
||||
|
||||
if (occupationIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch occupation label
|
||||
const params = new URLSearchParams({
|
||||
action: 'wbgetentities',
|
||||
ids: occupationIds[0],
|
||||
props: 'labels',
|
||||
languages: `${language}|en`,
|
||||
format: 'json',
|
||||
origin: '*',
|
||||
});
|
||||
|
||||
const response = await fetch(`${WIKIDATA_API}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
const entity = data.entities?.[occupationIds[0]];
|
||||
if (!entity?.labels) return null;
|
||||
|
||||
// Try preferred language first, then English
|
||||
const label = entity.labels[language]?.value || entity.labels['en']?.value;
|
||||
return label || null;
|
||||
} catch (error) {
|
||||
console.error('Error extracting occupations:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image URL using Wikimedia Commons API (more reliable than MD5 hashing)
|
||||
* @param {string} filename - Image filename from Wikidata
|
||||
* @returns {Promise<string|null>} Image URL or null
|
||||
*/
|
||||
async function getCommonsImageUrl(filename) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
action: 'query',
|
||||
titles: `File:${filename}`,
|
||||
prop: 'imageinfo',
|
||||
iiprop: 'url',
|
||||
iiurlwidth: '300',
|
||||
format: 'json',
|
||||
origin: '*',
|
||||
});
|
||||
|
||||
const response = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
const pages = data.query?.pages;
|
||||
if (!pages) return null;
|
||||
|
||||
const pageId = Object.keys(pages)[0];
|
||||
const imageUrl = pages[pageId]?.imageinfo?.[0]?.thumburl || pages[pageId]?.imageinfo?.[0]?.url;
|
||||
|
||||
return imageUrl || null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching Commons image URL:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Wikipedia link from Wikidata sitelinks
|
||||
* @param {object} sitelinks - Wikidata sitelinks object
|
||||
* @param {string} language - Preferred language
|
||||
* @returns {string|null} Wikipedia URL or null
|
||||
*/
|
||||
function extractWikipediaLink(sitelinks, language) {
|
||||
if (!sitelinks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try preferred language wiki first
|
||||
const preferredSite = `${language}wiki`;
|
||||
if (sitelinks[preferredSite]) {
|
||||
return sitelinks[preferredSite].url;
|
||||
}
|
||||
|
||||
// Fall back to English Wikipedia
|
||||
if (sitelinks.enwiki) {
|
||||
return sitelinks.enwiki.url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch author information from Wikidata
|
||||
* @param {string} authorName - Author name
|
||||
* @param {string} language - Language code (default: 'en')
|
||||
* @returns {Promise<object|null>} Author data object or null
|
||||
*/
|
||||
export async function fetchAuthorFromWikidata(authorName, language = 'en') {
|
||||
if (!authorName || authorName === 'Unknown') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedAuthorData(authorName, language);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Search for author entity
|
||||
const entityId = await searchAuthorEntity(authorName, language);
|
||||
if (!entityId) {
|
||||
// Cache negative result to avoid repeated lookups
|
||||
cacheAuthorData(authorName, language, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: Fetch entity data
|
||||
const params = new URLSearchParams({
|
||||
action: 'wbgetentities',
|
||||
ids: entityId,
|
||||
props: 'claims|sitelinks|descriptions',
|
||||
languages: `${language}|en`,
|
||||
format: 'json',
|
||||
origin: '*',
|
||||
});
|
||||
|
||||
const response = await fetch(`${WIKIDATA_API}?${params}`);
|
||||
const data = await response.json();
|
||||
const entity = data.entities?.[entityId];
|
||||
|
||||
if (!entity) {
|
||||
cacheAuthorData(authorName, language, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 3: Extract data
|
||||
const claims = entity.claims;
|
||||
const sitelinks = entity.sitelinks;
|
||||
|
||||
// Get occupation
|
||||
const occupation = await extractOccupations(claims, language);
|
||||
|
||||
// Get image
|
||||
let imageUrl = null;
|
||||
if (claims.P18 && claims.P18.length > 0) {
|
||||
const filename = claims.P18[0].mainsnak?.datavalue?.value;
|
||||
if (filename) {
|
||||
imageUrl = await getCommonsImageUrl(filename);
|
||||
}
|
||||
}
|
||||
|
||||
// Get Wikipedia link
|
||||
const wikipediaLink = extractWikipediaLink(sitelinks, language);
|
||||
|
||||
// Get description
|
||||
const description =
|
||||
entity.descriptions?.[language]?.value || entity.descriptions?.['en']?.value;
|
||||
|
||||
const authorData = {
|
||||
entityId,
|
||||
occupation,
|
||||
imageUrl,
|
||||
wikipediaLink,
|
||||
description,
|
||||
imageLicense: imageUrl ? 'Wikimedia Commons' : null,
|
||||
};
|
||||
|
||||
// Cache result
|
||||
cacheAuthorData(authorName, language, authorData);
|
||||
|
||||
return authorData;
|
||||
} catch (error) {
|
||||
console.error('Error fetching author from Wikidata:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all Wikidata cache
|
||||
*/
|
||||
export function clearWikidataCache() {
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith('wikidataAuthor_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user