The Data That Isn't on the Web

For a market intelligence platform I built recently, the single richest source of company data in the target industry wasn't a website. It was a trade publication's annual industry directory — hundreds of company listings with addresses, phone numbers, revenue figures, plant counts, and named executives — published as a digital flipbook: a JavaScript page-turner rendering scanned page images. No text layer, no search API, no downloadable PDF. Just pictures of a printed book.

Most teams look at that and either give up or hire someone to re-type it. Both are the wrong answer. The right answer is a pipeline — and the interesting parts of that pipeline have almost nothing to do with OCR itself. The OCR module in this system is 15 lines of code. Everything around it — segmentation, validation, an LLM that's allowed to help but not allowed to improvise — is where the engineering lives, and it's the difference between "we ran Tesseract on it" and 906 records with a 0.9% failure rate and an audit trail on every corrected field.

Rule Out the Easy Branches First

Before committing to OCR, I spent a discovery pass proving it was necessary. Flipbook viewers vary: some ship a hidden text layer or a search endpoint you can query directly (no OCR needed), and some load a source PDF you can pull once and parse with PyMuPDF. The decision tree was written down before any code: Branch A — find a text layer or search API; Branch B — find the source PDF; Branch C — page images and OCR, the branch of last resort.

This viewer shipped neither text nor PDF — the page-flip routing lived entirely in a client-side hash fragment that never even reached the server. Branch C it was. But the hour spent ruling out A and B is the cheapest hour in the whole project: OCR is the most expensive, most error-prone way to get text, and you should have to prove you need it.

Capture: Politely, Once, Cached Forever

The viewer itself rejected plain HTTP clients, but the CDN serving the page-scan images didn't — it just needed the cookies and referer a real viewer session carries. So capture works by loading the viewer once in a real browser context (Playwright with a stealth profile), then fetching each page image through that warmed context's request API. The scans follow a predictable URL pattern with a zero-padded page number; the highest-resolution rendition published works out to roughly a 150-DPI scan of each printed page.

Two operational rules from my standard scraping architecture apply unchanged. Pacing: a jittered 3–7 second delay between page fetches — it's a directory, not a race. And caching: every page's source JPG and its raw OCR text are written to disk, keyed by edition and page number. That cache is the foundation of the whole development loop — every parser fix gets validated by re-running the full edition against cached text in minutes, with zero network traffic and zero re-OCR. I re-parsed the complete directory dozens of times while tuning extraction; I fetched it once.

The OCR Itself Is the Boring Part

Grayscale conversion, a 2× upscale with LANCZOS resampling (Tesseract likes more pixels than a 150-DPI scan gives it), then pytesseract. That's the entire OCR module. Resist the urge to make this part clever — binarization experiments, deskewing, per-region processing — until the downstream pipeline tells you recognition quality is actually your bottleneck. Mine never did. Every defect I found live came from structure — deciding where one company's listing ends and the next begins — not from character recognition.

Segmentation: Where the Real Bugs Live

A directory page is a wall of OCR text containing several company listings back to back. Turning it into per-company blocks means finding boundaries, and the anchor I used is the address: a street line (house number or P.O. Box) followed by a headquarters line (City, ST 12345). Find that pair and you've found a listing; walk backwards from it — past parenthetical qualifiers, past extra address lines, tracking parenthesis balance across wrapped lines — and you've found the company name that starts the block.

The design principle that fell out of debugging this is the most reusable idea in the article: boundary detection should be loose, field parsing should be strict. The two failure modes are wildly asymmetric. If a boundary regex is too strict and misses a listing boundary, the entire next company gets silently swallowed into the previous one — wrong data, attributed to the wrong company, with no error anywhere. If a boundary fires spuriously, one listing gets split into two junk halves that fail field validation loudly and land in a review queue. A missed boundary corrupts silently; a spurious one fails safely. So the boundary patterns tolerate the OCR damage I actually observed — a leading digit misread as a letter (4666A666), O/0 confusion inside state codes, a lost space gluing a street number to a direction letter — while the field parsers stay rigid.

I learned that principle the honest way. On an early live run, OCR misread one company's street number as A666; the digit-first street pattern didn't match, the boundary never fired, and the next company's entire listing was swallowed — its neighbor came back from the pipeline carrying the swallowed company's website and email as its own. A confident, plausible, completely wrong record. No exception, no log line. That's the bug class segmentation produces, and it's why the output-reading discipline described below is non-negotiable.

Heuristics First, LLM Second

Field extraction is deliberately two-tier. The first tier is boring regex heuristics: address components, phone patterns, emails (including re-joining addresses the OCR wrapped across lines), websites with a bare-domain fallback, revenue and employee-count lines, and personnel entries gated by a title-keyword hint. A block is accepted only if it produced both a city and a state. In the latest full run, the heuristics handled about 91% of listings on their own.

Every block the heuristics reject goes to the second tier: a single LLM call (temperature 0) with a prompt that demands a JSON object with a fixed key set and nothing else. The prompt does two unusual things. It teaches the specific OCR error model — that a house number never begins with a letter, and that a misread character should be resolved to the digit it resembles (A→4, O→0, l→1, S→5, B→8), never deleted. And it hard-bans invention: any value not present in the text is null, full stop. The fallback recovered 80 of 906 records in the last run — mostly listings whose address format the strict heuristics rightly refused to guess about.

The ordering matters more than the model choice. Heuristics first means the LLM only ever sees the hard 9%, which keeps cost trivial and — more importantly — keeps the deterministic parser as the system of record for everything it can handle. An LLM-first design would make every field probabilistic. Here, probabilistic extraction is the exception path, and it's labeled as such in the data.

Put the LLM on a Leash

There's a second, separate LLM pass: verification. Deterministic shape checks run over every parsed record and flag fields that look OCR-damaged — a state code that isn't in the real state list, a ZIP that doesn't match the format, a contact name with digits fused into it. My favorite check exploits a postal fact: Canadian postal codes never contain the letters D, F, I, O, Q, or U — which is precisely the set OCR confusion produces from digits. A "postal code" containing an O isn't ambiguous; it's damaged, and detectably so with zero AI.

Flagged fields go to the LLM for repair — under contract. The heuristic parser is the author of record; the LLM may only replace a field the shape checks flagged, and only if its replacement passes the same shape checks. Every decision is written into the record as a permanent flag — fixed:street:A666→4666, or unresolved:phone when the model couldn't repair it honestly — so the audit trail rides with the data forever.

And then there's the guard that earns this section its title. During testing, the model "repaired" that same A666 street by deleting the misread character instead of resolving it — producing an address that started with a digit, passed every shape check, and was simply wrong. Run three times on identical input, it gave one wrong answer, two honest refusals, and zero correct answers. The fix is a rule no prompt can enforce but ten lines of code can: if the proposed repair is a subsequence of the original — the original with characters removed — reject it and mark the field unresolved. Prompts steer models; guards constrain them. Production systems need both, and the guard is the one you can trust.

Read the Output. All of It.

The pipeline's counters said everything was fine on every run. The counters were never the thing that caught a defect. What caught defects — every single one found live — was reading the output file by eye and checking every fixed: flag against the verbatim OCR block stored on each record. That's how the swallowed-company bug surfaced, and three smaller segmentation defects after it: listings with both a street and a P.O. Box gluing the street onto the company name; the first entry on a page being dropped when the name-walk ran off the top; a column-bleed line fusing onto a name.

Two design choices make that review cheap enough to actually do. Nothing is ever silently dropped — a block that can't be structured becomes a visible parse_error record instead of vanishing. And every record carries its raw OCR text verbatim, so checking a suspicious field means reading the block right there in the record, not re-running anything. For the final edition run, I read all 27 correction flags and spot-checked three against the original page scans. All correct. That sentence is only possible because the audit trail exists.

The Numbers

The latest edition: 84 pages captured and OCR'd, 906 company records extracted, 8 unstructurable blocks left as visible parse errors — a 0.9% failure rate. 80 records recovered by the LLM fallback, 27 OCR corrections applied with full audit flags, 26 fields left honestly unresolved rather than guessed. Feeding those records into the platform's deduplicated canonical store, most matched companies already known from other sources — each match adding a provenance row and filling blank fields — and 53 companies were genuinely new discoveries nothing else had surfaced.

The second edition of the directory, a year newer with a different page range, cost one dictionary entry in an editions registry. Zero parser changes. That's the payoff of keeping segmentation and parsing pure text-in, records-out functions — they're tested against committed fixtures of real OCR text, and they don't know or care which edition the text came from.

Wrap-Up

The pattern generalizes to any scanned or print-born source: trade directories, member rosters, supplier catalogs, historical filings. Prove OCR is necessary before using it. Capture once, cache everything, iterate on cached text. Keep boundaries loose and fields strict, because the two fail in opposite directions. Let heuristics own everything they can and route only the failures to an LLM. Verify with deterministic shape checks, let the LLM repair only what's flagged, and back the prompt with guards that make the dangerous failure modes structurally impossible. Then read your output, because the defect that matters is the one your counters can't see.

If your market's best data is trapped in a document instead of a database, that's a solvable engineering problem — the data collection service page covers how I approach these builds, and the AI Data Extraction hub has more on the extraction side.

Dustin Holdiman — Founder, ThinkGenius

Software engineer focused on production scraping, browser automation, anti-bot infrastructure, AI extraction pipelines, and the dashboards that let businesses actually run them. Builds custom Python, Playwright, Kameleo, Undetectable, MySQL, and operations-tooling systems for companies that have outgrown off-the-shelf tools.

Need a Custom Automation System?

Need help building a production scraping, browser automation, or AI data extraction system? I build custom Python, Playwright, Kameleo, Undetectable, MySQL, and dashboard-based automation systems for businesses.