Self-hostable uptime monitor and status page on Django: HTTP checks, Lighthouse audits, SEO crawls, and email and Discord alerts.
djangodockerhandcodedpythonself-hostedsqlitestatus-pageuptime-monitoringvite
1"""HTML parsing: raw body -> structured page dict."""
2import hashlib
3import json
4import logging
5from urllib.parse import urljoin
6
7from bs4 import BeautifulSoup
8
9
10logger = logging.getLogger(__name__)
11
12
13def parse_html(body, url):
14 """Extract everything the checks need from one HTML page.
15
16 Returns a dict with title, meta, headings, links, images, resources,
17 forms, json_ld, word count, text hash, favicon, lang, viewport, robots.
18 """
19 soup = BeautifulSoup(body, "lxml")
20
21 title = ""
22 if soup.title and soup.title.string:
23 title = soup.title.string.strip()
24
25 def meta_name(name):
26 tag = soup.find("meta", attrs={"name": name})
27 if tag and tag.get("content"):
28 return tag["content"].strip()
29 return ""
30
31 def meta_property(prop):
32 tag = soup.find("meta", attrs={"property": prop})
33 if tag and tag.get("content"):
34 return tag["content"].strip()
35 return ""
36
37 description = meta_name("description")
38 robots_meta = meta_name("robots")
39 viewport = meta_name("viewport")
40
41 canonical_tag = soup.find("link", rel="canonical")
42 canonical = ""
43 if canonical_tag and canonical_tag.get("href"):
44 canonical = urljoin(url, canonical_tag["href"].strip())
45
46 og = {
47 "title": meta_property("og:title"),
48 "description": meta_property("og:description"),
49 "image": meta_property("og:image"),
50 "url": meta_property("og:url"),
51 }
52
53 twitter = {
54 "card": meta_name("twitter:card"),
55 "title": meta_name("twitter:title"),
56 "description": meta_name("twitter:description"),
57 }
58
59 html_tag = soup.find("html")
60 lang = html_tag.get("lang", "").strip() if html_tag else ""
61
62 headings = {f"h{i}": [] for i in range(1, 7)}
63 for level in range(1, 7):
64 for h in soup.find_all(f"h{level}"):
65 headings[f"h{level}"].append(h.get_text(" ", strip=True))
66
67 links = []
68 for a in soup.find_all("a", href=True):
69 href = a["href"].strip()
70 if not href or href.startswith(("javascript:", "mailto:", "tel:", "#")):
71 continue
72 rel = a.get("rel") or []
73 if isinstance(rel, str):
74 rel = rel.split()
75 links.append(
76 {
77 "url": urljoin(url, href),
78 "text": a.get_text(" ", strip=True),
79 "rel": list(rel),
80 }
81 )
82
83 images = []
84 for img in soup.find_all("img"):
85 src = img.get("src", "").strip()
86 alt = img.get("alt") # None = missing attribute, "" = explicitly empty
87 images.append(
88 {
89 "src": urljoin(url, src) if src else "",
90 "alt": alt,
91 }
92 )
93
94 resources = []
95 for tag in soup.find_all(["script", "link", "img", "iframe", "source"]):
96 src = tag.get("src") or tag.get("href")
97 if src and src.strip():
98 resources.append(urljoin(url, src.strip()))
99
100 json_ld = []
101 for s in soup.find_all("script", type="application/ld+json"):
102 raw = s.string or s.get_text() or ""
103 if not raw.strip():
104 continue
105 try:
106 json_ld.append(json.loads(raw))
107 except (ValueError, TypeError):
108 json_ld.append(None) # parse error
109
110 favicon = ""
111 for link in soup.find_all("link", rel=True):
112 rels = link.get("rel", [])
113 if isinstance(rels, str):
114 rels = rels.split()
115 if any("icon" in r.lower() for r in rels):
116 href = link.get("href", "").strip()
117 if href:
118 favicon = urljoin(url, href)
119 break
120
121 forms = []
122 for form in soup.find_all("form"):
123 inputs = []
124 for i in form.find_all(["input", "textarea", "select"]):
125 inputs.append(
126 {
127 "type": i.get("type", "text"),
128 "name": i.get("name"),
129 "id": i.get("id"),
130 "aria_label": i.get("aria-label"),
131 }
132 )
133 label_fors = {lb.get("for") for lb in form.find_all("label") if lb.get("for")}
134 forms.append(
135 {
136 "action": urljoin(url, form.get("action", "")) if form.get("action") else url,
137 "inputs": inputs,
138 "label_fors": list(label_fors),
139 }
140 )
141
142 # Visible text for word count + duplicate detection.
143 for tag in soup(["script", "style", "noscript"]):
144 tag.decompose()
145 text = soup.get_text(" ", strip=True)
146 word_count = len(text.split())
147 text_hash = hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
148
149 return {
150 "title": title,
151 "description": description,
152 "canonical": canonical,
153 "robots_meta": robots_meta,
154 "viewport": viewport,
155 "lang": lang,
156 "og": og,
157 "twitter": twitter,
158 "headings": headings,
159 "links": links,
160 "images": images,
161 "resources": resources,
162 "json_ld": json_ld,
163 "favicon": favicon,
164 "forms": forms,
165 "word_count": word_count,
166 "text_hash": text_hash,
167 }