forked from ORGANISATION-KA-INTERNATIONALE/FEDIVERSE-OKI
6c2df9b1cd
- scripts/fetch-podcast-cover.mjs : pochettes Castopod (flux + épisodes) téléchargées et converties en WebP 512px au prebuild (2,3 Mo → 59 Ko) - scripts/optimize-remote-images.mjs : vignettes PeerTube (640px), avatars (96px) et médias Mastodon (800px) optimisés en WebP + mapping src/lib/server/image-map.json consommé par mapImage() - a11y : liens footer soulignés dans les paragraphes, cibles tactiles ≥24px sur les liens des posts Mastodon - image-map.ts : import JSON statique (le createRequire pointait sur le bundle) - doc : étape prebuild documentée (dépendance ImageMagick) Lighthouse mobile : 97/100/100/100 — LCP 2,4s, TBT 50ms, CLS 0, poids accueil 900 Ko (budgets du playbook respectés)
102 lines
3.5 KiB
TypeScript
102 lines
3.5 KiB
TypeScript
import { existsSync } from 'node:fs';
|
|
import { castopod } from '$lib/config';
|
|
import { fetchText } from './http';
|
|
import type { Episode } from '$lib/types';
|
|
|
|
// Pochette du flux optimisée au prebuild (scripts/fetch-podcast-cover.mjs)
|
|
const LOCAL_COVER = '/images/podcast-cover.webp';
|
|
const hasLocalCover = existsSync(`static${LOCAL_COVER}`);
|
|
|
|
function localEpisodeCover(remoteUrl: string | null): string | null {
|
|
if (!remoteUrl) return null;
|
|
const file = remoteUrl.split('/').pop()?.replace(/\.(png|jpe?g|gif|webp)$/i, '.webp');
|
|
if (!file) return remoteUrl;
|
|
const local = `/images/episodes/${file}`;
|
|
return existsSync(`static${local}`) ? local : remoteUrl;
|
|
}
|
|
|
|
function tag(xml: string, name: string): string {
|
|
const cdata = new RegExp(`<${name}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${name}>`, 'i');
|
|
const plain = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, 'i');
|
|
const match = xml.match(cdata) ?? xml.match(plain);
|
|
return match?.[1]?.trim() ?? '';
|
|
}
|
|
|
|
function attr(xml: string, tagName: string, attrName: string): string {
|
|
const tagMatch = xml.match(new RegExp(`<${tagName}\\b[^>]*>`, 'i'));
|
|
if (!tagMatch) return '';
|
|
const attrMatch = tagMatch[0].match(new RegExp(`${attrName}="([^"]*)"`, 'i'));
|
|
return attrMatch?.[1] ?? '';
|
|
}
|
|
|
|
function stripTags(html: string): string {
|
|
return html
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/�?39;|'/g, "'")
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function parseDuration(raw: string): number {
|
|
if (!raw) return 0;
|
|
const parts = raw.split(':').map(Number);
|
|
if (parts.some(Number.isNaN)) return 0;
|
|
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
|
return parts[0] ?? 0;
|
|
}
|
|
|
|
async function getEpisodesForSlug(slug: string): Promise<Episode[]> {
|
|
const feedUrl = `${castopod.url}/@${slug}/feed`;
|
|
const xml = await fetchText(feedUrl);
|
|
if (!xml) return [];
|
|
|
|
const channelImage =
|
|
tag(xml.split('<item')[0] ?? '', 'url') || attr(xml.split('<item')[0] ?? '', 'itunes:image', 'href');
|
|
const items = xml.split(/<item[\s>]/i).slice(1);
|
|
|
|
return items.map((item, index) => {
|
|
const body = item.split('</item>')[0] ?? item;
|
|
const title = tag(body, 'title');
|
|
const link = tag(body, 'link');
|
|
const guid = tag(body, 'guid') || link || `${slug}-${index}`;
|
|
const description = stripTags(tag(body, 'description') || tag(body, 'itunes:summary'));
|
|
const remoteImage = attr(body, 'itunes:image', 'href') || channelImage || null;
|
|
// Les pochettes du flux (souvent lourdes) sont remplacées par leurs versions optimisées au build
|
|
const image =
|
|
hasLocalCover && remoteImage === channelImage ? LOCAL_COVER : localEpisodeCover(remoteImage);
|
|
return {
|
|
id: guid,
|
|
title,
|
|
description,
|
|
audioUrl: attr(body, 'enclosure', 'url'),
|
|
audioType: attr(body, 'enclosure', 'type') || 'audio/mpeg',
|
|
duration: parseDuration(tag(body, 'itunes:duration')),
|
|
date: tag(body, 'pubDate'),
|
|
image,
|
|
link,
|
|
podcast: slug
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function getCastopodEpisodes(count = castopod.episodesCount): Promise<Episode[]> {
|
|
const all: Episode[] = [];
|
|
for (const slug of castopod.podcastSlugs) {
|
|
const episodes = await getEpisodesForSlug(slug);
|
|
all.push(...episodes);
|
|
if (castopod.podcastSlugs.length > 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
}
|
|
}
|
|
return all
|
|
.filter((episode) => episode.audioUrl)
|
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
.slice(0, count);
|
|
}
|