Files
AV-Watch/avwatch/sources/generic.py
T

311 lines
9.6 KiB
Python

"""Generic, config-driven sources: HTML scrape, JSON API, RSS/Atom, manual.
These carry the long tail of the fleet -- Shure, Crown, Novastar, Waves,
Focusrite and friends publish version numbers on ordinary web pages with no
API behind them. Everything is declared in inventory.yaml so a broken
selector is a one-line YAML fix rather than a code change.
"""
from __future__ import annotations
import html as html_mod
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from ..models import Item, VersionCheck
from ..versions import parse
from .base import Context, error, manual
_TAG_RE = re.compile(r"<(script|style)\b.*?</\1>", re.S | re.I)
_ANY_TAG = re.compile(r"<[^>]+>")
def _visible_text(raw: str) -> str:
text = _TAG_RE.sub(" ", raw)
text = _ANY_TAG.sub(" ", text)
text = html_mod.unescape(text)
return re.sub(r"\s+", " ", text)
class HtmlSource:
"""Pull a version out of a web page with a regex.
Inventory usage:
source:
kind: html
url: https://www.resolume.com/download/
pattern: 'Arena\\s+([\\d.]+)'
strip_tags: true # search visible text rather than raw markup
select: max # max (default) | first
date_pattern: '([A-Z][a-z]+ \\d{1,2}, \\d{4})' # optional
"""
kind = "html"
def check(self, item: Item, ctx: Context) -> VersionCheck:
url = item.source.get("url")
pattern = item.source.get("pattern")
if not url or not pattern:
return error(item, "html source requires 'url' and 'pattern'")
resp = ctx.http.get(url, ttl=item.source.get("ttl", 3600))
if not resp.ok:
return error(item, f"HTTP {resp.status} from {url}")
haystack = (
_visible_text(resp.text)
if item.source.get("strip_tags", True)
else resp.text
)
try:
rx = re.compile(pattern, re.I)
except re.error as exc:
return error(item, f"bad 'pattern' regex: {exc}")
found = [m.group(1) if m.groups() else m.group(0) for m in rx.finditer(haystack)]
versions = [p for p in (parse(f) for f in found) if p.valid]
if not versions:
return error(
item,
f"pattern matched nothing on {url} -- the page layout probably changed",
)
chosen = (
max(versions) if item.source.get("select", "max") == "max" else versions[0]
)
released = None
dpat = item.source.get("date_pattern")
if dpat:
dm = re.search(dpat, haystack)
if dm:
released = _try_dates(dm.group(1))
return VersionCheck(
item_id=item.id,
latest=chosen.raw,
released=released,
status="ok",
release_url=item.check_url or url,
detail=f"scraped from {url}",
)
def _dig(obj, path: str):
"""Walk a dotted path with optional [n] indices: 'data.releases[0].version'."""
cur = obj
for token in path.split("."):
if not token:
continue
m = re.match(r"^([^\[\]]*)((?:\[\d+\])*)$", token)
if not m:
return None
key, idxs = m.group(1), m.group(2)
if key:
if not isinstance(cur, dict) or key not in cur:
return None
cur = cur[key]
for i in re.findall(r"\[(\d+)\]", idxs):
if not isinstance(cur, list) or int(i) >= len(cur):
return None
cur = cur[int(i)]
return cur
class JsonSource:
"""Read a version from a JSON endpoint.
Inventory usage:
source:
kind: json
url: https://example.com/api/latest.json
version_path: data.latest.version
date_path: data.latest.released # optional
notes_path: data.latest.notes # optional
"""
kind = "json"
def check(self, item: Item, ctx: Context) -> VersionCheck:
url = item.source.get("url")
vpath = item.source.get("version_path")
if not url or not vpath:
return error(item, "json source requires 'url' and 'version_path'")
resp = ctx.http.get(url, ttl=item.source.get("ttl", 3600))
if not resp.ok:
return error(item, f"HTTP {resp.status} from {url}")
try:
data = resp.json()
except ValueError as exc:
return error(item, f"invalid JSON from {url}: {exc}")
raw_version = _dig(data, vpath)
if raw_version is None:
return error(item, f"version_path {vpath!r} not present in payload")
ver = parse(str(raw_version))
if not ver.valid:
return error(item, f"unparseable version {raw_version!r}")
released = None
if dpath := item.source.get("date_path"):
released = _try_dates(_dig(data, dpath))
notes = None
if npath := item.source.get("notes_path"):
val = _dig(data, npath)
notes = str(val)[:4000] if val else None
return VersionCheck(
item_id=item.id,
latest=ver.raw,
released=released,
status="ok",
release_notes=notes,
release_url=item.check_url or url,
detail=f"json:{vpath}",
)
class RssSource:
"""Version from the newest entry title in an RSS or Atom feed.
Inventory usage:
source:
kind: rss
url: https://example.com/releases.atom
pattern: '([\\d.]+)'
"""
kind = "rss"
def check(self, item: Item, ctx: Context) -> VersionCheck:
url = item.source.get("url")
if not url:
return error(item, "rss source requires 'url'")
resp = ctx.http.get(url, ttl=item.source.get("ttl", 1800))
if not resp.ok:
return error(item, f"HTTP {resp.status} from {url}")
try:
root = ET.fromstring(resp.text.encode("utf-8", "replace"))
except ET.ParseError as exc:
return error(item, f"malformed feed: {exc}")
entries = _feed_entries(root)
if not entries:
return error(item, "feed contained no items")
rx = re.compile(item.source.get("pattern", r"([0-9]+(?:\.[0-9]+)+)"))
for title, link, published in entries:
m = rx.search(title or "")
if not m:
continue
ver = parse(m.group(1) if m.groups() else m.group(0))
if not ver.valid:
continue
return VersionCheck(
item_id=item.id,
latest=ver.raw,
released=published,
status="ok",
release_url=link or item.check_url or url,
detail=title,
)
return error(item, "no feed entry title matched the version pattern")
def _feed_entries(root) -> list[tuple[str | None, str | None, datetime | None]]:
ns = {"atom": "http://www.w3.org/2005/Atom"}
out: list[tuple[str | None, str | None, datetime | None]] = []
for it in root.iter():
tag = it.tag.split("}")[-1]
if tag not in ("item", "entry"):
continue
title = _child_text(it, ("title",))
link = _child_text(it, ("link", "guid")) or _atom_link(it, ns)
date_text = _child_text(it, ("pubDate", "updated", "published", "date"))
out.append((title, link, _try_dates(date_text)))
return out
def _child_text(el, names: tuple[str, ...]) -> str | None:
for child in el:
if child.tag.split("}")[-1] in names and (child.text or "").strip():
return child.text.strip()
return None
def _atom_link(el, ns) -> str | None:
for child in el:
if child.tag.split("}")[-1] == "link":
href = child.attrib.get("href")
if href:
return href
return None
_DATE_FORMATS = (
"%Y-%m-%d", "%d %b %Y", "%d %B %Y", "%b %d, %Y", "%B %d, %Y",
"%m/%d/%Y", "%Y/%m/%d", "%Y-%m-%dT%H:%M:%S",
)
def _try_dates(text) -> datetime | None:
if text is None:
return None
if isinstance(text, (int, float)):
# Heuristic: values that large are milliseconds.
secs = text / 1000.0 if text > 1e11 else float(text)
try:
return datetime.fromtimestamp(secs, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
text = str(text).strip()
if not text:
return None
try:
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except ValueError:
pass
try:
dt = parsedate_to_datetime(text)
if dt:
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except (TypeError, ValueError):
pass
for fmt in _DATE_FORMATS:
try:
return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
except ValueError:
continue
return None
class ManualSource:
"""No machine-readable feed exists (or the vendor blocks scraping).
avwatch still tracks these: it runs the community risk scan, records what
you tell it via ``avwatch set-installed``, and puts the item in a
"verify by hand" section of the report with a deep link. That is a
deliberate design choice -- a wrong scraped version is worse than an
honest "go look".
"""
kind = "manual"
def check(self, item: Item, ctx: Context) -> VersionCheck:
note = item.source.get("note") or (
"Vendor publishes no machine-readable version feed. Check the link."
)
return manual(item, note)