Logan Jonesupdated jul 2026
← index

PullFirst

2026 · live in production · pullfirst.com

Hiring a contractor in Minnesota means trusting scattered public records: state licensing, enforcement actions, OSHA history, permit systems in 50+ cities, EPA compliance. Each lives in its own silo, in its own format. PullFirst pulls them into one searchable profile per contractor. Consumer site in front, B2B API behind it.

fig. 1 · one profile per contractor: license, penalties, permits · [zoom]

fig. 1 · one profile per contractor: license, penalties, permits[click] zoom in · [esc]

What it holds

4M+permits indexed across Minnesota
2M+permit inspections
270K+contractor licenses (MN DLI)
260K+OSHA violations across 120K+ inspections
1,000+DLI enforcement actions, $7M+ in penalties
5,000+Google Places profiles with review data

What it does

  • One profile per contractor: license status, enforcement history, OSHA record, permit activity, reviews.
  • Fuzzy search across every dataset. Related-contractor detection links relaunched businesses through shared addresses and phone numbers: the same crew under a new LLC stays visible.
  • Live permit queries against city ArcGIS APIs. Statewide permit map with time-window filters. Address lookup: every permit on file across a street, with the contractor behind each one.
fig. 2 · the record: enforcement actions, matched permits · [zoom]

fig. 2 · the record: enforcement actions, matched permits[click] zoom in · [esc]

The pipeline

50+ jurisdictions, no shared permit system. Cities buy from different vendors or run their own portals; each speaks its own dialect of HTML, JSON, or ArcGIS. Every platform gets its own scraper: 30+ scrape jobs, each streaming logs over SSE and leaving an audit trail.

accela · bs&a · citizenserve · cityview · energov · esuite · ims · iworq · logis · arcgis · custom city portals

Everything scraped flows through normalization: names, streets, cities, contacts. A hand-built address grammar knows Minnesota’s compound city names, so “St Paul” and “Saint Paul” resolve to the same place and “Inver Grove Heights” survives parsing intact.

Identity is the hard part. The same contractor appears as a license number in one dataset, a business name in another, a phone number in a third. The resolver builds identity keys from normalized addresses and phone digits, then clusters records with union-find (path compression, union by rank). A match engine scores name candidates from exact through prefix-stripped; every match carries a confidence grade and the signals that produced it. The same shared-key graph links a fresh LLC back to the business that dissolved at the same address and phone number.

fig. 3 · statewide permit map, clustered by jurisdiction · [zoom]

fig. 3 · statewide permit map, clustered by jurisdiction[click] zoom in · [esc]

The ops layer

None of it runs by hand. An ops server owns the fleet: 50+ jobs across scraping, import, and sync, scheduled, chained, retried, and audited from one dashboard. Big enough to be its own case study: Scrounged.

How it’s built

.NET 10 API on Fly.io. Next.js frontend on Vercel. Postgres on Neon. The pipeline is Python, driven from an ops dashboard with full job history.

The hard parts

  • Identity errors cut both ways: merge two unrelated contractors and one wears the other’s enforcement record; miss a merge and a bad actor’s history disappears behind a new LLC. Scoring, confidence grades, and match signals exist so every merge is explainable.
  • Keeping 30+ scrapers healthy against sources that change without notice. Hence the ops dashboard, streaming logs, and audit trails.
[0] ~/portfoliominnesota