PDF & HTML Scraping Pipelines

The majority of municipalities in the United States do not publish a machine-readable zoning feed. They publish a planning-commission agenda as a JavaScript-rendered table, an amendment as a 90-page PDF ordinance, and a rezoning boundary as a metes-and-bounds paragraph buried in an exhibit. For a PropTech platform tracking entitlement risk, this is the hardest entry point in the whole stack: there is no API contract to validate against, the document structure drifts every quarter, and a single missed table cell can silently drop a zoning change from an underwriting model. PDF and HTML scraping pipelines exist to convert these unstructured, human-facing publications into deterministic data streams that the rest of the Automated Feed Ingestion & GIS Data Parsing framework can treat exactly like any other feed. This page covers how to build that conversion layer so it fails loudly and recovers cleanly, rather than quietly emitting plausible-but-wrong records.

The discipline that separates a production scraper from a brittle one-off script is treating every document as an untrusted payload that must earn its way forward through a fetch-parse-validate sequence. Nothing extracted from a PDF or a portal is trusted until a parcel identifier has been cross-checked against an authoritative registry and a spatial reference has been resolved to a known projection. What follows is the operational context that must already be in place, the extraction architecture, runnable Python for both the HTML and PDF paths, the municipal-specific failure modes that break these pipelines in production, and the audit artifacts the step must emit for compliance review.

Prerequisites and operational context jump to heading

Scraping is not the first subsystem you build — it is the one that feeds everything else, so the contracts it must satisfy have to exist before the first crawler runs. Several conditions need to be in place:

  • An immutable staging store. Every fetched byte stream — HTML snapshot or PDF binary — lands in versioned object storage before any parsing, keyed by source URL, fetch timestamp, and a content hash. The parser reads from staging, never from the live municipal server, so a reprocessing run after a parser fix never re-hammers a county website or trips its WAF.
  • A projection contract. Geometry reconstructed from a legal description is meaningless without a known datum. The pipeline depends on the CRS alignment strategies defined for the jurisdiction so that coordinates parsed from a survey plat land in the same reference system as the existing parcel fabric.
  • A target schema. Extracted fields must map onto the canonical record shape enforced by schema validation & data quality checks. If the downstream contract requires a non-null parcel ID and an ISO 8601 effective date, the scraper has to know that before it decides what to quarantine.
  • A taxonomy reference. Local zoning codes are arbitrary strings until they are mapped through the project’s zoning taxonomy mapping tables, so the scraper should capture the raw code verbatim and defer interpretation.
  • A polite-crawling budget. Municipal servers are frequently single-instance government hardware. The crawler must operate within the same constraints as the rest of the ingestion layer’s municipal API rate limit management, honouring crawl delays and backing off on throttling.

With those in place, the scraper’s only job is to turn a document into a structurally valid candidate record and route everything it cannot prove into quarantine.

Extraction architecture and document routing jump to heading

Production scraping pipelines operate on a strict fetch-parse-validate triad. Raw documents enter the staging bucket, where a routing service classifies each payload by MIME type, source domain, and update cadence before any parser touches it. HTML endpoints carrying zoning-board agendas, public-hearing notices, or council minutes are dispatched to headless-browser extractors. PDFs carrying amendment tables, zoning-district maps, or metes-and-bounds descriptions are routed to layout-aware text and vector parsers. This classification step prevents parser collisions — a PDF parser handed an HTML error page, or vice versa — and ensures a malformed payload fails fast without blocking the broader batch.

Fetch-parse-validate architecture for the municipal scraping pipeline Three unstructured sources — a JavaScript-rendered planning portal, a PDF ordinance, and a scanned plat — feed an acquisition layer guarded by a crawl-delay, backoff, and circuit breaker. Acquired bytes land in an immutable, versioned, content-hashed raw staging store. A MIME and domain router reads from staging and splits payloads into a headless-browser HTML extractor and a layout-aware PDF parser. Both branches converge on a validation gate that cross-checks the parcel ID against an assessor registry, resolves the coordinate reference system, and enforces the target schema. Valid records flow right to attribute normalization and then the analytical GIS store, while every rejected record drops into a quarantine ledger below the gate. valid rejected Planning portal JS-rendered table PDF ordinance amendment table Scanned plat image-only circuit breaker · Retry-After Acquisition layer crawl-delay backoff gate Raw staging store versioned · hashed MIME / domain router Headless-browser HTML extractor Layout-aware PDF parser Validation gate parcel-ID cross-check CRS resolution schema enforcement Attribute normalization Analytical GIS store Quarantine ledger · reason coded

Routing logic carries circuit breakers and exponential backoff so that municipal-server throttling degrades gracefully instead of cascading. When a document fails checksum validation or exceeds a size threshold, it is quarantined for manual review while the pipeline keeps processing subsequent items. The architectural rule is that classification is cheap and reversible, but parsing is expensive and side-effectful — so as much filtering as possible happens before a parser is invoked.

The branching strategy can be expressed as a routing flow:

MIME-based routing and schema-validation decision flow A raw payload in staging reaches a MIME-type decision. A text/html payload routes to a headless-browser extractor; an application/pdf payload routes to a layout-aware PDF parser; any other or unknown type drops straight to the quarantine ledger. Both extractors feed a schema-valid decision: records that pass flow to attribute normalization and then the analytical GIS store, while records that fail are routed to the quarantine ledger. text/html application/pdf other / unknown yes no Raw payload in staging MIME type? Headless browser extractor Layout-aware PDF parser Schema valid? Attribute normalization Analytical GIS store Quarantine ledger

HTML extraction: handling dynamic municipal portals jump to heading

Municipal websites lean heavily on JavaScript-rendered tables, infinite scroll, and session-authenticated agendas. Static requests-based crawlers return an empty shell against these patterns because the data arrives after the initial document load. Production pipelines use headless-browser automation with explicit wait conditions tied to DOM selectors rather than arbitrary sleep timeouts, which are both slower and flakier.

import asyncio
from playwright.async_api import async_playwright
from typing import List, Dict, Optional
import logging

logger = logging.getLogger(__name__)

async def extract_zoning_hearings(
    url: str,
    target_table_id: str,
    max_retries: int = 3,
) -> List[Dict[str, Optional[str]]]:
    """Extract hearing data from dynamically rendered municipal agendas."""
    for attempt in range(max_retries):
        try:
            async with async_playwright() as p:
                browser = await p.chromium.launch(headless=True, args=["--no-sandbox"])
                context = await browser.new_context(
                    viewport={"width": 1280, "height": 800},
                    user_agent="Mozilla/5.0 (GIS Pipeline/1.0)",
                )
                page = await context.new_page()
                await page.goto(url, wait_until="domcontentloaded", timeout=20000)

                # Explicit wait for the dynamic agenda table, not an arbitrary sleep.
                await page.wait_for_selector(f"#{target_table_id}", timeout=15000)

                rows = await page.query_selector_all(f"#{target_table_id} tbody tr")
                hearings: List[Dict[str, Optional[str]]] = []

                for row in rows:
                    cells = await row.query_selector_all("td")
                    if len(cells) >= 4:
                        hearings.append({
                            "parcel_ref": (await cells[0].inner_text()).strip(),
                            "zoning_change": (await cells[1].inner_text()).strip(),
                            "hearing_date": (await cells[2].inner_text()).strip(),
                            "document_url": await cells[3].get_attribute("href"),
                        })
                await browser.close()
                return hearings

        except Exception as e:
            logger.warning("Extraction attempt %d failed: %s", attempt + 1, e)
            if attempt == max_retries - 1:
                raise RuntimeError(
                    f"Failed to extract hearings from {url} after {max_retries} attempts"
                )
            await asyncio.sleep(2 ** attempt)  # Exponential backoff between retries.

For implementation details on browser-context management and selector strategies, the official Playwright Python documentation is the canonical reference. Once extracted, payloads require immediate validation against municipal schema constraints: parcel identifiers are cross-referenced against county assessor APIs, and raw zoning-change codes are captured for later taxonomy resolution. This validation gate is what prevents garbage rows from a drifted table from propagating into spatial joins.

PDF parsing and spatial reference resolution jump to heading

PDFs present a dual challenge: extracting tabular amendment data, and resolving spatial references from legal descriptions or embedded coordinate grids. Production parsers separate text extraction from geometric reconstruction because the two failure modes are different. For tabular data, a layout-aware parser preserves row and column alignment that a naive text dump destroys. For spatial data, the parser must infer the coordinate reference system from marginalia, scale bars, or embedded metadata before a single coordinate can be trusted.

import pdfplumber
import re
from pyproj import Transformer
from typing import Dict, List, Optional
import logging

logger = logging.getLogger(__name__)

def parse_zoning_pdf_tables(pdf_path: str) -> List[Dict[str, str]]:
    """Extract structured zoning-amendment tables from PDFs using pdfplumber."""
    records: List[Dict[str, str]] = []
    try:
        with pdfplumber.open(pdf_path) as pdf:
            for page in pdf.pages:
                tables = page.extract_tables()
                for table in tables:
                    if not table or len(table) < 2:
                        continue
                    headers = [h.strip().lower() for h in table[0] if h]
                    for row in table[1:]:
                        if len(row) != len(headers):
                            continue  # Skip rows that do not match the header arity.
                        record = dict(zip(headers, [str(c).strip() if c else "" for c in row]))
                        if "parcel" in record and "district" in record:
                            records.append(record)
    except Exception as e:
        logger.error("PDF table extraction failed: %s", e)
    return records

def resolve_legal_description_to_geometry(
    description: str,
    source_crs: str = "EPSG:26915",
    target_crs: str = "EPSG:4326",
) -> Optional[Dict]:
    """Parse a metes-and-bounds description into a coordinate sequence."""
    # Bearing/distance pattern, e.g. "N 45° 30' E 250.00 FT".
    pattern = r"([NSEW])\s*(\d+)°\s*(\d+)'?\s*([NSEW])\s*([\d.]+)\s*(FT|M)"
    matches = re.findall(pattern, description.upper())
    if not matches:
        return None

    # Transform using PROJ once the local survey coordinates are reconstructed.
    transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True)
    coords: List = []
    # In production, run the traverse calculation here: accumulate bearing/distance
    # deltas from a known point of beginning using trigonometry, then call
    # transformer.transform(x, y) on each vertex before appending it to coords.
    return {"type": "LineString", "coordinates": coords, "crs": target_crs}

For advanced text-extraction strategies and legacy or scanned-document handling, see Scraping zoning PDFs with Python and PyPDF2. When reconstructing geometry from a legal description, every coordinate transformation must adhere to an authoritative projection definition; the PROJ library supplies the mathematical foundation for accurate datum shifts so that a parsed boundary aligns with the county parcel fabric rather than landing tens of metres off.

Edge cases and gotchas jump to heading

The failures that take down scraping pipelines are rarely the ones the happy-path code anticipates. The municipal-specific ones are worth enumerating:

  • HTML table drift. A county redesigns its planning portal and the agenda table gains a column, renames an id, or moves the document link from an href to a data- attribute. Selector-based extraction silently shifts every field by one column. Defend against this by asserting on header text, not column position, and by failing the run when the expected header set is absent rather than emitting misaligned rows.
  • Scanned (image-only) PDFs. Older ordinances are flatbed scans with no text layer, so extract_tables returns nothing. These must be detected (zero extractable characters across all pages) and routed to an OCR branch or quarantined — never treated as an empty-but-valid document.
  • Merged and spanning cells. Amendment tables routinely merge cells across rows for a single district affecting multiple parcels. Naive row iteration produces blank cells; the parser must forward-fill merged values within a logical group.
  • Datum ambiguity in legal descriptions. A metes-and-bounds traverse without a stated datum or point of beginning cannot be georeferenced. Guessing a state-plane zone produces coordinates that look valid and are wrong by hundreds of metres — exactly the kind of silent datum drift that the CRS alignment strategies layer is designed to catch. When the source CRS is unprovable, quarantine the geometry rather than assume.
  • Throttling and 429 storms. Crawling many agenda pages from one jurisdiction concurrently looks like an attack to a small government server. A per-host crawl delay and honouring Retry-After keep the pipeline within bounds; durable recovery from sustained outages is the job of dedicated error handling & retry logic.
  • Encoding and glyph corruption. PDFs with custom font encodings extract as mojibake or ligature artifacts (e.g. collapsing parcel numbers). Normalize Unicode and validate parcel IDs against a known format mask before accepting them.

Integration points jump to heading

A scraping pipeline is a producer, not a destination. Its validated output crosses two clear boundaries downstream.

First, every candidate record passes through attribute normalization rules before it touches the geospatial layer. Municipal jurisdictions use divergent naming for the same district — R-1, RES-1, and Single-Family Residential may all mean one thing — so the raw code captured by the scraper is mapped to a unified ontology through deterministic lookup and fuzzy-matching routines. Skipping this step is what produces orphaned features and misaligned district boundaries in the final overlay.

Second, once parsed and normalized, zoning-change records transition from staging to operational GIS layers, triggering spatial joins against the existing parcel fabric and updating overlay attributes. The finalized datasets are then handed to GIS export sync workflows for publication to web maps, internal dashboards, and third-party PropTech APIs. To keep that boundary clean, the orchestration layer should:

  • Queue normalized payloads for batch processing to avoid database lock contention, the same partitioning discipline used for async batch processing of large county feeds.
  • Implement idempotent upserts keyed on a composite of parcel ID and effective date, so reprocessing a re-fetched document never duplicates a record.
  • Decouple error handling from core parsing logic: failed spatial joins and API timeouts route to a dead-letter queue with retry metadata rather than aborting the parser.
Source artifact Extraction path Validation gate Downstream consumer
JS-rendered agenda table Headless browser + DOM selectors Header assertion, parcel-ID cross-check Attribute normalization
Amendment ordinance PDF pdfplumber table extraction Schema arity, ISO 8601 date check Attribute normalization
Metes-and-bounds exhibit Regex traverse + PROJ transform Source-CRS resolution, topology check GIS export sync
Scanned plat (image-only) OCR branch or quarantine Text-layer presence check Manual review queue

Compliance and audit artifacts jump to heading

Because zoning-change records feed entitlement decisions and PropTech underwriting, the scraping step has to be defensible after the fact, not merely correct in the moment. Every run must emit a fixed set of artifacts:

  • A source manifest. The original document URL, fetch timestamp, HTTP status, and content hash of the raw payload in staging — proof of exactly what was retrieved and when, so any extracted figure can be traced back to the page or PDF it came from.
  • An extraction provenance record. For each emitted field, which page and table (or DOM selector) it came from, enabling a reviewer to open the source document and confirm a disputed setback or district code.
  • A quarantine ledger. Every rejected record with an explicit, machine-readable failure reason — missing parcel ID, unresolvable CRS, header-set mismatch, image-only PDF. This satisfies the audit-trail expectations enforced by the project’s compliance framework integration and gives planning departments the transparency record many transparency mandates require.
  • A parser-version stamp. The version of the extraction code that produced each record, so that a later table-drift fix can identify and reprocess everything parsed by the broken version.

By treating municipal documents as structured data streams rather than static files — and by recording the provenance of every byte along the way — engineering teams can automate zoning-change tracking with deterministic, auditable accuracy. The result is less manual GIS labour, faster compliance reporting, and real-time visibility into urban development that holds up under scrutiny.

FAQ jump to heading

Why use a headless browser instead of requests + BeautifulSoup for municipal portals?

Most modern planning portals render their agenda and hearing tables with JavaScript after the initial page load, so a static requests fetch returns an empty shell with no data rows. A headless browser executes the page's scripts and lets you wait on the specific DOM selector that holds the table. Reserve requests for genuinely static HTML — it is faster and lighter when it actually works.

How do I extract tables from a scanned, image-only PDF?

pdfplumber and similar text parsers return nothing from a flatbed scan because there is no text layer. Detect this case by checking for zero extractable characters across all pages, then route the document to an OCR branch or quarantine it for manual review. Treating an image-only PDF as an empty-but-valid document silently drops every zoning change it contains.

What happens when a county changes its portal table layout?

If your extractor keys on column position, a new or reordered column shifts every field silently. Assert on header text instead, and fail the run loudly when the expected header set is missing rather than emitting misaligned rows. The bad records then land in the quarantine ledger with a header-mismatch reason instead of poisoning the analytical store.

Can I always reconstruct geometry from a metes-and-bounds description?

Only when the description states a datum and a point of beginning. A bearing/distance traverse with no stated coordinate reference system cannot be georeferenced reliably — guessing a state-plane zone produces coordinates that look valid but are wrong by hundreds of metres. When the source CRS is unprovable, quarantine the geometry rather than assume one.

How do I avoid getting rate-limited or IP-banned while scraping?

Apply a per-host crawl delay, honour Retry-After headers, and back off exponentially on 429 and 503 responses. Many municipal sites run on single-instance government hardware, so concurrent crawling of many pages from one jurisdiction looks like an attack. Read from your immutable staging store on reprocessing runs so a parser fix never re-hammers the live server.

Up: Automated Feed Ingestion & GIS Data Parsing