Extract structured property data from assessment card PDFs using LLM-powered text extraction.
Property cards (also called land cards or assessment cards) are PDF documents produced by county tax assessors.
pip install landrecords-card-readerWith optional extras:
# LangGraph wiring (only needed if you want to use the graph node)
pip install landrecords-card-reader[graph]
# Everything
pip install landrecords-card-reader[all]- An OpenAI-compatible chat-completions server (Ollama, vLLM, sglang,
TGI, llama-server, OpenAI itself, …) reachable at
CARD_READER_LLM_BASE_URLwith a text model loaded (e.g.gemma4:26b-a4b-it-q8_0). The pipeline talks to it via theopenaiPython SDK, so anything that speaks the/v1/chat/completionsshape works. - wkhtmltopdf for HTML→PDF conversion of property pages that aren't
served as PDFs:
# macOS brew install --cask wkhtmltopdf # Debian/Ubuntu sudo apt-get install wkhtmltopdf
OCR (docTR + PyTorch) ships as a Python dependency; the model weights
(~100MB) are downloaded to ~/.cache/doctr/ on first call. The OCR
predictor auto-selects CUDA, then Apple-Silicon MPS, then CPU. On GPU
OOM it transparently falls back to CPU, and on NVIDIA H200 it caps
its CUDA memory share at 80% so a co-located LLM can use the rest.
from landrecords_card_reader import read_property_card
data, photo = read_property_card("https://example.com/card.pdf")
print(data["ownername"]) # "SMITH, JOHN A"
print(data["totalvalue"]) # 285000
print(data["parceladdr"]) # "123 MAIN ST"
# photo is raw image bytes of the first property photo, or None
if photo:
with open("photo.jpg", "wb") as f:
f.write(photo)Use analyze_photo=True to send the property photo (if it exists) to the
vision model, filling in missing building details (exterior walls, roof
style, number of floors, etc.):
data, photo = read_property_card(url, analyze_photo=True)If you already have the PDF bytes, pass them directly to skip the download:
data, photo = read_property_card(url, pdf_bytes=raw_bytes)For URLs that might be HTML property report pages (e.g. Beacon, Tyler,
or other county assessment sites), use read_property_card_from_url.
It fetches the URL, detects whether the response is a PDF or HTML, and
converts HTML pages to PDF via pdfkit (wkhtmltopdf) automatically:
from landrecords_card_reader import read_property_card_from_url
data, photo = read_property_card_from_url(
"https://www.webgis.net/LinkedFiles/va/pulaski/pc/cards/PC17759.htm"
)landrecords-card-reader https://example.com/card.pdf --dry-run -vSet via environment variables or a .env file:
| Variable | Default | Description |
|---|---|---|
CARD_READER_LLM_BASE_URL |
http://graybase:8000/v1 |
OpenAI-compatible chat-completions endpoint |
CARD_READER_LLM_API_KEY |
dummy |
API key sent as Authorization: Bearer … (use any non-empty string for self-hosted servers) |
CARD_READER_EXTRACTION_MODEL |
gemma4:26b-a4b-it-q8_0 |
Model for structured extraction |
CARD_READER_PHOTO_CLASSIFICATION_MODEL |
gemma4:e2b |
Lightweight vision model for photo classification |
The extraction prompt maps over 80 property card fields including:
- Identity: parcelid, taxacctnum, taxyear
- Owner: ownername, owneraddr, ownercity, ownerstate, ownerzip
- Location: parceladdr, parcelcity, parcelstate, parcelzip, legaldesc
- Valuation: landvalue, imprvalue, totalvalue, assessedvalue, appraisedvalue
- Building: yearbuilt, bldgsqft, bedrooms, fullbaths, halfbaths, bldgtype
- Construction: foundation, roofcover, extwall, heating, heatfuel, cooling
- Sale: saleamt, saledate
- Zoning: zoningcode, zoningdesc, zoningtype
- Download the PDF (or accept pre-downloaded bytes). For HTML pages,
the renderer picks one of two paths:
- Image-scan HTML (e.g. richmondcountypropertycards.com) — the
thin HTML wrapper around
<img>scans is detected, the source JPEGs are fetched directly, and they're embedded losslessly into a PDF at native pixel size. This avoids wkhtmltopdf's re-encoding pass that destroys thin glyphs. - Rich HTML (server-rendered assessor reports) — sent through wkhtmltopdf with JavaScript enabled and a 4 s delay so async value-history / sales-history tables (Stafford County VA, other PublicAccessNow / Aumentum-rendered sites) finish populating before the PDF snapshot is taken.
- Image-scan HTML (e.g. richmondcountypropertycards.com) — the
thin HTML wrapper around
- In parallel:
- Per-page text extraction. Each page is first probed for a
usable native text layer via PyMuPDF (cheap, lossless). Pages
without one go to docTR OCR. As an optimisation, pages that are
dominated by a single embedded raster image (typical of the
image-scan HTML path) skip the pypdfium re-rasterisation entirely
— the source JPEG bytes are pulled straight out via
doc.extract_image(xref)and handed to docTR. This avoids a 16× pixel-count blow-up on cards where the PDF is just a wrapped scan, and is the main reason scan cards now finish in seconds instead of minutes. The OCR predictor is a thread-safe singleton — only one copy is loaded into VRAM per process. - Photo extraction & classification — candidate images are filtered by size / aspect ratio, then sent to a vision model to keep only actual photographs (discarding sketches, floorplans, maps, etc.).
- Per-page text extraction. Each page is first probed for a
usable native text layer via PyMuPDF (cheap, lossless). Pages
without one go to docTR OCR. As an optimisation, pages that are
dominated by a single embedded raster image (typical of the
image-scan HTML path) skip the pypdfium re-rasterisation entirely
— the source JPEG bytes are pulled straight out via
- Extract structured data by sending the OCR text to the configured LLM via the OpenAI-compatible chat-completions API. The model emits one labelled CSV row per chunk (~14 chunks of ≤10 cells each) — a chunked positional format that bounds counting errors to a single chunk instead of cascading across all 80+ fields.
- Reconcile + recover. Several deterministic post-passes run after
the LLM call to backstop common extractor failures:
landvalue + imprvalue == totalvalueis verified and the missing value computed arithmetically when any one is absent. When all three disagree, the smaller component is treated as a sub-row mistake and re-derived from the total.- Label-anchored fallbacks scan the OCR text for explicit field
labels and recover values the LLM dropped or mis-aligned:
heatfuel,heating,cooling,roofstyle/roofcover,extwall,zoningcode,taxacres,bldgsqft. - Misplacement strippers drop values the LLM put in the wrong
STRUCT slot (a roof material in
extwall, a digit-only artefact) so the recovery pass can fill the slot from the labelled cell instead. - Inference fills add
parcelstate/ownerstatefrom the matching ZIP prefix (full US state map) or from a "X County, " header, inferheatingfrom a "Central Air" signal when the card only shows a heat fuel + AC %, and translate bare HVAC codes ("C-HEAT", "C-AIR") to canonical values. - Unverified
"NONE"strings are dropped when the literal token doesn't appear in the OCR text.
- Targeted retries. If
parcelidlooks too short to be real, one focused LLM retry asks for a better candidate. Ifanalyze_photo=Truewas passed, a separate vision-model call fills any remaining building / exterior fields the text-only pass couldn't determine.
MIT