Skip to content

Instantly share code, notes, and snippets.

@cmj
Created July 21, 2026 10:18
Show Gist options
  • Select an option

  • Save cmj/3e322fcbb55f055f28ac7e782a629d9a to your computer and use it in GitHub Desktop.

Select an option

Save cmj/3e322fcbb55f055f28ac7e782a629d9a to your computer and use it in GitHub Desktop.
Grab screenshot of PurpleAir's US EPA PM2.5 AQI map
#!/usr/bin/env python3
# headless playwright script that screenshots the map canvas from PurpleAir's US EPA PM2.5 AQI map
# ex: add cronjob and use with KDE media frame widget https://userbase.kde.org/Plasma/PictureFrame
# */30 * * * * /usr/local/bin/purpleair.py -q --latest --dir=~/purpleair
import argparse
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from playwright.sync_api import sync_playwright
# map baselayer options: /l opt: '/ls' s=sat, d=dark, t=topo p=detailed (default)
# CONUS
#URL_TEMPLATE = "https://map.purpleair.com/air-quality-standards-us-epa-aqi?opt=/1/l{layer}/a10/p604800/cC0#3.74/39.61/-95.87"
# PNW
URL_TEMPLATE = "https://map.purpleair.com/air-quality-standards-us-epa-aqi?opt=/1/l{layer}/a10/p604800/cC0#5.54/46.311/-118.371"
BASELAYERS = {"s": "satellite", "d": "dark", "t": "topo", "p": "detailed"}
parser = argparse.ArgumentParser()
parser.add_argument("--headed", action="store_true", help="Open a browser window instead of headless")
parser.add_argument("--view", action="store_true", help="Open the screenshot after saving (uses xdg-open unless --viewer is set)")
parser.add_argument("--viewer", help="Command to open the screenshot with instead of xdg-open (implies --view)")
parser.add_argument(
"--baselayer",
choices=BASELAYERS.keys(),
default="s",
help="Map baselayer: s=satellite (default), d=dark, t=topo, p=detailed",
)
parser.add_argument(
"--latest",
action="store_true",
help="Save a copy to purpleair-latest.png (single updated file for KDE media frame widget)",
)
parser.add_argument("-q", "--quiet", action="store_true", help="Suppress print output")
parser.add_argument(
"--dir", "--savedir",
dest="savedir",
default=".",
help="Directory to save screenshots to (default: current directory)",
)
args = parser.parse_args()
URL = URL_TEMPLATE.format(layer=args.baselayer)
savedir = Path(args.savedir).expanduser()
savedir.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=not args.headed)
page = browser.new_page(viewport={"width": 1600, "height": 900})
page.goto(URL)
canvas = page.locator(".mapboxgl-canvas")
canvas.wait_for(state="visible")
# long wait here, but needed for icons/markers to populate
page.wait_for_timeout(7000)
page.evaluate("""
document.querySelectorAll('.paControlContainer').forEach(el => el.remove());
const gdpr = document.getElementById('gdpr-cookie-message');
if (gdpr) gdpr.remove();
document.querySelectorAll('header').forEach(el => el.remove());
""")
page.evaluate("window.dispatchEvent(new Event('resize'))")
page.wait_for_timeout(1000)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = savedir / f"purpleair_map_{timestamp}.png"
canvas.screenshot(path=str(filename))
if not args.quiet:
print(f"Screenshot saved {filename}")
if args.latest:
latest_filename = savedir / "purpleair-latest.png"
shutil.copyfile(filename, latest_filename)
if not args.quiet:
print(f"Screenshot saved {latest_filename}")
if args.headed:
input("Screenshot saved. Browser is open for inspection - press Enter to close...")
browser.close()
if args.view or args.viewer:
opener = args.viewer.split() if args.viewer else ["xdg-open"]
subprocess.Popen(opener + [str(filename)])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment