/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import type {MessageEmbedResponse} from '~/channel/EmbedTypes'; import {Logger} from '~/Logger'; import {BaseResolver} from '~/unfurler/resolvers/BaseResolver'; import {buildEmbedMediaPayload} from '~/unfurler/resolvers/media/MediaMetadataHelpers'; import * as FetchUtils from '~/utils/FetchUtils'; import {parseString} from '~/utils/StringUtils'; interface WikiSummaryResponse { type: string; title: string; extract: string; thumbnail?: { source: string; width: number; height: number; }; originalimage?: { source: string; width: number; height: number; }; description?: string; pageid: number; } type ProcessedThumbnail = NonNullable; export class WikipediaResolver extends BaseResolver { private readonly SUPPORTED_DOMAINS = [ 'wikipedia.org', 'www.wikipedia.org', ...['en', 'de', 'fr', 'es', 'it', 'ja', 'ru', 'zh'].map((lang) => `${lang}.wikipedia.org`), ]; match(url: URL, mimeType: string, _content: Uint8Array): boolean { return ( this.SUPPORTED_DOMAINS.includes(url.hostname) && url.pathname.startsWith('/wiki/') && mimeType.startsWith('text/html') ); } private getLanguageFromURL(url: URL): string { const subdomain = url.hostname.split('.')[0]; return this.SUPPORTED_DOMAINS.includes(`${subdomain}.wikipedia.org`) ? subdomain : 'en'; } private async fetchArticleSummary(title: string, baseUrl: string): Promise { const apiUrl = `${baseUrl}/api/rest_v1/page/summary/${encodeURIComponent(title)}`; try { const response = await FetchUtils.sendRequest({ url: apiUrl, }); if (response.status !== 200) { Logger.debug({title, status: response.status}, 'Failed to fetch Wikipedia article summary'); return null; } const responseText = await FetchUtils.streamToString(response.stream); return JSON.parse(responseText) as WikiSummaryResponse; } catch (error) { Logger.error({error, title}, 'Failed to fetch or parse Wikipedia response'); return null; } } private async processThumbnail( thumbnailData: WikiSummaryResponse['thumbnail'], isNSFWAllowed: boolean, ): Promise { if (!thumbnailData) return null; const thumbnailMetadata = await this.mediaService.getMetadata({ type: 'external', url: thumbnailData.source, isNSFWAllowed, }); return buildEmbedMediaPayload(thumbnailData.source, thumbnailMetadata, { width: thumbnailData.width, height: thumbnailData.height, }) as ProcessedThumbnail; } async resolve(url: URL, _content: Uint8Array, isNSFWAllowed: boolean = false): Promise> { try { const title = decodeURIComponent(url.pathname.split('/wiki/')[1]); if (!title) return []; const language = this.getLanguageFromURL(url); const baseUrl = `https://${language}.wikipedia.org`; const article = await this.fetchArticleSummary(title, baseUrl); if (!article) return []; const thumbnail = await this.processThumbnail(article.thumbnail, isNSFWAllowed); const originalImage = await this.processThumbnail(article.originalimage, isNSFWAllowed); const uniqueImages = this.deduplicateThumbnails([thumbnail, originalImage]); const primaryThumbnail = uniqueImages[0]; const embed: MessageEmbedResponse = { type: 'article', url: url.href, title: parseString(article.title, 256), description: parseString(article.extract, 350), thumbnail: primaryThumbnail ?? undefined, }; const extraImageEmbeds = uniqueImages.slice(1).map((image) => ({ type: 'rich' as const, url: url.href, image, })); return [embed, ...extraImageEmbeds]; } catch (error) { Logger.error({error, url: url.toString()}, 'Failed to resolve Wikipedia article'); return []; } } private deduplicateThumbnails(images: Array): Array { const seen = new Set(); const unique: Array = []; for (const image of images) { if (!image) continue; const normalized = this.normalizeUrl(image.url); if (!normalized) { unique.push(image); continue; } if (seen.has(normalized)) continue; seen.add(normalized); unique.push(image); } return unique; } private normalizeUrl(url?: string): string | null { if (!url) return null; try { return new URL(url).href.replace(/\/$/, ''); } catch (error) { Logger.debug({error, url}, 'Failed to normalize Wikipedia image URL'); return null; } } }