Files
annu-kute-ced/scripts/optimize-remote-images.mjs
T
sucupira 6c2df9b1cd 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)
2026-07-23 03:37:03 -04:00

113 lines
3.5 KiB
JavaScript

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`);