358 lines
10 KiB
JavaScript
358 lines
10 KiB
JavaScript
#!/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);
|
|
});
|