How to Turn Business Listings Data into a Neighborhood Rating Map with the Business Listings API

Business Data API
Table of Contents

Google Business Profile data usually arrives as a list of names, ratings, review counts, and addresses. Compiling that list by hand means scrolling one pin at a time and copy-pasting into a spreadsheet, with just a few hundred rows in the patterns becoming unmanageable. If you put the same data on a map and aggregate it by neighborhood, it becomes an actionable market view. This guide covers the full pipeline we used to build one for Milan, with 8,706 food and beverage listings fetched with the Business Data API and aggregated into 3,746 census tracts across 46 municipalities and published as an embeddable map.

Here is the end result:

Average Rating – Zornade Map · Data © OpenStreetMap

Why put ratings on a map

A sorted table tells you which single restaurants score best. A map tells you where the good ones cluster: that is the true value of data science. It can help in:

  • Site selection. Pick a street or a neighborhood by the density of highly rated places, not by one flagship.
  • Market research. Spot areas where demand exists and supply, measured in listings, does not.
  • Editorial work. A neighborhood rating map is a one-afternoon story for a local publication.
  • Local SEO reporting. Show a client where they stand against the restaurants around them, block by block.

Business Listings Search is the endpoint for this job. It searches Google Maps listings by category and location and returns ratings, vote counts, coordinates and categories in one call, while My Business Info covers the details of one specific business. For thousands of listings in an area, Business Listings Search is the tool to go.

Two parameters do most of the work. The location_coordinate takes latitude,longitude,radius_km and pins the search to an area. The categories array takes up to 10 category names, and the full list, with names and business counts, comes free from the Categories endpoint. Of course, you can also narrow the list before it reaches your database. The filters array takes conditions like ["rating.value",">=",4], and order_by sorts the results, for example ["rating.value,desc"].

import requests
from requests.auth import HTTPBasicAuth

API = "https://api.dataforseo.com/v3/business_data/business_listings/search/live"
AUTH = HTTPBasicAuth("your@email", "your_password")

CATEGORIES = [
    "restaurant", "pizza_restaurant", "cafe",
    "coffee_shop", "bakery", "pastry_shop",
]

def fetch_category(category, radius_km=12):
    items, offset = [], 0
    while True:
        task = [{
            "categories": [category],
            "location_coordinate": f"45.4642,9.1900,{radius_km}",
            "limit": 1000,
            "offset": offset,
        }]
        r = requests.post(API, json=task, auth=AUTH, timeout=120)
        t = r.json()["tasks"][0]
        if t.get("status_code") != 20000:
            raise RuntimeError(t.get("status_message"))
        result = t["result"][0]
        batch = result.get("items") or []
        items.extend(batch)
        if len(items) >= result.get("total_count", 0) or not batch:
            return items
        offset += len(batch)

Each item carries title, category, additional_categories, cid, place_id, address_info, latitude, longitude, price_level and is_claimed. The rating block holds value, votes_count and rating_distribution, the split across one to five stars. Here is that block for one listing from our Milan pull:

{
  "title": "Starbucks Reserve Roastery",
  "category": "Coffee shop",
  "address": "P.za Cordusio, 1, 20123 Milano MI, Italy",
  "latitude": 45.4649459,
  "longitude": 9.1861863,
  "is_claimed": true,
  "rating": {
    "rating_type": "Max5",
    "value": 4.4,
    "votes_count": 31006
  },
  "rating_distribution": {
    "1": 1272,
    "2": 799,
    "3": 2038,
    "4": 5814,
    "5": 21083
  }
}

In any case, averages mask spreads. There are 21,083 five-star ratings against 1,272 one-star ratings behind it, and the average on its own says nothing about that.

Every listing in our Milan pull already had coordinates. If yours does not, geocode the address with a Nominatim-compatible geocoder before the spatial join, a separate step outside the API. It’s free.

Aggregate points into census tracts with PostGIS

Listings are points. Census tracts are the polygons that represent neighborhoods. This query joins the two and computes, per tract, the listing count, the average rating weighted by votes, total votes, the top category, a category mix and listings per 1,000 residents. Of course, we used the Italian census sections from ISTAT 2021, which carry population figures.

-- listings: (title text, category text, rating_value float8,
--             votes int, geom geometry(Point, 4326))
-- census_sections: (pro_com text, sez21 text, pop21 int, geom geometry)

SELECT
    s.pro_com, s.sez21,
    count(f.title) AS n_listing,
    count(f.rating_value) AS n_rating,
    round((sum(f.rating_value * f.votes) / nullif(sum(f.votes), 0))::numeric, 2)
        AS avg_rating,
    coalesce(sum(f.votes), 0) AS sum_votes,
    mode() WITHIN GROUP (ORDER BY f.category) AS top_category,
    count(*) FILTER (WHERE f.category ILIKE '%restaurant%'
        OR f.category ILIKE '%pizza%') AS n_ristoranti,
    count(*) FILTER (WHERE f.category ILIKE '%cafe%'
        OR f.category ILIKE '%coffee%') AS n_caffe,
    count(*) FILTER (WHERE f.category ILIKE '%bar%'
        OR f.category ILIKE '%pub%') AS n_bar,
    round((count(f.title)::numeric / nullif(s.pop21, 0) * 1000)::numeric, 1)
        AS listing_per_1000_residents
FROM census_sections s
JOIN listings f ON ST_Contains(s.geom, f.geom)
WHERE ST_DWithin(
    s.geom::geography,
    ST_SetSRID(ST_MakePoint(9.19, 45.4642), 4326)::geography,
    12500
)
GROUP BY s.pro_com, s.sez21, s.pop21;

The result for Milan is 3,746 tracts with at least one listing, 4.3 million votes in total, a median tract rating of 4.29 and a visible gradient from the center to the periphery once the map is drawn.

From table to map

We exported the aggregated table as GeoJSON and imported it into an open source map builder. Once we picked the geometry and decided to map avg_rating as a graduated choropleth, set the tooltip to show the rating, the votes and the top category, we published it. The embed is plain HTML that drops into any CMS:

<figure style="margin:0">
  <iframe src="https://studio.zornade.com/embed/average-rating/6b6f1a51/index.html" width="100%" height="520" frameborder="0" scrolling="no" title="Average Rating" loading="lazy"></iframe>
  <figcaption style="font:13px/1.45 system-ui,-apple-system,sans-serif;color:#475569;margin-top:6px">
    <a href="https://studio.zornade.com/embed/average-rating/6b6f1a51/index.html" target="_blank" rel="noopener">Average Rating</a> - <a href="https://zornade.com/studio?utm_source=studio.zornade.com&amp;utm_medium=embed&amp;utm_campaign=share_caption_attribution" target="_blank" rel="noopener">Zornade Map</a> · Data © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>
  </figcaption>
</figure>

You can download a live example of this exact pipeline, including the CSV file, from Zornade’s website. The fetch script and the aggregation code are open source and hosted in this GitHub repository.

Cost

The Live endpoint bills 0.012 USD per request plus 0.00036 USD per item, with up to 1,000 items per request and 2,000 requests per minute. Our Milan run, 8,706 unique listings across seven categories, cost about 3.8 USD. See the Pricing page for more details.

Mistakes to avoid

1. Using location_name as the area filter. It does not filter on this endpoint. Pass location_coordinate with a radius and spot-check the returned addresses.

2. Republishing raw listings. Google’s terms and the DataForSEO API terms cover what you can store and redistribute. Aggregate and publish only the aggregates. It also makes a better story.

3. Averaging without weights. A tract with one 5.0 from a single vote would beat a tract with 600 votes averaging 4.6. Weight by votes_count, or the map lies.

4. Dropping unrated listings. They still exist on the ground. Count them in the density metrics, keep them out of the averages.

Wrapping up

A neighborhood rating map is three steps: fetch with Business Listings Search, join to polygons with PostGIS, and publish the embed. The whole Milan pipeline runs in a couple of minutes and a few dollars.

Was this article helpful?