This commit is contained in:
Mike McFetridge
2026-07-20 09:23:17 -04:00
parent c1315882da
commit 72272e4006
3179 changed files with 562960 additions and 14 deletions
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import sys
from collections import deque
from typing import Any
from urllib.parse import urljoin, urlparse
import requests
from bs4 import BeautifulSoup
from requests import Response
from requests.exceptions import RequestException
WEBSITE_URL = r"""<< website_url >>"""
MAX_PAGES_DEFAULT = int(r"""<< max_pages >>""")
REQUEST_TIMEOUT_SECONDS_DEFAULT = int(r"""<< request_timeout_seconds >>""")
VERIFY_TLS_DEFAULT = r"""<< verify_tls >>"""
FOLLOW_REDIRECTS_DEFAULT = r"""<< follow_redirects >>"""
REQUEST_HEADERS_JSON_DEFAULT = r"""<< request_headers_json >>"""
USER_AGENT_DEFAULT = r"""<< user_agent >>"""
def str_to_bool(value: Any) -> bool:
return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
def parse_json_object(value: str, field_name: str) -> dict[str, Any]:
if not value.strip():
return {}
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
raise SystemExit(f"{field_name} must be valid JSON: {exc}") from exc
if not isinstance(parsed, dict):
raise SystemExit(f"{field_name} must be a JSON object.")
return parsed
def build_session(headers: dict[str, str], user_agent: str) -> requests.Session:
session = requests.Session()
session.headers.update({"User-Agent": user_agent})
session.headers.update(headers)
return session
def sanitize_url(url: str) -> str | None:
parsed = urlparse(url)
if parsed.scheme in {"mailto", "javascript", "tel", "data"}:
return None
cleaned = parsed._replace(fragment="").geturl()
return cleaned or None
def same_host(url: str, website_host: str) -> bool:
host = (urlparse(url).hostname or "").lower()
return bool(host) and host == website_host
def is_html_response(response: Response) -> bool:
content_type = response.headers.get("Content-Type", "").lower()
return "text/html" in content_type or "application/xhtml+xml" in content_type
def probe_url(
session: requests.Session,
url: str,
timeout: int,
verify_tls: bool,
follow_redirects: bool,
) -> tuple[Response | None, str | None]:
try:
response = session.get(
url,
timeout=timeout,
verify=verify_tls,
allow_redirects=follow_redirects,
)
return response, None
except RequestException as exc:
return None, str(exc)
def main() -> int:
website_url = WEBSITE_URL.strip()
if not website_url:
raise SystemExit("website_url is required.")
website_host = (urlparse(website_url).hostname or "").lower()
if not website_host:
raise SystemExit("website_url must include a valid hostname.")
verify_tls = str_to_bool(VERIFY_TLS_DEFAULT)
follow_redirects = str_to_bool(FOLLOW_REDIRECTS_DEFAULT)
request_headers = parse_json_object(REQUEST_HEADERS_JSON_DEFAULT, "request_headers_json")
session = build_session(request_headers, USER_AGENT_DEFAULT.strip())
queue: deque[tuple[str, str]] = deque([(website_url, website_url)])
queued_urls = {website_url}
visited_pages: set[str] = set()
checked_resources: dict[str, dict[str, Any]] = {}
broken: dict[str, dict[str, Any]] = {}
pages_crawled = 0
while queue and pages_crawled < MAX_PAGES_DEFAULT:
page_url, source = queue.popleft()
if page_url in visited_pages:
continue
visited_pages.add(page_url)
response, error = probe_url(
session=session,
url=page_url,
timeout=REQUEST_TIMEOUT_SECONDS_DEFAULT,
verify_tls=verify_tls,
follow_redirects=follow_redirects,
)
pages_crawled += 1
if error:
broken.setdefault(
page_url,
{"url": page_url, "kind": "page", "status_code": None, "error": error, "sources": []},
)["sources"].append(source)
continue
if response is None:
continue
if response.status_code >= 400:
broken.setdefault(
page_url,
{
"url": page_url,
"kind": "page",
"status_code": response.status_code,
"error": f"HTTP {response.status_code}",
"sources": [],
},
)["sources"].append(source)
continue
if not is_html_response(response):
continue
soup = BeautifulSoup(response.text, "html.parser")
discovered: list[tuple[str, str]] = []
for tag_name, attribute, kind in [
("a", "href", "link"),
("img", "src", "asset"),
("script", "src", "asset"),
("link", "href", "asset"),
]:
for tag in soup.find_all(tag_name):
raw_value = tag.get(attribute)
if not raw_value:
continue
normalized = sanitize_url(urljoin(page_url, raw_value))
if not normalized:
continue
discovered.append((normalized, kind))
for discovered_url, kind in discovered:
if not same_host(discovered_url, website_host):
continue
if kind == "link" and discovered_url not in visited_pages and discovered_url not in queued_urls:
queue.append((discovered_url, page_url))
queued_urls.add(discovered_url)
if discovered_url in checked_resources:
checked_resources[discovered_url]["sources"].add(page_url)
if discovered_url in broken:
broken[discovered_url]["sources"].append(page_url)
continue
resource_response, resource_error = probe_url(
session=session,
url=discovered_url,
timeout=REQUEST_TIMEOUT_SECONDS_DEFAULT,
verify_tls=verify_tls,
follow_redirects=follow_redirects,
)
checked_resources[discovered_url] = {
"url": discovered_url,
"kind": kind,
"sources": {page_url},
}
if resource_error:
broken[discovered_url] = {
"url": discovered_url,
"kind": kind,
"status_code": None,
"error": resource_error,
"sources": [page_url],
}
continue
if resource_response is not None and resource_response.status_code >= 400:
broken[discovered_url] = {
"url": discovered_url,
"kind": kind,
"status_code": resource_response.status_code,
"error": f"HTTP {resource_response.status_code}",
"sources": [page_url],
}
for value in broken.values():
value["sources"] = sorted(set(value["sources"]))
summary = {
"pages_crawled": pages_crawled,
"urls_checked": len(set(checked_resources) | visited_pages),
"broken_urls": len(broken),
}
overall_status = "fail" if broken else "pass"
sorted_broken = sorted(broken.values(), key=lambda item: item["url"])
print(f"Broken Link Crawler: {overall_status.upper()}")
print(f"Website: {website_url}")
print(f"Pages crawled: {pages_crawled}")
print(f"URLs checked: {summary['urls_checked']}")
print(f"Broken URLs: {summary['broken_urls']}")
if sorted_broken:
print("")
print("Broken targets:")
for item in sorted_broken:
source = ", ".join(item["sources"][:3]) or "-"
print(f"- [{item['kind']}] {item['url']} | source={source} | error={item['error']}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,2 @@
beautifulsoup4==4.14.3
requests==2.33.1
+110
View File
@@ -0,0 +1,110 @@
{
"slug": "broken-link-crawler",
"kind": "python",
"metadata": {
"name": "Broken Link Crawler",
"description": "Standalone Python crawler for one website that walks same-host pages, checks discovered links and assets, and prints a stdout summary.",
"tags": [
"crawler",
"links",
"website",
"audit",
"python"
],
"icon": {
"provider": "simple-icons",
"id": "python",
"color": "emerald"
},
"draft": false,
"version": {
"name": "1.0.0",
"source_dep_name": "manual/broken-link-crawler"
}
},
"variables": [
{
"title": "Targets",
"name": "targets",
"items": [
{
"name": "website_url",
"type": "str",
"title": "Website URL",
"required": true,
"default": "https://example.com",
"description": "Single crawl entrypoint. The crawler stays on this exact host.",
"config": {
"placeholder": "https://example.com"
}
}
]
},
{
"title": "Crawler",
"name": "crawler",
"items": [
{
"name": "max_pages",
"type": "int",
"title": "Max Pages",
"required": true,
"default": 100,
"description": "Maximum number of HTML pages to crawl.",
"config": {
"placeholder": "100"
}
},
{
"name": "request_timeout_seconds",
"type": "int",
"title": "Timeout Seconds",
"required": true,
"default": 10,
"description": "Timeout for each outbound HTTP request.",
"config": {
"placeholder": "10"
}
},
{
"name": "verify_tls",
"type": "bool",
"title": "Verify TLS",
"required": false,
"default": true,
"description": "Validate remote TLS certificates during requests."
},
{
"name": "follow_redirects",
"type": "bool",
"title": "Follow Redirects",
"required": false,
"default": true,
"description": "Follow redirects when checking URLs."
},
{
"name": "request_headers_json",
"type": "str",
"title": "Request Headers JSON",
"required": false,
"default": "{}",
"description": "JSON object of headers added to every request.",
"config": {
"placeholder": "{\"Authorization\": \"Bearer replace-me\"}"
}
},
{
"name": "user_agent",
"type": "str",
"title": "User Agent",
"required": false,
"default": "boilerplates-broken-link-crawler/1.0",
"description": "User-Agent header value used by the crawler.",
"config": {
"placeholder": "boilerplates-broken-link-crawler/1.0"
}
}
]
}
]
}
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import socket
import subprocess
import sys
import urllib.request
MONITOR_PATH = r"""<< monitor_path >>"""
WARNING_PERCENT = int(r"""<< warning_percent >>""")
DISCORD_WEBHOOK_URL = r"""<< discord_webhook_url >>"""
DISCORD_USERNAME = r"""<< discord_username >>"""
def fetch_disk_usage() -> dict[str, str | int]:
path = MONITOR_PATH.strip() or "/"
if not 1 <= WARNING_PERCENT <= 100:
raise SystemExit("warning_percent must be between 1 and 100.")
result = subprocess.run(
["df", "-P", path],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip() or "unknown local error"
raise RuntimeError(f"Local df command failed with exit code {result.returncode}: {detail}")
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if len(lines) < 2:
raise RuntimeError(f"Unexpected df output: {result.stdout!r}")
parts = lines[-1].split(maxsplit=5)
if len(parts) != 6:
raise RuntimeError(f"Unable to parse df output line: {lines[-1]!r}")
usage_raw = parts[4]
if not usage_raw.endswith("%"):
raise RuntimeError(f"Unable to parse disk usage percentage: {usage_raw!r}")
return {
"filesystem": parts[0],
"blocks_kb": parts[1],
"used_kb": parts[2],
"available_kb": parts[3],
"usage_percent": int(usage_raw[:-1]),
"mounted_on": parts[5],
"path": path,
"host": socket.gethostname(),
}
def send_discord_warning(result: dict[str, str | int]) -> bool:
webhook_url = DISCORD_WEBHOOK_URL.strip()
if not webhook_url:
return False
content = "\n".join(
[
"Disk usage warning",
f"Host: {result['host']}",
f"Path: {result['path']}",
f"Mounted on: {result['mounted_on']}",
f"Filesystem: {result['filesystem']}",
f"Usage: {result['usage_percent']}% (threshold {WARNING_PERCENT}%)",
f"Available: {result['available_kb']} KB",
]
)
payload = json.dumps(
{
"username": DISCORD_USERNAME.strip() or "Disk Monitor",
"content": content[:1900],
}
).encode("utf-8")
request = urllib.request.Request(
webhook_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=10) as response: # noqa: S310
if response.status >= 400:
raise RuntimeError(f"Discord webhook returned HTTP {response.status}")
return True
def main() -> int:
result = fetch_disk_usage()
usage_percent = int(result["usage_percent"])
status = "warn" if usage_percent >= WARNING_PERCENT else "pass"
alert_sent = False
if status == "warn":
alert_sent = send_discord_warning(result)
print(f"Server Disk Usage Monitor: {status.upper()}")
print(f"Host: {result['host']}")
print(f"Path: {result['path']}")
print(f"Mounted on: {result['mounted_on']}")
print(f"Filesystem: {result['filesystem']}")
print(f"Usage: {usage_percent}%")
print(f"Threshold: {WARNING_PERCENT}%")
print(f"Available: {result['available_kb']} KB")
print(f"Discord alert sent: {'yes' if alert_sent else 'no'}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1 @@
# No external dependencies required.
@@ -0,0 +1,83 @@
{
"slug": "server-disk-usage-monitor",
"kind": "python",
"metadata": {
"name": "Server Disk Usage Monitor",
"description": "Standalone Python monitor that checks local disk usage for one path and sends a Discord warning when usage reaches a configurable threshold.",
"tags": [
"disk",
"monitoring",
"discord",
"python"
],
"icon": {
"provider": "simple-icons",
"id": "python",
"color": "emerald"
},
"draft": false,
"version": {
"name": "1.0.0",
"source_dep_name": "manual/server-disk-usage-monitor"
}
},
"variables": [
{
"title": "Monitor",
"name": "monitor",
"items": [
{
"name": "monitor_path",
"type": "str",
"title": "Monitor Path",
"required": true,
"default": "/",
"description": "Local path passed to `df -P`.",
"config": {
"placeholder": "/"
}
}
]
},
{
"title": "Alerting",
"name": "alerting",
"items": [
{
"name": "warning_percent",
"type": "int",
"title": "Warning Threshold Percent",
"required": true,
"default": 80,
"description": "Send a warning when disk usage reaches or exceeds this percentage.",
"config": {
"slider": true,
"min": 50,
"max": 100,
"step": 1,
"placeholder": "80"
}
},
{
"name": "discord_webhook_url",
"type": "secret",
"title": "Discord Webhook URL",
"required": false,
"default": "",
"description": "Optional Discord webhook endpoint for warning notifications."
},
{
"name": "discord_username",
"type": "str",
"title": "Discord Username",
"required": false,
"default": "Disk Monitor",
"description": "Sender name used for Discord webhook messages.",
"config": {
"placeholder": "Disk Monitor"
}
}
]
}
]
}