1

Page de statistiques

This commit is contained in:
2025-11-28 01:47:10 +01:00
parent 38926267a3
commit fd27dc7fb6
47 changed files with 3278 additions and 86 deletions

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
const fs = require("fs/promises");
const path = require("path");
const { resolveMarkdownTargets } = require("./lib/content");
const { extractRawDate, readFrontmatter, writeFrontmatter } = require("./lib/weather/frontmatter");
const { resolveArticleDate } = require("./lib/weather/time");
const { fetchWeather, hasConfiguredProvider, mergeWeather } = require("./lib/weather/providers");
@@ -9,62 +9,6 @@ const { loadWeatherConfig } = require("./lib/weather/config");
const CONTENT_ROOT = path.resolve("content");
async function collectMarkdownFiles(rootDir) {
const entries = await fs.readdir(rootDir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
const nested = await collectMarkdownFiles(fullPath);
files.push(...nested);
continue;
}
if (!entry.isFile()) continue;
if (!entry.name.endsWith(".md")) continue;
if (entry.name === "_index.md") continue;
files.push(fullPath);
}
return files;
}
async function resolveTargets(args) {
if (args.length === 0) {
return collectMarkdownFiles(CONTENT_ROOT);
}
const targets = new Set();
for (const input of args) {
const resolved = path.resolve(input);
try {
const stat = await fs.stat(resolved);
if (stat.isDirectory()) {
const nested = await collectMarkdownFiles(resolved);
nested.forEach((file) => targets.add(file));
continue;
}
if (stat.isFile()) {
if (!resolved.endsWith(".md")) continue;
if (path.basename(resolved) === "_index.md") continue;
targets.add(resolved);
}
} catch (error) {
console.error(`Skipping ${input}: ${error.message}`);
}
}
return Array.from(targets);
}
async function processFile(filePath, config, { force = false } = {}) {
const frontmatter = await readFrontmatter(filePath);
@@ -128,7 +72,7 @@ async function main() {
console.error("No weather provider configured. Update tools/config.json (weather.providers) before running this script.");
process.exit(1);
}
const files = await resolveTargets(pathArgs);
const files = await resolveMarkdownTargets(pathArgs, { rootDir: CONTENT_ROOT });
if (files.length === 0) {
console.log("No matching markdown files found.");

View File

@@ -88,11 +88,14 @@
"latitude": ,
"longitude": ,
"timezone": "Europe/Paris",
"pressureOffset": 40,
"illuminanceToLuxFactor": 126.7,
"windowMinutes": 90,
"precipitationThreshold": 0.1
}
"pressureOffset": 40,
"illuminanceToLuxFactor": 126.7,
"windowMinutes": 90,
"precipitationThreshold": 0.1
}
},
"goaccess": {
"url": ""
}
}
}

357
tools/generate_stats.js Normal file
View 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);
});

20
tools/lib/config.js Normal file
View File

@@ -0,0 +1,20 @@
const fs = require("fs/promises");
const path = require("path");
let cached = null;
async function loadToolsConfig(configPath = "tools/config.json") {
const resolved = path.resolve(configPath);
if (cached && cached.path === resolved) {
return cached.data;
}
const raw = await fs.readFile(resolved, "utf8");
const data = JSON.parse(raw);
cached = { path: resolved, data };
return data;
}
module.exports = {
loadToolsConfig,
};

99
tools/lib/content.js Normal file
View File

@@ -0,0 +1,99 @@
const fs = require("fs/promises");
const path = require("path");
async function collectMarkdownFiles(rootDir, { skipIndex = true } = {}) {
const entries = await fs.readdir(rootDir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
const nested = await collectMarkdownFiles(fullPath, { skipIndex });
files.push(...nested);
continue;
}
if (!entry.isFile()) continue;
if (!entry.name.toLowerCase().endsWith(".md")) continue;
if (skipIndex && entry.name === "_index.md") continue;
files.push(fullPath);
}
return files;
}
async function collectSectionIndexDirs(rootDir) {
const sections = new Set();
async function walk(dir) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (error) {
console.error(`Skipping section scan for ${dir}: ${error.message}`);
return;
}
let hasIndex = false;
for (const entry of entries) {
if (entry.isFile() && entry.name.toLowerCase() === "_index.md") {
hasIndex = true;
break;
}
}
if (hasIndex) {
sections.add(path.resolve(dir));
}
for (const entry of entries) {
if (entry.isDirectory()) {
await walk(path.join(dir, entry.name));
}
}
}
await walk(rootDir);
return sections;
}
async function resolveMarkdownTargets(inputs, { rootDir = process.cwd(), skipIndex = true } = {}) {
if (!inputs || inputs.length === 0) {
return collectMarkdownFiles(rootDir, { skipIndex });
}
const targets = new Set();
for (const input of inputs) {
const resolved = path.resolve(input);
try {
const stat = await fs.stat(resolved);
if (stat.isDirectory()) {
const nested = await collectMarkdownFiles(resolved, { skipIndex });
nested.forEach((file) => targets.add(file));
continue;
}
if (stat.isFile()) {
const lower = resolved.toLowerCase();
if (!lower.endsWith(".md")) continue;
if (skipIndex && path.basename(resolved) === "_index.md") continue;
targets.add(resolved);
}
} catch (error) {
console.error(`Skipping ${input}: ${error.message}`);
}
}
return Array.from(targets);
}
module.exports = {
collectMarkdownFiles,
collectSectionIndexDirs,
resolveMarkdownTargets,
};

View File

@@ -0,0 +1,91 @@
const path = require("path");
const { DateTime } = require("luxon");
const { collectMarkdownFiles, collectSectionIndexDirs } = require("../content");
const { readFrontmatter } = require("../weather/frontmatter");
function parseDate(value) {
if (!value) return null;
if (value instanceof Date) {
return DateTime.fromJSDate(value);
}
if (typeof value === "string") {
let parsed = DateTime.fromISO(value);
if (!parsed.isValid) {
parsed = DateTime.fromRFC2822(value);
}
return parsed.isValid ? parsed : null;
}
return null;
}
function countWords(body) {
if (!body) return 0;
const cleaned = body
.replace(/```[\s\S]*?```/g, " ") // fenced code blocks
.replace(/`[^`]*`/g, " ") // inline code
.replace(/<[^>]+>/g, " "); // html tags
const words = cleaned.match(/[\p{L}\p{N}'-]+/gu);
return words ? words.length : 0;
}
async function loadArticles(contentDir) {
const files = await collectMarkdownFiles(contentDir);
const sectionDirs = await collectSectionIndexDirs(contentDir);
const rootDir = path.resolve(contentDir);
const articles = [];
function resolveSection(filePath) {
const absolute = path.resolve(filePath);
let current = path.dirname(absolute);
while (current.startsWith(rootDir)) {
if (sectionDirs.has(current)) {
return path.relative(rootDir, current).replace(/\\/g, "/") || ".";
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return null;
}
for (const file of files) {
const frontmatter = await readFrontmatter(file);
if (!frontmatter) continue;
const date = parseDate(frontmatter.doc.get("date"));
const title = frontmatter.doc.get("title") || path.basename(file, ".md");
const body = frontmatter.body.trim();
const wordCount = countWords(body);
const relativePath = path.relative(contentDir, file);
const section = resolveSection(file);
articles.push({
path: file,
relativePath,
title,
date,
body,
wordCount,
section,
frontmatter: frontmatter.doc.toJS ? frontmatter.doc.toJS() : frontmatter.doc.toJSON(),
});
}
return articles;
}
module.exports = {
collectMarkdownFiles,
countWords,
loadArticles,
parseDate,
};

131
tools/lib/stats/goaccess.js Normal file
View File

@@ -0,0 +1,131 @@
const { request } = require("undici");
const { DateTime } = require("luxon");
async function fetchGoAccessJson(url) {
const res = await request(url, { method: "GET" });
if (res.statusCode < 200 || res.statusCode >= 300) {
throw new Error(`HTTP ${res.statusCode}`);
}
return res.body.json();
}
function crawlerRatios(data) {
const browsers = data.browsers?.data || [];
const crawler = browsers.find((entry) => entry.data === "Crawlers");
if (!crawler) return { hits: 0, visitors: 0 };
const totalHits = (browsers.reduce((sum, entry) => sum + (entry.hits?.count || 0), 0)) || 0;
const totalVisitors = (browsers.reduce((sum, entry) => sum + (entry.visitors?.count || 0), 0)) || 0;
const hitRatio = totalHits > 0 ? Math.min(1, (crawler.hits?.count || 0) / totalHits) : 0;
const visitorRatio = totalVisitors > 0 ? Math.min(1, (crawler.visitors?.count || 0) / totalVisitors) : 0;
return { hits: hitRatio, visitors: visitorRatio };
}
function groupVisitsByMonth(data, { adjustCrawlers = true } = {}) {
const entries = data.visitors?.data || [];
const ratios = adjustCrawlers ? crawlerRatios(data) : { hits: 0, visitors: 0 };
const months = new Map();
for (const entry of entries) {
const dateStr = entry.data;
if (!/^[0-9]{8}$/.test(dateStr)) continue;
const year = dateStr.slice(0, 4);
const month = dateStr.slice(4, 6);
const day = dateStr.slice(6, 8);
const key = `${year}-${month}`;
const hits = entry.hits?.count || 0;
const visitors = entry.visitors?.count || 0;
const current = months.get(key) || { hits: 0, visitors: 0, from: null, to: null };
const isoDate = `${year}-${month}-${day}`;
current.hits += hits;
current.visitors += visitors;
if (!current.from || isoDate < current.from) current.from = isoDate;
if (!current.to || isoDate > current.to) current.to = isoDate;
months.set(key, current);
}
const adjust = (value, ratio) => {
if (!adjustCrawlers) return value;
const scaled = value * (1 - ratio);
return Math.max(0, Math.round(scaled));
};
const sorted = Array.from(months.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, value]) => ({
month: key,
from: value.from,
to: value.to,
hits: adjust(value.hits, ratios.hits),
visitors: adjust(value.visitors, ratios.visitors),
}));
return sorted;
}
function aggregateLastNDays(data, days = 30, { adjustCrawlers = true } = {}) {
const entries = data.visitors?.data || [];
if (!entries.length || days <= 0) {
return { from: null, to: null, hits: 0, visitors: 0 };
}
const valid = entries.filter((entry) => /^[0-9]{8}$/.test(entry.data));
if (valid.length === 0) {
return { from: null, to: null, hits: 0, visitors: 0 };
}
const sorted = valid.slice().sort((a, b) => a.data.localeCompare(b.data));
const last = sorted[sorted.length - 1];
const end = DateTime.fromFormat(last.data, "yyyyLLdd", { zone: "UTC" });
if (!end.isValid) {
return { from: null, to: null, hits: 0, visitors: 0 };
}
const start = end.minus({ days: days - 1 });
let from = null;
let to = null;
let hits = 0;
let visitors = 0;
for (const entry of sorted) {
const current = DateTime.fromFormat(entry.data, "yyyyLLdd", { zone: "UTC" });
if (!current.isValid) continue;
if (current < start || current > end) continue;
const iso = current.toISODate();
if (!from || iso < from) from = iso;
if (!to || iso > to) to = iso;
hits += entry.hits?.count || 0;
visitors += entry.visitors?.count || 0;
}
const ratios = adjustCrawlers ? crawlerRatios(data) : { hits: 0, visitors: 0 };
const adjust = (value, ratio) => {
if (!adjustCrawlers) return value;
const scaled = value * (1 - ratio);
return Math.max(0, Math.round(scaled));
};
return {
from,
to,
hits: adjust(hits, ratios.hits),
visitors: adjust(visitors, ratios.visitors),
};
}
module.exports = {
fetchGoAccessJson,
groupVisitsByMonth,
aggregateLastNDays,
crawlerRatios,
};

32
tools/lib/stats/python.js Normal file
View File

@@ -0,0 +1,32 @@
const { spawn } = require("child_process");
const path = require("path");
async function renderWithPython({ type, data, outputPath }) {
return new Promise((resolve, reject) => {
const scriptPath = path.resolve(__dirname, "../../render_stats_charts.py");
const child = spawn("python3", [scriptPath, "--type", type, "--output", outputPath], {
stdio: ["pipe", "inherit", "inherit"],
});
const payload = JSON.stringify(data);
child.stdin.write(payload);
child.stdin.end();
child.on("error", (error) => {
reject(error);
});
child.on("exit", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Python renderer exited with code ${code}`));
}
});
});
}
module.exports = {
renderWithPython,
};

View File

@@ -0,0 +1,528 @@
#!/usr/bin/env python3
import argparse
import json
import math
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import matplotlib.colors as mcolors # noqa: E402
import numpy as np # noqa: E402
PALETTE = [
"#467FFF", # blue-500
"#40C474", # green-500
"#FF4D5A", # red-500
"#FFA93D", # amber-500
"#9E63E9", # purple-500
"#2FC4FF", # cyan-500
"#98C0FF", # blue-300
"#8FE4A2", # green-300
"#FF939B", # red-300
"#FFD08C", # amber-300
"#D2AAF7", # purple-300
"#8EE8FF", # cyan-300
]
BACKGROUND = "#0F1114" # gray-900
TEXT = "#D9E0E8" # gray-300
GRID = (1.0, 1.0, 1.0, 0.16) # soft white grid
FIG_WIDTH = 20.0 # ~1920px at DPI=96
FIG_HEIGHT = 10.8 # 16:9 ratio
DPI = 96
BASE_FONT_SIZE = 16
TICK_FONT_SIZE = 15
LEGEND_FONT_SIZE = 14
TITLE_FONT_SIZE = 18
def setup_rcparams():
matplotlib.rcParams.update(
{
"figure.figsize": (FIG_WIDTH, FIG_HEIGHT),
"figure.dpi": DPI,
"axes.facecolor": BACKGROUND,
"figure.facecolor": BACKGROUND,
"axes.edgecolor": TEXT,
"axes.labelcolor": TEXT,
"xtick.color": TEXT,
"ytick.color": TEXT,
"text.color": TEXT,
"font.size": BASE_FONT_SIZE,
}
)
def new_axes():
fig, ax = plt.subplots()
fig.set_facecolor(BACKGROUND)
ax.set_facecolor(BACKGROUND)
ax.grid(True, axis="y", color=GRID, linestyle="--", linewidth=0.7)
return fig, ax
def render_articles_per_month(data, output):
labels = data.get("labels") or []
series = data.get("series") or []
title = data.get("title") or "Articles par mois"
if not labels or not series:
fig, ax = new_axes()
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
return
x = np.arange(len(labels))
fig, ax = new_axes()
bottoms = np.zeros(len(labels))
for index, serie in enumerate(series):
values = np.array(serie.get("values") or [0] * len(labels), dtype=float)
color = PALETTE[index % len(PALETTE)]
ax.bar(x, values, bottom=bottoms, label=str(serie.get("label", "")), color=color, linewidth=0)
bottoms += values
ax.set_xticks(x)
ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=TICK_FONT_SIZE)
ax.tick_params(axis="y", labelsize=TICK_FONT_SIZE)
ax.set_ylabel("Articles")
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
ax.legend(fontsize=LEGEND_FONT_SIZE)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_articles_per_year(data, output):
labels = data.get("labels") or []
values = data.get("values") or []
title = data.get("title") or "Articles par an"
if not labels or not values:
fig, ax = new_axes()
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
return
x = np.arange(len(labels))
fig, ax = new_axes()
ax.bar(x, values, color=PALETTE[0])
ax.set_xticks(x)
ax.set_xticklabels(labels, rotation=0, fontsize=TICK_FONT_SIZE)
ax.tick_params(axis="y", labelsize=TICK_FONT_SIZE)
ax.set_ylabel("Articles")
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_articles_per_section(data, output):
labels = data.get("labels") or []
values = data.get("values") or []
title = data.get("title") or "Articles par section"
if not labels or not values:
fig, ax = new_axes()
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
return
fig, ax = new_axes()
# Donut chart
wedges, _ = ax.pie(
values,
labels=None,
colors=[PALETTE[i % len(PALETTE)] for i in range(len(values))],
startangle=90,
counterclock=False,
)
centre_circle = plt.Circle((0, 0), 0.60, fc=BACKGROUND)
fig.gca().add_artist(centre_circle)
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
ax.legend(
wedges,
labels,
title="Sections",
loc="center left",
bbox_to_anchor=(1.0, 0.5),
fontsize=LEGEND_FONT_SIZE,
title_fontsize=LEGEND_FONT_SIZE,
)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_cumulative(data, output):
labels = data.get("labels") or []
articles = data.get("articles") or []
words = data.get("words") or []
title = data.get("title") or "Cumul articles / mots"
if not labels or (not articles and not words):
fig, ax = new_axes()
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
return
x = np.arange(len(labels))
fig, ax_words = new_axes()
ax_articles = ax_words.twinx()
lines = []
labels_for_legend = []
if words:
lw = ax_words.plot(
x,
words,
label="Mots cumulés",
color=PALETTE[1],
linewidth=2.2,
marker="o",
markersize=4,
)
lines += lw
labels_for_legend += ["Mots cumulés"]
if articles:
la = ax_articles.plot(
x,
articles,
label="Articles cumulés",
color=PALETTE[0],
linewidth=2.2,
marker="o",
markersize=4,
)
lines += la
labels_for_legend += ["Articles cumulés"]
ax_words.set_xticks(x)
ax_words.set_xticklabels(labels, rotation=45, ha="right", fontsize=TICK_FONT_SIZE)
ax_words.tick_params(axis="y", labelsize=TICK_FONT_SIZE, colors=PALETTE[1])
ax_articles.tick_params(axis="y", labelsize=TICK_FONT_SIZE, colors=PALETTE[0])
ax_words.set_ylabel("Mots cumulés", color=PALETTE[1])
ax_articles.set_ylabel("Articles cumulés", color=PALETTE[0])
ax_words.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
ax_articles.grid(False)
ax_words.grid(True, axis="y", color=GRID, linestyle="--", linewidth=0.7)
fig.legend(lines, labels_for_legend, loc="upper left", fontsize=LEGEND_FONT_SIZE)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_words_histogram(data, output):
values = data.get("values") or []
title = data.get("title") or "Distribution des longueurs d'article"
bins = data.get("bins") or 20
fig, ax = new_axes()
if not values:
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
else:
ax.hist(values, bins=bins, color=PALETTE[0], edgecolor=TEXT, alpha=0.9)
ax.set_xlabel("Nombre de mots")
ax.set_ylabel("Articles")
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_top_requests(data, output):
labels = data.get("labels") or []
values = data.get("values") or []
title = data.get("title") or "Top requêtes"
fig, ax = new_axes()
if not labels or not values:
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
else:
y_pos = np.arange(len(labels))
ax.barh(y_pos, values, color=PALETTE[0])
ax.set_yticks(y_pos)
ax.set_yticklabels(labels, fontsize=TICK_FONT_SIZE)
ax.invert_yaxis()
ax.set_xlabel("Hits")
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_weather_hexbin(data, output):
temps = data.get("temps") or []
hums = data.get("hums") or []
presses = data.get("presses") or []
title = data.get("title") or "Météo à la publication"
fig, ax = new_axes()
if not temps or not hums:
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
else:
# If pressures are provided, use them for color; otherwise density
if presses and len(presses) == len(temps):
hb = ax.scatter(temps, hums, c=presses, cmap="viridis", alpha=0.75, s=50, edgecolors="none")
cbar = fig.colorbar(hb, ax=ax)
cbar.set_label("Pression (hPa)", color=TEXT)
cbar.ax.yaxis.set_tick_params(color=TEXT, labelsize=LEGEND_FONT_SIZE)
plt.setp(plt.getp(cbar.ax.axes, "yticklabels"), color=TEXT)
else:
norm = mcolors.LogNorm() if len(temps) > 0 else None
hb = ax.hexbin(
temps,
hums,
gridsize=28,
cmap="plasma",
mincnt=1,
linewidths=0.2,
edgecolors="none",
alpha=0.9,
norm=norm,
)
cbar = fig.colorbar(hb, ax=ax)
cbar.set_label("Densité", color=TEXT)
cbar.ax.yaxis.set_tick_params(color=TEXT, labelsize=LEGEND_FONT_SIZE)
plt.setp(plt.getp(cbar.ax.axes, "yticklabels"), color=TEXT)
ax.set_xlabel("Température (°C)")
ax.set_ylabel("Humidité (%)")
ax.tick_params(axis="x", labelsize=TICK_FONT_SIZE)
ax.tick_params(axis="y", labelsize=TICK_FONT_SIZE)
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_weekday_activity(data, output):
labels = data.get("labels") or []
articles = data.get("articles") or []
words = data.get("words") or []
title = data.get("title") or "Activité par jour"
fig, ax_left = new_axes()
ax_right = ax_left.twinx()
if not labels or (not articles and not words):
ax_left.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
else:
x = np.arange(len(labels))
width = 0.38
bars_articles = ax_left.bar(
x - width / 2,
articles,
width=width,
label="Articles",
color=PALETTE[0],
)
bars_words = ax_right.bar(
x + width / 2,
words,
width=width,
label="Mots",
color=PALETTE[1],
)
ax_left.set_xticks(x)
ax_left.set_xticklabels(labels, rotation=0, fontsize=TICK_FONT_SIZE)
ax_left.tick_params(axis="y", labelsize=TICK_FONT_SIZE, colors=PALETTE[0])
ax_right.tick_params(axis="y", labelsize=TICK_FONT_SIZE, colors=PALETTE[1])
ax_left.set_ylabel("Articles", color=PALETTE[0])
ax_right.set_ylabel("Mots", color=PALETTE[1])
lines = [bars_articles, bars_words]
labels_for_legend = ["Articles", "Mots"]
fig.legend(lines, labels_for_legend, loc="upper right", fontsize=LEGEND_FONT_SIZE)
ax_left.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def render_words_per_article(data, output):
labels = data.get("labels") or []
series = data.get("series") or []
title = data.get("title") or "Moyenne de mots par article (par mois)"
if not labels or not series:
fig, ax = new_axes()
ax.text(
0.5,
0.5,
"Aucune donnees",
ha="center",
va="center",
fontsize=BASE_FONT_SIZE,
)
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
return
x = np.arange(len(labels))
n_series = len(series)
width = 0.8 / max(n_series, 1)
fig, ax = new_axes()
for index, serie in enumerate(series):
values = np.array(serie.get("values") or [0] * len(labels), dtype=float)
color = PALETTE[index % len(PALETTE)]
offset = (index - (n_series - 1) / 2) * width
ax.bar(x + offset, values, width=width, label=str(serie.get("label", "")), color=color, linewidth=0)
ax.set_xticks(x)
ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=TICK_FONT_SIZE)
ax.tick_params(axis="y", labelsize=TICK_FONT_SIZE)
ax.set_ylabel("Mots par article (moyenne)")
ax.set_title(title, fontsize=TITLE_FONT_SIZE, color=TEXT)
ax.legend(fontsize=LEGEND_FONT_SIZE)
fig.tight_layout()
fig.savefig(output, bbox_inches="tight")
plt.close(fig)
def main():
parser = argparse.ArgumentParser(description="Render stats charts from JSON data.")
parser.add_argument(
"--type",
required=True,
choices=[
"articles_per_month",
"articles_per_year",
"articles_per_section",
"words_per_article",
"cumulative",
"words_histogram",
"top_requests",
"weather_hexbin",
"weekday_activity",
],
)
parser.add_argument("--output", required=True)
args = parser.parse_args()
try:
payload = json.load(sys.stdin)
except Exception as exc: # noqa: BLE001
print(f"Failed to read JSON from stdin: {exc}", file=sys.stderr)
sys.exit(1)
setup_rcparams()
chart_type = args.type
if chart_type == "articles_per_month":
render_articles_per_month(payload, args.output)
elif chart_type == "articles_per_year":
render_articles_per_year(payload, args.output)
elif chart_type == "articles_per_section":
render_articles_per_section(payload, args.output)
elif chart_type == "words_per_article":
render_words_per_article(payload, args.output)
elif chart_type == "cumulative":
render_cumulative(payload, args.output)
elif chart_type == "words_histogram":
render_words_histogram(payload, args.output)
elif chart_type == "top_requests":
render_top_requests(payload, args.output)
elif chart_type == "weather_hexbin":
render_weather_hexbin(payload, args.output)
elif chart_type == "weekday_activity":
render_weekday_activity(payload, args.output)
else:
print(f"Unknown chart type: {chart_type}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

107
tools/stats.json Normal file
View File

@@ -0,0 +1,107 @@
{
"config": {
"dataOutput": "content/stats/data/stats.json",
"defaultImageDir": "content/stats/images"
},
"sections": [
{
"title": "Habitudes d'écriture",
"statistics": [
{
"key": "most_prolific_month",
"title": "Mois le plus prolifique",
"type": "variable",
"script": "tools/stats/most_prolific_month.js"
},
{
"key": "weekday_activity",
"title": "Articles et mots par jour",
"type": "graphic",
"script": "tools/stats/weekday_activity.py",
"image": "content/stats/images/weekday_activity.png"
},
{
"key": "articles_avg_per_month",
"title": "Moyenne d'articles par mois",
"type": "variable",
"script": "tools/stats/articles_avg_per_month.js"
},
{
"key": "articles_per_month",
"title": "Articles par mois",
"type": "graphic",
"script": "tools/stats/articles_per_month.py",
"image": "content/stats/images/articles_per_month.png"
},
{
"key": "articles_per_year",
"title": "Articles par an",
"type": "graphic",
"script": "tools/stats/articles_per_year.py",
"image": "content/stats/images/articles_per_year.png"
},
{
"key": "cumulative_articles",
"title": "Cumul articles / mots",
"type": "graphic",
"script": "tools/stats/cumulative_articles.py",
"image": "content/stats/images/cumulative_articles.png"
},
{
"key": "articles_per_section",
"title": "Articles par section",
"type": "graphic",
"script": "tools/stats/articles_per_section.py",
"image": "content/stats/images/articles_per_section.png"
},
{
"key": "words_per_article",
"title": "Nombre de mots par article",
"type": "graphic",
"script": "tools/stats/words_per_article.py",
"image": "content/stats/images/words_per_article.png"
},
{
"key": "words_histogram",
"title": "Distribution des longueurs",
"type": "graphic",
"script": "tools/stats/words_histogram.py",
"image": "content/stats/images/words_histogram.png"
},
{
"key": "weather_hexbin",
"title": "Conditions météo à la publication",
"type": "graphic",
"script": "tools/stats/weather_hexbin.py",
"image": "content/stats/images/weather_hexbin.png"
}
]
},
{
"title": "Visites",
"statistics": [
{
"key": "pageviews_per_month",
"title": "Pages vues (mois courant)",
"type": "variable",
"script": "tools/stats/goaccess_monthly.js",
"metric": "hits"
},
{
"key": "unique_visitors_per_month_value",
"title": "Visiteurs uniques (mois courant)",
"type": "variable",
"script": "tools/stats/goaccess_monthly.js",
"metric": "visitors"
},
{
"key": "top_requests",
"title": "Top requêtes (30 jours)",
"type": "graphic",
"script": "tools/stats/top_requests.py",
"image": "content/stats/images/top_requests.png"
}
]
}
]
}

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
const { loadArticles } = require("../lib/stats/articles");
function computeAveragePerMonth(articles) {
let first = null;
let last = null;
for (const article of articles) {
if (!article.date) continue;
if (!first || article.date < first) first = article.date;
if (!last || article.date > last) last = article.date;
}
if (!first || !last) {
return { average: 0, months: 0 };
}
const monthSpan = Math.max(1, Math.round(last.diff(first.startOf("month"), "months").months) + 1);
const total = articles.filter((a) => a.date).length;
const average = total / monthSpan;
return { average, months: monthSpan };
}
async function run({ contentDir }) {
const articles = await loadArticles(contentDir || "content");
const { average, months } = computeAveragePerMonth(articles);
return { value: average.toFixed(2), meta: { months } };
}
module.exports = { run };

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
const { loadArticles } = require("../lib/stats/articles");
const { renderWithPython } = require("../lib/stats/python");
const MONTH_LABELS = ["Jan", "Fev", "Mar", "Avr", "Mai", "Jun", "Jul", "Aou", "Sep", "Oct", "Nov", "Dec"];
function groupByMonthAndYear(articles) {
const counts = new Map();
const years = new Set();
let first = null;
let last = null;
for (const article of articles) {
if (!article.date) continue;
const year = article.date.year;
const month = article.date.month; // 1..12
years.add(year);
const key = `${year}-${month}`;
counts.set(key, (counts.get(key) || 0) + 1);
if (!first || article.date < first) first = article.date;
if (!last || article.date > last) last = article.date;
}
const monthNumbers = Array.from({ length: 12 }, (_, index) => index + 1);
const labels = monthNumbers.map((month) => MONTH_LABELS[month - 1]);
const sortedYears = Array.from(years).sort((a, b) => a - b);
const series = sortedYears.map((year) => {
return {
label: String(year),
values: monthNumbers.map((month) => counts.get(`${year}-${month}`) || 0),
};
});
return { labels, series, first, last };
}
async function run({ contentDir, outputPath, publicPath }) {
if (!outputPath) {
throw new Error("outputPath manquant pour articles_per_month");
}
const articles = await loadArticles(contentDir || "content");
const { labels, series, first, last } = groupByMonthAndYear(articles);
await renderWithPython({
type: "articles_per_month",
outputPath,
data: {
labels,
series,
title: "Articles par mois",
},
});
return {
image: publicPath,
meta: {
from: first ? first.toISODate() : null,
to: last ? last.toISODate() : null,
months: labels.length,
},
};
}
module.exports = { run };

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
import sys
import json
import os
from collections import defaultdict
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PARENT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
if CURRENT_DIR not in sys.path:
sys.path.append(CURRENT_DIR)
if PARENT_DIR not in sys.path:
sys.path.append(PARENT_DIR)
from common import load_articles, MONTH_LABELS, write_result # noqa: E402
def main():
try:
payload = json.load(sys.stdin)
except Exception as exc: # noqa: BLE001
print(f"Failed to read JSON: {exc}", file=sys.stderr)
sys.exit(1)
content_dir = payload.get("contentDir") or "content"
output_path = payload.get("outputPath")
public_path = payload.get("publicPath")
articles = load_articles(content_dir)
counts = defaultdict(int)
years = set()
first = None
last = None
for article in articles:
date = article.get("date")
if not date:
continue
year = date.year
month = date.month
years.add(year)
counts[(year, month)] += 1
if not first or date < first:
first = date
if not last or date > last:
last = date
month_numbers = list(range(1, 13))
labels = [MONTH_LABELS[m - 1] for m in month_numbers]
sorted_years = sorted(years)
series = []
for year in sorted_years:
values = [counts.get((year, m), 0) for m in month_numbers]
series.append({"label": str(year), "values": values})
# Render via shared renderer
try:
from render_stats_charts import render_articles_per_month, setup_rcparams
except ImportError as exc: # noqa: BLE001
print(f"Failed to import renderer: {exc}", file=sys.stderr)
sys.exit(1)
setup_rcparams()
render_articles_per_month({"labels": labels, "series": series, "title": "Articles par mois"}, output_path)
write_result(
{
"image": public_path,
"meta": {
"from": first.isoformat() if first else None,
"to": last.isoformat() if last else None,
"months": len(labels),
},
}
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env node
const { loadArticles } = require("../lib/stats/articles");
const { renderWithPython } = require("../lib/stats/python");
function groupBySection(articles) {
const counts = new Map();
for (const article of articles) {
const key = article.section || "root";
counts.set(key, (counts.get(key) || 0) + 1);
}
const entries = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
return entries;
}
async function run({ contentDir, outputPath, publicPath }) {
if (!outputPath) {
throw new Error("outputPath manquant pour articles_per_section");
}
const articles = await loadArticles(contentDir || "content");
const entries = groupBySection(articles);
const maxSlices = 21;
const top = entries.slice(0, maxSlices);
const rest = entries.slice(maxSlices);
if (rest.length > 0) {
const restSum = rest.reduce((sum, [, value]) => sum + value, 0);
top.push(["Others", restSum]);
}
const labels = top.map(([key]) => key);
const values = top.map(([, value]) => value);
await renderWithPython({
type: "articles_per_section",
outputPath,
data: {
labels,
values,
title: "Articles par section",
},
});
return {
image: publicPath,
meta: {
sections: entries.length,
},
};
}
module.exports = { run };

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
import sys
import json
import os
from collections import defaultdict
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PARENT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
if CURRENT_DIR not in sys.path:
sys.path.append(CURRENT_DIR)
if PARENT_DIR not in sys.path:
sys.path.append(PARENT_DIR)
from common import load_articles, write_result # noqa: E402
def main():
try:
payload = json.load(sys.stdin)
except Exception as exc: # noqa: BLE001
print(f"Failed to read JSON: {exc}", file=sys.stderr)
sys.exit(1)
content_dir = payload.get("contentDir") or "content"
output_path = payload.get("outputPath")
public_path = payload.get("publicPath")
articles = load_articles(content_dir)
counts = defaultdict(int)
for article in articles:
section = article.get("section") or "root"
counts[section] += 1
entries = sorted(counts.items(), key=lambda item: item[1], reverse=True)
max_slices = 21
top = entries[:max_slices]
rest = entries[max_slices:]
if rest:
rest_sum = sum(v for _, v in rest)
top.append(("Others", rest_sum))
labels = [label for label, _ in top]
values = [value for _, value in top]
try:
from render_stats_charts import render_articles_per_section, setup_rcparams
except ImportError as exc: # noqa: BLE001
print(f"Failed to import renderer: {exc}", file=sys.stderr)
sys.exit(1)
setup_rcparams()
render_articles_per_section({"labels": labels, "values": values, "title": "Articles par section"}, output_path)
write_result(
{
"image": public_path,
"meta": {
"sections": len(entries),
},
}
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
const { loadArticles } = require("../lib/stats/articles");
const { renderWithPython } = require("../lib/stats/python");
function groupByYear(articles) {
const counts = new Map();
let first = null;
let last = null;
for (const article of articles) {
if (!article.date) continue;
const year = article.date.year;
counts.set(year, (counts.get(year) || 0) + 1);
if (!first || article.date < first) first = article.date;
if (!last || article.date > last) last = article.date;
}
const entries = Array.from(counts.entries()).sort((a, b) => a[0] - b[0]);
const labels = entries.map(([year]) => `${year}`);
const values = entries.map(([, value]) => value);
return { labels, values, first, last };
}
async function run({ contentDir, outputPath, publicPath }) {
if (!outputPath) {
throw new Error("outputPath manquant pour articles_per_year");
}
const articles = await loadArticles(contentDir || "content");
const { labels, values, first, last } = groupByYear(articles);
await renderWithPython({
type: "articles_per_year",
outputPath,
data: {
labels,
values,
title: "Articles par an",
},
});
return {
image: publicPath,
meta: {
from: first ? first.toISODate() : null,
to: last ? last.toISODate() : null,
years: labels.length,
},
};
}
module.exports = { run };

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import sys
import json
import os
from collections import defaultdict
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PARENT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
if CURRENT_DIR not in sys.path:
sys.path.append(CURRENT_DIR)
if PARENT_DIR not in sys.path:
sys.path.append(PARENT_DIR)
from common import load_articles, write_result # noqa: E402
def main():
try:
payload = json.load(sys.stdin)
except Exception as exc: # noqa: BLE001
print(f"Failed to read JSON: {exc}", file=sys.stderr)
sys.exit(1)
content_dir = payload.get("contentDir") or "content"
output_path = payload.get("outputPath")
public_path = payload.get("publicPath")
articles = load_articles(content_dir)
counts = defaultdict(int)
first = None
last = None
for article in articles:
date = article.get("date")
if not date:
continue
year = date.year
counts[year] += 1
if not first or date < first:
first = date
if not last or date > last:
last = date
sorted_years = sorted(counts.keys())
labels = [str(y) for y in sorted_years]
values = [counts[y] for y in sorted_years]
try:
from render_stats_charts import render_articles_per_year, setup_rcparams
except ImportError as exc: # noqa: BLE001
print(f"Failed to import renderer: {exc}", file=sys.stderr)
sys.exit(1)
setup_rcparams()
render_articles_per_year({"labels": labels, "values": values, "title": "Articles par an"}, output_path)
write_result(
{
"image": public_path,
"meta": {
"from": first.isoformat() if first else None,
"to": last.isoformat() if last else None,
"years": len(labels),
},
}
)
if __name__ == "__main__":
main()

166
tools/stats/common.py Normal file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
import os
import re
import json
import yaml
from datetime import datetime, date, timezone
MONTH_LABELS = ["Jan", "Fev", "Mar", "Avr", "Mai", "Jun", "Jul", "Aou", "Sep", "Oct", "Nov", "Dec"]
def find_markdown_files(root):
files = []
for dirpath, dirnames, filenames in os.walk(root):
for filename in filenames:
if not filename.lower().endswith(".md"):
continue
if filename == "_index.md":
continue
files.append(os.path.join(dirpath, filename))
return files
def collect_section_dirs(root):
section_dirs = set()
for dirpath, dirnames, filenames in os.walk(root):
if "_index.md" in filenames:
section_dirs.add(os.path.abspath(dirpath))
return section_dirs
def leaf_sections(section_dirs):
leaves = set()
for section in section_dirs:
is_leaf = True
for other in section_dirs:
if other == section:
continue
if other.startswith(section + os.sep):
is_leaf = False
break
if is_leaf:
leaves.add(section)
return leaves
def parse_frontmatter(path):
with open(path, "r", encoding="utf-8") as handle:
content = handle.read()
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
fm_text = parts[1]
body = parts[2]
else:
return {}, content
else:
return {}, content
try:
data = yaml.safe_load(fm_text) or {}
except Exception:
data = {}
return data, body
def parse_date(value):
if not value:
return None
dt = None
if isinstance(value, datetime):
dt = value
elif isinstance(value, date):
dt = datetime.combine(value, datetime.min.time())
elif isinstance(value, (int, float)):
try:
dt = datetime.fromtimestamp(value)
except Exception:
dt = None
elif isinstance(value, str):
# try ISO-like formats
for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%Y/%m/%d", "%d/%m/%Y"):
try:
dt = datetime.strptime(value, fmt)
break
except Exception:
continue
if dt is None:
try:
dt = datetime.fromisoformat(value)
except Exception:
dt = None
if dt is None:
return None
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
WORD_RE = re.compile(r"[\w'-]+", re.UNICODE)
def count_words(text):
if not text:
return 0
words = WORD_RE.findall(text)
return len(words)
def resolve_section(file_path, content_root, leaf_dirs):
content_root = os.path.abspath(content_root)
current = os.path.abspath(os.path.dirname(file_path))
best = None
while current.startswith(content_root):
if current in leaf_dirs:
best = current
break
parent = os.path.dirname(current)
if parent == current:
break
current = parent
if not best:
return None
rel = os.path.relpath(best, content_root)
return rel.replace(os.sep, "/") if rel != "." else "."
def load_articles(content_root):
files = find_markdown_files(content_root)
section_dirs = collect_section_dirs(content_root)
leaf_dirs = leaf_sections(section_dirs)
articles = []
for file_path in files:
fm, body = parse_frontmatter(file_path)
date = parse_date(fm.get("date"))
title = fm.get("title") or os.path.splitext(os.path.basename(file_path))[0]
word_count = count_words(body)
rel_path = os.path.relpath(file_path, content_root)
section = resolve_section(file_path, content_root, leaf_dirs)
weather = fm.get("weather") if isinstance(fm, dict) else None
articles.append(
{
"path": file_path,
"relativePath": rel_path,
"title": title,
"date": date,
"wordCount": word_count,
"section": section,
"weather": weather,
}
)
return articles
def write_result(data):
import sys
json.dump(data, sys.stdout)
sys.stdout.flush()

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
import sys
import json
import os
from collections import defaultdict
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PARENT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
if CURRENT_DIR not in sys.path:
sys.path.append(CURRENT_DIR)
if PARENT_DIR not in sys.path:
sys.path.append(PARENT_DIR)
from common import load_articles, write_result # noqa: E402
def month_key(dt):
return f"{dt.year}-{dt.month:02d}"
def main():
try:
payload = json.load(sys.stdin)
except Exception as exc: # noqa: BLE001
print(f"Failed to read JSON: {exc}", file=sys.stderr)
sys.exit(1)
content_dir = payload.get("contentDir") or "content"
output_path = payload.get("outputPath")
public_path = payload.get("publicPath")
articles = load_articles(content_dir)
monthly_articles = defaultdict(int)
monthly_words = defaultdict(int)
months_set = set()
for article in articles:
date = article.get("date")
if not date:
continue
key = month_key(date)
monthly_articles[key] += 1
monthly_words[key] += article.get("wordCount") or 0
months_set.add(key)
sorted_months = sorted(months_set)
cum_articles = []
cum_words = []
labels = []
total_a = 0
total_w = 0
for key in sorted_months:
total_a += monthly_articles.get(key, 0)
total_w += monthly_words.get(key, 0)
labels.append(key)
cum_articles.append(total_a)
cum_words.append(total_w)
try:
from render_stats_charts import render_cumulative, setup_rcparams
except ImportError as exc: # noqa: BLE001
print(f"Failed to import renderer: {exc}", file=sys.stderr)
sys.exit(1)
setup_rcparams()
render_cumulative(
{
"labels": labels,
"articles": cum_articles,
"words": cum_words,
"title": "Cumul articles / mots",
},
output_path,
)
write_result(
{
"image": public_path,
"meta": {
"months": len(labels),
"articles": total_a,
"words": total_w,
},
}
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env node
const { fetchGoAccessJson, aggregateLastNDays } = require("../lib/stats/goaccess");
const { loadToolsConfig } = require("../lib/config");
let cache = null;
async function loadData(url) {
if (!cache) {
cache = await fetchGoAccessJson(url);
}
return cache;
}
function latestMonthEntry(months) {
if (!months || months.length === 0) return null;
return months[months.length - 1];
}
async function run({ stat }) {
const toolsConfig = await loadToolsConfig();
const url = stat.url || toolsConfig.goaccess?.url || "";
const metric = stat.metric || "hits";
const windowDays = Number.isFinite(stat.days) ? stat.days : 30;
const data = await loadData(url);
const window = aggregateLastNDays(data, windowDays, { adjustCrawlers: true });
if (!window || !window.to) return { value: 0 };
const value = metric === "visitors" ? window.visitors : window.hits;
return {
value,
meta: {
from: window.from || null,
to: window.to || null,
days: windowDays,
metric,
raw: value,
},
};
}
module.exports = { run };

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env node
const { DateTime } = require("luxon");
const { loadArticles } = require("../lib/stats/articles");
async function computeMostProlificMonth(contentDir) {
const articles = await loadArticles(contentDir);
const counts = new Map();
for (const article of articles) {
if (!article.date) continue;
const monthKey = article.date.toFormat("yyyy-MM");
counts.set(monthKey, (counts.get(monthKey) || 0) + 1);
}
if (counts.size === 0) {
return null;
}
const sorted = Array.from(counts.entries()).sort((a, b) => {
if (b[1] !== a[1]) return b[1] - a[1];
return a[0] < b[0] ? -1 : 1;
});
const [monthKey, count] = sorted[0];
const label = DateTime.fromISO(`${monthKey}-01`).setLocale("fr").toFormat("LLLL yyyy");
return { value: `${label} (${count})`, month: monthKey, count };
}
async function run({ contentDir }) {
const result = await computeMostProlificMonth(contentDir || "content");
return result || { value: null };
}
module.exports = { run };

107
tools/stats/top_requests.py Normal file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
import sys
import json
import os
import urllib.request
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
TOOLS_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
ROOT_DIR = os.path.abspath(os.path.join(TOOLS_DIR, os.pardir))
if CURRENT_DIR not in sys.path:
sys.path.append(CURRENT_DIR)
if TOOLS_DIR not in sys.path:
sys.path.append(TOOLS_DIR)
def load_config():
cfg_path = os.path.join(ROOT_DIR, "tools", "config.json")
try:
with open(cfg_path, "r", encoding="utf-8") as handle:
return json.load(handle)
except Exception:
return {}
def fetch_goaccess(url, timeout=10):
with urllib.request.urlopen(url, timeout=timeout) as resp:
data = resp.read().decode("utf-8")
return json.loads(data)
def crawler_ratios(data):
browsers = (data.get("browsers") or {}).get("data") or []
crawler = next((entry for entry in browsers if entry.get("data") == "Crawlers"), None)
if not crawler:
return {"hits": 0.0, "visitors": 0.0}
def total(field):
return sum((entry.get(field, {}) or {}).get("count", 0) for entry in browsers)
total_hits = total("hits")
total_visitors = total("visitors")
return {
"hits": min(1.0, (crawler.get("hits", {}) or {}).get("count", 0) / total_hits) if total_hits else 0.0,
"visitors": min(1.0, (crawler.get("visitors", {}) or {}).get("count", 0) / total_visitors)
if total_visitors
else 0.0,
}
def adjust(value, ratio):
return max(0, round(value * (1 - ratio)))
def main():
try:
payload = json.load(sys.stdin)
except Exception as exc: # noqa: BLE001
print(f"Failed to read JSON: {exc}", file=sys.stderr)
sys.exit(1)
output_path = payload.get("outputPath")
public_path = payload.get("publicPath")
url = payload.get("stat", {}).get("url")
cfg = load_config()
goaccess_url = url or (cfg.get("goaccess") or {}).get("url") or ""
try:
data = fetch_goaccess(goaccess_url)
except Exception as exc: # noqa: BLE001
print(f"Failed to fetch GoAccess JSON: {exc}", file=sys.stderr)
sys.exit(1)
ratios = crawler_ratios(data)
reqs = (data.get("requests") or {}).get("data") or []
# entries have .data = path, hits.count, visitors.count ?
cleaned = []
for entry in reqs:
path = entry.get("data") or ""
hits = (entry.get("hits") or {}).get("count", 0)
if not path or hits <= 0:
continue
cleaned.append((path, adjust(hits, ratios["hits"])))
cleaned.sort(key=lambda item: item[1], reverse=True)
top = cleaned[:10]
labels = [item[0] for item in top]
values = [item[1] for item in top]
try:
from render_stats_charts import render_top_requests, setup_rcparams
except ImportError as exc: # noqa: BLE001
print(f"Failed to import renderer: {exc}", file=sys.stderr)
sys.exit(1)
setup_rcparams()
render_top_requests({"labels": labels, "values": values, "title": "Top 10 requêtes (hors crawlers)"}, output_path)
json.dump({"image": public_path}, sys.stdout)
sys.stdout.flush()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env node
const fs = require("fs/promises");
const { renderChart, makeBarConfig } = require("../lib/stats/charts");
const { fetchGoAccessJson, groupVisitsByMonth } = require("../lib/stats/goaccess");
const { loadToolsConfig } = require("../lib/config");
async function run({ stat, outputPath, publicPath }) {
if (!outputPath) {
throw new Error("outputPath manquant pour unique_visitors_per_month");
}
const toolsConfig = await loadToolsConfig();
const url = stat.url || toolsConfig.goaccess?.url || "";
const data = await fetchGoAccessJson(url);
const months = groupVisitsByMonth(data, { adjustCrawlers: true });
const labels = months.map((entry) => entry.month);
const values = months.map((entry) => entry.visitors);
const buffer = await renderChart(
makeBarConfig({
labels,
data: values,
title: "Visiteurs uniques par mois (hors crawlers)",
color: "rgba(239, 68, 68, 0.8)",
}),
);
await fs.writeFile(outputPath, buffer);
const latest = months[months.length - 1];
return {
image: publicPath,
meta: {
month: latest?.month || null,
visitors: latest?.visitors || 0,
},
};
}
module.exports = { run };

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
import sys
import json
import os
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PARENT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
if CURRENT_DIR not in sys.path:
sys.path.append(CURRENT_DIR)
if PARENT_DIR not in sys.path:
sys.path.append(PARENT_DIR)
from common import load_articles, write_result # noqa: E402
def main():
try:
payload = json.load(sys.stdin)
except Exception as exc: # noqa: BLE001
print(f"Failed to read JSON: {exc}", file=sys.stderr)
sys.exit(1)
content_dir = payload.get("contentDir") or "content"
output_path = payload.get("outputPath")
public_path = payload.get("publicPath")
articles = load_articles(content_dir)
temps = []
hums = []
presses = []
for article in articles:
weather = article.get("weather") or {}
try:
t = float(weather.get("temperature"))
h = float(weather.get("humidity"))
except Exception:
continue
temps.append(t)
hums.append(h)
try:
p = float(weather.get("pressure"))
presses.append(p)
except Exception:
presses.append(None)
# Align pressures length
if all(p is None for p in presses):
presses = []
else:
presses = [p for p in presses if p is not None]
try:
from render_stats_charts import render_weather_hexbin, setup_rcparams
except ImportError as exc: # noqa: BLE001
print(f"Failed to import renderer: {exc}", file=sys.stderr)
sys.exit(1)
setup_rcparams()
render_weather_hexbin(
{
"temps": temps,
"hums": hums,
"presses": presses if len(presses) == len(temps) else [],
"title": "Conditions météo à la publication",
},
output_path,
)
write_result({"image": public_path, "meta": {"points": len(temps)}})
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
import sys
import json
import os
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PARENT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
if CURRENT_DIR not in sys.path:
sys.path.append(CURRENT_DIR)
if PARENT_DIR not in sys.path:
sys.path.append(PARENT_DIR)
from common import load_articles, write_result # noqa: E402
WEEKDAY_LABELS = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
def main():
try:
payload = json.load(sys.stdin)
except Exception as exc: # noqa: BLE001
print(f"Failed to read JSON: {exc}", file=sys.stderr)
sys.exit(1)
content_dir = payload.get("contentDir") or "content"
output_path = payload.get("outputPath")
public_path = payload.get("publicPath")
articles = load_articles(content_dir)
counts = [0] * 7
words = [0] * 7
for article in articles:
dt = article.get("date")
if not dt:
continue
weekday = dt.weekday() # Monday=0
counts[weekday] += 1
words[weekday] += article.get("wordCount") or 0
try:
from render_stats_charts import render_weekday_activity, setup_rcparams
except ImportError as exc: # noqa: BLE001
print(f"Failed to import renderer: {exc}", file=sys.stderr)
sys.exit(1)
setup_rcparams()
render_weekday_activity(
{
"labels": WEEKDAY_LABELS,
"articles": counts,
"words": words,
"title": "Articles et mots par jour de semaine",
},
output_path,
)
write_result({"image": public_path})
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env python3
import sys
import json
import os
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PARENT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
if CURRENT_DIR not in sys.path:
sys.path.append(CURRENT_DIR)
if PARENT_DIR not in sys.path:
sys.path.append(PARENT_DIR)
from common import load_articles, write_result # noqa: E402
def main():
try:
payload = json.load(sys.stdin)
except Exception as exc: # noqa: BLE001
print(f"Failed to read JSON: {exc}", file=sys.stderr)
sys.exit(1)
content_dir = payload.get("contentDir") or "content"
output_path = payload.get("outputPath")
public_path = payload.get("publicPath")
articles = load_articles(content_dir)
values = [a.get("wordCount") or 0 for a in articles if a.get("wordCount")]
try:
from render_stats_charts import render_words_histogram, setup_rcparams
except ImportError as exc: # noqa: BLE001
print(f"Failed to import renderer: {exc}", file=sys.stderr)
sys.exit(1)
setup_rcparams()
render_words_histogram(
{
"values": values,
"title": "Distribution des longueurs d'article",
"bins": 20,
},
output_path,
)
write_result(
{
"image": public_path,
"meta": {
"articles": len(values),
},
}
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
const { loadArticles } = require("../lib/stats/articles");
const { renderWithPython } = require("../lib/stats/python");
const MONTH_LABELS = ["Jan", "Fev", "Mar", "Avr", "Mai", "Jun", "Jul", "Aou", "Sep", "Oct", "Nov", "Dec"];
function groupAverageWordsByMonth(articles) {
const buckets = new Map();
const years = new Set();
let totalWords = 0;
let totalArticles = 0;
for (const article of articles) {
if (!article.date) continue;
const year = article.date.year;
const month = article.date.month;
const key = `${year}-${month}`;
years.add(year);
const current = buckets.get(key) || { words: 0, count: 0 };
current.words += article.wordCount || 0;
current.count += 1;
buckets.set(key, current);
totalWords += article.wordCount || 0;
totalArticles += 1;
}
const monthNumbers = Array.from({ length: 12 }, (_, index) => index + 1);
const labels = monthNumbers.map((month) => MONTH_LABELS[month - 1]);
const sortedYears = Array.from(years).sort((a, b) => a - b);
const series = sortedYears.map((year) => {
const values = monthNumbers.map((month) => {
const entry = buckets.get(`${year}-${month}`);
if (!entry || entry.count === 0) return 0;
return Math.round(entry.words / entry.count);
});
return {
label: String(year),
values,
};
});
const average = totalArticles > 0 ? Math.round(totalWords / totalArticles) : 0;
return { labels, series, average, articles: totalArticles };
}
async function run({ contentDir, outputPath, publicPath }) {
if (!outputPath) {
throw new Error("outputPath manquant pour le graphique words_per_article");
}
const articles = await loadArticles(contentDir || "content");
const { labels, series, average, articles: totalArticles } = groupAverageWordsByMonth(articles);
await renderWithPython({
type: "words_per_article",
outputPath,
data: {
labels,
series,
title: "Moyenne de mots par article (par mois)",
},
});
return {
image: publicPath,
meta: {
average,
articles: totalArticles,
},
};
}
module.exports = { run };

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
import sys
import json
import os
from collections import defaultdict
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PARENT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.pardir))
if CURRENT_DIR not in sys.path:
sys.path.append(CURRENT_DIR)
if PARENT_DIR not in sys.path:
sys.path.append(PARENT_DIR)
from common import load_articles, MONTH_LABELS, write_result # noqa: E402
def main():
try:
payload = json.load(sys.stdin)
except Exception as exc: # noqa: BLE001
print(f"Failed to read JSON: {exc}", file=sys.stderr)
sys.exit(1)
content_dir = payload.get("contentDir") or "content"
output_path = payload.get("outputPath")
public_path = payload.get("publicPath")
articles = load_articles(content_dir)
buckets = defaultdict(lambda: {"words": 0, "count": 0})
years = set()
total_words = 0
total_articles = 0
for article in articles:
date = article.get("date")
if not date:
continue
year = date.year
month = date.month
key = (year, month)
years.add(year)
buckets[key]["words"] += article.get("wordCount") or 0
buckets[key]["count"] += 1
total_words += article.get("wordCount") or 0
total_articles += 1
month_numbers = list(range(1, 13))
labels = [MONTH_LABELS[m - 1] for m in month_numbers]
sorted_years = sorted(years)
series = []
for year in sorted_years:
values = []
for month in month_numbers:
entry = buckets.get((year, month))
if not entry or entry["count"] == 0:
values.append(0)
else:
values.append(round(entry["words"] / entry["count"]))
series.append({"label": str(year), "values": values})
average = round(total_words / total_articles) if total_articles > 0 else 0
try:
from render_stats_charts import render_words_per_article, setup_rcparams
except ImportError as exc: # noqa: BLE001
print(f"Failed to import renderer: {exc}", file=sys.stderr)
sys.exit(1)
setup_rcparams()
render_words_per_article({"labels": labels, "series": series, "title": "Moyenne de mots par article (par mois)"}, output_path)
write_result(
{
"image": public_path,
"meta": {
"average": average,
"articles": total_articles,
},
}
)
if __name__ == "__main__":
main()