Ctrl K
ComunidadGuías…

scripts/page_host_scan.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Extract the hostnames a page's markup references.

Rendering a page in a browser shows exactly what it requested, but it is slow
and cannot reach the hundreds of origins a corpus needs. Fetching the document
and reading the hostnames out of its markup is far less precise — it misses
anything a script builds at runtime — but it scales, and for deriving which
services a site belongs to that is the trade worth making.

Use it for breadth and the browser capture for depth; both are this
repository's own observation, and neither admits a rule on its own.

Usage::

    python scripts/page_host_scan.py origins.txt hosts.json
"""

from __future__ import annotations

import concurrent.futures
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import zlib
from collections import defaultdict
from pathlib import Path

TIMEOUT = 20
WORKERS = 16
MAX_BYTES = 3_000_000
# A page's markup names its CDN and image hosts, but the endpoints an
# application actually calls are usually built inside its script bundles.
# Following a few scripts per origin reaches them without a browser.
SCRIPTS_PER_ORIGIN = 6
SCRIPT_MAX_BYTES = 2_000_000
USER_AGENT = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/140.0 Safari/537.36"
)

# src=, href=, url(), and protocol-relative or absolute URLs inside scripts.
#
# A bare "//" is not enough to anchor on: minified JavaScript ends statements
# with trailing line comments such as "}//console.log(x)", and the text after
# the slashes then looks exactly like a hostname. Requiring either an explicit
# scheme or a delimiter that precedes a URL in markup and script keeps those
# out without losing protocol-relative references.
HOST_PATTERN = re.compile(
    r"""(?:https?://|(?<=[\s"'`(=\[{])//)([a-z0-9](?:[a-z0-9\-._]*[a-z0-9])?\.[a-z]{2,})""",
    re.IGNORECASE,
)


def decode(raw: bytes, encoding: str | None) -> str:
    """Decode a body, tolerating a stream cut short by the read limit.

    Reading at most MAX_BYTES routinely truncates a compressed response, so the
    one-shot decompressors raise. An incremental decompressor keeps whatever it
    managed to inflate, which is all this scan needs.
    """
    if encoding in {"gzip", "deflate", "zlib"}:
        wbits = 47 if encoding == "gzip" else zlib.MAX_WBITS
        for bits in (wbits, -zlib.MAX_WBITS):
            try:
                raw = zlib.decompressobj(bits).decompress(raw)
                break
            except zlib.error:
                continue
    return raw.decode("utf-8", errors="replace")


SCRIPT_SRC = re.compile(
    r"""<script[^>]+src\s*=\s*["']([^"']+)["']""", re.IGNORECASE
)


def fetch(url: str, limit: int) -> str | None:
    request = urllib.request.Request(
        url,
        headers={
            "user-agent": USER_AGENT,
            "accept": "*/*",
            "accept-encoding": "gzip, deflate",
        },
    )
    try:
        with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
            return decode(response.read(limit), response.headers.get("content-encoding"))
    except Exception:
        # A scan of the open web meets every failure mode a server can invent:
        # truncated chunked bodies, bad redirects, malformed headers, expired
        # certificates. None of them is worth aborting a run of thousands of
        # origins over, and an unreachable origin is simply one without
        # evidence, so every failure reads the same way here.
        return None


def scan(origin: str) -> tuple[str, list[str] | None]:
    """Fetch ``origin`` and return the hostnames it and its scripts reference."""
    text = fetch(origin, MAX_BYTES)
    if text is None:
        return origin, None
    hosts = {match.group(1).lower().rstrip(".") for match in HOST_PATTERN.finditer(text)}

    scripts = []
    for match in SCRIPT_SRC.finditer(text):
        src = urllib.parse.urljoin(origin, match.group(1).strip())
        if src.startswith(("http://", "https://")) and src not in scripts:
            scripts.append(src)
        if len(scripts) >= SCRIPTS_PER_ORIGIN:
            break
    for src in scripts:
        body = fetch(src, SCRIPT_MAX_BYTES)
        if body:
            hosts |= {
                match.group(1).lower().rstrip(".")
                for match in HOST_PATTERN.finditer(body)
            }

    return origin, sorted(host for host in hosts if not host.endswith(".w3.org"))


def main(argv: list[str]) -> int:
    if len(argv) != 3:
        print(__doc__, file=sys.stderr)
        return 2
    origins = [
        line.strip()
        for line in Path(argv[1]).read_text(encoding="utf-8").splitlines()
        if line.strip() and not line.startswith("#")
    ]
    captured: dict[str, list[str]] = {}
    failed: list[str] = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=WORKERS) as pool:
        for origin, hosts in pool.map(scan, origins):
            if hosts is None:
                failed.append(origin)
            else:
                captured[origin] = hosts

    seen: dict[str, set[str]] = defaultdict(set)
    for origin, hosts in captured.items():
        for host in hosts:
            seen[host].add(origin)
    Path(argv[2]).write_text(
        json.dumps(
            {k: captured[k] for k in sorted(captured)}, ensure_ascii=False, indent=2
        )
        + "\n",
        encoding="utf-8",
        newline="\n",
    )
    print(
        f"{len(captured)}/{len(origins)} origins scanned, "
        f"{len(seen)} distinct hostnames, {len(failed)} failed"
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))