();
+ const keyCount = Math.min(Math.max(0, storage.length), 1_000);
+ for (let index = 0; index < keyCount; index += 1) {
+ const key = storage.key(index);
+ if (key?.startsWith(WIKIPEDIA_ARTICLE_STORAGE_PREFIX)) {
+ try {
+ const slug = parseWikipediaSlug(key.slice(WIKIPEDIA_ARTICLE_STORAGE_PREFIX.length));
+ const article = loadWikipediaArticle(storage, slug);
+ if (article) articles.set(slug, article);
+ } catch {
+ // Ignore unrelated or malformed storage entries.
+ }
+ }
+ }
+ return [...articles.values()].sort((left, right) => right.updatedAt - left.updatedAt);
+};
+
+const normalizeWikipediaSearchText = (value: string): string =>
+ value
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLocaleLowerCase()
+ .trim();
+
+export const searchWikipediaArticles = (
+ articles: readonly WikipediaArticle[],
+ query: string,
+): WikipediaArticle[] => {
+ if (typeof query !== 'string' || query.length > MAXIMUM_WIKIPEDIA_SEARCH_LENGTH) return [];
+ const needle = normalizeWikipediaSearchText(query);
+ if (!needle) return [...articles];
+ return articles.filter((article) =>
+ normalizeWikipediaSearchText(`${article.title} ${article.slug}`).includes(needle),
+ );
+};
+
+export const createWikipediaArticle = (
+ slug: string,
+ objectId: string,
+ title: string,
+ now: () => number,
+): WikipediaArticle =>
+ parseWikipediaArticle({
+ version: 1,
+ slug: parseWikipediaSlug(slug),
+ objectId: parseBotsuDocumentObjectId(objectId),
+ title,
+ updatedAt: now(),
+ });
diff --git a/apps/client/src/botsu/wikipedia/wikipedia-scraper.test.ts b/apps/client/src/botsu/wikipedia/wikipedia-scraper.test.ts
new file mode 100644
index 0000000..d0cd6a4
--- /dev/null
+++ b/apps/client/src/botsu/wikipedia/wikipedia-scraper.test.ts
@@ -0,0 +1,110 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ fetchWikipediaArticle,
+ parseWikipediaApiResponse,
+ searchWikipediaArticles,
+ parseWikipediaSearchResults,
+} from './wikipedia-scraper.ts';
+
+test('parses a Wikipedia API parse response into clean HTML', () => {
+ const apiResponse = {
+ parse: {
+ title: 'Albert Einstein',
+ pageid: 736,
+ text: {
+ '*': 'Albert Einstein est un physicien.
Il a développé la théorie de la relativité.
',
+ },
+ },
+ };
+
+ const result = parseWikipediaApiResponse(apiResponse, 'fr');
+
+ assert.equal(result.title, 'Albert Einstein');
+ assert.ok(result.html.includes('Albert Einstein est un physicien.
'));
+ assert.ok(result.html.includes('relativité'));
+ assert.equal(result.url, 'https://fr.wikipedia.org/wiki/Albert_Einstein');
+ assert.equal(result.pageId, 736);
+});
+
+test('parseWikipediaApiResponse strips mw-parser-output wrapper', () => {
+ const apiResponse = {
+ parse: {
+ title: 'Test',
+ pageid: 1,
+ text: {
+ '*': '',
+ },
+ },
+ };
+
+ const result = parseWikipediaApiResponse(apiResponse, 'fr');
+ assert.ok(result.html.includes('Contenu
'));
+ assert.ok(!result.html.includes('mw-parser-output'));
+});
+
+test('parseWikipediaApiResponse rejects malformed responses', () => {
+ assert.throws(() => parseWikipediaApiResponse(null, 'fr'), /response/);
+ assert.throws(() => parseWikipediaApiResponse({}, 'fr'), /response/);
+ assert.throws(() => parseWikipediaApiResponse({ parse: {} }, 'fr'), /response/);
+ assert.throws(() => parseWikipediaApiResponse({ parse: { title: 123 } }, 'fr'), /response/);
+});
+
+test('parseWikipediaSearchResults parses opensearch results', () => {
+ const response: [string, string[], string[], string[]] = [
+ 'einstein',
+ ['Albert Einstein', 'Einstein (unité)', 'Einsteinium'],
+ ['Physicien allemand', 'Unité photochimique', 'Élément chimique'],
+ [
+ 'https://fr.wikipedia.org/wiki/Albert_Einstein',
+ 'https://fr.wikipedia.org/wiki/Einstein_(unit%C3%A9)',
+ 'https://fr.wikipedia.org/wiki/Einsteinium',
+ ],
+ ];
+
+ const results = parseWikipediaSearchResults(response);
+
+ assert.equal(results.length, 3);
+ assert.equal(results[0].title, 'Albert Einstein');
+ assert.equal(results[0].description, 'Physicien allemand');
+ assert.equal(results[0].url, 'https://fr.wikipedia.org/wiki/Albert_Einstein');
+ assert.equal(results[1].title, 'Einstein (unité)');
+});
+
+test('parseWikipediaSearchResults rejects malformed responses', () => {
+ assert.throws(() => parseWikipediaSearchResults(null), /response/);
+ assert.throws(() => parseWikipediaSearchResults([]), /response/);
+ assert.throws(() => parseWikipediaSearchResults(['q', [], []]), /response/);
+});
+
+test('searchWikipediaArticles searches real Wikipedia', async () => {
+ const results = await searchWikipediaArticles('Einstein', 'fr');
+
+ assert.ok(results.length > 0, 'should return at least one result');
+ assert.equal(results[0].title, 'Albert Einstein');
+ assert.ok(results[0].url.includes('wikipedia.org'));
+ // opensearch descriptions are often empty — that's fine
+});
+
+test('searchWikipediaArticles returns empty for nonsense query', async () => {
+ const results = await searchWikipediaArticles('xyzzynonexistent987654321', 'fr');
+ assert.equal(results.length, 0);
+});
+
+test('fetchWikipediaArticle fetches and parses a real Wikipedia article', async () => {
+ const result = await fetchWikipediaArticle('France', 'fr');
+
+ assert.equal(result.title, 'France');
+ assert.ok(result.html.length > 100, 'HTML content should be substantial');
+ assert.ok(result.html.includes(''), 'should contain paragraph tags');
+ assert.equal(result.url, 'https://fr.wikipedia.org/wiki/France');
+ assert.ok(result.pageId > 0);
+});
+
+test('fetchWikipediaArticle handles non-existent pages', async () => {
+ await assert.rejects(
+ fetchWikipediaArticle('XyzzyNonexistentPage12345', 'fr'),
+ /not found|missing|introuvable/i,
+ );
+});
diff --git a/apps/client/src/botsu/wikipedia/wikipedia-scraper.ts b/apps/client/src/botsu/wikipedia/wikipedia-scraper.ts
new file mode 100644
index 0000000..6a7e351
--- /dev/null
+++ b/apps/client/src/botsu/wikipedia/wikipedia-scraper.ts
@@ -0,0 +1,139 @@
+export type WikipediaArticleContent = {
+ title: string;
+ html: string;
+ url: string;
+ pageId: number;
+};
+
+export type WikipediaSearchResult = {
+ title: string;
+ description: string;
+ url: string;
+};
+
+const WIKIPEDIA_API_BASE = 'https://fr.wikipedia.org/w/api.php';
+
+const buildApiUrl = (params: Record): string => {
+ const searchParams = new URLSearchParams({
+ format: 'json',
+ origin: '*',
+ ...params,
+ });
+ return `${WIKIPEDIA_API_BASE}?${searchParams.toString()}`;
+};
+
+export const parseWikipediaApiResponse = (
+ value: unknown,
+ lang: string,
+): WikipediaArticleContent => {
+ if (typeof value !== 'object' || value === null) {
+ throw new TypeError('Invalid Wikipedia API response');
+ }
+ const response = value as Record;
+ const parse = response.parse;
+ if (typeof parse !== 'object' || parse === null) {
+ throw new TypeError('Invalid Wikipedia API response: missing parse');
+ }
+ const p = parse as Record;
+
+ if (typeof p.title !== 'string' || p.title.length < 1) {
+ throw new TypeError('Invalid Wikipedia API response: missing title');
+ }
+ if (typeof p.pageid !== 'number' || !Number.isSafeInteger(p.pageid) || p.pageid < 1) {
+ throw new TypeError('Invalid Wikipedia API response: missing pageid');
+ }
+ const text = p.text;
+ if (typeof text !== 'object' || text === null) {
+ throw new TypeError('Invalid Wikipedia API response: missing text');
+ }
+ const rawHtml = (text as Record)['*'];
+ if (typeof rawHtml !== 'string' || rawHtml.length < 1) {
+ throw new TypeError('Invalid Wikipedia API response: missing text content');
+ }
+
+ // Strip the mw-parser-output wrapper div
+ const innerMatch = /([\s\S]*)<\/div>\s*$/i.exec(rawHtml);
+ const html = innerMatch ? innerMatch[1].trim() : rawHtml;
+
+ const encodedTitle = encodeURIComponent(p.title.replace(/ /g, '_'));
+ const url = `https://${lang}.wikipedia.org/wiki/${encodedTitle}`;
+
+ return {
+ title: p.title,
+ html,
+ url,
+ pageId: p.pageid,
+ };
+};
+
+export const parseWikipediaSearchResults = (value: unknown): WikipediaSearchResult[] => {
+ if (!Array.isArray(value) || value.length < 4) {
+ throw new TypeError('Invalid Wikipedia search response');
+ }
+ const [_query, titles, descriptions, urls] = value as [string, string[], string[], string[]];
+
+ if (!Array.isArray(titles) || !Array.isArray(descriptions) || !Array.isArray(urls)) {
+ throw new TypeError('Invalid Wikipedia search response');
+ }
+
+ const results: WikipediaSearchResult[] = [];
+ const count = Math.min(titles.length, descriptions.length, urls.length);
+ for (let i = 0; i < count; i++) {
+ if (typeof titles[i] !== 'string' || typeof urls[i] !== 'string') continue;
+ results.push({
+ title: titles[i],
+ description: typeof descriptions[i] === 'string' ? descriptions[i] : '',
+ url: urls[i],
+ });
+ }
+ return results;
+};
+
+export const searchWikipediaArticles = async (
+ query: string,
+ lang: string = 'fr',
+ fetchImpl: typeof fetch = fetch,
+): Promise => {
+ const url = buildApiUrl({
+ action: 'opensearch',
+ search: query,
+ limit: '10',
+ namespace: '0',
+ language: lang,
+ });
+
+ const response = await fetchImpl(url);
+ if (!response.ok) {
+ throw new Error(`Wikipedia search failed: ${response.status}`);
+ }
+
+ const data: unknown = await response.json();
+ return parseWikipediaSearchResults(data);
+};
+
+export const fetchWikipediaArticle = async (
+ title: string,
+ lang: string = 'fr',
+ fetchImpl: typeof fetch = fetch,
+): Promise => {
+ const url = buildApiUrl({
+ action: 'parse',
+ page: title,
+ prop: 'text',
+ language: lang,
+ });
+
+ const response = await fetchImpl(url);
+ if (!response.ok) {
+ throw new Error(`Wikipedia fetch failed: ${response.status}`);
+ }
+
+ const data: unknown = await response.json();
+ const result = parseWikipediaApiResponse(data, lang);
+
+ if (!result.html || result.html.length < 10) {
+ throw new Error('Article introuvable ou vide');
+ }
+
+ return result;
+};
diff --git a/apps/client/src/botsu/wikipedia/wikipedia.css b/apps/client/src/botsu/wikipedia/wikipedia.css
new file mode 100644
index 0000000..5db9c91
--- /dev/null
+++ b/apps/client/src/botsu/wikipedia/wikipedia.css
@@ -0,0 +1,337 @@
+/* Wikipedia home — search-first */
+.botsu-wikipedia {
+ padding: 24px 32px;
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+.botsu-wikipedia-header {
+ margin-bottom: 20px;
+}
+
+.botsu-wikipedia-title {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--botsu-color-text);
+ margin: 0 0 4px;
+}
+
+.botsu-wikipedia-subtitle {
+ font-size: 0.9rem;
+ color: var(--botsu-color-text-muted);
+ margin: 0;
+}
+
+/* Search bar */
+.botsu-wikipedia-search-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border: var(--botsu-border-width) solid var(--botsu-color-border);
+ border-radius: var(--botsu-radius);
+ background: var(--botsu-color-surface);
+ margin-bottom: 20px;
+}
+
+.botsu-wikipedia-search-bar:focus-within {
+ border-color: var(--botsu-color-accent);
+ box-shadow: 0 0 0 2px var(--botsu-color-focus);
+}
+
+.botsu-wikipedia-search-icon {
+ color: var(--botsu-color-text-muted);
+ flex-shrink: 0;
+}
+
+.botsu-wikipedia-search-input {
+ flex: 1;
+ border: none;
+ background: transparent;
+ color: var(--botsu-color-text);
+ font-family: var(--botsu-font-family);
+ font-size: 1rem;
+ outline: none;
+}
+
+.botsu-wikipedia-search-input::placeholder {
+ color: var(--botsu-color-text-muted);
+}
+
+.botsu-wikipedia-searching {
+ font-size: 0.8rem;
+ color: var(--botsu-color-text-muted);
+ flex-shrink: 0;
+}
+
+/* Search results */
+.botsu-wikipedia-results {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.botsu-wikipedia-result-item {
+ margin: 0;
+}
+
+.botsu-wikipedia-result-link {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 12px 16px;
+ border-radius: var(--botsu-radius);
+ text-decoration: none;
+ color: inherit;
+ transition: background 0.1s;
+}
+
+.botsu-wikipedia-result-link:hover {
+ background: var(--botsu-color-surface-raised);
+}
+
+.botsu-wikipedia-result-title {
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: var(--botsu-color-accent);
+}
+
+.botsu-wikipedia-result-desc {
+ font-size: 0.85rem;
+ color: var(--botsu-color-text);
+ line-height: 1.4;
+}
+
+.botsu-wikipedia-result-url {
+ font-size: 0.75rem;
+ color: var(--botsu-color-text-muted);
+}
+
+/* Empty / error states */
+.botsu-wikipedia-empty {
+ text-align: center;
+ padding: 48px 16px;
+ color: var(--botsu-color-text-muted);
+}
+
+.botsu-message {
+ padding: 12px 16px;
+ border-radius: var(--botsu-radius);
+ background: var(--botsu-color-surface);
+ color: var(--botsu-color-text-muted);
+ font-size: 0.9rem;
+}
+
+.botsu-wikipedia-error {
+ background: #fef2f2;
+ color: #dc2626;
+ margin-bottom: 16px;
+}
+
+/* Article view — read-only */
+.botsu-wikipedia-article {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.botsu-wikipedia-article-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 64px 16px;
+ color: var(--botsu-color-text-muted);
+}
+
+.botsu-wikipedia-article-empty a {
+ color: var(--botsu-color-accent);
+ text-decoration: none;
+}
+
+.botsu-wikipedia-article-empty a:hover {
+ text-decoration: underline;
+}
+
+.botsu-wikipedia-article-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 16px;
+ border-bottom: var(--botsu-border-width) solid var(--botsu-color-border);
+ background: var(--botsu-color-surface);
+ flex-shrink: 0;
+}
+
+.botsu-wikipedia-home-link {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px;
+ border-radius: var(--botsu-radius);
+ color: var(--botsu-color-text-muted);
+ text-decoration: none;
+ flex-shrink: 0;
+}
+
+.botsu-wikipedia-home-link:hover {
+ background: var(--botsu-color-surface-raised);
+ color: var(--botsu-color-accent);
+}
+
+.botsu-wikipedia-external-link {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px;
+ border-radius: var(--botsu-radius);
+ color: var(--botsu-color-text-muted);
+ text-decoration: none;
+ flex-shrink: 0;
+ margin-left: auto;
+}
+
+.botsu-wikipedia-external-link:hover {
+ background: var(--botsu-color-surface-raised);
+ color: var(--botsu-color-accent);
+}
+
+.botsu-wikipedia-article-header-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--botsu-color-text);
+ margin: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex-shrink: 1;
+}
+
+.botsu-wikipedia-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0;
+}
+
+.botsu-wikipedia-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 64px 16px;
+ color: var(--botsu-color-text-muted);
+}
+
+/* Wikipedia article body — rendered HTML from API */
+.botsu-wikipedia-article-body {
+ padding: 24px 32px;
+ max-width: 800px;
+ margin: 0 auto;
+ font-family: var(--botsu-font-family);
+ font-size: 0.95rem;
+ line-height: 1.7;
+ color: var(--botsu-color-text);
+}
+
+/* Wikipedia content styling — keep it readable in BOTSU theme */
+.botsu-wikipedia-article-body h2 {
+ font-size: 1.4rem;
+ font-weight: 600;
+ margin: 28px 0 12px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid var(--botsu-color-border);
+ color: var(--botsu-color-text);
+}
+
+.botsu-wikipedia-article-body h3 {
+ font-size: 1.15rem;
+ font-weight: 600;
+ margin: 20px 0 8px;
+ color: var(--botsu-color-text);
+}
+
+.botsu-wikipedia-article-body h4 {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 16px 0 6px;
+ color: var(--botsu-color-text);
+}
+
+.botsu-wikipedia-article-body p {
+ margin: 0 0 12px;
+}
+
+.botsu-wikipedia-article-body a {
+ color: var(--botsu-color-accent);
+ text-decoration: none;
+}
+
+.botsu-wikipedia-article-body a:hover {
+ text-decoration: underline;
+}
+
+.botsu-wikipedia-article-body ul,
+.botsu-wikipedia-article-body ol {
+ margin: 0 0 12px;
+ padding-left: 24px;
+}
+
+.botsu-wikipedia-article-body li {
+ margin-bottom: 4px;
+}
+
+.botsu-wikipedia-article-body img {
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--botsu-radius);
+}
+
+.botsu-wikipedia-article-body table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 12px 0;
+ font-size: 0.9rem;
+}
+
+.botsu-wikipedia-article-body th,
+.botsu-wikipedia-article-body td {
+ border: 1px solid var(--botsu-color-border);
+ padding: 6px 10px;
+ text-align: left;
+}
+
+.botsu-wikipedia-article-body th {
+ background: var(--botsu-color-surface);
+ font-weight: 600;
+}
+
+.botsu-wikipedia-article-body blockquote {
+ margin: 12px 0;
+ padding: 8px 16px;
+ border-left: 3px solid var(--botsu-color-accent);
+ background: var(--botsu-color-surface);
+ border-radius: 0 var(--botsu-radius) var(--botsu-radius) 0;
+ font-style: italic;
+}
+
+.botsu-wikipedia-article-body .infobox {
+ float: right;
+ margin: 0 0 16px 16px;
+ max-width: 280px;
+ font-size: 0.85rem;
+}
+
+.botsu-wikipedia-article-body .mw-editsection {
+ display: none;
+}
+
+.botsu-wikipedia-article-body sup.reference {
+ font-size: 0.75rem;
+}
+
+.botsu-wikipedia-article-body .references {
+ font-size: 0.85rem;
+}