Synchronisation du contenu avec lemmy
This commit is contained in:
@@ -47,6 +47,9 @@ node "$SCRIPT_DIR/tools/check_internal_links.js"
|
||||
echo "==> Enrichissement météo des articles"
|
||||
node "$SCRIPT_DIR/tools/add_weather.js"
|
||||
|
||||
echo "==> Synchronisation des commentaires Lemmy"
|
||||
node "$SCRIPT_DIR/tools/sync_lemmy_comments.js"
|
||||
|
||||
echo "==> Génération des statistiques"
|
||||
npm run stats:generate
|
||||
|
||||
|
||||
193
package-lock.json
generated
193
package-lock.json
generated
@@ -9,7 +9,9 @@
|
||||
"@napi-rs/canvas": "^0.1.59",
|
||||
"chart.js": "^4.4.4",
|
||||
"chartjs-node-canvas": "^5.0.0",
|
||||
"lemmy-js-client": "^0.19.6",
|
||||
"luxon": "^3.7.2",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "^1.49.0",
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-nested": "^7.0.2",
|
||||
@@ -945,28 +947,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.8.0.tgz",
|
||||
"integrity": "sha512-yTwt2KWRmCQAfhvbCRjebaSX8pV1//I0Y3g+A7f/eS7gf0l4eRJoUCvcYdVtboeU4CTOZQuqYbZNS8aBYb8ROQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"progress": "^2.0.3",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"semver": "^7.7.1",
|
||||
"tar-fs": "^3.0.8",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"browsers": "lib/cjs/main-cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/focus": {
|
||||
"version": "3.21.2",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz",
|
||||
@@ -2034,20 +2014,6 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-2.1.2.tgz",
|
||||
"integrity": "sha512-vtRWBK2uImo5/W2oG6/cDkkHSm+2t6VHgnj+Rcwhb0pP74OoUb4GipyRX/T/y39gYQPhioP0DPShn+A7P6CHNw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"devtools-protocol": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
@@ -3728,6 +3694,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lemmy-js-client": {
|
||||
"version": "0.19.6",
|
||||
"resolved": "https://registry.npmjs.org/lemmy-js-client/-/lemmy-js-client-0.19.6.tgz",
|
||||
"integrity": "sha512-5bs9NfjKcXVFJE3goHvu0Uo3u6+CMyQsccT2XCJyp0JExtNZM3F9oBl3t1/wiOMMGyNKiDJ+mCC9Y9gXwa1/iw==",
|
||||
"license": "AGPL-3.0"
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -4205,6 +4177,96 @@
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
"pg-protocol": "^1.10.3",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.2.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -4627,6 +4689,45 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
@@ -5464,7 +5565,6 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
@@ -6115,6 +6215,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
@@ -6173,16 +6282,6 @@
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.24.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
||||
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
"stats:generate": "node tools/generate_stats.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.59",
|
||||
"@influxdata/influxdb-client": "^1.35.0",
|
||||
"@napi-rs/canvas": "^0.1.59",
|
||||
"chart.js": "^4.4.4",
|
||||
"chartjs-node-canvas": "^5.0.0",
|
||||
"lemmy-js-client": "^0.19.6",
|
||||
"luxon": "^3.7.2",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "^1.49.0",
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-nested": "^7.0.2",
|
||||
"puppeteer": "^23.11.1",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"playwright": "^1.49.0",
|
||||
"sharp": "^0.33.5",
|
||||
"undici": "^7.16.0",
|
||||
"user-agents": "^1.1.480"
|
||||
|
||||
@@ -108,5 +108,29 @@
|
||||
},
|
||||
"goaccess": {
|
||||
"url": null
|
||||
},
|
||||
"lemmy": {
|
||||
"instanceUrl": null,
|
||||
"siteUrl": "https://richard-dern.fr",
|
||||
"auth": {
|
||||
"username": null,
|
||||
"password": null,
|
||||
"jwt": null
|
||||
},
|
||||
"community": {
|
||||
"prefixOverrides": {
|
||||
"collections": "collection",
|
||||
"critiques": "critique",
|
||||
"interets": "interet"
|
||||
},
|
||||
"visibility": "Public",
|
||||
"nsfw": false,
|
||||
"descriptionTemplate": "Espace dédié aux échanges autour de {{path}}."
|
||||
},
|
||||
"cacheFile": "tools/cache/lemmy_sync.json",
|
||||
"verificationTtlHours": {
|
||||
"community": 168,
|
||||
"post": 24
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,33 @@ function applyEnvOverrides(config = {}) {
|
||||
merged.goaccess.url = process.env.GOACCESS_URL;
|
||||
}
|
||||
|
||||
const lemmy = config.lemmy || {};
|
||||
const community = lemmy.community || {};
|
||||
merged.lemmy = {
|
||||
...lemmy,
|
||||
auth: { ...(lemmy.auth || {}) },
|
||||
community: {
|
||||
...community,
|
||||
prefixOverrides: { ...(community.prefixOverrides || {}) },
|
||||
},
|
||||
verificationTtlHours: { ...(lemmy.verificationTtlHours || {}) },
|
||||
};
|
||||
if (process.env.LEMMY_INSTANCE_URL) {
|
||||
merged.lemmy.instanceUrl = process.env.LEMMY_INSTANCE_URL;
|
||||
}
|
||||
if (process.env.LEMMY_SITE_URL) {
|
||||
merged.lemmy.siteUrl = process.env.LEMMY_SITE_URL;
|
||||
}
|
||||
if (process.env.LEMMY_JWT) {
|
||||
merged.lemmy.auth.jwt = process.env.LEMMY_JWT;
|
||||
}
|
||||
if (process.env.LEMMY_USERNAME) {
|
||||
merged.lemmy.auth.username = process.env.LEMMY_USERNAME;
|
||||
}
|
||||
if (process.env.LEMMY_PASSWORD) {
|
||||
merged.lemmy.auth.password = process.env.LEMMY_PASSWORD;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,8 +92,53 @@ async function resolveMarkdownTargets(inputs, { rootDir = process.cwd(), skipInd
|
||||
return Array.from(targets);
|
||||
}
|
||||
|
||||
async function collectBundles(rootDir) {
|
||||
const bundles = [];
|
||||
await walk(rootDir, rootDir, bundles);
|
||||
bundles.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
return bundles;
|
||||
}
|
||||
|
||||
async function walk(rootDir, currentDir, bucket) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Lecture impossible de ${currentDir}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let hasIndex = false;
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name === "index.md") {
|
||||
hasIndex = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasIndex) {
|
||||
const relative = path.relative(rootDir, currentDir);
|
||||
const parts = relative.split(path.sep).filter(Boolean);
|
||||
const slug = parts[parts.length - 1] || path.basename(currentDir);
|
||||
bucket.push({
|
||||
dir: currentDir,
|
||||
indexPath: path.join(currentDir, "index.md"),
|
||||
relativePath: parts.join("/"),
|
||||
parts,
|
||||
slug,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
||||
await walk(rootDir, path.join(currentDir, entry.name), bucket);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectMarkdownFiles,
|
||||
collectSectionIndexDirs,
|
||||
resolveMarkdownTargets,
|
||||
collectBundles,
|
||||
};
|
||||
|
||||
@@ -100,8 +100,48 @@ function formatDateTime(value = null) {
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une valeur de frontmatter en DateTime si elle est valide.
|
||||
* @param {import("luxon").DateTime|Date|string|number|null|undefined} value Valeur lue depuis le frontmatter.
|
||||
* @returns {import("luxon").DateTime|null} DateTime utilisable ou null si invalide.
|
||||
*/
|
||||
function parseFrontmatterDate(value) {
|
||||
const zone = getHugoTimeZone();
|
||||
|
||||
if (DateTime.isDateTime(value)) {
|
||||
const zoned = value.setZone(zone);
|
||||
return zoned.isValid ? zoned : null;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
const zoned = DateTime.fromJSDate(value, { zone });
|
||||
return zoned.isValid ? zoned : null;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const iso = DateTime.fromISO(trimmed, { setZone: true }).setZone(zone);
|
||||
if (iso.isValid) {
|
||||
return iso;
|
||||
}
|
||||
const rfc2822 = DateTime.fromRFC2822(trimmed, { setZone: true }).setZone(zone);
|
||||
return rfc2822.isValid ? rfc2822 : null;
|
||||
}
|
||||
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
const zoned = DateTime.fromMillis(value, { zone }).setZone(zone);
|
||||
return zoned.isValid ? zoned : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatDateTime,
|
||||
getHugoTimeZone,
|
||||
toHugoDateTime,
|
||||
parseFrontmatterDate,
|
||||
};
|
||||
|
||||
53
tools/lib/frontmatter.js
Normal file
53
tools/lib/frontmatter.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const fs = require("node:fs");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?/;
|
||||
|
||||
/**
|
||||
* Lit le frontmatter d'un fichier Markdown et retourne son contenu brut.
|
||||
* La fonction préserve également le corps du fichier afin de permettre
|
||||
* une réécriture propre après modification.
|
||||
* @param {string} filePath Chemin absolu du fichier à analyser.
|
||||
* @returns {{ data: Record<string, any>, body: string, frontmatterText: string, raw: string }|null}
|
||||
*/
|
||||
function readFrontmatterFile(filePath) {
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const match = raw.match(FRONTMATTER_PATTERN);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const frontmatterText = match[1];
|
||||
const data = yaml.load(frontmatterText) || {};
|
||||
const body = raw.slice(match[0].length);
|
||||
return {
|
||||
data,
|
||||
body,
|
||||
frontmatterText,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Réécrit complètement le fichier Markdown avec un frontmatter mis à jour.
|
||||
* @param {string} filePath Chemin absolu du fichier.
|
||||
* @param {Record<string, any>} frontmatter Objet contenant les métadonnées.
|
||||
* @param {string} body Corps Markdown déjà prêt à être réinséré.
|
||||
*/
|
||||
function writeFrontmatterFile(filePath, frontmatter, body) {
|
||||
if (
|
||||
typeof frontmatter !== "object" ||
|
||||
frontmatter === null ||
|
||||
Array.isArray(frontmatter)
|
||||
) {
|
||||
throw new Error(`Frontmatter invalide pour ${filePath}`);
|
||||
}
|
||||
const serialized = yaml.dump(frontmatter, { lineWidth: 120, sortKeys: false }).trimEnd();
|
||||
const contentBody = typeof body === "string" ? body : "";
|
||||
const rewritten = `---\n${serialized}\n---\n${contentBody}`;
|
||||
fs.writeFileSync(filePath, rewritten, "utf8");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readFrontmatterFile,
|
||||
writeFrontmatterFile,
|
||||
};
|
||||
741
tools/sync_lemmy_comments.js
Normal file
741
tools/sync_lemmy_comments.js
Normal file
@@ -0,0 +1,741 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const sharp = require("sharp");
|
||||
const { LemmyHttp } = require("lemmy-js-client");
|
||||
const { collectBundles } = require("./lib/content");
|
||||
const { loadToolsConfig } = require("./lib/config");
|
||||
const { parseFrontmatterDate } = require("./lib/datetime");
|
||||
const { readFrontmatterFile, writeFrontmatterFile } = require("./lib/frontmatter");
|
||||
|
||||
const CONTENT_ROOT = path.join(__dirname, "..", "content");
|
||||
const FRONTMATTER_COMMENT_FIELD = "comments_url";
|
||||
const FRONTMATTER_COVER_FIELD = "cover";
|
||||
const MAX_COMMUNITY_NAME_LENGTH = 20;
|
||||
const MIN_COMMUNITY_NAME_LENGTH = 3;
|
||||
const MAX_THUMBNAIL_WIDTH = 320;
|
||||
const MAX_THUMBNAIL_HEIGHT = 240;
|
||||
const THUMBNAIL_QUALITY = 82;
|
||||
const THUMBNAIL_CACHE_DIR = path.join(__dirname, "cache", "lemmy_thumbnails");
|
||||
const THUMBNAIL_FORMAT = "png";
|
||||
|
||||
main().then(
|
||||
() => {
|
||||
process.exit(0);
|
||||
},
|
||||
(error) => {
|
||||
console.error(`❌ Synchronisation Lemmy interrompue : ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Point d'entrée principal : charge la configuration, collecte les articles et orchestre la synchronisation.
|
||||
*/
|
||||
async function main() {
|
||||
const toolsConfig = await loadToolsConfig(path.join(__dirname, "config", "config.json"));
|
||||
const lemmyConfig = normalizeLemmyConfig(toolsConfig.lemmy);
|
||||
const client = await createLemmyClient(lemmyConfig);
|
||||
const bundles = await collectBundles(CONTENT_ROOT);
|
||||
const articles = selectArticles(bundles);
|
||||
|
||||
if (articles.length === 0) {
|
||||
console.log("Aucun article à synchroniser.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const article of articles) {
|
||||
await synchroniseArticle(article, lemmyConfig, client);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide et normalise la configuration Lemmy issue des fichiers et variables d'environnement.
|
||||
* @param {object} rawConfig Configuration telle que chargée par loadToolsConfig.
|
||||
* @returns {object} Configuration prête à l'emploi pour Lemmy.
|
||||
*/
|
||||
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.");
|
||||
}
|
||||
|
||||
const prefixOverrides = buildOverrides(rawConfig.community?.prefixOverrides || {});
|
||||
const descriptionTemplate =
|
||||
typeof rawConfig.community?.descriptionTemplate === "string" &&
|
||||
rawConfig.community.descriptionTemplate.trim().length > 0
|
||||
? rawConfig.community.descriptionTemplate.trim()
|
||||
: "Espace dédié aux échanges autour de {{path}}.";
|
||||
|
||||
return {
|
||||
instanceUrl,
|
||||
siteUrl,
|
||||
auth: {
|
||||
jwt: hasJwt ? auth.jwt.trim() : null,
|
||||
username: hasCredentials ? auth.username.trim() : null,
|
||||
password: hasCredentials ? auth.password : null,
|
||||
},
|
||||
community: {
|
||||
visibility: rawConfig.community?.visibility || "Public",
|
||||
nsfw: rawConfig.community?.nsfw === true,
|
||||
descriptionTemplate,
|
||||
prefixOverrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un client Lemmy authentifié via JWT ou couple utilisateur/mot de passe.
|
||||
* @param {object} lemmyConfig Configuration normalisée.
|
||||
* @returns {Promise<LemmyHttp>} Client prêt pour les appels API.
|
||||
*/
|
||||
async function createLemmyClient(lemmyConfig) {
|
||||
const client = new LemmyHttp(lemmyConfig.instanceUrl);
|
||||
if (lemmyConfig.auth.jwt) {
|
||||
client.setHeaders({ Authorization: `Bearer ${lemmyConfig.auth.jwt}` });
|
||||
return client;
|
||||
}
|
||||
const loginResponse = await client.login({
|
||||
username_or_email: lemmyConfig.auth.username,
|
||||
password: lemmyConfig.auth.password,
|
||||
});
|
||||
client.setHeaders({ Authorization: `Bearer ${loginResponse.jwt}` });
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare la liste des articles à synchroniser : frontmatter présent, date valide, comments_url absent.
|
||||
* Le tri est effectué par date croissante, puis par chemin en cas d'égalité.
|
||||
* @param {Array<object>} bundles Bundles collectés sous content/.
|
||||
* @returns {Array<object>} Articles prêts pour la synchronisation.
|
||||
*/
|
||||
function selectArticles(bundles) {
|
||||
const articles = [];
|
||||
|
||||
for (const bundle of bundles) {
|
||||
const frontmatter = readFrontmatterFile(bundle.indexPath);
|
||||
if (!frontmatter) {
|
||||
console.warn(`⚠️ ${bundle.relativePath} : frontmatter introuvable, article ignoré.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingComments =
|
||||
typeof frontmatter.data?.[FRONTMATTER_COMMENT_FIELD] === "string"
|
||||
? frontmatter.data[FRONTMATTER_COMMENT_FIELD].trim()
|
||||
: "";
|
||||
if (existingComments) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const publication = parseFrontmatterDate(frontmatter.data?.date);
|
||||
if (!publication) {
|
||||
console.warn(`⚠️ ${bundle.relativePath} : date absente ou invalide, article ignoré.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = typeof frontmatter.data?.title === "string" ? frontmatter.data.title.trim() : "";
|
||||
if (!title) {
|
||||
console.warn(`⚠️ ${bundle.relativePath} : titre manquant, article ignoré.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
articles.push({
|
||||
bundle,
|
||||
frontmatter,
|
||||
publication,
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
articles.sort((a, b) => {
|
||||
const diff = a.publication.toMillis() - b.publication.toMillis();
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
return a.bundle.relativePath.localeCompare(b.bundle.relativePath);
|
||||
});
|
||||
|
||||
return articles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronise un article unique : communauté, miniature, post Lemmy et mise à jour du frontmatter.
|
||||
* @param {object} article Article préparé par selectArticles.
|
||||
* @param {object} lemmyConfig Configuration Lemmy.
|
||||
* @param {LemmyHttp} client Client Lemmy.
|
||||
*/
|
||||
async function synchroniseArticle(article, lemmyConfig, client) {
|
||||
const communityDescriptor = buildCommunityDescriptor(article.bundle.parts, lemmyConfig.community);
|
||||
const community = await ensureCommunity(client, communityDescriptor, lemmyConfig.community);
|
||||
const coverPath = resolveCoverPath(article.bundle, article.frontmatter.data);
|
||||
|
||||
let thumbnailUrl = null;
|
||||
let thumbnailCachePath = null;
|
||||
if (coverPath) {
|
||||
const coverRelative = path.relative(process.cwd(), coverPath);
|
||||
console.log(`Préparation de ${article.bundle.relativePath} (couverture : ${coverRelative})`);
|
||||
|
||||
const thumbnail = await buildThumbnailAsset(coverPath, article.bundle);
|
||||
thumbnailCachePath = thumbnail.cachePath;
|
||||
console.log(`Miniature générée : ${thumbnail.cachePath}`);
|
||||
|
||||
thumbnailUrl = await uploadThumbnail(client, thumbnail.buffer, {
|
||||
cachePath: thumbnail.cachePath,
|
||||
bundlePath: article.bundle.relativePath,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`⚠️ ${article.bundle.relativePath} : aucune couverture exploitable, création du post sans miniature.`
|
||||
);
|
||||
}
|
||||
|
||||
const articleUrl = buildArticleUrl(lemmyConfig.siteUrl, article.bundle.parts);
|
||||
const post = await ensurePost(client, community, {
|
||||
title: article.title,
|
||||
articleUrl,
|
||||
thumbnailUrl,
|
||||
});
|
||||
const commentsUrl = buildCommentsUrl(lemmyConfig.instanceUrl, post.id);
|
||||
|
||||
article.frontmatter.data[FRONTMATTER_COMMENT_FIELD] = commentsUrl;
|
||||
writeFrontmatterFile(article.bundle.indexPath, article.frontmatter.data, article.frontmatter.body);
|
||||
cleanupThumbnail(thumbnailCachePath);
|
||||
|
||||
console.log(`✅ ${article.bundle.relativePath} → ${community.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforme les segments de chemin en nom et titre de communauté Lemmy.
|
||||
* @param {string[]} parts Segments du chemin du bundle.
|
||||
* @param {object} communityConfig Configuration de la section community.
|
||||
* @returns {{ name: string, title: string, description: string }}
|
||||
*/
|
||||
function buildCommunityDescriptor(parts, communityConfig) {
|
||||
const intermediate = stripDateSegments(parts.slice(0, -1));
|
||||
if (intermediate.length === 0) {
|
||||
throw new Error(`Impossible de déduire une communauté depuis ${parts.join("/")}.`);
|
||||
}
|
||||
|
||||
const normalized = intermediate.map((segment) => applyOverride(segment, communityConfig.prefixOverrides));
|
||||
const sanitized = normalized.map((segment) => sanitizeSegment(segment)).filter(Boolean);
|
||||
if (sanitized.length === 0) {
|
||||
throw new Error(`Les segments ${intermediate.join("/")} sont invalides pour une communauté.`);
|
||||
}
|
||||
|
||||
const name = enforceCommunityLength(sanitized);
|
||||
if (name.length < MIN_COMMUNITY_NAME_LENGTH) {
|
||||
throw new Error(`Nom de communauté trop court pour ${parts.join("/")}.`);
|
||||
}
|
||||
|
||||
const title = normalized.map((segment) => capitalizeLabel(segment)).join(" / ");
|
||||
const labelPath = normalized.join("/");
|
||||
const description = buildCommunityDescription(communityConfig.descriptionTemplate, labelPath);
|
||||
|
||||
return { name, title, description };
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime les segments correspondant à un pattern année/mois/jour consécutif.
|
||||
* @param {string[]} segments Segments intermédiaires du chemin.
|
||||
* @returns {string[]} Segments épurés des dates.
|
||||
*/
|
||||
function stripDateSegments(segments) {
|
||||
const filtered = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < segments.length) {
|
||||
if (
|
||||
isYearSegment(segments[index]) &&
|
||||
isMonthSegment(segments[index + 1]) &&
|
||||
isDaySegment(segments[index + 2])
|
||||
) {
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
filtered.push(segments[index]);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si un segment représente une année sur 4 chiffres.
|
||||
* @param {string|undefined} value Segment à tester.
|
||||
* @returns {boolean} true si le segment est une année.
|
||||
*/
|
||||
function isYearSegment(value) {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
return /^\d{4}$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si un segment représente un mois numérique valide.
|
||||
* @param {string|undefined} value Segment à tester.
|
||||
* @returns {boolean} true si le segment est un mois.
|
||||
*/
|
||||
function isMonthSegment(value) {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (!/^\d{1,2}$/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
const numeric = Number.parseInt(value, 10);
|
||||
return numeric >= 1 && numeric <= 12;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si un segment représente un jour numérique valide.
|
||||
* @param {string|undefined} value Segment à tester.
|
||||
* @returns {boolean} true si le segment est un jour.
|
||||
*/
|
||||
function isDaySegment(value) {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (!/^\d{1,2}$/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
const numeric = Number.parseInt(value, 10);
|
||||
return numeric >= 1 && numeric <= 31;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique une table de remplacements sur un segment en respectant la casse initiale.
|
||||
* @param {string} segment Segment brut.
|
||||
* @param {Record<string, string>} overrides Table de substitutions.
|
||||
* @returns {string} Segment éventuellement remplacé.
|
||||
*/
|
||||
function applyOverride(segment, overrides) {
|
||||
const lookup = segment.toLowerCase();
|
||||
if (overrides[lookup]) {
|
||||
return overrides[lookup];
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie un segment pour l'utiliser dans un nom de communauté Lemmy.
|
||||
* @param {string} segment Valeur brute.
|
||||
* @returns {string} Segment assaini.
|
||||
*/
|
||||
function sanitizeSegment(segment) {
|
||||
return segment
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/_{2,}/g, "_")
|
||||
.replace(/^_|_$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Garantit que le nom de communauté respecte la longueur imposée par Lemmy.
|
||||
* @param {string[]} segments Segments déjà assainis.
|
||||
* @returns {string} Nom final.
|
||||
*/
|
||||
function enforceCommunityLength(segments) {
|
||||
const reduced = segments.map((segment) => segment.slice(0, MAX_COMMUNITY_NAME_LENGTH));
|
||||
let current = reduced.join("_");
|
||||
if (current.length <= MAX_COMMUNITY_NAME_LENGTH) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const working = [...reduced];
|
||||
let cursor = working.length - 1;
|
||||
while (current.length > MAX_COMMUNITY_NAME_LENGTH && cursor >= 0) {
|
||||
if (working[cursor].length > 1) {
|
||||
working[cursor] = working[cursor].slice(0, -1);
|
||||
current = working.join("_");
|
||||
continue;
|
||||
}
|
||||
cursor -= 1;
|
||||
}
|
||||
|
||||
if (current.length <= MAX_COMMUNITY_NAME_LENGTH) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const compactSource = segments.join("_");
|
||||
const compact = compactSource.replace(/_/g, "").slice(0, Math.max(1, MAX_COMMUNITY_NAME_LENGTH - 5));
|
||||
const hash = crypto.createHash("sha1").update(compactSource).digest("hex");
|
||||
const suffixLength = Math.max(2, MAX_COMMUNITY_NAME_LENGTH - compact.length - 1);
|
||||
const suffix = hash.slice(0, suffixLength);
|
||||
return `${compact}_${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit un titre lisible pour la communauté.
|
||||
* @param {string} value Segment brut.
|
||||
* @returns {string} Version capitalisée.
|
||||
*/
|
||||
function capitalizeLabel(value) {
|
||||
const spaced = value.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!spaced) {
|
||||
return value;
|
||||
}
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une description de communauté à partir du template configuré.
|
||||
* @param {string} template Modèle issu de la configuration.
|
||||
* @param {string} labelPath Chemin textuel des segments.
|
||||
* @returns {string} Description finale.
|
||||
*/
|
||||
function buildCommunityDescription(template, labelPath) {
|
||||
if (template.includes("{{path}}")) {
|
||||
return template.replace("{{path}}", labelPath);
|
||||
}
|
||||
return `${template} (${labelPath})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche une communauté par nom, et la crée si nécessaire.
|
||||
* @param {LemmyHttp} client Client Lemmy.
|
||||
* @param {object} descriptor Nom, titre et description attendus.
|
||||
* @param {object} communityConfig Paramètres nsfw/visibilité.
|
||||
* @returns {Promise<{ id: number, name: string }>} Communauté finalisée.
|
||||
*/
|
||||
async function ensureCommunity(client, descriptor, communityConfig) {
|
||||
const existing = await searchCommunity(client, descriptor.name);
|
||||
if (existing) {
|
||||
return { id: existing.community.id, name: existing.community.name };
|
||||
}
|
||||
const response = await client.createCommunity({
|
||||
name: descriptor.name,
|
||||
title: descriptor.title,
|
||||
description: descriptor.description,
|
||||
nsfw: communityConfig.nsfw,
|
||||
visibility: communityConfig.visibility,
|
||||
});
|
||||
return {
|
||||
id: response.community_view.community.id,
|
||||
name: response.community_view.community.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche une communauté précise via l'API de recherche.
|
||||
* @param {LemmyHttp} client Client Lemmy.
|
||||
* @param {string} name Nom recherché.
|
||||
* @returns {Promise<object|null>} Vue de communauté ou null.
|
||||
*/
|
||||
async function searchCommunity(client, name) {
|
||||
const response = await client.search({ q: name, type_: "Communities", limit: 50 });
|
||||
if (!response.communities || response.communities.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return response.communities.find((communityView) => communityView.community.name === name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns {Promise<string>} URL de la miniature hébergée par Lemmy.
|
||||
*/
|
||||
async function uploadThumbnail(client, thumbnailBuffer, info) {
|
||||
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}`);
|
||||
}
|
||||
return upload.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée ou met à jour un post Lemmy correspondant à l'article.
|
||||
* @param {LemmyHttp} client Client Lemmy.
|
||||
* @param {{ id: number, name: string }} community Communauté cible.
|
||||
* @param {{ title: string, articleUrl: string, thumbnailUrl: string|null }} payload Données du post.
|
||||
* @returns {Promise<{ id: number }>} Post final.
|
||||
*/
|
||||
async function ensurePost(client, community, payload) {
|
||||
const existing = await searchPostByUrl(client, community, payload.articleUrl);
|
||||
if (existing) {
|
||||
if (shouldUpdatePost(existing, payload.title, payload.thumbnailUrl, payload.articleUrl)) {
|
||||
const edited = await client.editPost({
|
||||
post_id: existing.post.id,
|
||||
name: payload.title,
|
||||
url: payload.articleUrl,
|
||||
custom_thumbnail: payload.thumbnailUrl || undefined,
|
||||
});
|
||||
return { id: edited.post_view.post.id };
|
||||
}
|
||||
return { id: existing.post.id };
|
||||
}
|
||||
|
||||
const response = await client.createPost({
|
||||
name: payload.title,
|
||||
community_id: community.id,
|
||||
url: payload.articleUrl,
|
||||
custom_thumbnail: payload.thumbnailUrl || undefined,
|
||||
});
|
||||
return { id: response.post_view.post.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie la nécessité d'une mise à jour du post existant.
|
||||
* @param {object} postView Post obtenu par recherche.
|
||||
* @param {string} expectedTitle Titre attendu.
|
||||
* @param {string|null} thumbnailUrl URL attendue pour la miniature.
|
||||
* @param {string} articleUrl URL de l'article.
|
||||
* @returns {boolean} true si une édition est requise.
|
||||
*/
|
||||
function shouldUpdatePost(postView, expectedTitle, thumbnailUrl, articleUrl) {
|
||||
const currentTitle = typeof postView.post.name === "string" ? postView.post.name : "";
|
||||
const currentUrl = typeof postView.post.url === "string" ? postView.post.url : "";
|
||||
const currentThumbnail =
|
||||
typeof postView.post.thumbnail_url === "string" ? postView.post.thumbnail_url : null;
|
||||
|
||||
if (currentTitle !== expectedTitle) {
|
||||
return true;
|
||||
}
|
||||
if (currentUrl !== articleUrl) {
|
||||
return true;
|
||||
}
|
||||
if (thumbnailUrl && currentThumbnail !== thumbnailUrl) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cherche un post existant pointant vers l'URL de l'article dans la communauté donnée.
|
||||
* @param {LemmyHttp} client Client Lemmy.
|
||||
* @param {{ id: number, name: string }} community Communauté cible.
|
||||
* @param {string} articleUrl URL Hugo recherchée.
|
||||
* @returns {Promise<object|null>} PostView correspondant ou null.
|
||||
*/
|
||||
async function searchPostByUrl(client, community, articleUrl) {
|
||||
const response = await client.search({
|
||||
q: articleUrl,
|
||||
type_: "Url",
|
||||
community_name: community.name,
|
||||
limit: 50,
|
||||
});
|
||||
if (!response.posts || response.posts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return response.posts.find(
|
||||
(postView) =>
|
||||
typeof postView.post.url === "string" &&
|
||||
postView.post.url.trim() === articleUrl &&
|
||||
postView.post.community_id === community.id
|
||||
) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit l'URL publique des commentaires Lemmy.
|
||||
* @param {string} instanceUrl Domaine de l'instance Lemmy.
|
||||
* @param {number} postId Identifiant du post.
|
||||
* @returns {string} URL des commentaires.
|
||||
*/
|
||||
function buildCommentsUrl(instanceUrl, postId) {
|
||||
return `${instanceUrl}/post/${postId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie les URLs en supprimant les slashs terminaux et espaces superflus.
|
||||
* @param {string|null} 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(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforme l'objet de remplacements brut en table normalisée.
|
||||
* @param {Record<string, string>} overrides Remplacements issus de la configuration.
|
||||
* @returns {Record<string, string>} Table prête à l'emploi.
|
||||
*/
|
||||
function buildOverrides(overrides) {
|
||||
const table = {};
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (typeof key !== "string" || typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalizedKey = key.trim().toLowerCase();
|
||||
const normalizedValue = value.trim();
|
||||
if (normalizedKey && normalizedValue) {
|
||||
table[normalizedKey] = normalizedValue;
|
||||
}
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* É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 créé.
|
||||
* @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);
|
||||
}
|
||||
184
tools/update_lemmy_post_dates.js
Normal file
184
tools/update_lemmy_post_dates.js
Normal file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Met à jour la date de publication 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).
|
||||
*
|
||||
* Règles :
|
||||
* - 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).
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
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 CONTENT_ROOT = path.join(__dirname, "..", "content");
|
||||
const DEFAULT_DATABASE_URL = "postgres:///lemmy?host=/run/postgresql&user=lemmy";
|
||||
|
||||
main().then(
|
||||
() => {
|
||||
process.exit(0);
|
||||
},
|
||||
(error) => {
|
||||
console.error(`❌ Mise à jour interrompue : ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Point d'entrée : collecte les articles, se connecte à Postgres, applique les dates.
|
||||
*/
|
||||
async function main() {
|
||||
loadEnv();
|
||||
const databaseUrl = resolveDatabaseUrl();
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
const bundles = await collectBundles(CONTENT_ROOT);
|
||||
const articles = collectArticlesWithPostId(bundles);
|
||||
|
||||
if (articles.length === 0) {
|
||||
console.log("Aucun article muni d'un comments_url et d'une date valide.");
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
let unchanged = 0;
|
||||
let missing = 0;
|
||||
|
||||
for (const article of articles) {
|
||||
const targetDate = article.publication.set({ millisecond: 0 });
|
||||
const iso = targetDate.toISO();
|
||||
const row = await fetchPost(pool, article.postId);
|
||||
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()) {
|
||||
unchanged += 1;
|
||||
continue;
|
||||
}
|
||||
await applyDate(pool, article.postId, iso);
|
||||
updated += 1;
|
||||
console.log(`✅ Post ${article.postId} mis à ${iso} (${article.bundle.relativePath})`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
console.log("");
|
||||
console.log("Résumé des ajustements Lemmy");
|
||||
console.log(`Posts mis à jour : ${updated}`);
|
||||
console.log(`Posts déjà alignés : ${unchanged}`);
|
||||
console.log(`Posts introuvables : ${missing}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine l'URL de connexion Postgres.
|
||||
* @returns {string} Chaîne de connexion.
|
||||
*/
|
||||
function resolveDatabaseUrl() {
|
||||
if (typeof process.env.LEMMY_DATABASE_URL === "string" && process.env.LEMMY_DATABASE_URL.trim()) {
|
||||
return process.env.LEMMY_DATABASE_URL.trim();
|
||||
}
|
||||
if (typeof process.env.DATABASE_URL === "string" && process.env.DATABASE_URL.trim()) {
|
||||
return process.env.DATABASE_URL.trim();
|
||||
}
|
||||
return DEFAULT_DATABASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit la liste des articles éligibles avec identifiant de post Lemmy.
|
||||
* @param {Array<object>} bundles Bundles Hugo.
|
||||
* @returns {Array<object>} Articles prêts à être appliqués.
|
||||
*/
|
||||
function collectArticlesWithPostId(bundles) {
|
||||
const articles = [];
|
||||
for (const bundle of bundles) {
|
||||
const frontmatter = readFrontmatterFile(bundle.indexPath);
|
||||
if (!frontmatter) {
|
||||
continue;
|
||||
}
|
||||
const publication = parseFrontmatterDate(frontmatter.data?.date);
|
||||
if (!publication) {
|
||||
continue;
|
||||
}
|
||||
const title = typeof frontmatter.data?.title === "string" ? frontmatter.data.title.trim() : "";
|
||||
if (!title) {
|
||||
continue;
|
||||
}
|
||||
const commentsUrl =
|
||||
typeof frontmatter.data?.comments_url === "string" ? frontmatter.data.comments_url.trim() : "";
|
||||
if (!commentsUrl) {
|
||||
continue;
|
||||
}
|
||||
const postId = extractPostId(commentsUrl);
|
||||
if (postId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
articles.push({
|
||||
bundle,
|
||||
publication,
|
||||
postId,
|
||||
});
|
||||
}
|
||||
|
||||
articles.sort((a, b) => {
|
||||
const diff = a.publication.toMillis() - b.publication.toMillis();
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
return a.bundle.relativePath.localeCompare(b.bundle.relativePath);
|
||||
});
|
||||
|
||||
return articles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait l'identifiant numérique d'un comments_url Lemmy.
|
||||
* @param {string} url URL issue du frontmatter.
|
||||
* @returns {number|null} Identifiant ou null si non reconnu.
|
||||
*/
|
||||
function extractPostId(url) {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const normalized = trimmed.replace(/\/+$/, "");
|
||||
const match = normalized.match(/\/(?:post|c\/[^/]+\/post)\/(\d+)(?:$|\?)/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return Number.parseInt(match[1], 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un post Lemmy par identifiant.
|
||||
* @param {Pool} pool Pool Postgres.
|
||||
* @param {number} postId Identifiant du post.
|
||||
* @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]);
|
||||
if (result.rowCount !== 1) {
|
||||
return null;
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique la date ISO fournie sur le post ciblé.
|
||||
* @param {Pool} pool Pool Postgres.
|
||||
* @param {number} postId Identifiant du post.
|
||||
* @param {string} isoDate Timestamp ISO.
|
||||
*/
|
||||
async function applyDate(pool, postId, isoDate) {
|
||||
await pool.query("update post set published = $1 where id = $2", [isoDate, postId]);
|
||||
}
|
||||
Reference in New Issue
Block a user