Spaces:
Sleeping
Sleeping
File size: 8,999 Bytes
9c879c3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | """Browser-based URL verifier β fallback for hosts that block scripted HTTP.
Many insurer / aggregator sites (Akamai, Cloudflare) return 403/503 to httpx
but load fine in a real browser. This tool uses headless Chromium (via
Playwright) to verify those URLs the way a human would.
Two modes:
1. Standalone: `python tools/browser_verify.py [urls...]`
- Reads URLs from argv or MUST_FIX.md
- Renders each in headless Chromium
- Writes results to tools/browser_verified.json
2. Library: `from tools.browser_verify import verify_one`
- check_link_rot.py imports this to retry after httpx failure
Verdict rules:
ALIVE β page loaded with status 200-399 AND title doesn't say "404/not found/error"
DEAD β main-resource HTTP 4xx/5xx OR title says 404
TIMEOUT β page didn't finish loading within 25s
The allowlist (tools/browser_verified.json) is checked-in so the daily cron
trusts past verifications for 30 days before re-running browser checks.
"""
from __future__ import annotations
import json
import re
import sys
import time
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
ALLOWLIST = PROJECT_ROOT / "tools" / "browser_verified.json"
MUST_FIX = PROJECT_ROOT / "MUST_FIX.md"
DEAD_TITLE_PATTERNS = re.compile(
r"(?i)404|not[\s-]found|page[\s-]not[\s-]available|error[\s-]occurred|access[\s-]denied"
)
def verify_one(url: str, page, context) -> dict:
"""Render `url` and return a verdict dict.
PDFs use the browser's request stack (request.head) β chromium would try
to download them which fails the page.goto contract. HTML uses full
navigation so the JS challenge from Akamai/DataDome resolves.
"""
is_pdf = url.lower().endswith(".pdf") or "/pdf/" in url.lower()
last_err = ""
if is_pdf:
# Many insurer CDNs (Star Health on Akamai, ICICI) require a challenge
# cookie from the parent host before serving PDFs. Warm up if 403.
warmed = False
for attempt in range(3):
try:
resp = context.request.get(url, timeout=60000, max_redirects=10)
status = resp.status
content_type = resp.headers.get("content-type", "")
body_head = b""
try:
body_head = resp.body()[:5] if status == 200 else b""
except Exception: # noqa: BLE001
body_head = b""
is_real_pdf = body_head == b"%PDF-" or "pdf" in content_type.lower()
if 200 <= status < 400 and is_real_pdf:
return {"verdict": "ALIVE", "status": status, "title": "[pdf]",
"reason": f"ct={content_type}", "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
# 403 from Akamai-style bot defence β try cookie warmup on parent host
if status == 403 and not warmed:
try:
host_root = re.sub(r"(https?://[^/]+).*", r"\1", url)
page.goto(host_root, wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(2500) # let JS challenge resolve
warmed = True
continue
except Exception: # noqa: BLE001
pass
if status >= 400:
return {"verdict": "DEAD", "status": status, "title": "[pdf]",
"reason": f"main_status={status}", "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
return {"verdict": "DEAD", "status": status, "title": "[pdf]",
"reason": f"not_pdf ct={content_type} head={body_head!r}",
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
except Exception as e: # noqa: BLE001
last_err = str(e).splitlines()[0][:160]
if attempt < 2:
time.sleep(2)
continue
return {"verdict": "ERROR", "status": 0, "title": "[pdf]",
"reason": last_err, "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
# HTML path
for attempt in range(2):
try:
response = page.goto(url, wait_until="domcontentloaded", timeout=45000)
status = response.status if response else 0
title = page.title() or ""
if status >= 400:
return {"verdict": "DEAD", "status": status, "title": title[:120],
"reason": f"main_status={status}", "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
if DEAD_TITLE_PATTERNS.search(title):
return {"verdict": "DEAD", "status": status, "title": title[:120],
"reason": f"title={title!r}", "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
return {"verdict": "ALIVE", "status": status, "title": title[:120],
"reason": "", "ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
except Exception as e: # noqa: BLE001
last_err = str(e).splitlines()[0][:160]
if attempt == 0:
time.sleep(2)
continue
verdict = "TIMEOUT" if "Timeout" in last_err or "timeout" in last_err else "ERROR"
return {"verdict": verdict, "status": 0, "title": "", "reason": last_err,
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
return {"verdict": "ERROR", "status": 0, "title": "", "reason": last_err,
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
def parse_must_fix() -> list[str]:
"""Extract URLs from MUST_FIX.md table."""
if not MUST_FIX.exists():
return []
urls: list[str] = []
for line in MUST_FIX.read_text().splitlines():
if not line.startswith("|") or "url" in line.lower() and "status" in line.lower():
continue
parts = [p.strip() for p in line.strip("|").split("|")]
if len(parts) >= 2:
m = re.search(r"https?://\S+", parts[1])
if m:
urls.append(m.group(0))
return urls
def load_allowlist() -> dict:
if ALLOWLIST.exists():
try:
return json.loads(ALLOWLIST.read_text())
except json.JSONDecodeError:
return {}
return {}
def save_allowlist(d: dict) -> None:
ALLOWLIST.write_text(json.dumps(d, indent=2, sort_keys=True))
def main(argv: list[str]) -> int:
from playwright.sync_api import sync_playwright
urls = argv[1:] if len(argv) > 1 else parse_must_fix()
if not urls:
print("[browser-verify] no URLs to check β pass on argv or populate MUST_FIX.md")
return 0
allowlist = load_allowlist()
counts = {"ALIVE": 0, "DEAD": 0, "TIMEOUT": 0, "ERROR": 0}
from playwright_stealth import Stealth
with sync_playwright() as p:
# Launch with anti-bot-detection flags. Akamai/Cloudflare detect
# chrome-headless-shell easily; we use full chromium + stealth tricks.
browser = p.chromium.launch(
headless=True,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-features=IsolateOrigins,site-per-process",
"--no-sandbox",
],
)
context = browser.new_context(
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
),
viewport={"width": 1366, "height": 800},
locale="en-IN",
extra_http_headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "en-IN,en;q=0.9",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Ch-Ua": '"Chromium";v="126", "Google Chrome";v="126"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"macOS"',
},
)
Stealth().apply_stealth_sync(context)
page = context.new_page()
for i, url in enumerate(urls, 1):
print(f" [{i}/{len(urls)}] {url[:90]}", flush=True)
v = verify_one(url, page, context)
counts[v["verdict"]] = counts.get(v["verdict"], 0) + 1
if v["verdict"] == "ALIVE":
allowlist[url] = v
print(f" -> {v['verdict']} | {v.get('status', '')} | {v.get('title', '')[:80]}")
browser.close()
save_allowlist(allowlist)
print(
f"\n[browser-verify] {sum(counts.values())} URLs checked | "
f"ALIVE={counts['ALIVE']} DEAD={counts['DEAD']} "
f"TIMEOUT={counts['TIMEOUT']} ERROR={counts['ERROR']}"
)
print(f"[browser-verify] allowlist saved -> {ALLOWLIST.relative_to(PROJECT_ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
|