feat: integrate Wikidata API for fetching author information, including images and occupations

This commit is contained in:
alexsparkes
2026-02-01 11:49:54 +00:00
parent e25c8dedb7
commit 7c2213a38f
3 changed files with 423 additions and 139 deletions

View File

@@ -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">

View File

@@ -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,

View 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);
}
});
}