Page de statistiques
This commit is contained in:
357
tools/generate_stats.js
Normal file
357
tools/generate_stats.js
Normal file
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs/promises");
|
||||
const path = require("path");
|
||||
|
||||
const DEFAULT_CONFIG_PATH = "tools/stats.json";
|
||||
const DEFAULT_DATA_OUTPUT = "content/stats/data/stats.json";
|
||||
const DEFAULT_IMAGE_DIR = "content/stats/images";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = argv[index];
|
||||
const next = argv[index + 1];
|
||||
|
||||
switch (current) {
|
||||
case "--config":
|
||||
case "-c":
|
||||
args.config = next;
|
||||
index += 1;
|
||||
break;
|
||||
case "--data":
|
||||
case "-d":
|
||||
args.data = next;
|
||||
index += 1;
|
||||
break;
|
||||
case "--only":
|
||||
case "-o":
|
||||
args.only = next;
|
||||
index += 1;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
async function loadDefinition(configPath) {
|
||||
const raw = await fs.readFile(configPath, "utf8");
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Impossible de parser ${configPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModule(scriptPath) {
|
||||
const resolved = path.resolve(scriptPath);
|
||||
await fs.access(resolved);
|
||||
|
||||
// allow re-run without cache
|
||||
delete require.cache[resolved];
|
||||
const mod = require(resolved);
|
||||
|
||||
if (!mod || typeof mod.run !== "function") {
|
||||
throw new Error(`Le script ${scriptPath} doit exporter une fonction run(context)`);
|
||||
}
|
||||
|
||||
return mod;
|
||||
}
|
||||
|
||||
function resolvePythonInterpreter() {
|
||||
const envPython = process.env.VIRTUAL_ENV
|
||||
? path.join(process.env.VIRTUAL_ENV, "bin", "python")
|
||||
: null;
|
||||
const candidates = [
|
||||
envPython,
|
||||
path.join(process.cwd(), ".venv", "bin", "python"),
|
||||
path.join(process.cwd(), ".venv", "bin", "python3"),
|
||||
"python3",
|
||||
].filter(Boolean);
|
||||
return candidates.find((candidate) => {
|
||||
try {
|
||||
const stat = require("fs").statSync(candidate);
|
||||
return stat.isFile() || stat.isSymbolicLink();
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}) || "python3";
|
||||
}
|
||||
|
||||
async function runPython(scriptPath, payload) {
|
||||
const resolved = path.resolve(scriptPath);
|
||||
await fs.access(resolved);
|
||||
|
||||
const interpreter = resolvePythonInterpreter();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = require("child_process").spawn(interpreter, [resolved], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
const err = new Error(stderr || `Python exited with code ${code}`);
|
||||
err.code = code;
|
||||
return reject(err);
|
||||
}
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(trimmed));
|
||||
} catch (error) {
|
||||
reject(new Error(`Invalid JSON from ${scriptPath}: ${error.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(JSON.stringify(payload));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
function toPublicPath(target, { rootDir = process.cwd(), staticDir = path.resolve("static"), absOutput } = {}) {
|
||||
if (!target) return "";
|
||||
const normalized = target.replace(/\\/g, "/");
|
||||
if (/^https?:\/\//i.test(normalized)) return normalized;
|
||||
if (normalized.startsWith("/")) {
|
||||
return normalized.replace(/\/{2,}/g, "/");
|
||||
}
|
||||
|
||||
const absolute = absOutput || path.resolve(rootDir, target);
|
||||
|
||||
if (absolute.startsWith(staticDir)) {
|
||||
const rel = path.relative(staticDir, absolute).replace(/\\/g, "/");
|
||||
return `/${rel}`;
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(target) && !target.startsWith("/")) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const relRoot = path.relative(rootDir, absolute).replace(/\\/g, "/");
|
||||
return `/${relRoot}`;
|
||||
}
|
||||
|
||||
function resolveGraphicPaths(stat, defaultImageDir, { rootDir, staticDir }) {
|
||||
const target = stat.image || (defaultImageDir ? path.join(defaultImageDir, `${stat.key}.png`) : null);
|
||||
|
||||
if (!target) {
|
||||
throw new Error("Chemin d'image manquant (image ou defaultImageDir)");
|
||||
}
|
||||
|
||||
const absOutput = path.isAbsolute(target) ? target : path.resolve(rootDir, target);
|
||||
const publicPath = toPublicPath(target, { rootDir, staticDir, absOutput });
|
||||
|
||||
return { publicPath, outputPath: absOutput };
|
||||
}
|
||||
|
||||
function mergeResult(base, result, { publicPath } = {}) {
|
||||
const entry = { ...base };
|
||||
|
||||
if (publicPath && base.type === "graphic") {
|
||||
entry.image = publicPath;
|
||||
}
|
||||
|
||||
if (result === undefined || result === null) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (typeof result === "object" && !Array.isArray(result)) {
|
||||
if (Object.prototype.hasOwnProperty.call(result, "value")) {
|
||||
entry.value = result.value;
|
||||
}
|
||||
if (result.image) {
|
||||
entry.image = toPublicPath(result.image);
|
||||
}
|
||||
if (result.meta) {
|
||||
entry.meta = result.meta;
|
||||
}
|
||||
if (result.data) {
|
||||
entry.data = result.data;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
entry.value = result;
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function runStat(stat, context) {
|
||||
const base = {
|
||||
key: stat.key,
|
||||
title: stat.title,
|
||||
type: stat.type,
|
||||
};
|
||||
|
||||
if (!stat.key) {
|
||||
throw new Error("Cle manquante pour cette statistique");
|
||||
}
|
||||
|
||||
if (!stat.script) {
|
||||
throw new Error(`Script manquant pour ${stat.key}`);
|
||||
}
|
||||
|
||||
if (!stat.type) {
|
||||
throw new Error(`Type manquant pour ${stat.key}`);
|
||||
}
|
||||
|
||||
const isPython = stat.script.endsWith(".py");
|
||||
|
||||
if (isPython) {
|
||||
if (stat.type === "graphic") {
|
||||
const { publicPath, outputPath } = resolveGraphicPaths(stat, context.defaultImageDir, context);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
const result = await runPython(stat.script, {
|
||||
...context,
|
||||
stat,
|
||||
outputPath,
|
||||
publicPath,
|
||||
});
|
||||
return mergeResult(base, result, { publicPath });
|
||||
}
|
||||
if (stat.type === "variable") {
|
||||
const result = await runPython(stat.script, {
|
||||
...context,
|
||||
stat,
|
||||
});
|
||||
return mergeResult(base, result);
|
||||
}
|
||||
throw new Error(`Type inconnu pour ${stat.key}: ${stat.type}`);
|
||||
} else {
|
||||
const mod = await loadModule(stat.script);
|
||||
|
||||
if (stat.type === "graphic") {
|
||||
const { publicPath, outputPath } = resolveGraphicPaths(stat, context.defaultImageDir, context);
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const result = await mod.run({
|
||||
...context,
|
||||
stat,
|
||||
outputPath,
|
||||
publicPath,
|
||||
});
|
||||
|
||||
return mergeResult(base, result, { publicPath });
|
||||
}
|
||||
|
||||
if (stat.type === "variable") {
|
||||
const result = await mod.run({
|
||||
...context,
|
||||
stat,
|
||||
});
|
||||
|
||||
return mergeResult(base, result);
|
||||
}
|
||||
|
||||
throw new Error(`Type inconnu pour ${stat.key}: ${stat.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildOnlyFilter(onlyRaw) {
|
||||
if (!onlyRaw) return null;
|
||||
|
||||
const parts = onlyRaw
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return new Set(parts);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cliArgs = parseArgs(process.argv.slice(2));
|
||||
const definitionPath = path.resolve(cliArgs.config || DEFAULT_CONFIG_PATH);
|
||||
const definition = await loadDefinition(definitionPath);
|
||||
const statsConfig = definition.config || {};
|
||||
const dataOutput = path.resolve(cliArgs.data || statsConfig.dataOutput || DEFAULT_DATA_OUTPUT);
|
||||
const defaultImageDir = statsConfig.defaultImageDir || DEFAULT_IMAGE_DIR;
|
||||
const onlyFilter = buildOnlyFilter(cliArgs.only);
|
||||
|
||||
const context = {
|
||||
rootDir: process.cwd(),
|
||||
contentDir: path.resolve("content"),
|
||||
staticDir: path.resolve("static"),
|
||||
definitionPath,
|
||||
defaultImageDir,
|
||||
config: statsConfig,
|
||||
};
|
||||
|
||||
const output = {
|
||||
generated_at: new Date().toISOString(),
|
||||
sections: [],
|
||||
};
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const section of definition.sections || []) {
|
||||
const results = [];
|
||||
|
||||
for (const stat of section.statistics || []) {
|
||||
if (onlyFilter && !onlyFilter.has(stat.key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await runStat(stat, context);
|
||||
results.push(entry);
|
||||
console.log(`[ok] ${stat.key}`);
|
||||
} catch (error) {
|
||||
errors.push({ key: stat.key, message: error.message });
|
||||
console.error(`[err] ${stat.key}: ${error.message}`);
|
||||
results.push({
|
||||
key: stat.key,
|
||||
title: stat.title,
|
||||
type: stat.type,
|
||||
error: error.message,
|
||||
image: stat.image ? toPublicPath(stat.image) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
output.sections.push({
|
||||
title: section.title,
|
||||
statistics: results,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
output.errors = errors;
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(dataOutput), { recursive: true });
|
||||
await fs.writeFile(dataOutput, `${JSON.stringify(output, null, 2)}\n`, "utf8");
|
||||
|
||||
const relativeOutput = path.relative(process.cwd(), dataOutput);
|
||||
console.log(`\nFichier de donnees genere: ${relativeOutput}`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log(`Statistiques en erreur: ${errors.length}. Les entrees concernees contiennent le message d'erreur.`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user