Page de statistiques
This commit is contained in:
32
tools/stats/articles_avg_per_month.js
Normal file
32
tools/stats/articles_avg_per_month.js
Normal 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 };
|
||||
67
tools/stats/articles_per_month.js
Normal file
67
tools/stats/articles_per_month.js
Normal 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 };
|
||||
81
tools/stats/articles_per_month.py
Normal file
81
tools/stats/articles_per_month.py
Normal 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()
|
||||
55
tools/stats/articles_per_section.js
Normal file
55
tools/stats/articles_per_section.js
Normal 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 };
|
||||
69
tools/stats/articles_per_section.py
Normal file
69
tools/stats/articles_per_section.py
Normal 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()
|
||||
|
||||
54
tools/stats/articles_per_year.js
Normal file
54
tools/stats/articles_per_year.js
Normal 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 };
|
||||
73
tools/stats/articles_per_year.py
Normal file
73
tools/stats/articles_per_year.py
Normal 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
166
tools/stats/common.py
Normal 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()
|
||||
95
tools/stats/cumulative_articles.py
Normal file
95
tools/stats/cumulative_articles.py
Normal 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()
|
||||
|
||||
42
tools/stats/goaccess_monthly.js
Normal file
42
tools/stats/goaccess_monthly.js
Normal 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 };
|
||||
36
tools/stats/most_prolific_month.js
Normal file
36
tools/stats/most_prolific_month.js
Normal 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
107
tools/stats/top_requests.py
Normal 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()
|
||||
43
tools/stats/unique_visitors_per_month.js
Normal file
43
tools/stats/unique_visitors_per_month.js
Normal 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 };
|
||||
78
tools/stats/weather_hexbin.py
Normal file
78
tools/stats/weather_hexbin.py
Normal 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()
|
||||
|
||||
65
tools/stats/weekday_activity.py
Normal file
65
tools/stats/weekday_activity.py
Normal 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()
|
||||
|
||||
59
tools/stats/words_histogram.py
Normal file
59
tools/stats/words_histogram.py
Normal 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()
|
||||
|
||||
79
tools/stats/words_per_article.js
Normal file
79
tools/stats/words_per_article.js
Normal 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 };
|
||||
88
tools/stats/words_per_article.py
Normal file
88
tools/stats/words_per_article.py
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user