forked from ORGANISATION-KA-INTERNATIONALE/FEDIVERSE-OKI
Optimise les images distantes au build et fiabilise la QA
- 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)
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { writeFileSync, statSync, mkdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join, dirname, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const FEED_URL = 'https://kute.o-k-i.net/@annu_kute_cedric/feed';
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const EPISODES_DIR = join(ROOT, 'static', 'images', 'episodes');
|
||||
const CHANNEL_COVER = join(ROOT, 'static', 'images', 'podcast-cover.webp');
|
||||
|
||||
mkdirSync(EPISODES_DIR, { recursive: true });
|
||||
|
||||
const xml = await (await fetch(FEED_URL)).text();
|
||||
|
||||
const urls = new Set();
|
||||
const channel = xml.split('<item')[0] ?? '';
|
||||
const channelMatch =
|
||||
channel.match(/<itunes:image[^>]*href="([^"]+)"/i) ?? channel.match(/<url>([^<]+)<\/url>/i);
|
||||
if (channelMatch) urls.add(channelMatch[1]);
|
||||
|
||||
for (const item of xml.split(/<item[\s>]/i).slice(1)) {
|
||||
const match = item.match(/<itunes:image[^>]*href="([^"]+)"/i);
|
||||
if (match) urls.add(match[1]);
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
|
||||
for (const url of urls) {
|
||||
const isChannel = url === channelMatch?.[1];
|
||||
const out = isChannel
|
||||
? CHANNEL_COVER
|
||||
: join(EPISODES_DIR, basename(url).replace(/\.(png|jpe?g|gif|webp)$/i, '.webp'));
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
console.warn(`[covers] HTTP ${res.status} pour ${url}`);
|
||||
continue;
|
||||
}
|
||||
const source = Buffer.from(await res.arrayBuffer());
|
||||
const tmp = `/tmp/opencode/cover-${Date.now()}`;
|
||||
writeFileSync(tmp, source);
|
||||
execFileSync('convert', [tmp, '-resize', '512x512>', '-quality', '82', '-define', 'webp:method=6', out]);
|
||||
const size = statSync(out).size;
|
||||
total += size;
|
||||
console.log(`[covers] ${basename(out)} : ${Math.round(source.length / 1024)} Ko → ${Math.round(size / 1024)} Ko`);
|
||||
} catch (err) {
|
||||
console.warn(`[covers] échec pour ${url}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[covers] total optimisé : ${Math.round(total / 1024)} Ko`);
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, writeFileSync, statSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const OUT_DIR = join(ROOT, 'static', 'images', 'remote');
|
||||
const MAP_FILE = join(ROOT, 'src', 'lib', 'server', 'image-map.json');
|
||||
|
||||
const PEERTUBE = 'https://gade.o-k-i.net';
|
||||
const MASTODON = 'https://bokante.o-k-i.net';
|
||||
const MASTODON_ACCT = 'cedric';
|
||||
const PRIORITY_CATEGORIES = [11, 15, 4, 9, 10];
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const urls = new Map();
|
||||
|
||||
function localPathFor(remoteUrl, width) {
|
||||
const hash = createHash('sha1').update(remoteUrl).digest('hex').slice(0, 16);
|
||||
return { local: `/images/remote/${hash}.webp`, width };
|
||||
}
|
||||
|
||||
async function collectVideos(params, width) {
|
||||
const query = new URLSearchParams({ isLocal: 'true', ...params });
|
||||
try {
|
||||
const res = await fetch(`${PEERTUBE}/api/v1/videos?${query}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
for (const video of data?.data ?? []) {
|
||||
if (video.previewPath) {
|
||||
const url = `${PEERTUBE}${video.previewPath}`;
|
||||
if (!urls.has(url)) urls.set(url, localPathFor(url, width));
|
||||
}
|
||||
const avatar = video.channel?.avatars?.[0]?.path;
|
||||
if (avatar) {
|
||||
const url = `${PEERTUBE}${avatar}`;
|
||||
if (!urls.has(url)) urls.set(url, localPathFor(url, 96));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[images] collectVideos échoué:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Pool récent (couvre récentes, shorts et l'essentiel des catégories) + tendances + chaque catégorie prioritaire
|
||||
await collectVideos({ sort: '-publishedAt', count: '100' }, 640);
|
||||
await collectVideos({ sort: '-trending', count: '6' }, 640);
|
||||
for (const id of PRIORITY_CATEGORIES) {
|
||||
await collectVideos({ categoryOneOf: String(id), sort: '-publishedAt', count: '6' }, 640);
|
||||
}
|
||||
|
||||
// Médias de la timeline Mastodon
|
||||
try {
|
||||
const account = await (
|
||||
await fetch(`${MASTODON}/api/v1/accounts/lookup?acct=${encodeURIComponent(MASTODON_ACCT)}`)
|
||||
).json();
|
||||
if (account?.id) {
|
||||
const statuses = await (
|
||||
await fetch(`${MASTODON}/api/v1/accounts/${account.id}/statuses?limit=10&exclude_replies=true`)
|
||||
).json();
|
||||
for (const status of statuses ?? []) {
|
||||
const source = status.reblog ?? status;
|
||||
for (const media of source.media_attachments ?? []) {
|
||||
const url = media.preview_url ?? media.url;
|
||||
if (url && !urls.has(url)) urls.set(url, localPathFor(url, 800));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[images] médias Mastodon échoués:', err.message);
|
||||
}
|
||||
|
||||
console.log(`[images] ${urls.size} image(s) distante(s) à optimiser`);
|
||||
|
||||
const map = {};
|
||||
let total = 0;
|
||||
let done = 0;
|
||||
|
||||
for (const [remote, { local, width }] of urls) {
|
||||
const out = join(OUT_DIR, local.split('/').pop());
|
||||
try {
|
||||
const res = await fetch(remote);
|
||||
if (!res.ok) {
|
||||
console.warn(`[images] HTTP ${res.status} pour ${remote}`);
|
||||
continue;
|
||||
}
|
||||
const source = Buffer.from(await res.arrayBuffer());
|
||||
const tmp = `/tmp/opencode/img-${done}`;
|
||||
writeFileSync(tmp, source);
|
||||
execFileSync('convert', [
|
||||
tmp,
|
||||
'-auto-orient',
|
||||
'-resize',
|
||||
`${width}x${width}>`,
|
||||
'-quality',
|
||||
'80',
|
||||
'-define',
|
||||
'webp:method=6',
|
||||
out
|
||||
]);
|
||||
map[remote] = local;
|
||||
total += statSync(out).size;
|
||||
done++;
|
||||
} catch (err) {
|
||||
console.warn(`[images] échec pour ${remote}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(MAP_FILE, JSON.stringify(map, null, '\t') + '\n');
|
||||
console.log(`[images] ${done} optimisée(s), ${Math.round(total / 1024)} Ko au total`);
|
||||
Reference in New Issue
Block a user