diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index 734618daa..d8d51047b 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -20,9 +20,19 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Check out audience statistics + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: AA-Turner/plausible-stats + path: plausible-stats + sparse-checkout: stats/docs.python.org_*/*.visitors.json + sparse-checkout-cone-mode: false + persist-credentials: false - run: sudo apt-get install -y gettext - run: pip install -r requirements.txt - run: uv run generate.py # generates index.html and index.json + env: + PLAUSIBLE_STATS_DIR: plausible-stats/stats - name: Deploy 🚀 if: github.event_name != 'pull_request' uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 diff --git a/audience.py b/audience.py new file mode 100644 index 000000000..f03e1a549 --- /dev/null +++ b/audience.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +import logging +from collections.abc import Iterable +from pathlib import Path + + +def get_audience( + stats_dir: Path, + language_codes: Iterable[str], + snapshots: int = 30, + *, + required: bool = False, +) -> dict[str, int]: + """Return a rolling audience score for each language. + + Plausible's public exports contain one visitor count per hour, so the score + is the sum of those counts across the most recent available snapshots. + """ + if snapshots < 1: + raise ValueError('snapshots must be at least 1') + + audience = dict.fromkeys(language_codes, 0) + if not stats_dir.is_dir(): + message = f'Plausible statistics directory not found: {stats_dir}' + if required: + raise FileNotFoundError(message) + logging.warning(message) + return audience + + snapshot_dirs = sorted( + (path for path in stats_dir.glob('docs.python.org_*') if path.is_dir()), + key=lambda path: path.name, + reverse=True, + ) + if not snapshot_dirs: + message = f'No Plausible statistics snapshots found in {stats_dir}' + if required: + raise FileNotFoundError(message) + logging.warning(message) + return audience + + snapshots_read = dict.fromkeys(audience, 0) + files_read = 0 + for snapshot_dir in snapshot_dirs: + for language_code in audience: + if snapshots_read[language_code] >= snapshots: + continue + visitors_file = snapshot_dir / ( + f'{snapshot_dir.name}.prefix-{language_code}.visitors.json' + ) + if not visitors_file.exists(): + continue + + rows = json.loads(visitors_file.read_text()) + audience[language_code] += sum(int(row['visitors']) for row in rows) + snapshots_read[language_code] += 1 + files_read += 1 + + if required and not files_read: + raise FileNotFoundError(f'No Plausible visitor statistics found in {stats_dir}') + + return audience diff --git a/generate.py b/generate.py index a6df9c6f3..86f4fb8ca 100644 --- a/generate.py +++ b/generate.py @@ -4,6 +4,7 @@ import concurrent.futures import itertools import logging +import os import subprocess from collections.abc import Iterator from dataclasses import dataclass, asdict @@ -16,10 +17,14 @@ import translated_names import contribute +from audience import get_audience from completion import branches_from_peps, get_completion from repositories import Language, get_languages_and_repos generation_time = datetime.now(timezone.utc) +default_plausible_stats_dir = ( + Path(__file__).resolve().parent.parent / 'plausible-stats' / 'stats' +) def get_completion_progress() -> Iterator[LanguageProjectData]: @@ -49,13 +54,21 @@ def get_completion_progress() -> Iterator[LanguageProjectData]: language: translated_name for language, translated_name in translated_names.get_languages(PoolManager()) } + languages_and_repos = list(get_languages_and_repos(devguide_dir)) + plausible_stats_dir = os.environ.get('PLAUSIBLE_STATS_DIR') + audience = get_audience( + Path(plausible_stats_dir or default_plausible_stats_dir), + (language.code for language, _ in languages_and_repos), + required=plausible_stats_dir is not None, + ) with concurrent.futures.ThreadPoolExecutor() as executor: return executor.map( get_project_data, - *zip(*get_languages_and_repos(devguide_dir)), + *zip(*languages_and_repos), itertools.repeat(languages_built), itertools.repeat(clones_dir), + itertools.repeat(audience), ) @@ -64,6 +77,7 @@ def get_project_data( repo: str | None, languages_built: dict[str, str], clones_dir: str, + audience: dict[str, int], ) -> LanguageProjectData: built = language.code in languages_built if repo: @@ -88,6 +102,7 @@ def get_project_data( or translated_names.babel_autonym(language.code) or '', contribution_link=contribute.get_contrib_link(language.code, repo), + audience=audience[language.code], ) @@ -103,6 +118,7 @@ class LanguageProjectData: built: bool translated_name: str contribution_link: str | None + audience: int if __name__ == '__main__': diff --git a/templates/index.html.jinja b/templates/index.html.jinja index 9b5897455..e02405cb8 100644 --- a/templates/index.html.jinja +++ b/templates/index.html.jinja @@ -1,9 +1,32 @@ {% extends "base.html.jinja" %} {% block main %} -