Build a Store and Venue Locator Dataset with Apple Maps
Build a store or venue locator dataset from Apple Maps with coordinates, opening hours, photos and deep links, then emit schema.org LocalBusiness JSON-LD.

Thirdwatch's Apple Maps Scraper returns everything a locator page or venue-finder app needs from one query: name, category path, structured address, WGS-84 coordinates, IANA timezone, per-weekday opening hours, amenities, categorised photos and a maps.apple.com deep link. Search by plain text or by latitude, longitude and radius. Built for developers assembling a store finder, a venue directory or the seed data behind a local-search product.
Why build a locator dataset from Apple Maps
Every locator project stalls in the same place: the map pin is easy, the attributes are not. Coordinates alone give you dots; a usable locator needs hours that survive daylight-saving changes, a phone number in dialable form, a category taxonomy you can filter on, and an image that is not a stock photo. The scale of the problem is the reason nobody types it by hand — the US Census Bureau's County Business Patterns counts more than eight million business establishments in the United States alone, and that is one country and one snapshot.
Three jobs-to-be-done dominate. A franchise or retail brand needs a locator page per outlet with correct hours and a working "Directions" link, because those pages are the site's highest-intent landing pages. A marketplace or booking product needs seed venues in every launch city before it has any supply of its own. An internal tools team needs a canonical place table so that sales, support and logistics stop maintaining three conflicting spreadsheets. Apple's data suits all three because the place card is complete on arrival: hours, timezone, amenities and photos come back in the same record as the coordinates.
How does this compare to the alternatives?
Building the dataset yourself is where locator projects lose a quarter.
| Approach | Cost model | Reliability | Setup time | Maintenance |
|---|---|---|---|---|
| DIY Python against map endpoints | Your engineering time | Fragile when response shapes change | Days before the first clean row | Every schema change is your ticket |
| Official mapping platform SDK | Paid developer account, per-call fees | Terms restrict storing results long-term | Days of account, key and signing setup | Two systems to keep licensed |
| Thirdwatch Apple Maps Scraper | Pay per place returned | Parsed place cards, stable field names | Five minutes | Thirdwatch tracks Apple-side changes |
Apple's own MapKit JS exists to render maps in a browser, not to hand you a table of venues you can persist. The Apple Maps Scraper actor page gives you the table, keyed on placeId, which is what a locator database actually needs.
How to build a locator dataset from Apple Maps in 5 steps
Step 1: How do I authenticate and pick a search strategy?
Get a token from Apify Settings then Integrations after signing up at apify.com.
export APIFY_TOKEN="apify_api_xxxxxxxxxxxxxxxx"There are two search modes and the choice matters. Text queries with the location inside the string ("bike shops in Portland, OR") are best for city-wide directories. Coordinates with a radius are best when the dataset is defined by a catchment — everything within 3 km of each of your existing sites.
Step 2: How do I collect venues around fixed coordinates?
Set latitude, longitude and radiusKm, and every query in the run is searched inside that circle rather than the place named in the text.
import os, requests, pandas as pd
ACTOR = "thirdwatch~apple-maps-scraper"
TOKEN = os.environ["APIFY_TOKEN"]
SITES = [
{"label": "soho", "latitude": 51.5142, "longitude": -0.1360},
{"label": "shoreditch", "latitude": 51.5265, "longitude": -0.0784},
]
rows = []
for site in SITES:
r = requests.post(
f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items",
params={"token": TOKEN},
json={
"queries": ["coffee shops", "bakeries", "bookshops"],
"latitude": site["latitude"],
"longitude": site["longitude"],
"radiusKm": 2,
"maxResults": 120,
"language": "en-GB",
"countryCode": "GB",
"includePhotos": True,
"maxPhotos": 3,
"includeReviews": False,
},
timeout=1800,
)
for item in r.json():
item["site"] = site["label"]
rows.append(item)
venues = pd.DataFrame(rows).drop_duplicates(subset=["placeId"])
print(f"{len(venues)} venues across {venues.site.nunique()} catchments")radiusKm accepts 0.5 to 100. Small radii around many points beat one enormous circle, because each search returns a bounded page and tight circles keep the results relevant to the catchment.
Step 3: How do I normalise opening hours for a locator UI?
hours is keyed by weekday and each day holds an array of open and close pairs, so split shifts and closed days are both representable.
WEEK = ["Monday", "Tuesday", "Wednesday", "Thursday",
"Friday", "Saturday", "Sunday"]
def render_hours(place):
hours = place.get("hours") or {}
if place.get("open24Hours"):
return {day: "Open 24 hours" for day in WEEK}
out = {}
for day in WEEK:
spans = hours.get(day) or []
if not spans:
out[day] = "Closed"
else:
out[day] = ", ".join(
f"{s['open']}-{'00:00' if s['close'] == '24:00' else s['close']}"
for s in spans
)
return out
venues["hours_display"] = venues.apply(
lambda row: render_hours(row.to_dict()), axis=1
)Store timezone alongside the hours. It is the IANA zone identifier for the place, so an "open now" badge computed from the tz database stays correct through daylight-saving transitions without any per-country special-casing.
Step 4: How do I emit schema.org markup for the locator pages?
Locator pages earn their traffic from structured data. Every field the LocalBusiness type wants is already in the record.
import json
DAY_URI = {d: f"https://schema.org/{d}" for d in WEEK}
def to_jsonld(p):
spec = []
for day, spans in (p.get("hours") or {}).items():
for s in spans or []:
spec.append({
"@type": "OpeningHoursSpecification",
"dayOfWeek": DAY_URI.get(day, day),
"opens": s["open"],
"closes": "23:59" if s["close"] == "24:00" else s["close"],
})
doc = {
"@context": "https://schema.org",
"@type": "LocalBusiness",
"name": p["name"],
"telephone": p.get("phone"),
"url": p.get("website"),
"address": {
"@type": "PostalAddress",
"streetAddress": p.get("street"),
"addressLocality": p.get("city"),
"addressRegion": p.get("stateCode") or p.get("state"),
"postalCode": p.get("postalCode"),
"addressCountry": p.get("countryCode"),
},
"geo": {
"@type": "GeoCoordinates",
"latitude": p["latitude"],
"longitude": p["longitude"],
},
"image": (p.get("coverPhoto") or {}).get("url"),
"openingHoursSpecification": spec,
"hasMap": p.get("appleMapsUrl"),
}
if p.get("ratingOutOf5") and p.get("ratingCount"):
doc["aggregateRating"] = {
"@type": "AggregateRating",
"ratingValue": p["ratingOutOf5"],
"reviewCount": p["ratingCount"],
"bestRating": 5,
}
return json.dumps({k: v for k, v in doc.items() if v}, ensure_ascii=False)Only publish aggregateRating when you are entitled to republish the provider's score — check ratingProvider first, since it names whose rating you are surfacing.
Step 5: How do I keep the dataset in sync?
Upsert on placeId and only write rows whose watched fields actually moved.
WATCH = ["name", "phone", "website", "address", "latitude", "longitude",
"hours", "category", "appleMapsUrl"]
def diff(existing: dict, incoming: dict) -> dict:
return {f: incoming.get(f) for f in WATCH
if existing.get(f) != incoming.get(f)}Run it monthly through the Apify scheduler and pull results with the dataset API. New placeId values are openings, ids that stop appearing across two consecutive runs are candidates for closure review, and everything else is a cheap no-op.
Sample output
One record with photos on and reviews off, trimmed to the locator-relevant fields.
[
{
"placeId": "I15FF30DE01EC121F",
"muid": "12844930495393483729",
"name": "Daydreamer Coffee",
"category": "Coffee Shop",
"categories": ["Dining", "Coffee Shop", "Cafe"],
"mapsCategoryId": "dining.cafe.coffee_shop",
"placeType": "BUSINESS",
"address": "80 Rainey St, Austin, TX 78701, United States",
"addressLines": ["80 Rainey St", "Austin, TX 78701", "United States"],
"street": "80 Rainey St",
"neighborhood": "Downtown",
"city": "Austin",
"stateCode": "TX",
"postalCode": "78701",
"countryCode": "US",
"latitude": 30.2592149,
"longitude": -97.7389039,
"timezone": "America/Chicago",
"phone": "+17404000238",
"website": "https://www.daydreamer.coffee/",
"hours": {
"Monday": [{ "open": "07:00", "close": "22:00" }],
"Saturday": [{ "open": "08:00", "close": "24:00" }]
},
"open24Hours": false,
"amenities": [
{ "name": "Accepts Apple Pay", "id": "crossbusiness.payments.applepay", "available": true }
],
"coverPhoto": { "url": "https://is1-ssl.mzstatic.com/image/thumb/.../750x1000bb.jpg", "provider": "Yelp" },
"photos": [
{ "url": "https://is1-ssl.mzstatic.com/image/thumb/.../750x1000bb.jpg", "caption": "interior", "provider": "Yelp" }
],
"quickLinks": [{ "name": "Menu", "url": "https://www.daydreamer.coffee/menu" }],
"appleMapsUrl": "https://maps.apple.com/place?place-id=I15FF30DE01EC121F"
}
]addressLines gives you a pre-broken address block for a card layout, while street, city, stateCode and postalCode give you the same data as columns. mapsCategoryId is a dotted machine path, so filtering a locator to all cafes is a prefix match on dining.cafe rather than a list of display strings. quickLinks carries menu, reservation and ordering URLs where the business publishes them, which is usually the second-most-clicked element on a locator card after directions.
Common pitfalls
Locator datasets rot in four specific ways. Storing hours as a display string loses the structure the moment you need an "open now" badge; keep the raw hours object and render at request time. Ignoring timezone produces badges that are wrong twice a year in every market that observes daylight saving. Treating 24:00 as invalid breaks late-night venues — it means midnight at the end of that day, and split shifts arrive as two pairs in the same array. Publishing photos without checking provider can put third-party imagery on your page; the field is there so you can filter to sources you are cleared to use.
Thirdwatch's Actor returns hours, timezone and structured address components already parsed, de-duplicates on placeId across every query in a run, and finishes a search that legitimately found nothing as a successful run with a status message, so a scheduled refresh never fails over a quiet catchment.
Related use cases
Frequently asked questions
Is there an Apple Maps API for exporting place data?
MapKit JS is licensed for building map interfaces, not for exporting business listings, and it requires a paid developer account with token signing. The Actor needs no Apple credential: you send plain queries or coordinates and receive structured place records you can store.
How are opening hours returned?
The hours field is keyed by weekday, with each day holding an array of open and close pairs in 24-hour form. A close value of 24:00 means midnight, split shifts appear as two pairs, and a missing weekday means closed. Flags open24Hours and hoursType cover the edge cases.
Can I search around a point instead of naming a city?
Yes. Set latitude and longitude with radiusKm between 0.5 and 100, and every query in the run searches that circle instead of the location named in the query text. This is the reliable way to build catchment-based datasets around fixed sites.
Do I get images I can use in a locator UI?
Set includePhotos true and each place returns coverPhoto plus a photos array capped by maxPhotos, each entry carrying a URL, thumbnail, dimensions, caption and provider. Check the provider before publishing, since rights depend on who supplied the image.
How do I keep the locator dataset fresh?
Schedule the run monthly and upsert on placeId, which is Apple's stable place identifier. Compare the incoming hours, phone and website against the stored record and only touch rows that actually changed, so your locator pages avoid needless cache invalidation.
Related
100 free credits, no credit card.
About 30 real searches. Add the MCP to Claude or Cursor in two minutes.