#!/usr/bin/env python3 """ Falah OS — Broken Link Checker =============================== Crawls all known Falah OS pages, extracts every internal link, and verifies each returns a valid HTTP response. Usage: python3 falah-broken-link-checker.py # Full crawl python3 falah-broken-link-checker.py --ci-exit # Exit 1 on broken links python3 falah-broken-link-checker.py --domain mobile # Only mobile app domain python3 falah-broken-link-checker.py --quick # Only top-level pages Output: /root/.hermes/reports/broken-links/broken-links-YYYYMMDD.json """ import argparse, json, os, re, sys, time, urllib.request, urllib.error from urllib.parse import urljoin, urlparse from html.parser import HTMLParser from datetime import datetime # ── Config ───────────────────────────────────────────────────── DOMAINS = { "mobile": { "base": "https://falahos.my/mobile", "seed_pages": [ "/", "/prayer", "/dhikr", "/qibla", "/halal-monitor", "/nur", "/forum", "/souq", "/groups", "/learn", "/wallet", "/profile", ], "exclude_patterns": [ r"tel:", r"mailto:", r"javascript:", r"#.*", r"api/", r"\.(pdf|zip|doc|jpg|png|svg|ico)$", r"https?://(www\.)?(facebook|twitter|x|instagram|linkedin|tiktok|youtube)\.com", ], }, "istore": { "base": "https://store.falah-os.com", "seed_pages": ["/", "/apps", "/marketplace", "/developer", "/account"], "exclude_patterns": [ r"tel:", r"mailto:", r"javascript:", r"#.*", r"api/", r"\.(pdf|zip|doc)$", r"https?://(www\.)?(facebook|twitter|x|instagram|linkedin|tiktok|youtube)\.com", ], }, } REPORT_DIR = "/root/.hermes/reports/broken-links" TIMEOUT = 15 MAX_LINKS = 300 # Max links to check per run (avoid rate limiting) USER_AGENT = "Falah-Broken-Link-Checker/1.0" class LinkExtractor(HTMLParser): """Extract all href attributes from tags.""" def __init__(self): super().__init__() self.links = [] def handle_starttag(self, tag, attrs): ad = dict(attrs) if tag == "a" and "href" in ad: self.links.append(ad["href"]) # Also check sources and imports if tag == "link" and "href" in ad: self.links.append(ad["href"]) if tag == "script" and "src" in ad: self.links.append(ad["src"]) if tag == "img" and "src" in ad: self.links.append(ad["src"]) def fetch_page(url, timeout=TIMEOUT): """Fetch a page and return its text content.""" req = urllib.request.Request( url, headers={"User-Agent": USER_AGENT}, method="GET" ) try: with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read() return { "status": resp.status, "text": raw.decode("utf-8", errors="replace"), "error": None, } except urllib.error.HTTPError as e: raw = e.read() return { "status": e.code, "text": raw.decode("utf-8", errors="replace"), "error": None, } except Exception as e: return {"status": 0, "text": "", "error": str(e)} def check_link(url, timeout=TIMEOUT): """Verify a single link returns a valid response. Returns (status, error_string).""" # Skip non-HTTP links if not url.startswith("http"): return None, "non-http" # HEAD request to check without downloading body req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}, method="HEAD") try: with urllib.request.urlopen(req, timeout=timeout) as resp: if resp.status in (200, 301, 302, 307, 308): return resp.status, None return resp.status, f"HTTP {resp.status}" except urllib.error.HTTPError as e: if e.code in (301, 302, 307, 308): return e.code, None # Redirects are fine return e.code, f"HTTP {e.code}" except urllib.error.URLError as e: return 0, f"DNS/timeout: {str(e.reason)[:60]}" except Exception as e: return 0, str(e)[:60] def is_internal(url, base_domain): """Check if URL belongs to the same domain.""" parsed = urlparse(url) return parsed.netloc == "" or base_domain in parsed.netloc def should_exclude(url, patterns): """Check if URL matches any exclude pattern.""" for pat in patterns: if re.search(pat, url): return True return False def normalize_url(href, base_url): """Convert a relative href to absolute URL.""" if href.startswith("//"): return "https:" + href return urljoin(base_url, href) def crawl_and_check(domain_config, quick=False): """Crawl all pages in a domain and check every internal link.""" base = domain_config["base"] seed_pages = domain_config["seed_pages"] exclude_patterns = domain_config["exclude_patterns"] base_domain = urlparse(base).netloc visited_pages = set() all_links = set() broken_links = [] checked = 0 skipped = 0 print(f"\n{'='*60}") print(f" Crawling: {base}") print(f" Seeds: {len(seed_pages)} pages") print(f"{'='*60}") # Phase 1: Crawl pages and extract links for seed in seed_pages: url = base.rstrip("/") + ("/" + seed.lstrip("/") if seed != "/" else "") if url in visited_pages: continue visited_pages.add(url) print(f" 📄 Fetching: {url}") page = fetch_page(url) if page["status"] != 200: print(f" ⚠️ Page returned HTTP {page['status']} — skipping extraction") broken_links.append({ "page": url, "link": url, "status": page["status"], "error": page["error"] or f"HTTP {page['status']}", "type": "page_fetch_failed", }) continue # Extract links from HTML extractor = LinkExtractor() try: extractor.feed(page["text"]) except Exception as e: print(f" ⚠️ HTML parse error: {e}") continue for href in extractor.links: abs_url = normalize_url(href, url) if not is_internal(abs_url, base_domain): skipped += 1 continue if should_exclude(abs_url, exclude_patterns): skipped += 1 continue all_links.add(abs_url) print(f" Found {len(extractor.links)} links, {len(set(normalize_url(h, url) for h in extractor.links if is_internal(normalize_url(h, url), base_domain) and not should_exclude(normalize_url(h, url), exclude_patterns)))} internal to check") print(f"\n Total unique internal links to check: {len(all_links)}") # Phase 2: Verify each link limit = min(MAX_LINKS, len(all_links)) if not quick else 50 link_list = list(all_links)[:limit] print(f" Checking {len(link_list)} links (MAX_LINKS={limit})...\n") for i, url in enumerate(link_list, 1): checked += 1 status, error = check_link(url) if error: broken_links.append({ "page": visited_pages.copy().pop() if visited_pages else base, "link": url, "status": status, "error": error, "type": "broken" if status in (0, 404, 410) else "warning", }) print(f" {i:3d}. ❌ {status} {url[:90]}") print(f" Error: {error}") else: if i <= 5 or i % 20 == 0: # Show progress periodically print(f" {i:3d}. ✅ {status} {url[:80]}") return { "domain": base, "checked": checked, "skipped": skipped, "total_extracted": len(all_links), "limit_applied": limit < len(all_links), "broken": broken_links, "broken_count": len(broken_links), "success_count": checked - len(broken_links), } def main(): parser = argparse.ArgumentParser(description="Falah OS Broken Link Checker") parser.add_argument("--ci-exit", action="store_true", help="Exit 1 on broken links") parser.add_argument("--domain", choices=["mobile", "istore", "all"], default="all", help="Domain to check (default: all)") parser.add_argument("--quick", action="store_true", help="Quick check (fewer links)") args = parser.parse_args() os.makedirs(REPORT_DIR, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") report = { "timestamp": datetime.now().isoformat(), "domains_checked": [], "total_broken": 0, "total_checked": 0, "passed": True, } domains_to_check = [] if args.domain in ("mobile", "all"): domains_to_check.append(("mobile", DOMAINS["mobile"])) if args.domain in ("istore", "all"): domains_to_check.append(("istore", DOMAINS["istore"])) for name, config in domains_to_check: result = crawl_and_check(config, quick=args.quick) report["domains_checked"].append(result) report["total_broken"] += result["broken_count"] report["total_checked"] += result["checked"] report["passed"] = report["total_broken"] == 0 # Save report report_file = os.path.join(REPORT_DIR, f"broken-links-{timestamp}.json") with open(report_file, "w") as f: json.dump(report, f, indent=2, default=str) print(f"\n{'='*60}") print(f" BROKEN LINK CHECK COMPLETE") print(f" Total checked: {report['total_checked']}") print(f" Total broken: {report['total_broken']}") print(f" Status: {'✅ PASS' if report['passed'] else '❌ FAIL'}") print(f" Report: {report_file}") print(f"{'='*60}") # Print broken links summary if report["total_broken"] > 0: print(f"\n Broken Links:") for domain in report["domains_checked"]: for bl in domain["broken"]: print(f" [{bl['status']}] {bl['link']}") print(f" ({bl['error']})") sys.exit(0 if report["passed"] else (1 if args.ci_exit else 0)) if __name__ == "__main__": main()