CCE

Simple Pollen Count API

Contents

I should make a simple thing that scraps the local pollen count page and outputs a JSON with the counts and the notes. I use this with my Home Assistant cluster to send me morning notifications if the grass pollen yesterday was real bad, to have an idea what today might bring.

Simple Flask server

python:tangle ~/Code/pollen/app.py:comments none
import os
import bs4
import urllib.request
import itertools

from flask import Flask
app = Flask(__name__)

This uses BeautifulSoup to try to extract the relevant texts:

python:tangle ~/Code/pollen/app.py:comments none
def fetch_counts():
    req = urllib.request.Request(
        "https://www.oregonallergyassociates.com/pollen-counts/",
        headers={
            "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:126.0) "
            "Gecko/20100101 Firefox/126.0"
        }
    )
    resp = urllib.request.urlopen(req)

    assert resp.code == 200

    pchtml = resp.read()
    soup = bs4.BeautifulSoup(pchtml, 'html.parser')

    headlines = soup.find_all("div", class_="pollent-count-headline")
    pars = soup.find_all("p")

    texts = list()
    sentinel = "Kraig W. Jacobson, MD."
    for par in pars:
        if par.text == sentinel:
            break
        if "grass" in par.text.lower():
            texts.append(par.text)

    notice = '\n'.join(texts)

    counts = dict()
    levels = dict()
    for desc, num in itertools.zip_longest(headlines[::2], headlines[1::2]):
        desc = desc.text.lower()
        num = int(num.text)

        if "grass" in desc:
            counts['grass'] = num
            levels['grass'] = normalize_grass(num)
        elif "tree" in desc:
            counts['trees'] = num
            levels['trees'] = normalize_trees(num)
        elif "weed" in desc:
            counts['weeds'] = num
        else:
            counts['unknown'] = num

    return (notice, counts, levels)

These clusterings are brought from the website:

python:tangle ~/Code/pollen/app.py:comments none
def normalize_grass(num):
    if num <= 4:
        return "LOW"
    elif num <= 19:
        return "MODERATE"
    elif num <= 199:
        return "HIGH"
    return "VERY HIGH"


def normalize_trees(num):
    if num <= 14:
        return "LOW"
    elif num <= 89:
        return "MODERATE"
    elif num <= 499:
        return "HIGH"
    return "VERY HIGH"

It's a simple endpoint to return them to HomeAss

python:tangle ~/Code/pollen/app.py:comments none
@app.route("/counts")
def flask_handler():
    notice, counts, levels = fetch_counts()
    return {
        "notice": notice,
        "counts": counts,
        "levels": levels,
    }


if __name__ == '__main__':
    listen_port = int(os.environ.get("PC_PORT", "8080"))
    app.run(host="0.0.0.0", port=listen_port)

Packaging

You can nix run this like nix run git+http://code.rix.si/rrix/pollen.

nix:tangle ~/Code/pollen/flake.nix:mkdirp yes
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    systems.url = "github:nix-systems/default";
  };

  outputs = {
    self,
      nixpkgs,
      flake-utils,
      systems,
  }:
    flake-utils.lib.eachSystem (import systems)
      (system: let
        pkgs = import nixpkgs {
          inherit system;
        };

        python-with-my-packages = pkgs.python3.withPackages (p: with p; [
          beautifulsoup4
          flask
        ]);

        pc = import ./default.nix { inherit pkgs; };
      in {
        apps = rec {
          default = pollen-count;
          pollen-count = flake-utils.lib.mkApp {
            drv = pc;
          };
        };

        packages = flake-utils.lib.flattenTree rec {
          default = pc;
          pollen-count = pc;

          devShells.default = pkgs.mkShell {
            packages = [
              python-with-my-packages
            ];
          };
        };
      });
}

And nix-build it.

nix:tangle ~/Code/pollen/default.nix
{ pkgs ? import <nixpkgs> {}, ... }:

pkgs.writers.writePython3Bin "pollen-count" {
  flakeIgnore = ["E302" "W292"];
  libraries = with pkgs.python3.pkgs; [
    beautifulsoup4
    flask
  ];
} (builtins.readFile ./app.py)

The Pollen Counter as a NixOS Module

And include it on NixOS. I oughta make this a gunicorn but meh. i'm the only one that will ever touch it!

nix:tangle ~/nix/nixos/pollen-count.nix
{ lib, pkgs, inputs, config, ... }:

with lib; 
{
  options.services.pollen-count = {
    enable = mkOption {
      type = types.bool;
      default = true;
    };
    package = mkOption {
      type = types.package;
      default = inputs.pollen-count.packages.${pkgs.stdenv.hostPlatform.system}.default;
    };
    port = mkOption {
      type = types.port;
      default = 18567;
    };
  };

  config = mkIf config.services.pollen-count.enable {
    networking.firewall.allowedTCPPorts = [
      config.services.pollen-count.port
    ];

    users.groups.python-scripts = {};
    users.users.python-scripts = {
      createHome = true;
      home = "/var/lib/python-scripts";
      group = "python-scripts";
      isSystemUser = true;
    };
    systemd.services.pollen-counter = {
      enable = true;
      description = "Fetch pollen count from Oregon Allergy Associates";
      after = [ "network.target" ];
      script = "${config.services.pollen-count.package}/bin/pollen-count";
      wantedBy = [ "default.target" ];

      environment = {
        PC_PORT = "${builtins.toString config.services.pollen-count.port}";
      };  

      serviceConfig = {
        RestartSec = 5;
        Restart = "on-failure";
        User = "python-scripts";
      };
    };
  };
}

Make the pollen counter available in an Arroyo System Flake

nix:tangle ~/nix/snippets/pollen-count-inputs.nix
pollen-count = {
  url = "git+http://code.rix.si/rrix/pollen";
  inputs.nixpkgs.follows = "nixpkgs";
};