#!/usr/bin/env python3 """Download issue table HTML from Common Crawl for PA docs release note pages. Usage: python crawl_cc.py [--crawl CC-MAIN-2026-12] [--product PAN-OS] [--branch 10.2] """ import argparse import gzip import io import json import logging import re import sys import time from pathlib import Path from urllib.parse import urlparse import requests from bs4 import BeautifulSoup from warcio.archiveiterator import ArchiveIterator CC_INDEX_URL = "https://index.commoncrawl.org/{crawl}-index" CC_DATA_URL = "https://data.commoncrawl.org/{filename}" SCRIPT_DIR = Path(__file__).parent DATA_DIR = SCRIPT_DIR.parent / "data" ENTRY_POINTS_FILE = SCRIPT_DIR / "entry_points.json" DEFAULT_MIN_REQUEST_INTERVAL = 1.0 _MIN_REQUEST_INTERVAL_SECONDS = DEFAULT_MIN_REQUEST_INTERVAL _LAST_REQUEST_TS = 0.0 ISSUE_TYPE_SLUGS = { "addressed": "-addressed-issues", "known": "-known-issues", } def get_issue_type_from_url(url: str) -> str | None: """Return issue type from the final path slug, not parent path segments.""" slug = urlparse(url).path.rstrip("/").rsplit("/", 1)[-1] for issue_type, suffix in ISSUE_TYPE_SLUGS.items(): if slug.endswith(suffix): return issue_type return None def load_entry_points(): with open(ENTRY_POINTS_FILE) as f: return json.load(f) def set_request_rate_limit(min_interval_seconds: float) -> None: global _MIN_REQUEST_INTERVAL_SECONDS _MIN_REQUEST_INTERVAL_SECONDS = max(0.0, float(min_interval_seconds)) def rate_limited_get(url: str, **kwargs): global _LAST_REQUEST_TS if _MIN_REQUEST_INTERVAL_SECONDS > 0: elapsed = time.monotonic() - _LAST_REQUEST_TS sleep_for = _MIN_REQUEST_INTERVAL_SECONDS - elapsed if sleep_for > 0: time.sleep(sleep_for) response = requests.get(url, **kwargs) _LAST_REQUEST_TS = time.monotonic() return response def query_cc_index(crawl: str, url: str) -> list[dict]: """Query the CC index for all records matching *url* exactly. Returns [] if not found.""" return _query_cc_index(crawl, url, match_type="exact") def query_cc_index_prefix(crawl: str, prefix_url: str) -> list[dict]: """Query the CC index for all records whose URL starts with *prefix_url*. Returns a flat list of all matching records (may be large). """ return _query_cc_index(crawl, prefix_url, match_type="prefix") def _query_cc_index(crawl: str, url: str, match_type: str) -> list[dict]: index_url = CC_INDEX_URL.format(crawl=crawl) resp = rate_limited_get( index_url, params={"url": url, "matchType": match_type, "output": "json"}, timeout=60, ) if resp.status_code == 404: return [] resp.raise_for_status() records = [] for line in resp.text.strip().splitlines(): line = line.strip() if line: try: records.append(json.loads(line)) except json.JSONDecodeError: continue return records def pick_latest_record(records: list[dict]) -> dict | None: """Return the record with the most recent timestamp.""" if not records: return None return max(records, key=lambda r: r.get("timestamp", "")) def _url_candidates(url: str) -> list[str]: stripped = url.rstrip("/") with_slash = stripped + "/" candidates = [stripped, with_slash] if stripped.startswith("https://"): alt = "http://" + stripped[len("https://") :] candidates.extend([alt, alt + "/"]) elif stripped.startswith("http://"): alt = "https://" + stripped[len("http://") :] candidates.extend([alt, alt + "/"]) seen = set() out = [] for candidate in candidates: if candidate not in seen: seen.add(candidate) out.append(candidate) return out def find_latest_record_for_url(crawl: str, url: str) -> dict | None: """Find the latest CC record for a URL, with normalization fallbacks.""" for candidate in _url_candidates(url): record = pick_latest_record(query_cc_index(crawl, candidate)) if record: return record # Fallback: search the parent path with prefix match and pick the closest URL. parent_prefix = url.rsplit("/", 1)[0] + "/" slug = urlparse(url).path.rstrip("/").rsplit("/", 1)[-1] pref_records = query_cc_index_prefix(crawl, parent_prefix) if not pref_records: return None candidates = [] normalized_targets = set(_url_candidates(url)) for rec in pref_records: rec_url = str(rec.get("url", "")).split("?", 1)[0].split("#", 1)[0] if rec_url in normalized_targets: candidates.append(rec) continue rec_slug = urlparse(rec_url).path.rstrip("/").rsplit("/", 1)[-1] if rec_slug == slug: candidates.append(rec) return pick_latest_record(candidates) def fetch_warc_record(filename: str, offset: int, length: int) -> bytes: """Fetch a WARC record via HTTP range request and return the raw WARC bytes. Retries transient 5xx/429 failures a few times before giving up. """ url = CC_DATA_URL.format(filename=filename) headers = {"Range": f"bytes={offset}-{offset + length - 1}"} last_error = None for attempt in range(1, 5): try: resp = rate_limited_get(url, headers=headers, timeout=60) if resp.status_code in (429, 500, 502, 503, 504): raise requests.HTTPError( f"Transient HTTP {resp.status_code}", response=resp ) resp.raise_for_status() return resp.content except requests.RequestException as exc: last_error = exc if attempt == 4: break wait_seconds = attempt * 2 logging.warning( "Retrying WARC fetch (%d/4) after error for %s: %s", attempt, filename, exc, ) time.sleep(wait_seconds) raise RuntimeError(f"Failed to fetch WARC record after retries: {filename}") from last_error def extract_html_from_warc(warc_bytes: bytes) -> str | None: """Extract the HTTP response body from a WARC record (handles gzip body encoding).""" for record in ArchiveIterator(io.BytesIO(warc_bytes)): if record.rec_type == "response": body = record.content_stream().read() # Handle Content-Encoding: gzip on the HTTP response body encoding = "" if record.http_headers: encoding = record.http_headers.get_header("Content-Encoding", "").lower() if "gzip" in encoding: try: body = gzip.decompress(body) except Exception: pass # Determine charset from Content-Type charset = "utf-8" if record.http_headers: ct = record.http_headers.get_header("Content-Type", "") m = re.search(r"charset=([^\s;]+)", ct, re.I) if m: charset = m.group(1) return body.decode(charset, errors="replace") return None def extract_version_from_slug(page_slug: str, slug_prefix: str) -> str | None: """ Extract a dotted version string from a PA docs page slug. Examples (slug_prefix='pan-os-'): pan-os-10-2-1-addressed-issues -> 10.2.1 pan-os-10-2-1-h3-addressed-issues -> 10.2.1-h3 pan-os-10-2-known-issues -> 10.2 """ # Strip issue type suffix stripped = page_slug for suffix in ISSUE_TYPE_SLUGS.values(): if stripped.endswith(suffix): stripped = stripped[: -len(suffix)] break # Strip product prefix if stripped.startswith(slug_prefix): stripped = stripped[len(slug_prefix):] else: return None if not stripped: return None # Convert "10-2-1-h3" -> "10.2.1-h3" parts = stripped.split("-") numeric_parts = [] remainder = [] for part in parts: if part.isdigit() and not remainder: numeric_parts.append(part) else: remainder.append(part) if not numeric_parts: return None version = ".".join(numeric_parts) if remainder: version += "-" + "-".join(remainder) return version def find_issue_table(html: str) -> str | None: """ Find the issue table in an HTML page. Prefers a table whose header row contains both 'Issue ID' and 'Description'. Falls back to the first table. """ soup = BeautifulSoup(html, "html.parser") for table in soup.find_all("table"): header_text = " ".join( th.get_text(separator=" ", strip=True) for th in table.find_all("th") ).lower() if "issue" in header_text and "description" in header_text: return str(table) table = soup.find("table") return str(table) if table else None def download_version_page( record: dict, url: str, issue_type: str, slug_prefix: str, out_dir: Path ) -> bool: """ Download and save the issue table HTML for one version page. Returns True on success, False if skipped. """ page_slug = urlparse(url).path.rstrip("/").rsplit("/", 1)[-1] version = extract_version_from_slug(page_slug, slug_prefix) if not version: logging.warning("Could not extract version from URL %s — skipping", url) return False out_file = out_dir / f"{version}-{issue_type}.html" if out_file.exists(): logging.info("Already downloaded %s", out_file.relative_to(DATA_DIR.parent)) return False try: warc_bytes = fetch_warc_record( record["filename"], int(record["offset"]), int(record["length"]), ) except Exception as exc: logging.warning("Could not fetch WARC for %s — skipping (%s)", url, exc) return False html = extract_html_from_warc(warc_bytes) if not html: logging.warning("Could not extract HTML from WARC for %s — skipping", url) return False table_html = find_issue_table(html) if not table_html: logging.warning("No issue table found in %s — skipping", url) return False out_dir.mkdir(parents=True, exist_ok=True) out_file.write_text(table_html, encoding="utf-8") logging.info("Saved %s", out_file.relative_to(DATA_DIR.parent)) return True def process_branch( crawl: str, product: str, product_cfg: dict, branch_cfg: dict, max_results: int = 0, ) -> None: branch = branch_cfg["branch"] main_page = branch_cfg["main_page"] slug_prefix = product_cfg.get("slug_prefix", "") out_dir = DATA_DIR / crawl / product # Derive the release-notes directory URL for a prefix query. # e.g. ".../pan-os/10-2/pan-os-release-notes/features-introduced-in-pan-os" # -> ".../pan-os/10-2/pan-os-release-notes/" notes_dir = main_page.rsplit("/", 1)[0] + "/" logging.info("=== Branch %s ===", branch) logging.info("Querying CC index (prefix) for %s", notes_dir) all_records = query_cc_index_prefix(crawl, notes_dir) if not all_records: logging.warning("No CC records found under %s — skipping branch", notes_dir) return # Deduplicate: keep the latest record per URL. latest_by_url: dict[str, dict] = {} for rec in all_records: url = rec.get("url", "") if url not in latest_by_url or rec.get("timestamp", "") > latest_by_url[url].get("timestamp", ""): latest_by_url[url] = rec # Filter for issue pages. issue_urls: dict[str, str] = {} for url in latest_by_url: issue_type = get_issue_type_from_url(url) if issue_type: issue_urls[url] = issue_type if not issue_urls: logging.warning("No issue page URLs found under %s", notes_dir) return selected_issue_items = sorted(issue_urls.items()) if max_results > 0: logging.info( "Found %d issue page URLs (will stop after %d successful downloads)", len(issue_urls), max_results, ) else: logging.info("Found %d issue page URLs", len(issue_urls)) successful_downloads = 0 for url, issue_type in selected_issue_items: if max_results > 0 and successful_downloads >= max_results: break try: saved = download_version_page(latest_by_url[url], url, issue_type, slug_prefix, out_dir) if saved: successful_downloads += 1 except Exception as exc: logging.warning("Unexpected error while processing %s — skipping (%s)", url, exc) def process_single_url(crawl: str, product: str, product_cfg: dict, url: str) -> None: slug_prefix = product_cfg.get("slug_prefix", "") out_dir = DATA_DIR / crawl / product issue_type = get_issue_type_from_url(url) if not issue_type: logging.error( "URL does not look like a known/addressed issue page: %s", url, ) return record = find_latest_record_for_url(crawl, url) if not record: logging.warning("No CC record for %s (including normalized fallbacks)", url) return download_version_page(record, url, issue_type, slug_prefix, out_dir) def main(): logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") parser = argparse.ArgumentParser( description="Download PA docs issue tables from Common Crawl." ) parser.add_argument("--crawl", default="CC-MAIN-2026-12", help="Common Crawl index ID") parser.add_argument( "--product", default="PAN-OS", help="Product name (must match a key in entry_points.json)", ) parser.add_argument("--branch", help="Process only this branch (e.g. 10.2)") parser.add_argument( "--url", help="Process exactly one issue page URL (must include -addressed-issues or -known-issues)", ) parser.add_argument( "--min-request-interval", type=float, default=DEFAULT_MIN_REQUEST_INTERVAL, help="Minimum seconds between HTTP requests to Common Crawl (default: 1.0)", ) parser.add_argument( "--max-results", type=int, default=0, help="When using entry-point branch mode, download only the first N discovered issue pages (0 = no limit)", ) args = parser.parse_args() set_request_rate_limit(args.min_request_interval) logging.info("Using request rate limit: %.2f seconds/request", args.min_request_interval) entry_points = load_entry_points() if args.product not in entry_points: sys.exit(f"Product {args.product!r} not found in entry_points.json") product_cfg = entry_points[args.product] if args.url: process_single_url(args.crawl, args.product, product_cfg, args.url) return branches = product_cfg["branches"] if args.branch: branches = [b for b in branches if b["branch"] == args.branch] if not branches: sys.exit(f"Branch {args.branch!r} not found for product {args.product!r}") for branch_cfg in branches: process_branch( args.crawl, args.product, product_cfg, branch_cfg, max_results=max(0, args.max_results), ) if __name__ == "__main__": main()