1

Fix post-deploiement sur lemmy

This commit is contained in:
2025-12-12 06:25:23 +01:00
parent f2db43f197
commit 03dee1a03d
3 changed files with 449 additions and 11 deletions

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
/**
* Met à jour la date de publication des posts Lemmy à partir des articles Hugo.
* Met à jour la date de publication et le titre des posts Lemmy à partir des articles Hugo.
* Pré-requis : accès en écriture à la base Postgres de Lemmy
* (par exemple via LEMMY_DATABASE_URL=postgres:///lemmy?host=/run/postgresql&user=lemmy
* et exécution en tant qu'utilisateur système lemmy).
@@ -10,17 +10,30 @@
* - L'article doit contenir un frontmatter valide avec un champ date.
* - L'article doit contenir un comments_url pointant vers /post/{id}.
* - La date est appliquée sur post.published (timestamp avec fuseau Hugo).
* - Le titre Lemmy est aligné sur le titre Hugo (post.name et, si disponible, post.embed_title).
*/
const path = require("node:path");
const fs = require("node:fs");
const crypto = require("node:crypto");
const sharp = require("sharp");
const { LemmyHttp } = require("lemmy-js-client");
const { Pool } = require("pg");
const { collectBundles } = require("./lib/content");
const { parseFrontmatterDate } = require("./lib/datetime");
const { readFrontmatterFile } = require("./lib/frontmatter");
const { loadEnv } = require("./lib/env");
const { loadToolsConfig } = require("./lib/config");
const CONTENT_ROOT = path.join(__dirname, "..", "content");
const DEFAULT_DATABASE_URL = "postgres:///lemmy?host=/run/postgresql&user=lemmy";
const TOOLS_CONFIG_PATH = path.join(__dirname, "config", "config.json");
const THUMBNAIL_CACHE_DIR = path.join(__dirname, "cache", "lemmy_thumbnails");
const THUMBNAIL_FORMAT = "png";
const MAX_THUMBNAIL_WIDTH = 320;
const MAX_THUMBNAIL_HEIGHT = 240;
const THUMBNAIL_QUALITY = 82;
const FRONTMATTER_COVER_FIELD = "cover";
main().then(
() => {
@@ -39,6 +52,7 @@ async function main() {
loadEnv();
const databaseUrl = resolveDatabaseUrl();
const pool = new Pool({ connectionString: databaseUrl });
const hasEmbedTitle = await detectEmbedTitleColumn(pool);
const bundles = await collectBundles(CONTENT_ROOT);
const articles = collectArticlesWithPostId(bundles);
@@ -51,24 +65,112 @@ async function main() {
let updated = 0;
let unchanged = 0;
let missing = 0;
let lemmyClient = null;
let lemmyConfig = null;
for (const article of articles) {
const targetDate = article.publication.set({ millisecond: 0 });
const iso = targetDate.toISO();
const row = await fetchPost(pool, article.postId);
const row = await fetchPost(pool, article.postId, hasEmbedTitle);
if (!row) {
missing += 1;
console.warn(`⚠️ Post ${article.postId} introuvable pour ${article.bundle.relativePath}`);
continue;
}
const currentIso = new Date(row.published).toISOString();
if (currentIso === targetDate.toUTC().toISO()) {
const expectedUtcIso = targetDate.toUTC().toISO();
const expectedTitle = article.title;
const currentTitle = typeof row.name === "string" ? row.name.trim() : "";
const needsDateUpdate = currentIso !== expectedUtcIso;
const needsTitleUpdate = currentTitle !== expectedTitle;
let needsEmbedTitleUpdate = false;
if (hasEmbedTitle) {
const currentEmbedTitle =
typeof row.embed_title === "string" ? row.embed_title.trim() : "";
needsEmbedTitleUpdate = currentEmbedTitle !== expectedTitle;
}
const currentThumbnailUrl =
typeof row.thumbnail_url === "string" ? row.thumbnail_url.trim() : "";
let needsThumbnailCreation = false;
let coverPath = null;
const currentUrl = typeof row.url === "string" ? row.url.trim() : "";
const urlLooksLikePictrsImage = currentUrl.includes("/pictrs/image/");
if (!currentThumbnailUrl || urlLooksLikePictrsImage) {
coverPath = resolveCoverPath(article.bundle, article.frontmatter.data);
if (coverPath) {
needsThumbnailCreation = true;
}
}
if (!needsDateUpdate && !needsTitleUpdate && !needsEmbedTitleUpdate && !needsThumbnailCreation) {
unchanged += 1;
continue;
}
await applyDate(pool, article.postId, iso);
if (needsDateUpdate || needsTitleUpdate || needsEmbedTitleUpdate) {
await applyPostUpdates(
pool,
article.postId,
needsDateUpdate ? iso : null,
needsTitleUpdate ? expectedTitle : null,
needsEmbedTitleUpdate ? expectedTitle : null,
hasEmbedTitle
);
}
let createdThumbnailDescription = null;
if (needsThumbnailCreation && coverPath) {
if (!lemmyClient) {
const lemmySetup = await createLemmyClientFromConfig();
lemmyClient = lemmySetup.client;
lemmyConfig = lemmySetup.config;
}
const thumbnail = await buildThumbnailAsset(coverPath, article.bundle);
console.log(`Miniature générée : ${thumbnail.cachePath}`);
const thumbnailUrl = await uploadThumbnail(
lemmyClient,
thumbnail.buffer,
{
cachePath: thumbnail.cachePath,
bundlePath: article.bundle.relativePath,
},
lemmyConfig.instanceUrl
);
const articleUrl = buildArticleUrl(lemmyConfig.siteUrl, article.bundle.parts);
await attachThumbnailToPost(
lemmyClient,
article.postId,
expectedTitle,
currentUrl || null,
articleUrl,
thumbnailUrl
);
cleanupThumbnail(thumbnail.cachePath);
createdThumbnailDescription = "miniature";
}
updated += 1;
console.log(`✅ Post ${article.postId} mis à ${iso} (${article.bundle.relativePath})`);
const operations = [];
if (needsDateUpdate) {
operations.push(`date ${iso}`);
}
if (needsTitleUpdate || needsEmbedTitleUpdate) {
operations.push("titre");
}
if (createdThumbnailDescription) {
operations.push(createdThumbnailDescription);
}
const details = operations.length > 0 ? ` (${operations.join(" + ")})` : "";
console.log(`✅ Post ${article.postId} mis à jour${details} (${article.bundle.relativePath})`);
}
await pool.end();
@@ -126,6 +228,8 @@ function collectArticlesWithPostId(bundles) {
articles.push({
bundle,
publication,
title,
frontmatter,
postId,
});
}
@@ -141,6 +245,113 @@ function collectArticlesWithPostId(bundles) {
return articles;
}
/**
* Indique si la colonne embed_title est présente sur la table post.
* @param {Pool} pool Pool Postgres.
* @returns {Promise<boolean>} true si embed_title est disponible.
*/
async function detectEmbedTitleColumn(pool) {
const result = await pool.query(
"select column_name from information_schema.columns where table_name = 'post' and column_name = 'embed_title' limit 1"
);
return result.rowCount === 1;
}
/**
* Normalise une URL simple en supprimant les espaces et slashs finaux.
* @param {string|null|undefined} url URL brute.
* @returns {string|null} URL normalisée ou null.
*/
function normalizeUrl(url) {
if (typeof url !== "string") {
return null;
}
const trimmed = url.trim();
if (!trimmed) {
return null;
}
return trimmed.replace(/\/+$/, "");
}
/**
* Normalise la configuration Lemmy extraite des fichiers tools/config.
* @param {object} rawConfig Configuration brute.
* @returns {{ instanceUrl: string, siteUrl: string, auth: { jwt: string|null, username: string|null, password: string|null } }} Configuration prête à l'emploi.
*/
function normalizeLemmyConfig(rawConfig) {
if (!rawConfig || typeof rawConfig !== "object") {
throw new Error("La configuration Lemmy est manquante (tools/config/config.json).");
}
const instanceUrl = normalizeUrl(rawConfig.instanceUrl);
if (!instanceUrl) {
throw new Error(
"lemmy.instanceUrl doit être renseigné dans tools/config/config.json ou via l'environnement."
);
}
const siteUrl = normalizeUrl(rawConfig.siteUrl);
if (!siteUrl) {
throw new Error("lemmy.siteUrl doit être défini pour construire les URLs des articles.");
}
const auth = rawConfig.auth || {};
const hasJwt = typeof auth.jwt === "string" && auth.jwt.trim().length > 0;
const hasCredentials =
typeof auth.username === "string" &&
auth.username.trim().length > 0 &&
typeof auth.password === "string" &&
auth.password.length > 0;
if (!hasJwt && !hasCredentials) {
throw new Error("lemmy.auth.jwt ou lemmy.auth.username + lemmy.auth.password doivent être fournis.");
}
return {
instanceUrl,
siteUrl,
auth: {
jwt: hasJwt ? auth.jwt.trim() : null,
username: hasCredentials ? auth.username.trim() : null,
password: hasCredentials ? auth.password : null,
},
};
}
/**
* Crée un client Lemmy configuré à partir de tools/config/config.json.
* @returns {Promise<{ client: LemmyHttp, config: { instanceUrl: string, siteUrl: string, auth: object } }>}
*/
async function createLemmyClientFromConfig() {
const toolsConfig = await loadToolsConfig(TOOLS_CONFIG_PATH);
const rawLemmy = toolsConfig.lemmy || {};
const config = normalizeLemmyConfig(rawLemmy);
const client = new LemmyHttp(config.instanceUrl);
if (config.auth.jwt) {
client.setHeaders({ Authorization: `Bearer ${config.auth.jwt}` });
return { client, config };
}
const loginResponse = await client.login({
username_or_email: config.auth.username,
password: config.auth.password,
});
client.setHeaders({ Authorization: `Bearer ${loginResponse.jwt}` });
return { client, config };
}
/**
* Construit l'URL publique d'un article à partir de son chemin Hugo.
* @param {string} siteUrl Domaine du site.
* @param {string[]} parts Segments du chemin du bundle.
* @returns {string} URL finale.
*/
function buildArticleUrl(siteUrl, parts) {
const relative = parts.join("/");
return `${siteUrl}/${relative}`;
}
/**
* Extrait l'identifiant numérique d'un comments_url Lemmy.
* @param {string} url URL issue du frontmatter.
@@ -163,10 +374,14 @@ function extractPostId(url) {
* Récupère un post Lemmy par identifiant.
* @param {Pool} pool Pool Postgres.
* @param {number} postId Identifiant du post.
* @param {boolean} withEmbedTitle true pour récupérer embed_title si disponible.
* @returns {Promise<object|null>} Enregistrement ou null.
*/
async function fetchPost(pool, postId) {
const result = await pool.query("select id, published from post where id = $1", [postId]);
async function fetchPost(pool, postId, withEmbedTitle) {
const columns = withEmbedTitle
? "id, published, name, embed_title, url, thumbnail_url"
: "id, published, name, url, thumbnail_url";
const result = await pool.query(`select ${columns} from post where id = $1`, [postId]);
if (result.rowCount !== 1) {
return null;
}
@@ -174,11 +389,232 @@ async function fetchPost(pool, postId) {
}
/**
* Applique la date ISO fournie sur le post ciblé.
* Applique la date et le titre fournis sur le post ciblé.
* @param {Pool} pool Pool Postgres.
* @param {number} postId Identifiant du post.
* @param {string} isoDate Timestamp ISO.
* @param {string|null} isoDate Timestamp ISO (null pour laisser inchangé).
* @param {string|null} title Titre attendu (null pour laisser inchangé).
* @param {string|null} embedTitle Titre embarqué attendu (null pour laisser inchangé).
* @param {boolean} withEmbedTitle true si embed_title peut être mis à jour.
*/
async function applyDate(pool, postId, isoDate) {
await pool.query("update post set published = $1 where id = $2", [isoDate, postId]);
async function applyPostUpdates(pool, postId, isoDate, title, embedTitle, withEmbedTitle) {
const fields = [];
const values = [];
let index = 1;
if (isoDate !== null) {
fields.push(`published = $${index}`);
values.push(isoDate);
index += 1;
}
if (title !== null) {
fields.push(`name = $${index}`);
values.push(title);
index += 1;
}
if (withEmbedTitle && embedTitle !== null) {
fields.push(`embed_title = $${index}`);
values.push(embedTitle);
index += 1;
}
if (fields.length === 0) {
return;
}
values.push(postId);
const sql = `update post set ${fields.join(", ")} where id = $${index}`;
await pool.query(sql, values);
}
/**
* Détermine le chemin absolu vers l'image de couverture déclarée.
* @param {object} bundle Bundle en cours de traitement.
* @param {object} frontmatterData Données du frontmatter.
* @returns {string|null} Chemin absolu ou null si inexistant.
*/
function resolveCoverPath(bundle, frontmatterData) {
const cover =
typeof frontmatterData?.[FRONTMATTER_COVER_FIELD] === "string"
? frontmatterData[FRONTMATTER_COVER_FIELD].trim()
: "";
if (!cover) {
return null;
}
const normalized = cover.replace(/^\.?\//, "").replace(/\/{2,}/g, "/");
if (!normalized) {
return null;
}
const absolute = path.join(bundle.dir, normalized);
if (!fs.existsSync(absolute)) {
console.warn(`⚠️ ${bundle.relativePath} : couverture ${normalized} introuvable.`);
return null;
}
const stats = fs.statSync(absolute);
if (!stats.isFile()) {
console.warn(`⚠️ ${bundle.relativePath} : ${normalized} n'est pas un fichier image.`);
return null;
}
return absolute;
}
/**
* Génère une miniature et la sauvegarde sur disque pour inspection si besoin.
* @param {string} coverPath Chemin absolu de l'image source.
* @param {object} bundle Bundle concerné.
* @returns {Promise<{ buffer: Buffer, cachePath: string }>} Miniature prête à l'emploi.
*/
async function buildThumbnailAsset(coverPath, bundle) {
const buffer = await createThumbnail(coverPath, THUMBNAIL_FORMAT);
const cachePath = writeThumbnailToCache(buffer, bundle, coverPath, THUMBNAIL_FORMAT);
return { buffer, cachePath };
}
/**
* Construit la miniature à partir de l'image de couverture.
* @param {string} absolutePath Chemin absolu vers l'image source.
* @param {string} format Format de sortie (jpeg|png).
* @returns {Promise<Buffer>} Données JPEG redimensionnées.
*/
async function createThumbnail(absolutePath, format = "jpeg") {
const base = sharp(absolutePath).resize({
width: MAX_THUMBNAIL_WIDTH,
height: MAX_THUMBNAIL_HEIGHT,
fit: "inside",
withoutEnlargement: true,
});
if (format === "png") {
return base
.png({
compressionLevel: 9,
palette: true,
effort: 5,
})
.toBuffer();
}
return base.jpeg({ quality: THUMBNAIL_QUALITY, mozjpeg: true }).toBuffer();
}
/**
* Téléverse une miniature vers Lemmy et retourne l'URL publique.
* @param {LemmyHttp} client Client Lemmy.
* @param {Buffer} thumbnailBuffer Données de l'image.
* @param {object} info Informations de contexte pour les erreurs.
* @param {string|null|undefined} instanceUrl URL de l'instance Lemmy (pour harmoniser le schéma).
* @returns {Promise<string>} URL de la miniature hébergée par Lemmy.
*/
async function uploadThumbnail(client, thumbnailBuffer, info, instanceUrl) {
const label = info?.bundlePath ? ` pour ${info.bundlePath}` : "";
const location = info?.cachePath ? ` (miniature : ${info.cachePath})` : "";
const upload = await client.uploadImage({ image: thumbnailBuffer }).catch((error) => {
const reason = typeof error?.message === "string" && error.message.trim() ? error.message : "erreur inconnue";
throw new Error(`Téléversement Lemmy échoué${label}${location} : ${reason}`);
});
if (!upload) {
throw new Error(`Miniature rejetée${label}${location} : réponse vide`);
}
if (upload.error) {
throw new Error(`Miniature rejetée${label}${location} : ${upload.error}`);
}
if (upload.msg !== "ok" || !upload.url) {
const details = JSON.stringify(upload);
throw new Error(`Miniature rejetée${label}${location} : réponse inattendue ${details}`);
}
let url = upload.url;
if (typeof url !== "string") {
throw new Error(`Miniature rejetée${label}${location} : URL de réponse invalide`);
}
url = url.trim();
if (!url) {
throw new Error(`Miniature rejetée${label}${location} : URL de réponse vide`);
}
if (
typeof instanceUrl === "string" &&
instanceUrl.startsWith("https://") &&
url.startsWith("http://")
) {
url = `https://${url.slice("http://".length)}`;
}
return url;
}
/**
* Associe une miniature à un post Lemmy existant via l'API.
* @param {LemmyHttp} client Client Lemmy.
* @param {number} postId Identifiant du post.
* @param {string} title Titre attendu.
* @param {string|null} currentUrl URL actuelle du post.
* @param {string} articleUrl URL Hugo de l'article.
* @param {string} thumbnailUrl URL de la miniature hébergée.
* @returns {Promise<void>} Promesse résolue une fois l'édition terminée.
*/
async function attachThumbnailToPost(client, postId, title, currentUrl, articleUrl, thumbnailUrl) {
const normalizedCurrentUrl =
typeof currentUrl === "string" && currentUrl.trim().length > 0 ? currentUrl.trim() : null;
const payload = {
post_id: postId,
name: title,
custom_thumbnail: thumbnailUrl,
};
let targetUrl = normalizedCurrentUrl;
if (!targetUrl || targetUrl.includes("/pictrs/image/")) {
targetUrl = articleUrl;
}
if (targetUrl) {
payload.url = targetUrl;
}
await client.editPost(payload);
}
/**
* Écrit la miniature générée sur disque dans tools/cache pour inspection.
* @param {Buffer} buffer Miniature prête.
* @param {object} bundle Bundle concerné.
* @param {string} coverPath Chemin de la couverture d'origine.
* @param {string} format Format de la miniature (jpeg|png).
* @returns {string} Chemin du fichier écrit.
*/
function writeThumbnailToCache(buffer, bundle, coverPath, format) {
const targetPath = computeThumbnailCachePath(bundle, coverPath, format);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, buffer);
return targetPath;
}
/**
* Supprime la miniature mise en cache une fois le post Lemmy mis à jour.
* @param {string|null} cachePath Chemin éventuel de la miniature.
*/
function cleanupThumbnail(cachePath) {
if (!cachePath) {
return;
}
if (fs.existsSync(cachePath)) {
fs.unlinkSync(cachePath);
}
}
/**
* Construit un nom de fichier stable et assaini pour la miniature en cache.
* @param {object} bundle Bundle concerné.
* @param {string} coverPath Chemin de la couverture.
* @param {string} format Format de la miniature.
* @returns {string} Chemin cible dans tools/cache/lemmy_thumbnails.
*/
function computeThumbnailCachePath(bundle, coverPath, format = "jpeg") {
const base = `${bundle.relativePath}__${path.basename(coverPath)}`;
const safeBase = base
.replace(/[^a-zA-Z0-9._-]+/g, "_")
.replace(/_{2,}/g, "_")
.replace(/^_|_$/g, "");
const hash = crypto.createHash("sha1").update(base).digest("hex").slice(0, 10);
const extension = format === "png" ? "png" : "jpg";
const name = `${safeBase || "thumbnail"}_${hash}.${extension}`;
return path.join(THUMBNAIL_CACHE_DIR, name);
}