Webalizer widget screenshot

Script

Webalizer

by Matthew Charlesworth

WEBALIZER-WIDGET.PY(1)

NAME

webalizer-widget.py — display Webalizer website statistics as graphs in TerminalWidget

SYNOPSIS

webalizer-widget.py [OPTIONS] WEBALIZER_URL

Examples:

webalizer-widget.py https://stats.example.org/webalizer/
webalizer-widget.py https://stats.example.org/webalizer/ \
    --view monthly \
    --months 12
webalizer-widget.py https://stats.example.org/webalizer/ \
    --username USERNAME \
    --password-file ~/.webalizer-password

DESCRIPTION

webalizer-widget.py reads statistics generated by Webalizer and displays them as graphical widgets using Brett Terpstra’s TerminalWidget. The script is intended to be reusable with any standard Webalizer installation. The only required argument is the base URL of the Webalizer report directory. For example:

https://stats.example.org/webalizer/

The script retrieves the Webalizer HTML reports, extracts traffic statistics, and passes the resulting data to the terminal-widget command-line utility. Two principal views are available:

daily
Displays a rolling graph of daily statistics, normally covering the most recent 30 days.
monthly
Displays monthly totals from the Webalizer summary page, normally covering the most recent 12 months.

The default statistic is Visits, which generally provides a more useful indication of website activity than raw HTTP Hits. The widget is clickable and opens the corresponding Webalizer report in the default browser.

DEFAULTS

Unless otherwise specified, the script uses:

View:          daily
Metric:        visits
Daily period:  30 days
Monthly period: 12 months
Daily chart:   area
Monthly chart: bar
Daily target:  webstats
Monthly target: webstats-monthly
404 warning:   5%

DAILY VIEW

The default daily display graphs the most recent Webalizer daily Visit counts. The caption contains:

Today 245 · 7d 1,828 · Aug 3,827

If Webalizer has not yet generated statistics for the current day, the date of the most recent available report is shown instead:

14 Aug 245 · 7d 1,828 · Aug 3,827

If the percentage of HTTP 404 responses exceeds the configured warning threshold, the percentage is appended:

Today 245 · 7d 1,828 · Aug 3,827 · 404 8.4%

The graph automatically crosses month boundaries by retrieving the required usage_YYYYMM.html files.

MONTHLY VIEW

Monthly mode reads the main Webalizer summary page and graphs the monthly totals. For example:

webalizer-widget.py \
    https://stats.example.org/webalizer/ \
    --view monthly

A typical caption might be:

Aug MTD 3,827 · Jul 6,638 · 12m 68,421

The current month is identified as MTD (“month to date”) because its total is incomplete until the month has ended.

METRICS

The following Webalizer statistics may be graphed:

visits
Webalizer Visits. This is the default and usually the most useful general traffic statistic.
pages
Page requests recorded by Webalizer.
hits
All HTTP requests, including pages, images, stylesheets and other resources. Hits can be substantially higher than actual website usage.
files
Requests that resulted in a file being transferred.
sites
Unique hosts or sites recorded by Webalizer. This should not necessarily be interpreted as an exact count of individual human visitors.
kbytes
Amount of data transferred.

Select a metric with:

--metric METRIC

For example:

webalizer-widget.py \
    https://stats.example.org/webalizer/ \
    --metric pages

OPTIONS

--view {daily,monthly}

Select the type of graph.

--view daily

or:

--view monthly

Default:

daily

--metric METRIC

Select the Webalizer statistic to display. Valid values are:

visits
pages
hits
files
sites
kbytes

Default:

visits

--days NUMBER

Number of days displayed by the daily graph. For example:

--days 60

The permitted range is 7–128 days. Default:

30

--months NUMBER

Number of months displayed by the monthly graph. For example:

--months 24

The permitted range is 2–128 months. Default:

12

--target NAME

Specify the TerminalWidget target to update. For example:

--target website-stats

If omitted, the defaults are:

daily:    webstats
monthly:  webstats-monthly

This permits daily and monthly widgets to coexist.

--title TEXT

Override the automatically generated widget title. Normally the title is derived from the website name reported by Webalizer. Example:

--title "Fr Isaac SJ · Visits"

--chart-format FORMAT

Override the TerminalWidget chart format. For example:

--chart-format smooth

Daily mode defaults to:

area

Monthly mode defaults to:

bar

--warn-404 PERCENT

Set the percentage of HTTP 404 responses at which the daily widget adds a warning to its caption. For example:

--warn-404 10

Default:

5

To effectively disable the warning:

--warn-404 100

AUTHENTICATION

The script supports HTTP Basic Authentication for Webalizer reports protected by a username and password. Credentials may be supplied using command-line arguments, environment variables, a password file, or .netrc.

Command-line credentials

webalizer-widget.py \
    https://stats.example.org/webalizer/ \
    --username matthew \
    --password 'secret-password'

This is convenient for testing but is not recommended for automated jobs because the password may be visible in the command line.

--username USERNAME

Specify the HTTP Basic Authentication username.

--password PASSWORD

Specify the HTTP Basic Authentication password.

--password-file FILE

Read the password from a file. For example:

webalizer-widget.py \
    https://stats.example.org/webalizer/ \
    --username matthew \
    --password-file ~/.webalizer-password

The password file should normally be restricted:

chmod 600 ~/.webalizer-password

.netrc

For unattended operation, including LaunchAgents, .netrc is usually the preferred method. Example:

machine stats.example.org
    login matthew
    password secret-password

Secure the file with:

chmod 600 ~/.netrc

The script then automatically selects the credentials according to the hostname in the Webalizer URL:

webalizer-widget.py \
    https://stats.example.org/webalizer/

--netrc-file FILE

Use a particular netrc file instead of the default:

~/.netrc

--no-netrc

Prevent the script from consulting .netrc.

ENVIRONMENT

Several options may also be specified through environment variables.

WEBALIZER_URL

Default Webalizer URL. Example:

export WEBALIZER_URL="https://stats.example.org/webalizer/"

The script may then be invoked without a URL argument.

WEBALIZER_VIEW

Default view:

daily
monthly

WEBALIZER_METRIC

Default metric. For example:

export WEBALIZER_METRIC=pages

WEBALIZER_DAYS

Default number of days shown in daily mode.

WEBALIZER_MONTHS

Default number of months shown in monthly mode.

WEBALIZER_TARGET

TerminalWidget target name.

WEBALIZER_TITLE

Override the automatically generated widget title.

WEBALIZER_CHART_FORMAT

Override the TerminalWidget chart type.

WEBALIZER_404_WARN

Set the HTTP 404 warning threshold.

WEBALIZER_USERNAME

HTTP Basic Authentication username.

WEBALIZER_PASSWORD

HTTP Basic Authentication password.

TERMINAL_WIDGET

Explicit path to the terminal-widget executable.

WEBALIZER_BG

Widget background colour. Example:

WEBALIZER_BG="#172f24"

WEBALIZER_FG

Graph foreground colour.

WEBALIZER_TITLE_COLOR

Widget title colour.

WEBALIZER_CAPTION_COLOR

Widget caption colour.

WEBALIZER_CHART_HEIGHT

Height of the chart within the widget. Default:

72%

WEBALIZER_LABEL_Y

Controls whether the Y-axis labels are displayed. Set:

WEBALIZER_LABEL_Y=0

to suppress them.

EXAMPLES

Standard 30-day Visits widget

webalizer-widget.py \
    https://stats.frisaacsj.org/webalizer/

Twelve-month Visits widget

webalizer-widget.py \
    https://stats.frisaacsj.org/webalizer/ \
    --view monthly

Sixty-day Pages graph

webalizer-widget.py \
    https://stats.frisaacsj.org/webalizer/ \
    --metric pages \
    --days 60

Twenty-four-month traffic history

webalizer-widget.py \
    https://stats.frisaacsj.org/webalizer/ \
    --view monthly \
    --months 24

Separate TerminalWidget target

webalizer-widget.py \
    https://stats.example.org/webalizer/ \
    --target example-webstats

Authenticated Webalizer using .netrc

machine stats.example.org
    login statistics
    password secret-password

Then:

webalizer-widget.py \
    https://stats.example.org/webalizer/

Authenticated Webalizer using environment variables

WEBALIZER_USERNAME=statistics \
WEBALIZER_PASSWORD='secret-password' \
webalizer-widget.py \
    https://stats.example.org/webalizer/

Custom appearance

WEBALIZER_BG="#172f24" \
WEBALIZER_FG="#8fd6a3" \
webalizer-widget.py \
    https://stats.example.org/webalizer/

WEBALIZER FILES USED

In daily mode the script retrieves monthly Webalizer reports of the form:

usage_YYYYMM.html

For example:

usage_202608.html
usage_202607.html

Enough monthly files are downloaded to construct the requested rolling daily period. In monthly mode, the script reads the main Webalizer index page and extracts the monthly summary table.

TERMINALWIDGET TARGETS

A TerminalWidget must exist with a Target matching the target used by the script. For the default daily widget:

webstats

For the default monthly widget:

webstats-monthly

A custom target can be specified with --target.

EXIT STATUS

0
Widget generated successfully.
1
Webalizer data could not be retrieved or parsed, authentication failed, or another runtime error occurred.
2
terminal-widget could not be found or command-line arguments were invalid.

Where possible, runtime errors are also displayed in the corresponding TerminalWidget.

SECURITY

Passwords supplied with --password may be visible to other processes or stored in shell history. For automated execution, .netrc or --password-file is preferable. Credential files should normally have permissions:

chmod 600 FILE

Authentication credentials are used only when retrieving Webalizer reports. They are not embedded in the TerminalWidget’s open-url action. Clicking the widget therefore opens the normal Webalizer URL without placing the username or password in the URL.

NOTES

Webalizer derives its statistics from web-server logs. Consequently:

  • Hits count HTTP requests, not visitors.
  • Sites normally correspond to unique hosts and should not be treated as an exact count of individual people.
  • Visits are Webalizer’s inferred sessions and are generally the most useful default measure for a simple traffic widget.
  • Search engines, crawlers, bots and automated requests may affect the reported statistics.
  • The current month’s values are incomplete and should be interpreted as month-to-date figures.

REQUIREMENTS

The script requires:

Python 3
TerminalWidget
terminal-widget command-line utility
A Webalizer-generated statistics directory

No third-party Python modules are required.

FILES

~/bin/webalizer-widget.py

Typical optional credential files:

~/.netrc
~/.webalizer-password

AUTHOR

Designed as a generic Webalizer statistics adapter for Brett Terpstra’s TerminalWidget.

Script (webalizer-widget.py)

#!/usr/bin/env python3

import argparse
import base64
import netrc
import os
import re
import shutil
import subprocess
import sys

from datetime import date, datetime, timedelta
from html.parser import HTMLParser
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin, urlparse
from urllib.request import Request, urlopen


# ============================================================
# Webalizer column positions
# ============================================================

# Daily Webalizer table looks like:
#
# Day
# Hits   %
# Files  %
# Pages  %
# Visits %
# Sites  %
# KBytes %
#
# Hence the odd-numbered positions below.

DAILY_METRICS = {
    "hits":   (1,  "Hits"),
    "files":  (3,  "Files"),
    "pages":  (5,  "Pages"),
    "visits": (7,  "Visits"),
    "sites":  (9,  "Sites"),
    "kbytes": (11, "Data"),
}


# Webalizer summary table:
#
# Month |
# Daily Avg:
#     Hits Files Pages Visits
# Monthly Totals:
#     Sites KBytes Visits Pages Files Hits

MONTHLY_METRICS = {
    "hits":   (10, "Hits"),
    "files":  (9,  "Files"),
    "pages":  (8,  "Pages"),
    "visits": (7,  "Visits"),
    "sites":  (5,  "Sites"),
    "kbytes": (6,  "Data"),
}


MONTH_NAMES = {
    "Jan": 1,
    "Feb": 2,
    "Mar": 3,
    "Apr": 4,
    "May": 5,
    "Jun": 6,
    "Jul": 7,
    "Aug": 8,
    "Sep": 9,
    "Oct": 10,
    "Nov": 11,
    "Dec": 12,
}


# ============================================================
# HTML parser
# ============================================================

class WebalizerParser(HTMLParser):

    def __init__(self):
        super().__init__(convert_charrefs=True)

        self.tables = []
        self.table = None
        self.row = None
        self.cell = None

        self.in_h1 = False
        self.h1 = []

    def handle_starttag(self, tag, attrs):

        tag = tag.lower()

        if tag == "table":
            self.table = []

        elif tag == "tr" and self.table is not None:
            self.row = []

        elif tag in ("td", "th") and self.row is not None:
            self.cell = []

        elif tag == "h1":
            self.in_h1 = True

    def handle_data(self, data):

        if self.cell is not None:
            self.cell.append(data)

        if self.in_h1:
            self.h1.append(data)

    def handle_endtag(self, tag):

        tag = tag.lower()

        if (
            tag in ("td", "th")
            and self.cell is not None
            and self.row is not None
        ):
            text = " ".join("".join(self.cell).split())
            self.row.append(text)
            self.cell = None

        elif (
            tag == "tr"
            and self.row is not None
            and self.table is not None
        ):
            if self.row:
                self.table.append(self.row)

            self.row = None

        elif tag == "table" and self.table is not None:
            self.tables.append(self.table)
            self.table = None

        elif tag == "h1":
            self.in_h1 = False


# ============================================================
# Utility functions
# ============================================================

def int_from(text):

    cleaned = re.sub(
        r"[^0-9-]",
        "",
        text or ""
    )

    if cleaned in ("", "-"):
        return 0

    return int(cleaned)


def pct_from(text):

    match = re.search(
        r"([0-9]+(?:\.[0-9]+)?)\s*%",
        text or ""
    )

    if not match:
        return None

    return float(match.group(1))


def human(value, metric):

    if metric != "kbytes":
        return f"{int(value):,}"

    kb = float(value)

    if kb < 1024:
        return f"{kb:,.0f} KB"

    mb = kb / 1024

    if mb < 1024:
        return f"{mb:,.1f} MB"

    gb = mb / 1024

    return f"{gb:,.2f} GB"


def site_from_parser(parser):

    h1 = " ".join(
        "".join(parser.h1).split()
    )

    match = re.search(
        r"Usage Statistics for\s+(.+)$",
        h1,
        re.I,
    )

    if match:
        return match.group(1).strip()

    return None


def months_between(start, end):

    year = start.year
    month = start.month

    output = []

    while (year, month) <= (end.year, end.month):

        output.append(f"{year:04d}{month:02d}")

        if month == 12:
            year += 1
            month = 1
        else:
            month += 1

    return output


# ============================================================
# TerminalWidget
# ============================================================

def find_terminal_widget():

    requested = os.environ.get("TERMINAL_WIDGET")

    candidates = [
        requested,
        shutil.which("terminal-widget"),
        os.path.expanduser("~/bin/terminal-widget"),
        "/opt/homebrew/bin/terminal-widget",
        "/usr/local/bin/terminal-widget",
        "/Applications/TerminalWidget.app/Contents/MacOS/TerminalWidget",
    ]

    for path in candidates:

        if (
            path
            and os.path.isfile(path)
            and os.access(path, os.X_OK)
        ):
            return path

    return None


# ============================================================
# Authentication
# ============================================================

def resolve_credentials(args, base_url):

    hostname = urlparse(base_url).hostname

    username = (
        args.username
        if args.username is not None
        else os.environ.get("WEBALIZER_USERNAME")
    )

    password = (
        args.password
        if args.password is not None
        else os.environ.get("WEBALIZER_PASSWORD")
    )

    # --------------------------------------------------------
    # Password file
    # --------------------------------------------------------

    if args.password_file:

        try:
            password = (
                Path(args.password_file)
                .expanduser()
                .read_text()
                .rstrip("\r\n")
            )
        except OSError as exc:
            raise RuntimeError(
                f"Cannot read password file "
                f"{args.password_file}: {exc}"
            )

    # --------------------------------------------------------
    # ~/.netrc
    #
    # Only use it where explicit credentials have not already
    # supplied both username and password.
    # --------------------------------------------------------

    if (
        hostname
        and not (username and password)
        and not args.no_netrc
    ):

        netrc_path = (
            Path(args.netrc_file).expanduser()
            if args.netrc_file
            else Path.home() / ".netrc"
        )

        if netrc_path.exists():

            try:

                credentials = netrc.netrc(
                    str(netrc_path)
                ).authenticators(hostname)

                if credentials:

                    netrc_username, _, netrc_password = credentials

                    if not username:
                        username = netrc_username

                    if not password:
                        password = netrc_password

            except (netrc.NetrcParseError, OSError) as exc:

                print(
                    f"Warning: could not read "
                    f"{netrc_path}: {exc}",
                    file=sys.stderr,
                )

    # --------------------------------------------------------
    # Require a complete pair
    # --------------------------------------------------------

    if username and not password:
        raise RuntimeError(
            "A Webalizer username was supplied "
            "but no password was found."
        )

    if password and not username:
        raise RuntimeError(
            "A Webalizer password was supplied "
            "but no username was found."
        )

    return username, password


# ============================================================
# HTTP fetch
# ============================================================

def fetch(url, username=None, password=None):

    headers = {
        "User-Agent": (
            "webalizer-widget.py/1.0 "
            "(TerminalWidget Webalizer client)"
        ),
        "Accept": "text/html,*/*",
    }

    # --------------------------------------------------------
    # HTTP Basic Authentication
    # --------------------------------------------------------

    if username and password:

        token = base64.b64encode(
            f"{username}:{password}".encode("utf-8")
        ).decode("ascii")

        headers["Authorization"] = (
            f"Basic {token}"
        )

    request = Request(
        url,
        headers=headers,
    )

    try:

        with urlopen(
            request,
            timeout=30
        ) as response:

            charset = (
                response.headers
                .get_content_charset()
                or "utf-8"
            )

            return response.read().decode(
                charset,
                errors="replace"
            )

    except HTTPError as exc:

        if exc.code == 404:
            return None

        if exc.code == 401:
            raise RuntimeError(
                f"HTTP 401 Unauthorized while fetching {url}. "
                f"Check the Webalizer username/password."
            )

        if exc.code == 403:
            raise RuntimeError(
                f"HTTP 403 Forbidden while fetching {url}."
            )

        raise RuntimeError(
            f"HTTP {exc.code} while fetching {url}"
        )

    except URLError as exc:

        raise RuntimeError(
            f"Could not fetch {url}: {exc.reason}"
        )


# ============================================================
# Parse Webalizer monthly report
# ============================================================

def parse_month(html, ym, metric):

    parser = WebalizerParser()
    parser.feed(html)

    site = site_from_parser(parser)

    metric_index = DAILY_METRICS[
        metric
    ][0]

    daily = {}

    # --------------------------------------------------------
    # Daily statistics table
    # --------------------------------------------------------

    for table in parser.tables:

        is_daily_table = any(
            row
            and row[0].lower() == "day"
            and any(
                cell.lower() == "hits"
                for cell in row
            )
            and any(
                cell.lower() == "visits"
                for cell in row
            )
            for row in table
        )

        if not is_daily_table:
            continue

        year = int(ym[:4])
        month = int(ym[4:])

        for row in table:

            if (
                not row
                or not re.fullmatch(
                    r"\d{1,2}",
                    row[0]
                )
            ):
                continue

            day = int(row[0])

            if len(row) <= metric_index:
                continue

            try:

                d = date(
                    year,
                    month,
                    day
                )

            except ValueError:
                continue

            daily[d] = int_from(
                row[metric_index]
            )

        break

    # --------------------------------------------------------
    # Monthly total
    # --------------------------------------------------------

    total_labels = {
        "hits":   "Total Hits",
        "files":  "Total Files",
        "pages":  "Total Pages",
        "visits": "Total Visits",
        "sites":  "Total Unique Sites",
        "kbytes": "Total KBytes",
    }

    wanted = total_labels[
        metric
    ].lower()

    month_total = None

    for table in parser.tables:

        for row in table:

            if (
                len(row) >= 2
                and row[0].strip().lower()
                == wanted
            ):

                month_total = int_from(
                    row[1]
                )

                break

        if month_total is not None:
            break

    # --------------------------------------------------------
    # HTTP 404 percentage
    # --------------------------------------------------------

    error404 = None

    for table in parser.tables:

        for row in table:

            if (
                row
                and re.match(
                    r"Code\s+404\b",
                    row[0],
                    re.I,
                )
            ):

                for cell in row[1:]:

                    value = pct_from(cell)

                    if value is not None:
                        error404 = value
                        break

            if error404 is not None:
                break

        if error404 is not None:
            break

    return (
        site,
        daily,
        month_total,
        error404,
    )


# ============================================================
# Parse Webalizer summary index
# ============================================================

def parse_index(html, metric):

    parser = WebalizerParser()
    parser.feed(html)

    site = site_from_parser(parser)

    metric_index = MONTHLY_METRICS[
        metric
    ][0]

    months = []

    month_pattern = re.compile(
        r"^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
        r"\s+(\d{4})$"
    )

    for table in parser.tables:

        for row in table:

            if not row:
                continue

            match = month_pattern.match(
                row[0].strip()
            )

            if not match:
                continue

            if len(row) <= metric_index:
                continue

            month_name = match.group(1)
            year = int(match.group(2))
            month = MONTH_NAMES[month_name]

            month_date = date(
                year,
                month,
                1
            )

            value = int_from(
                row[metric_index]
            )

            months.append(
                (month_date, value)
            )

    # Webalizer normally displays newest first.
    # We want chart data oldest -> newest.

    months.sort(
        key=lambda item: item[0]
    )

    return site, months


# ============================================================
# Error widget
# ============================================================

def widget_error(
    terminal_widget,
    target,
    base_url,
    message,
):

    subprocess.run([
        terminal_widget,

        "--target",
        target,

        "--title",
        "Webalizer",

        "--icon",
        "exclamationmark.triangle.fill",

        "--text",
        message,

        "--bg",
        os.environ.get(
            "WEBALIZER_BG",
            "#10252d"
        ),

        "--fg",
        "#f6c177",

        "--action-kind",
        "open-url",

        "--action-value",
        base_url,

    ], check=False)


# ============================================================
# Render TerminalWidget chart
# ============================================================

def render_chart(
    terminal_widget,
    target,
    base_url,
    title,
    values,
    caption,
    chart_format,
):

    chart = " ".join(
        str(value)
        for value in values
    )

    command = [

        terminal_widget,

        "--target",
        target,

        "--title",
        title,

        "--title-alignment",
        "left",

        "--chart",
        chart,

        "--chart-format",
        chart_format,

        "--chart-height",
        os.environ.get(
            "WEBALIZER_CHART_HEIGHT",
            "72%"
        ),

        "--base-zero",

        "--caption-text",
        caption,

        "--timestamp",

        "--bg",
        os.environ.get(
            "WEBALIZER_BG",
            "#10252d"
        ),

        "--fg",
        os.environ.get(
            "WEBALIZER_FG",
            "#65d1c5"
        ),

        "--title-color",
        os.environ.get(
            "WEBALIZER_TITLE_COLOR",
            "#eef8f7"
        ),

        "--caption-color",
        os.environ.get(
            "WEBALIZER_CAPTION_COLOR",
            "#b6cdca"
        ),

        "--action-kind",
        "open-url",

        "--action-value",
        base_url,
    ]

    if (
        os.environ.get(
            "WEBALIZER_LABEL_Y",
            "1"
        ) != "0"
    ):
        command.append(
            "--label-y"
        )

    subprocess.run(
        command,
        check=True,
    )


# ============================================================
# Daily widget
# ============================================================

def daily_widget(
    args,
    terminal_widget,
    base_url,
    username,
    password,
):

    today = date.today()

    fetch_start = today - timedelta(
        days=args.days + 31
    )

    all_daily = {}
    month_totals = {}

    site = None
    latest_404 = None

    for ym in months_between(
        fetch_start,
        today,
    ):

        url = urljoin(
            base_url,
            f"usage_{ym}.html",
        )

        html = fetch(
            url,
            username,
            password,
        )

        if not html:
            continue

        (
            this_site,
            daily,
            month_total,
            error404,
        ) = parse_month(
            html,
            ym,
            args.metric,
        )

        if this_site:
            site = this_site

        all_daily.update(
            daily
        )

        if month_total is not None:
            month_totals[
                ym
            ] = month_total

        if error404 is not None:
            latest_404 = error404

    usable_dates = sorted(
        d
        for d in all_daily
        if d <= today
    )

    if not usable_dates:

        raise RuntimeError(
            "No daily Webalizer data found."
        )

    latest = usable_dates[-1]

    start = latest - timedelta(
        days=args.days - 1
    )

    dates = [
        start + timedelta(days=i)
        for i in range(args.days)
    ]

    values = [
        all_daily.get(d, 0)
        for d in dates
    ]

    latest_value = all_daily.get(
        latest,
        0
    )

    # --------------------------------------------------------
    # Last seven Webalizer calendar days
    # --------------------------------------------------------

    week_start = latest - timedelta(
        days=6
    )

    week_total = sum(
        value
        for d, value in all_daily.items()
        if week_start <= d <= latest
    )

    # --------------------------------------------------------
    # Current Webalizer month
    # --------------------------------------------------------

    latest_ym = latest.strftime(
        "%Y%m"
    )

    month_total = month_totals.get(
        latest_ym
    )

    if month_total is None:

        month_total = sum(
            value
            for d, value in all_daily.items()
            if (
                d.year == latest.year
                and d.month == latest.month
                and d <= latest
            )
        )

    if not site:
        site = (
            urlparse(base_url).hostname
            or "Webalizer"
        )

    site = re.sub(
        r"^www\.",
        "",
        site,
        flags=re.I,
    )

    metric_label = DAILY_METRICS[
        args.metric
    ][1]

    # --------------------------------------------------------
    # Caption
    # --------------------------------------------------------

    if latest == today:
        latest_label = "Today"
    else:
        latest_label = (
            f"{latest.day} "
            f"{latest.strftime('%b')}"
        )

    caption = (
        f"{latest_label} "
        f"{human(latest_value, args.metric)}"
        f" · 7d "
        f"{human(week_total, args.metric)}"
        f" · {latest.strftime('%b')} "
        f"{human(month_total, args.metric)}"
    )

    warn_threshold = args.warn_404

    if (
        latest_404 is not None
        and latest_404 >= warn_threshold
    ):

        caption += (
            f" · 404 "
            f"{latest_404:.1f}%"
        )

    title = (
        args.title
        or f"{site} · {metric_label} · {args.days}d"
    )

    chart_format = (
        args.chart_format
        or "area"
    )

    render_chart(
        terminal_widget,
        args.target,
        base_url,
        title,
        values,
        caption,
        chart_format,
    )


# ============================================================
# Monthly widget
# ============================================================

def monthly_widget(
    args,
    terminal_widget,
    base_url,
    username,
    password,
):

    html = fetch(
        base_url,
        username,
        password,
    )

    if not html:
        raise RuntimeError(
            "Could not read Webalizer summary page."
        )

    site, month_data = parse_index(
        html,
        args.metric,
    )

    if not month_data:
        raise RuntimeError(
            "No monthly Webalizer data found."
        )

    month_data = month_data[
        -args.months:
    ]

    values = [
        value
        for _, value in month_data
    ]

    latest_month, latest_value = (
        month_data[-1]
    )

    previous_value = (
        month_data[-2][1]
        if len(month_data) >= 2
        else None
    )

    period_total = sum(values)

    if not site:
        site = (
            urlparse(base_url).hostname
            or "Webalizer"
        )

    site = re.sub(
        r"^www\.",
        "",
        site,
        flags=re.I,
    )

    metric_label = MONTHLY_METRICS[
        args.metric
    ][1]

    today = date.today()

    if (
        latest_month.year == today.year
        and latest_month.month == today.month
    ):
        latest_label = (
            f"{latest_month.strftime('%b')} MTD"
        )
    else:
        latest_label = (
            latest_month.strftime("%b")
        )

    caption = (
        f"{latest_label} "
        f"{human(latest_value, args.metric)}"
    )

    if previous_value is not None:

        previous_month = month_data[-2][0]

        caption += (
            f" · {previous_month.strftime('%b')} "
            f"{human(previous_value, args.metric)}"
        )

    caption += (
        f" · {len(month_data)}m "
        f"{human(period_total, args.metric)}"
    )

    title = (
        args.title
        or (
            f"{site} · {metric_label}"
            f" · {len(month_data)}mo"
        )
    )

    chart_format = (
        args.chart_format
        or "bar"
    )

    render_chart(
        terminal_widget,
        args.target,
        base_url,
        title,
        values,
        caption,
        chart_format,
    )


# ============================================================
# Main
# ============================================================

def main():

    parser = argparse.ArgumentParser(
        description=(
            "Display Webalizer statistics "
            "in a TerminalWidget chart."
        )
    )

    parser.add_argument(
        "url",
        nargs="?",
        default=os.environ.get(
            "WEBALIZER_URL"
        ),
        help=(
            "Webalizer base URL, e.g. "
            "https://stats.example.org/webalizer/"
        ),
    )

    parser.add_argument(
        "--view",
        choices=[
            "daily",
            "monthly",
        ],
        default=os.environ.get(
            "WEBALIZER_VIEW",
            "daily"
        ),
        help="daily or monthly chart",
    )

    parser.add_argument(
        "--metric",
        choices=[
            "visits",
            "pages",
            "hits",
            "files",
            "sites",
            "kbytes",
        ],
        default=os.environ.get(
            "WEBALIZER_METRIC",
            "visits"
        ),
    )

    parser.add_argument(
        "--days",
        type=int,
        default=int(
            os.environ.get(
                "WEBALIZER_DAYS",
                "30"
            )
        ),
        help="number of days in daily chart",
    )

    parser.add_argument(
        "--months",
        type=int,
        default=int(
            os.environ.get(
                "WEBALIZER_MONTHS",
                "12"
            )
        ),
        help="number of months in monthly chart",
    )

    parser.add_argument(
        "--target",
        default=None,
        help="TerminalWidget target name",
    )

    parser.add_argument(
        "--title",
        default=os.environ.get(
            "WEBALIZER_TITLE"
        ),
    )

    parser.add_argument(
        "--chart-format",
        default=os.environ.get(
            "WEBALIZER_CHART_FORMAT"
        ),
    )

    parser.add_argument(
        "--warn-404",
        type=float,
        default=float(
            os.environ.get(
                "WEBALIZER_404_WARN",
                "5"
            )
        ),
        help=(
            "show 404 percentage in daily "
            "caption when at or above this value"
        ),
    )

    # --------------------------------------------------------
    # Authentication
    # --------------------------------------------------------

    parser.add_argument(
        "--username",
        default=None,
        help="HTTP Basic Authentication username",
    )

    parser.add_argument(
        "--password",
        default=None,
        help="HTTP Basic Authentication password",
    )

    parser.add_argument(
        "--password-file",
        default=None,
        help=(
            "read HTTP Basic Authentication "
            "password from this file"
        ),
    )

    parser.add_argument(
        "--netrc-file",
        default=None,
        help=(
            "use a specific netrc file instead "
            "of ~/.netrc"
        ),
    )

    parser.add_argument(
        "--no-netrc",
        action="store_true",
        help="do not look for credentials in ~/.netrc",
    )

    args = parser.parse_args()

    if not args.url:

        parser.error(
            "a Webalizer URL is required"
        )

    if args.days < 7 or args.days > 128:

        parser.error(
            "--days must be between 7 and 128"
        )

    if args.months < 2 or args.months > 128:

        parser.error(
            "--months must be between 2 and 128"
        )

    base_url = (
        args.url.rstrip("/")
        + "/"
    )

    # --------------------------------------------------------
    # Default TerminalWidget targets
    #
    # Allows the daily and monthly widgets to coexist.
    # --------------------------------------------------------

    if args.target is None:

        env_target = os.environ.get(
            "WEBALIZER_TARGET"
        )

        if env_target:
            args.target = env_target

        elif args.view == "monthly":
            args.target = "webstats-monthly"

        else:
            args.target = "webstats"

    terminal_widget = find_terminal_widget()

    if not terminal_widget:

        print(
            "terminal-widget not found. "
            "Set TERMINAL_WIDGET or install "
            "the TerminalWidget CLI.",
            file=sys.stderr,
        )

        return 2

    try:

        username, password = resolve_credentials(
            args,
            base_url,
        )

        if args.view == "monthly":

            monthly_widget(
                args,
                terminal_widget,
                base_url,
                username,
                password,
            )

        else:

            daily_widget(
                args,
                terminal_widget,
                base_url,
                username,
                password,
            )

    except RuntimeError as exc:

        print(
            f"webalizer-widget.py: {exc}",
            file=sys.stderr,
        )

        widget_error(
            terminal_widget,
            args.target,
            base_url,
            str(exc),
        )

        return 1

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Download script

← All recipes