Turning 51,428 anonymous website visitors into 101 sales-actionable leads.

An industrial valve manufacturer with a global customer base couldn't tell which web visitors were actual prospects vs. crawlers, scanners, and noise. We built a daily-refreshed sales-intent dashboard, matched against their existing customer list. Six weeks, in production.

Industry
Industrial Manufacturing
Engagement
6 weeks · fixed scope
Stack
Python, Flask, MySQL, ip-api, OSM Overpass, Nominatim
Status
In production

The before state

The client makes industrial valves for oil & gas, petrochemical, and pipeline operators. Their website got steady traffic but the AWStats output was unusable for sales: 51,428 unique IPs in a 90-day window, dominated by Asian crawlers, security scanners, and AWS/Azure/GCP automation pinging the RFQ form.

When sales asked "who's actually looking at our products this week?", the answer was: here's a list of IP addresses, good luck. The team had a 329-customer SAGE ERP export and a 266-pair distributor list with cities — but no way to connect either to the live web traffic.

What we shipped

1. Pipeline: log → enriched leads

A daily cron parses both Plesk access logs, HTTP and SSL (90-day rolling window across rotated archives), filters crawlers by user-agent, drops attack-probe paths, then enriches every surviving IP with:

2. Internal HQ dashboard

An M365-SSO-gated Flask page renders the leads as a sortable, filterable table. Each row shows: hits, category (corporate / residential), IP + reverse DNS, location, ASN organization, pages visited, last seen, customer matches with distance, and a status pill (new / reviewed / interesting / ignored). Sales reps can expand any row to add notes, set status, and write a company-name guess that gets written back to MySQL.

Noise filter
51,428 → 101
Unique IPs in a 90-day window narrowed to non-bot visitors who browsed more than one page, came back on another day, or went straight to the RFQ form. 62 corporate, 39 residential.
Customer matches
60 leads
Visitors within 25mi of a known customer location, 23 of them on street-level geocodes. Closest street-level hit: 0.3mi from an existing account in Houston.

3. The funnel

Raw unique IPs (90d)
51,428
After crawler, probe & bounce filters
6,708
Top slice by behaviour score
600
After automation & hosting drops
101
Within 25mi of a customer
60
Started an RFQ and left it
17

4. Architecture

Access logs (HTTP+SSL) ip-api · OSM Customer xlsx Daily cron parse / enrich / geocode JSON cache /sales dashboard MySQL review state Python · Flask · M365 SSO

Why the pipeline has so many filter layers

Raw web traffic on a B2B industrial site is dominated by automation: foreign crawlers, security scanners, cloud-hosted bots, and AI training scrapers. Without aggressive filtering, the highest-ranked "leads" are all bots. We architected for that from day one:

The numbers on this page are higher than the ones it carried at handoff, and that is worth explaining. The first version read a single access log which, on this vhost, holds nothing but 301 redirects to HTTPS — about one eighth of real traffic, and none of the referer, status or asset-load signals that separate a person from a crawler. Reading both logs surfaced the visitors the original scan never saw, and made it obvious that ranking on page breadth ranks bots first by construction: walking the sitemap once scored the maximum. Scoring was rebuilt around behaviour — best page visited, return days, human read cadence, asset ratio — and of the 127 "leads" the old version ranked, three survived it.

How a 6-week loop actually ran

Week 1. Walk the data: customer list shapes, log paths, existing AWStats output. Lock the scope: lead list with proximity matches, internal-only, M365-gated, daily refresh.

Week 2-3. Pipeline: log parser, ip-api enrichment, multi-layer automation filter, OSM proximity scan, JSON cache. First end-to-end run reaching the production database.

Week 4. HQ dashboard route, table UI, status/notes review state in MySQL, sidebar navigation. Customer-list ingest from the client's existing distributor xlsx (266 unique customer/location pairs, geocoded via Nominatim).

Week 5. UAT with sales. Tuned the noise list against their domain knowledge (they flagged a few hosting providers we hadn't seen before), added water-industry filtering (out of scope for valves), and switched the default sort to recency-first based on how reps actually used the page.

Week 6. Documentation, cron setup at 06:30 daily, deploy memos, runbooks. SSO next_url preservation so direct-link sharing works after login. Handoff.

What's next

Phase 2 has largely shipped. The city-only customer file was replaced with 1,199 geocoded locations, the pipeline now reads both access logs, and scoring was rebuilt around visitor behaviour rather than page count. Company URLs are enriched from submitted email domains first, then from a verified search pass for the rest.

What is still open is the identification ceiling, and it is not a filtering problem. Most visitors with genuine intent arrive on residential or mobile IPs that no IP-intelligence product, free or paid, can resolve to a company. Asking is the only reliable answer, which is why the datasheet downloads now ask once for an email and mirror it into the contact list. The remaining roadmap is CRM sync and a weekly digest for accounts nobody has reviewed.

Have data your team can't get answers from?

If you've got messy operational data — web logs, customer lists, scanned PDFs, technical specs — the same playbook works.

Start a Conversation →