In October 2026 we measured the websites of 4,918 US HVAC companies that advertise on Google. We checked how long the main content takes to show on a phone, whether the page shows a rating, whether it has a form, and how big its menu is. This post covers how the sample was built, how each measure was taken, which Lighthouse runs we threw away and why, and where the numbers fall short. The aggregate data is open under CC BY 4.0.
Disclosure: Urilio builds Google Ads landing pages for HVAC companies. No company is named in this post or in the data.
1. Building the sample
The pipeline is a handful of Python scripts plus a small Node helper that drives the installed Chrome through playwright-core.
Search. For every US city of 50,000 people or more in our city list, headless Chrome opened Google Maps at the city's coordinates (zoom 12) and searched eight phrases: furnace repair, hvac repair, ac repair, heating and cooling, hvac contractor, emergency hvac, furnace installation and heat pump installation. It scrolled the results feed four times and read each listing card: the name, the website link, the rating and review count (from the 4.9(1,234) pattern on the card), and whether the card was marked Sponsored. The searches ran on 17 and 18 October 2026, two browsers at a time with a random pause before each search.
Filters at intake. A listing was dropped when its website was a shared platform page (a social profile, a link-in-bio page, a directory listing), because it has no domain of its own to measure. Organic listings were kept only with a rating of 4.5 or higher from at least 100 reviews; sponsored listings were kept regardless. That floor matters later: every company in the data is well reviewed on Maps.
Advertiser check. For each remaining domain, headless Chrome opened the Google Ads Transparency Center for the US region and read the ad count from the page text (1,234 ads, ~2K ads). A company counted as an advertiser with at least three ads, or with a sponsored Maps listing.
The searches found candidate companies in 805 US cities. The 4,918 measured advertisers come from 747 of those cities.
2. One page per company: the one Google Maps links to
We fetched the website address on the Maps listing. That is not necessarily where the company's ads send people, and we cannot see where the ads land, so every figure describes the page the Maps listing links to.
The fetch used a phone browser's user agent. On a refusal (403, 406, 429, 503 or no response) it retried once as desktop Chrome. If the result was still an error, under 500 bytes, or a bot check ("Just a moment...", "Attention Required!"), the page was loaded in headless Chrome, which waited up to 20 seconds for the challenge to clear. Pages that still failed are not in the data. The fetches ran on 17 and 18 October.
3. Three structural checks, all on the HTML
All three checks run regular expressions over the HTML string: no DOM, no layout, no JavaScript. That keeps them fast and repeatable, and it also defines their blind spots.
Rating shown. We remove script, style, noscript, svg and template elements and HTML comments, strip the remaining tags, and search the visible text:
reviews = bool(re.search( r'\b[1-5]\.\d\s*(?:stars?|rating|out of 5|/\s*5)' r'|\b\d[\d,]{1,6}\s*(?:google\s+|five[- ]star\s+|5[- ]star\s+)?reviews\b', text, re.I)) "4.9 stars", "4.8/5" and "1,200 Google reviews" match. A rating drawn as an image doesn't, and neither does one that a review widget's script or iframe adds after load. So "no rating shown" can run high.
Form. Any <form> element counts. A search box therefore counts as a form, so "no form" can run low. A form that a script injects later, or one inside a third-party iframe, is not in the HTML we parsed, so "no form" can also run high.
Menu size. This is the largest number of links inside a single <nav> or <header> element:
def count_nav_links(h): blocks = re.findall(r'<(?:nav|header)\b[^>]*>.*?</(?:nav|header)>', h, flags=re.S | re.I) if blocks: return max(len(re.findall(r'<a\s[^>]*href', b, flags=re.I)) for b in blocks) return len(re.findall(r'<a\s[^>]*href', h[:max(len(h) // 3, 20000)], flags=re.I)) Pages with neither element fall back to the links in the first third of the HTML. Because nothing is rendered, links in a collapsed mobile menu count too. We report the share of pages with 40 or more.
4. Lighthouse, and the runs we threw away
Load time is Largest Contentful Paint (LCP) from the Lighthouse CLI with its default mobile settings: a simulated mid-range phone on a throttled connection.
npx --no-install lighthouse "$URL" --output=json --output-path=out.json --quiet \ --only-categories=performance --chrome-flags="--headless=new --no-sandbox" From the JSON report we keep audits['largest-contentful-paint'].numericValue, the performance score and environment.benchmarkIndex.
A busy machine makes every page look slower, whatever the site itself does. We ran thousands of audits on one machine, two at a time, so that risk was real. The pipeline has two guards.
1. Don't start on a busy machine. Before a batch, and again before each run, it checks the one-minute load average. Above 8, the run is skipped and the page stays in the queue.
def cpu_busy(): load = os.getloadavg()[0] return load if load > A.get('max_load', 6) else 0 # max_load is 8 in our config 2. Throw away runs Lighthouse itself flags as slow. After each run, it reads benchmarkIndex, Lighthouse's own CPU benchmark score for the machine during that run. Below 2000, the result is discarded and the page goes back in the queue for a later pass:
# min_benchmark is 2000 in our config if sp['speed_source'] == 'lighthouse-local' and (sp.get('benchmark') or 0) < A.get('min_benchmark', 800): log('speed discarded for %s: machine was slow during the run (benchmark %s); will retry' % (r['url'], sp.get('benchmark'))) continue The load-average check alone was not enough: runs that it had allowed to start still came back with benchmark scores under the line. Checking the benchmark after the fact catches contention the load average misses.
A run that returned neither an LCP nor a score (a redirect loop, a block, an error) was marked failed and not retried. The Lighthouse runs went from 18 to 20 October 2026, and 4,562 of the 4,918 pages have a result.
5. What came out
Across the 4,918 sites (LCP figures across the 4,562 speed-tested ones):
- Median LCP on the simulated phone: 7.8 s. Google's "good" mark is 2.5 s.
- LCP over 4 seconds: 79% of the 4,562.
- No rating in the page text: 74%.
- No HTML form: 43%.
- A menu with 40 or more links: 45%.
- Median Google Maps rating: 4.9, from 508 reviews (keep the 4.5 rating and 100-review floor in mind).
The gap between the last line and the third is the one we find most interesting: every company here has a strong Maps rating, yet 74% of the sites don't show a rating in their text. The blind spots above say how far to trust that.
For the 64 metros with at least 15 measured companies, we publish counts with their n, such as "14 of 16", not percentages. With samples of 15 to 33 companies, a percentage would look more precise than it is.
6. Limits
- One load of one page per company. A single run can catch a slow moment.
- The page is the one the Maps listing links to. Ads may land somewhere else.
- Regular expressions on server HTML miss image ratings and anything a script or iframe adds, and they count search boxes as forms.
- The rating floor means the Maps figures describe well-reviewed companies, not every HVAC advertiser.
- All runs came from one machine. The benchmark guard reduces host noise but cannot remove it.
The data
The national figures and the 64 metro rows, with a data dictionary, are on Zenodo: doi.org/10.5281/zenodo.24092458. The method, with the published figures, is at urilio.com/methodology. We publish totals only: no company names, domains or per-company rows.
This post was written by an AI agent and checked against the pipeline code and the published data.