Stop wasting your budget on slow, resource-heavy browser-based scrapers. This is the fastest, most cost-effective, and data-rich Google Maps scraper on Apify, designed for high-scale lead generation and market research.
All notable changes to this Actor are documented here. Public builds use
Apify build revisions such as 2.0.0 and 1.6.120.
[2.0.25] - 2026-07-27
A city matched as a point is now searched as the city, not as a square
OpenStreetMap holds a city as two separate objects: the city as a labelled point,
and the city as an outline. A location search answers with whichever of the two
its name matched better, and for a great many cities that is the point — a city's
name is more prominent than its boundary's. When that happened, the Actor had no
outline to work with and fell back to the placeholder box OpenStreetMap puts
around a point: a square roughly 35 km on a side, the same square for every city
on Earth. It missed whole districts of a city on one side while reaching into
farmland on the other, and in strict mode it decided what counted as "inside" the
city by that arbitrary square.
The city's real outline is now found and used. It is usually sitting in the very
same response, listed one line below the point — that costs nothing to read and
is where most of these cities are now fixed. When it is not there, the previous
route through the city's place hierarchy still applies. Which object the outline
comes from is now decided the same way in both: it must be an administrative
boundary, hold the city's own point, carry the city's name, and be at least a
municipality. A city keeps its point only when neither route finds one.
This fixes cities in whole countries at once, not one by one. In Australia, no
council boundary is used as part of an address, which is exactly why none of them
could ever be found by the hierarchy route, and every Australian city searched as
that square. Boundaries that are simply not in OpenStreetMap — small towns,
sub-city neighbourhoods — are unaffected and still search from their point, as
before.
A district inside a city can no longer stand in for the city
The hierarchy route took the nearest administrative unit above the city without
reading its name. In countries that divide a city into administrative wards, the
nearest unit above the city centre is one ward inside the city — the right rank,
holding the right point, and a small fraction of the place the user asked for. One
wide city was being searched as a 6 km box because of it. The name is now checked,
so a piece of a city cannot pass for the city.
Where a city has no boundary of its own in OpenStreetMap but the district around
it carries the same name, that district is used and the run log says so, along
with the note that customGeolocation will draw the built-up area exactly. It
covers the city rather than missing most of it, but it does reach past it.
The name test works in every language, not just in English
A city is almost never spelled exactly like its own boundary, and every language
disagrees about how. The qualifier may follow the name or lead it, and the name
itself is often inflected on the way in — Swedish turns a town into "…s kommun",
Greek puts the city in the genitive behind the word for municipality. Matching
the two names word for word answers "different place" to all of that, and the
city loses its boundary. Names outside the Latin alphabet had it worse: they
compared as empty, so nothing matched anything.
A translation can also disagree with itself. Names come back in the language the
run asks for, and English spells one Saudi city's name one way and its
governorate's another — far enough apart to read as two different places, while
the original Arabic on both is the same word. So the two names are now compared
in the run's language and in the original, and agreement in either settles it:
matching in one language is evidence of one place, while differing in one is as
likely to be a transliteration as a difference. A town that sits inside a
differently-named neighbour's municipality disagrees in both, and is still
declined.
The city's name is now looked for anywhere inside the boundary's, and compared
for near-equality rather than sameness, in whatever alphabet it is written in.
Measured over a spread of small towns in about fifty countries, this is the
difference between a dozen towns keeping their boundary and losing it. The
tolerance is deliberately narrow, so places whose names merely resemble each
other stay apart, and a boundary named after a different town — the case where
a small town sits inside a larger neighbour's municipality — is still declined.
A recovered boundary now frames the search too
When a city's outline was recovered, it decided what counted as inside the city
but not where the search itself was pointed — that stayed framed on the
placeholder square. A city wider than the square was searched only in its middle.
The recovered outline now sets the framing as well, so the search covers the city
it just identified.
[2.0.22] - 2026-07-25
Short runs no longer spend a third of themselves waiting on a counter
A run that finished right after saving its results spent 5 to 9.5 seconds doing
nothing before it exited — on runs that took 13 to 19 seconds in total, close to
half of the run.
It was waiting for Apify's count of the saved rows to catch up with the rows
themselves. The two settle at different speeds: every row is readable the moment
it is saved, while the count that reports how many there are trails several
seconds behind. The Actor cross-checks that count against what it saved, and it
was waiting the difference out in a ladder of retries, up to nine seconds of
them.
The cross-check now asks about the rows instead of the count, which answers the
same question immediately, and only pauses when a row really is missing — the
case the check exists for. Runs that keep searching for another ten seconds
after their last save, which is most of them, never paid this and are unchanged.
It also could not report fewer places than it saved any more. If the count was
still behind when the retries ran out, the smaller number was the one that went
into the run summary.
A pin on the map opens the place, not a search for it
Making the map lighter in 2.0.21 dropped the Google Maps link each pin carried
and rebuilt it from the pin's coordinates. That opens a search for a name near a
point, which is not the same thing as the place: a common business name, or one
of the many places Google gives a stand-in coordinate because they have no
storefront, can land on a neighbour or on a list of results.
Pins now carry the place's identifier and the link names it directly, the way
the saved row's own link does. It costs about a quarter of what the full link
cost, so the map still holds roughly 48,000 places of typical lead data.
[2.0.21] - 2026-07-25
The map of the results no longer stops at 20,000 places
A run of nearly 40,000 places drew 20,000 of them on the map. That ceiling was set
against a storage limit the platform has since removed, and it stayed behind
after the reason for it went away.
The map now budgets how heavy the page is instead of counting pins, because
pins are not the same size — a place with a long address, a website and a phone
is several times a bare one. Roughly 54,000 places of typical lead data now fit,
and around 260,000 of sparser rows. Two things made room: each pin used to carry
its own Google Maps link, nearly a third of the whole page, which is now built
from the pin's coordinates when you open it; and empty fields were being written
out as blanks on every place that lacked them.
The map is also cheaper to keep up to date. It is rewritten in full each time it
refreshes, so a large one was being re-uploaded every ten seconds all run. Small
maps still refresh every ten seconds; larger ones refresh less often, so the
cost stays flat as results accumulate instead of growing with them.
The run log says what the map left off
A map that drew 20,000 places out of nearly twice that reported only the ones it drew and the
places it could not locate, which read as though it held nearly everything. It
now names the page limit alongside them.
"Action needed" tells you again to move a location out of a search term
The 2.0.20 note about this advice firing when it did not apply went too far: it
stopped firing for the searches it is for — a term that names a city while
that same city is also the Location. Those runs get the guidance back, and the
false positive stays fixed.
[2.0.20] - 2026-07-25
The map search no longer waits for business websites
With contact enrichment on, a search of one map area was held open until every
place it found had had its website checked. Checking websites is most of what
such a run does, so map coverage advanced at the speed of the slowest websites:
on a statewide run a search that found nothing finished in 3 seconds while a
full one took 800-1250, and the run reached 36 of the 99 map areas it had
identified before its time ran out. The remaining areas were reported as
skipped.
Website checks now run alongside the search instead of inside it. The map search
completes its areas at its own pace, and the website pass works through the
places it found for as long as the run has time. Two things follow:
Coverage no longer depends on website speed. The search finishes the map
areas it identified, so results are drawn from the whole requested area rather
than from the part reached before the clock ran out.
The most prominent businesses everywhere are checked first. The website
pass takes the top result of every map area before the second result of any of
them, so a run that runs out of time has covered the area evenly instead of
exhausting a few places in it.
What is saved is unchanged: a place is only saved once its website has actually
been checked, so a run that asked for contact details is never padded with rows
that have none. Places found after the time for website checks ran out are not
saved and not charged for, and the run summary now names how many there were.
Runs that also request reviews or place details, and lead-list runs that keep
only places with an email, are unaffected — those still need the website result
before the row can be finished, so they keep the previous behaviour.
Contact pages get the whole timeout instead of a retry that rarely worked
Two residential-proxy retries in website enrichment were measured across five
statewide runs and removed.
Retrying a contact page over proxy returned a usable page 3% of the time
(117 of 3,978 attempts). Keeping the retry available also cost every direct
contact-page fetch most of its time: the direct attempt was cut to 3.5 seconds
to leave room for the retry, against a site timeout that is normally 10.
Contact pages are now fetched direct only, with the whole site timeout.
Measured on the same search before and after: the time spent per email
recovered from a contact page fell from 17.4 to 11.9 seconds, and the average
time spent on one website fell from 5.3 to 5.0 seconds. The share of contact
pages that never answer did not move (33.8% before, 33.2% after) — those sites
were not refusing the datacenter route, they simply have no page at the address
the link pointed to.
Retrying a homepage that answered but published no contacts recovered
contacts once in 1,351 attempts. A site that responds and shows no email is not
hiding one behind a datacenter IP. The retry also ran inside the site's own time
budget, taking it from the contact-page pass that does find emails.
The homepage proxy fallback for sites that do not respond at all is unchanged —
that one succeeds 41% of the time and is how blocked sites get read.
Business websites are asked for a compressed page
The Actor told every business website not to compress its reply, so a page it
only reads the first slice of still arrived in full size. That was there to make
the read limit exact, which it never needed to be — the transfer is cut off by
the Actor itself either way. Pages now arrive compressed and are decoded while
they download, which measured 8% faster per page across 77 sites with byte-for
byte identical content, and sends a fraction of the data over the wire. It also
removes a mismatch: the Actor presents itself as Chrome, and no browser asks a
site to skip compression.
Businesses with no storefront no longer carry a made-up location
A business Google has no address for — a mobile plumber, a contractor working
out of a van — gets a placeholder coordinate instead, one that several unrelated
firms share. Those rows were already flagged locationUnverified: true and left
off the run's map, but the placeholder itself was still saved in location, so
anything that plotted the results or filtered them by distance placed the
business wherever the filler happened to point. The commonest of those points is
open ocean; most of the others land on a city centre and look entirely real,
which is what made them hard to notice. Between 9% and 27% of rows in a typical
lead-generation run are affected.
Those rows now arrive with locationUnverified: true and no location at all —
the same rule the address fields already followed: an empty field rather than an
invented one. Everything else about them is unchanged, and places with a real
address keep their coordinates exactly as before.
"Action needed" no longer tells you to fix something you did correctly
A run that put the service in the search terms and the city in Location — the
documented way round — could still be told to "put only the service/category in
searchStringsArray and the city in Location(s)". The advice was triggered by the
words "search term" and "location" appearing anywhere in the run's warnings, so
an unrelated timeout notice ending "Reduce search terms/languages/reviews/depth"
was enough to set it off. It now appears only when a warning actually reports a
location stuck inside a search term.
Clearer name for a search diagnostic
searchPagesFetched / searchPagesSaved counted the page Google embeds its
search request in — fetched once per search term and reused for every map area —
not pages of results. Reported as "1" beside 36 completed searches it read as a
broken counter instead of the saving it describes. They are now
searchXhrTemplatesFetched and searchXhrTemplatesReused.
[2.0.19] - 2026-07-24
A location like "City, IN" is read as the country, not a US state
A two-letter code after a city name — "Kolkata, IN", "Medellín, CO", "Rabat, MA"
— is now understood as the country it names (India, Colombia, Morocco) rather
than the US state that happens to share the abbreviation (Indiana, Colorado,
Massachusetts). Previously the comma made the code look like a US state, so the
location was resolved on the wrong continent: the seed landed in the United
States, the city's real boundary was lost, and the residential proxy could no
longer be matched to the target country. Spelling out the country
("City, IN, USA") still selects the US state, and codes that are not country
codes (FL, TX, NY, …) are unaffected.
[2.0.18] - 2026-07-23
City-boundary recovery now works the same way in every country
The recovery added over the last two builds picked the boundary by a fixed map
zoom, which lands on a municipality in one country and a state in another — so it
worked in some places and had to be held back in others. It now reads the
boundary straight from the city's own place hierarchy in OpenStreetMap: the
city's municipality is identified by its position in that hierarchy, not by a
zoom, so the exact area is recovered consistently regardless of how a country
numbers its administrative levels. A neighbourhood or suburb still keeps its own
point, and a city whose boundary the search already returns is untouched.
[2.0.17] - 2026-07-23
Boundary recovery no longer widens a neighbourhood into a state
The boundary recovery added in 2.0.16 looked up the administrative area sitting
at a city point at a fixed zoom. That zoom maps to different administrative
levels in different countries — a municipality in one, a whole state in another —
so a neighbourhood search in some regions came back framed by an entire state.
Recovery now runs only for a city/town-level query (a neighbourhood or suburb
keeps its own point, which is the right scale for it), and the area it accepts
must be a municipal-level boundary — a state, region, province, or country is
never used in place of the city, whatever its size. Cities whose exact
municipality is available still get it; everything else keeps the point it had.
[2.0.16] - 2026-07-23
The exact city boundary is now found even when the search returns only a point
A city's outline is what a strict search area is measured against, and what the
seed grid is drawn over. But OpenStreetMap's text search does not always return
it: for some cities — especially where the everyday name differs from the
administrative one — the search answers with the city point and a placeholder
box around it, and never returns the boundary relation at all, even though it
exists. The run then treated a ~35 km square of countryside as the city:
insideSearchArea was judged against a box that was not the city, and the seeds
gridded the land around it.
When the search comes back with only a point for a city, the boundary is now
looked up by location instead of by name — the actual administrative area sitting
at that point is fetched and used as the exact search area. It is accepted only
when it is a real administrative boundary, city or county in scale (never a state
standing in for a city), that genuinely encloses the point; otherwise the point
is kept as before. Cities whose boundary the search already returns are
unaffected — the extra lookup only runs when there was no outline to use.
[2.0.15] - 2026-07-23
A run that hits its cost limit now stops cleanly instead of paying out a quiet tail
When a run reached its maximum cost (maxTotalChargeUsd), the search stopped
taking new work — but the requests already in flight did not. A worker mid-fetch
would still run its full proxy-retry ladder, mint a fresh residential session
for the next attempt, and even open a "second look" at a viewport, all after the
run had already decided to stop. Every one of those was proxy bandwidth bought
for rows that could no longer be saved, and the run then idled for several
seconds with nothing in the log before the summary was written.
In-flight work now honours the stop the moment it is called. Retries at every
layer, fresh-session attempts, and the extra second-look and re-query passes all
bail out as soon as the cost limit — or the soft time budget — is reached, so the
run stops spending on the proxy it set out to save. The first, already-open
request of each task is left to finish so a sticky session is never torn
mid-flight; only the paid re-tries and new requests are cut.
The wind-down is honest now, too. The "stopping search early" line used to report
skipping zero queued tasks — the workers had already emptied the queue by the
time it printed — while the run summary reported the real figure. It now reports
the running total and how many tasks are still finishing, and it agrees with the
summary. The short wait for in-flight requests to settle logs a heartbeat instead
of going silent.
[2.0.14] - 2026-07-20
A custom search area is now read in whatever form you drew it
customGeolocation only accepted a bare GeoJSON geometry — Polygon,
MultiPolygon, or a Point with radiusKm. But drawing an area and exporting
it almost never gives you that: geojson.io, QGIS, Mapbox and My Maps all wrap
the shape in a FeatureCollection, and exporting a single shape gives a
Feature. Pasting either one produced no search area at all, and the run
finished in seconds having saved nothing.
Two things made that hard to act on. The area was dropped silently — the log
said "Using customGeolocation" and then that no search area was available — and
the advice printed at the end was to "provide customGeolocation for an exact
area", which is exactly what had been provided.
The area is now read out of any envelope it arrives in: FeatureCollection,
Feature, GeometryCollection, a plain array of any of those, a
{"geometry": …} wrapper, and the whole thing as a JSON string. Alongside
polygons and points, MultiPoint seeds one area per point, and a LineString
that closes back on itself is read as the polygon it draws. Several shapes in
one collection each become a search area, and all of them count for
membership. Coordinates pasted latitude-first are corrected when that is
provable — a value past ±90 in the second slot can only be a longitude —
and left alone when it is genuinely ambiguous, per the spec.
When an area still cannot be read, the run now says so directly and names what
is accepted, instead of blaming the Location field.
[2.0.13] - 2026-07-18
Multi-city runs no longer freeze on area membership
Deciding whether a place falls inside a requested area does two things per place:
a point-in-polygon test, and — when that fails — a distance to the area's
boundary. The point-in-polygon test has used a fast edge index for a while. The
distance test never did: it walked every segment of every requested area's
outline, and a large OSM city boundary has thousands. On a run over many
adjacent towns that ran once per returned place against every requested area, so
one search's membership pass could burn ~11 seconds of pure CPU tracing outlines
for a number it only ever compared to a threshold.
That CPU ran on the event loop, and a frozen loop cannot fire its own request
timeouts — so in-flight searches ran unbounded and surfaced as a wave of
"proxy/network" timeouts and retries that were really the loop locked up. Worst
on smaller memory tiers, where there is less CPU to absorb it.
The distance is now settled without the trace wherever a cheaper bound already
decides it: the bounding box (which encloses the outline) gives a lower bound, so
an area whose box is already past the cut-off is skipped, and the first area
close enough ends the search — usually the one that surfaced the place. The
name-match leash checks the name before measuring the distance. Same verdicts to
the row (every membership decision is identical), same billing; a 50-town
membership pass drops from ~11 s to ~40 ms of CPU.
Geocoding setup for many-location runs is about twice as fast
Before a single search runs, each requested location is geocoded, and each one
used to take two calls to the OpenStreetMap geocoder — one to find the place, a
second to fetch its exact boundary — with a mandatory one-second spacing between
every call. A run over dozens of towns spent that entire setup waiting on the
round-trips, serially, before any results could come back.
The boundary is now asked for in the first call, so each location takes one
request instead of two. City- and county-scale outlines are byte-for-byte the
same as before; country-scale areas are still fetched simplified on their own.
On a 50-location run the geocode setup drops from ~120 s to ~65 s.
Multi-city runs stop paying for searches that no longer find anything
The search frontier already stops opening map areas once they stop turning up
new places. It measured that "stopped paying" point per city, though, so every
city in a run re-learned the same lesson from scratch, spending several probe
searches each before the brake could apply. That decay is a property of the
search term, not of any one city, so it is now measured across the run: the term
learns its curve once and applies it everywhere. Genuinely dense cities still
explore, because a split off a high-yield area still ranks high on its own merit.
On a many-city run this cut total searches and proxy traffic by roughly half for
a ~3% change in places found — most of it map areas that were returning only
duplicates of what a neighbour had already saved.
The end-of-run summary no longer stacks its writes
Finishing a run persists three things to Apify — resume state, the results map,
and the OUTPUT summary — as independent writes to separate keys. They ran one
after another, so their round-trip times stacked up (~12 s on a large run, all
after the last place was already scraped). They now run concurrently, so the tail
costs the slowest single write rather than their sum; each still keeps its own
error handling.
[2.0.12] - 2026-07-17
Fewer results from other towns: a city search now asks Google for the city
Asked for a map at a given zoom, Google frames four times the ground the Actor
believed it did. The two numbers that decide this — the one that picks a zoom for
an area, and the one that says what a zoom covers — have both been wrong the same
way since the project's first commit, so they agreed with each other and neither
agreed with Google.
The effect: a city resolving to zoom 10 was searched through a 95 km map view
rather than the 24 km it was meant to be. On a real run over one city, 53% of
the results landed outside the area the Actor thought it had asked about, and
they were not Google misbehaving — 93% of everything returned sat inside the view
it had genuinely been handed. Towns 40 km away were in the picture because the
picture was 95 km wide.
Zoom is now read off what Google actually frames. Measured, same input, same run
shape — the same searches, the same depths, the same traffic:
places inside the city: unchanged (215 → 208)
places more than 24 km away: 50 → 18
total rows: 459 → 360
Nothing else moves. For everything from a small town to a state the geometry is
identical to the digit; only the zoom written into the request changes, so what
the run counts as inside an area, how it lays its grid and where it splits are
all exactly as before. Country-scale runs get an honest grid out of it — a
continent now fits the cells it is given instead of falling back.
Probed across zooms: narrowing the view does not thin the answer, it re-aims it.
Every zoom comes back with the same ~207 places; the tighter one spends them on
the city — and turns up 36 the wide view never showed.
[2.0.11] - 2026-07-17
Fixed: a strict run kept searching long after it had everything
geoStrictMatch=true drops the places that fall outside the area and the ones
Google publishes without a location. That is the point of it — but the search was
deciding where to look next by counting every place it had been shown, dropped
ones included. A strict search answering with 182 places and keeping 2 was read
as having found 85, so the map areas below it were ranked at forty times their
worth, and the check that stops a run once looking stops paying could not fire at
all. One strict run over a single city spent 100 searches on the deepest levels
to keep 3 places, and was still going when it was stopped by hand.
A place is worth something once it is saved, so saved is now the only thing the
search ranks or judges by. On that same run: 114 searches down to 16, a fifth
of the data, and the same places. Loose runs keep what they are shown and were
never affected, which is why this only ever showed up in strict mode.
[2.0.9] - 2026-07-17
Faster: a smaller map area no longer downloads the places it was just given
Google ranks an area's answer outward from its centre, so when a map area is
split, each smaller one opens on the same well-known places the bigger one had
already returned. The run downloaded them again and threw them away.
Probed over one city, on four splits of one area: the first place the parent had
not already returned sat at rank 41, 44, 47 and 107 of about 209. A split now
asks Google to start past that head, which it answers exactly — probed at ranks
20, 40 and 50, nothing at or beyond the offset went missing. A fifth fewer
places are downloaded for the same results.
That matters because downloaded places are what a run's clock is made of: its
speed is fixed however many searches it runs at once, so the wall time is simply
what it pulled down. A seed is not affected — it has no parent, and its head is
its best places.
More accurate: what a search must find to be worth running is now measured
A search only earns its place if it finds enough that is new. How much is
"enough" depends on how much it costs, and that is not a constant: measured
across three real runs, the same search came back with 186 KB, 246 KB and 460 KB
— a spread of two and a half times, decided entirely by how much Google chose to
answer with.
A run now reads that off its own answers instead of assuming it, so an area whose
answers come back big has to find proportionally more to be worth opening. The
run had been assuming the cheap end, which had it opening areas that were not
worth their traffic on exactly the searches that cost the most.
[2.0.8] - 2026-07-17
Replaces 2.0.3 through 2.0.7, which were this worked out in public.
The search opens the most promising map area next, and stops when a search stops paying
The search used to divide every map area into four, and each of those into four
again, down to a fixed limit — whatever the ground held. Nothing bounded it
except a term's own place budget, so a term whose city holds fewer places than
the budget could never reach it and never stopped. Measured on a real run: three
terms filled their budget in 5 to 80 searches, and a fourth, with only 56 places
to find in that city, found its last on search 44 and then ran 118 more finding
nothing — 41% of the whole run.
Smaller map areas are now offered to a frontier rather than queued, ranked by
what the area they came from actually found, and a worker always opens the best
one known. That ordering is what lets the run stop on an argument instead of a
rule of thumb: if the most promising area left is not expected to find enough to
pay for the search, nothing else can be, because everything else ranked below it.
Both halves of that are measured on the run itself rather than assumed. What an
area is expected to find comes from how much this run's splits have found against
what they were split from — that decay ranges from 1.1x to 47x a level between
runs, so there is nothing to assume. And what a search has to find to be worth
running is read off what this run's searches actually download, which is no more
constant: the same search comes back with 186 KB over one city and 460 KB over
another.
Measured on a real run, against the same input before any of this:
places
time
data
searches
before
710
350 s
106 MB
860
now
593
57 s
12 MB
63
Six times quicker on a ninth of the data, for 16% fewer places. Those places
are not lost to a cap — they are the ones a search would spend more finding than
they add, and the run now declines them rather than not noticing. 136 searches
were never started, as against being queued and then abandoned.
A map area's shape is measured rather than assumed
A viewport frames a square of ground: Google's own map height, which it sends
with every search, divided by cos(latitude) is one constant across Merida,
Bologna, Nottingham and Bergen. The Actor divided by that cosine where it had to
multiply, so the shape came out wrong by 0.5 / cos²(latitude) — exactly 1.0 at
45°, which is where every city the coverage was ever tuned on happens to sit.
Near the equator a map area was modelled half as tall as it is, so the four it
split into packed together and answered with each other's places. North of 45° it
was modelled up to twice too tall, so they were thrown past the city's edge and
dropped for being outside it. One fault, two opposite symptoms, and no test
covered any of it — reverting the fix passed all 694.
The width is deliberately left as it was. Google frames four times what the
Actor models, which is now written down where the number lives, but closing that
gap makes map areas stop overlapping — and overlap is what re-centres Google's
ranking and shakes out different places. Tried, measured, worse on both counts.
The seed goes in first, and the grid follows only if it asks for one
A run whose place budget was under 120 had a grid of smaller map areas laid over
its city before a single search had run. That was there because such a run could
not find its own way to the grid: an area earned its split by coming back "full",
"full" meant "as many places as this run still wants", and so a run asking for 20
called an area truncated at 20 — whether Google put 20 or 6 on the first page was
luck, and the run returned 5 places one time and 19 the next. That bar stopped
moving with the budget in 2.0.2, and the reason went with it.
What it cost, measured on a 20-term run over one city with a budget of 100: the
grid turned 20 seed searches into 80 searches of quarter-areas, so the seeds —
which return 49 new places each against 4 to 5 for a split, and fill a budget
outright where the ground is dense — never ran at all. Reaching the first
thousand places took 176 seconds and 92 searches; it now takes 75 and 34.
The country grid buys what its budget affords
Covering a continent evenly does not fit in sixteen searches once a map area's
height is measured honestly, and the grid returned nothing at all rather than
what the budget does buy. It used to look like it fit only because cells were
modelled twice as tall as they are at those latitudes — two rows "covering" four
rows of ground, the gaps invisible rather than absent. It now spends its budget
on the shape of the ground and samples what it cannot tile. A box wrapped around
the planet still yields no grid, because it bounds nothing to lay one on.
[2.0.2] - 2026-07-16
Fixed: a run could spend its whole budget dividing the map instead of searching it
2.0.0 removed the rule that stopped a map area being split when it showed
nothing new — correctly, because "nothing new" measures what has already been
downloaded, not what the area still holds. But that rule had been hiding a
second one underneath it, and removing the cover exposed it.
Whether Google truncated an area was judged against a bar that moved with the
run's remaining budget. With ten places left to save, the bar became ten — and
an area that returned thirteen read as "Google is holding more back", split into
four, and each child did the same. The tree exploded at exactly the moment there
was no budget left to spend on it. Worse, the answer was also read down to the
remaining budget, so the two numbers met: every area returned exactly the bar and
so every area looked truncated.
Both are now what they always described. The bar is a property of Google's
answer and does not move. The whole answer is read — it is paid for either way,
one request returns all of it — and the per-term budget is enforced where it
always really was, when rows are claimed for saving.
Measured against runs that hit the bug:
A run asking for 20 places in one city returned 15 after 342 requests and
hit its own timeout with 59 areas still queued. It now returns all 20, in 27
seconds, in 22 requests.
A capped multi-city run: same places, half the wall clock, an eighth of the
proxy traffic, 220 requests down to 28.
Runs whose budget is never reached — where the bar could not collapse — are
unaffected.
Strict-area runs gain the most: the answer is full of rows outside the area that
are dropped and never charged, so reading only as many rows as there was budget
left meant reading mostly the rows that were about to be thrown away.
[2.0.0] - 2026-07-16
Runs are two to four times faster and return far more places
Three things were wrong at once, and each was hiding the next. Measured on the
test Actor, several repetitions per configuration, one variable at a time.
Area membership was starving the event loop. Deciding whether a place fell
inside the requested area was a ray cast over every vertex of the area's
boundary — for a state-sized region, sixteen thousand of them, 5.11 ms per
place, against 0.17 ms to parse the whole search response. It ran synchronously,
so while it ran no worker could send or receive a single byte. Boundary edges
are now indexed by latitude and only the handful that can touch the ray are
walked: 5.11 ms → 0.004 ms per place, and the answer is identical — the
skipped edges provably cannot change it. On default settings this alone took a
run from 216 to 772 places per minute and cut proxy errors by 92%: most of them
were the stalled loop failing to service its own sockets, not bad exits.
Subdivision was pruning the very areas it should have split. A map area was
split only if it showed enough new places. Novelty measures what has already
been downloaded, not what the area still holds — and because results are
deduplicated across the whole run and neighbouring areas overlap, whichever area
was searched first left the next looking empty. The tree's shape depended on
which request happened to finish first: the same input returned 683 or 1191
places from one run to the next. Supply is now counted over everything Google
returns, duplicates included, and an area is split when Google truncates it,
whoever saw it first. Places per run +110%, and the run-to-run spread fell
from 1.74x to 1.23x.
Six requests were fetching what one can. Google's own page asks for 20
results at a time and every published scraper copies that number. It is not a
limit — asked for 250, Google answers with everything it holds for the area
(measured 104 to 230; there is no fixed cap, and the same request returns 196
one minute and 208 the next). One request now replaces up to thirteen: +25%
places for the same wall clock, and each place costs less than it did.
Together, on an identical input: +43% places in 26% less time.
What this means for a run
More rows, so a larger bill for the same input.maxCrawledPlacesPerSearch
(default 50) and maxTotalChargeUsd still bound every run exactly as before —
raise the first only if you want the extra places.
Nothing about the output shape, the fields, the strict-area rules or the
billing events changed. Only how much of the map a run manages to look at.
Runs are noticeably steadier: the same input now returns close to the same
number of places each time.
Fixed
A page-size above 199 silently reset itself to 20 and paginated to a nonsense
offset — the offset was spliced into the middle of the page size, because
!7i20 is a prefix of !7i200.
The counter reporting how many saturated areas went unsplit was itself gated
on novelty, so it could not see the areas the novelty rule blocked.
[1.6.120] - 2026-07-16
New add-on: full place details (opt-in)
extractPlaceDetails fetches each place's own Maps detail page to add the
fields the search response never carries. One extra residential request per
place, billed per enriched place (place-with-details), off by default — base
runs are unchanged. It adds:
openingHours — the full seven-day schedule (search gives only today).
popularTimesHistogram — the per-day/per-hour "how busy" graph.
peopleAlsoSearch — related places; editorialSummary; timeSpent.
gasPrices — live per-grade fuel prices for gas stations, and
evConnectors — charger type/power/availability for EV stations. Neither is
populated by competing scrapers today.
[1.6.119] - 2026-07-16
Booking links and place photos, pulled from the same search response
Every place now carries its photo as a usable image, and bookable venues
carry their reservation links — both read straight from the search response,
so there is no extra request and no change to pricing or result counts.
reserveTableUrl and bookingLinks expose the "Reserve a table" providers
Google shows on the panel (Resy, OpenTable, ...), each as {url, provider}.
On a restaurant search ~80% of places came back with at least one.
imageUrls (and the images gallery) are now populated for ordinary places
— restaurants, shops — not just hotels. Previously a non-hotel row had only
the tiny imageUrl thumbnail; now it also gets the full-resolution photo
URL. The search response carries only a photo or two per place; the complete
gallery still needs a per-place detail fetch, which this does not add.
[1.6.118] - 2026-07-16
A city whose boundary reaches distant islands is framed on the city, not the ocean
Searching a place like Tokyo, Japan now covers the city densely instead of
barely sampling it. An administrative boundary can bundle far-flung island
territory — Tokyo Metropolis reaches ~1,000 km south to the Ogasawara and Iwo
islands — which blows its bounding box up to a size that framed the search at
continental zoom. Google then returns only a thin national skim, so the actual
city was badly under-covered (a restaurant search of Tokyo returned ~50
places where a proper city-zoom search returns the full requested amount).
The seed is now framed on the boundary's mainland — the ring carrying the vast
majority of the outline's detail — while the whole boundary (islands included)
still decides what counts as inside the area. Ordinary cities, whose outline is
one solid piece, are unaffected; only a boundary inflated by distant outliers
is pulled back to where its city actually is.
[1.6.117] - 2026-07-16
A language typed by name (or in its own script) no longer breaks the search
Additional languages now accepts a language's name, maps it to the right
code, and never lets a bad value break a search. Entering Arabic — or
العربية — instead of the code ar used to be accepted silently and then, for
a non-Latin value, crash every search task that ran in that language deep in
the HTTP layer: those searches simply failed and their results went missing,
with nothing in the run to explain it. Common language names (in English and
in their own script) are now mapped to the correct code, a value that is
neither a code nor a name we know is dropped with a clear warning instead of
taken into a doomed request, and the language is escaped on the way out so it
can never crash a search again. A run that used to lose ~8% of its coverage to
this now keeps it.
[1.6.116] - 2026-07-16
Fewer network errors and faster searches, by reusing a warm proxy exit
Each worker now keeps one warm residential IP and reuses it across searches
instead of allocating a brand-new exit for every viewport. Asking the proxy
for a fresh exit on every task is expensive twice over: the allocation itself
is slow, and a freshly drawn exit has a real chance of being a bad one that
times out or resets — so the run spent much of its time paying for, and then
retrying around, exits it had no reason to draw. A warm exit that has already
answered cleanly keeps answering cleanly.
Measured against the previous behaviour on the same searches: ~80–100% fewer
proxy/network errors and 20–45% faster, with identical coverage. Each
worker rotates to a fresh exit periodically (and immediately if its current
one misbehaves), so no single IP carries enough of the run's traffic to draw
Google's attention — validated across healthy and difficult proxy regions,
light and heavy runs, with zero blocks.
A subdivided viewport no longer re-fetches a thin first page on fresh
exits. When Google answers a search with a short first page, the run retries
it on a couple of fresh IPs in case that was a glitch. That is worth doing for
a whole city, but not for one small cell of a subdivided area, where a short
answer is simply the truth — so those re-fetches (each a slow cold exit) are
now skipped below the top level, cutting wasted requests on large gridded runs
with no loss of coverage.
[1.6.115] - 2026-07-16
A viewport that runs out of good proxy exits gets another try, not dropped
A search that fails after cycling through several fresh residential exits is
no longer written off. Residential proxy pools have bad moments — a handful
of exits in a country time out or reset at once — and a viewport unlucky
enough to draw only those used to be abandoned, leaving a hole in coverage the
user could only fill by re-running the whole job. Such a viewport is now put
back at the end of the queue and retried later (bounded, so a genuinely
unreachable target can't loop), by which time the pool has usually rotated in
working exits. Runs that finish cleanly are unaffected — there is nothing to
retry — and a run stopping for a charge limit or time budget still stops at
once so its partial results save.
[1.6.114] - 2026-07-16
Runs over many large areas no longer freeze, and big runs finish faster
A run that searches a dozen city-sized areas at once no longer stalls.
Every place Google returns is checked against the outline of each requested
area to label whether it falls inside. Those outlines can be enormous — a
single city boundary runs to ten or fifteen thousand points — and each place
was being traced against every area's full outline, tens of millions of
geometry checks per batch, all on the one thread that also has to fire request
timeouts. On a large multi-area run that thread could be busy for a minute at
a stretch: timeouts stopped firing, requests hung, and the run looked frozen.
A place is now cheaply rejected against an area's bounding box before its
outline is traced at all — an area a place is nowhere near is dismissed in one
comparison instead of fifteen thousand. The inside/outside labelling is
unchanged; the run just stops doing the work it never needed. Large multi-area
runs that used to freeze now run clean, and about twice as fast.
Large runs use the extra workers they were already asking for. When the
queue dwarfs the worker count the run widens its pool so it spends its time
searching instead of waiting in line, but the search body ran under a gate
sized to the original count, quietly capping the wider pool. The gate now
opens as wide as the pool. Small runs are unaffected; each task still holds
its own residential IP with per-IP spacing, so a wider pool asks no more of
any one exit.
[1.6.111] - 2026-07-14
A run that searched the wrong place now says so
When a Location resolves to somewhere you did not mean, the run tells you —
and tells you where your results actually were. A place name can match
something else that carries it: a hamlet, a railway station, a district in
another region. Written without the suffix the local map uses, it often does.
The search then runs on the wrong ground; Google answers it perfectly well; and
every result lands hundreds of kilometres from the area, so the guard that
drops unrelated places throws all of it away.
From the inside that is indistinguishable from an empty city — no rows, nothing
broken — and the run would finish SUCCEEDED with a handful of rows and no
complaint. What tells the two apart is what Google returned: an empty area
returns nothing, a wrong one returns a full page of good results that all sit
somewhere else.
Such a run now finishes INPUT_NEEDS_ATTENTION and names the towns the
results were really in — which is usually the exact Location you should have
typed — along with how to fix it.
A non-strict run is no longer told to turn off strict mode. The hint shown
when results land outside the search area was written for geoStrictMatch=true
and was shown regardless of the setting, so a run with strict mode off was
advised to switch off the thing that was already off. It now explains what
actually happened in each mode. The list of nearby towns holding the results is
also filled in whenever there are any, not only when a run saved nothing at
all — a run that saves almost nothing needs it most.
[1.6.109] - 2026-07-14
When Google reads a search as an address, ask again
A Location written out in full could return a single result: the place
itself, instead of the businesses in it. The Actor adds your Location to your
search term, which is what keeps a search on the place you asked for. Usually
the full spelling costs nothing. But for some places — small towns especially —
Google stops reading the query as a search and reads it as an address, and
answers with one result: the locality.
With one result there is nothing to subdivide, so the run would stop there and
report itself healthy: from the inside, nothing had failed.
No proxy IP can fix a query, so retrying on fresh ones (which is what used to
happen) only spent money proving it. Now a seed that comes back with a single
place is asked once more with only the most specific part of the Location — the
town rather than the town, its region and its country — and if that reads as a
search, the run keeps that wording and passes it down to every viewport under
it. In testing this turned single-result runs into full ones across several
languages and country conventions.
A Location with nothing to trim behaves exactly as before, and a run that was
never answered with a single place never re-queries.
OUTPUT.searchStats.placeLookupRequeries counts when it fires.
[1.6.108] - 2026-07-14
Distance to the search area is measured against the area
The guard that drops far-away junk works for country searches again. Two
checks still measured distance against the bounding box after 1.6.107 gave
countries a real boundary: the guard that drops a place sitting hundreds of
kilometres from anything you asked for, and the 25 km leash that lets a place
just past a city's official line count as inside when Google files it under
that city's name. Against a country's globe-wide box, every place on Earth
measured 0 km from the area — so the guard never fired and the leash was
not a leash. A business a thousand kilometres beyond a country's border was
being called zero kilometres outside it.
Both now measure against the boundary when there is one, fall back to the box
where there is none (a postal code, a coordinate, a Google Maps URL), and
return unknown rather than a confident zero when the box is one of the
globe-wide ones.
What this changes for you: a country search drops a small number of rows it
used to keep and charge for — the ones that sit in a different country
altogether. In testing this was around 1% of a country run's rows. Places just
over a border are kept, as before: the threshold is distance from the boundary,
not which side of it you are on, so a business a few dozen kilometres outside
still counts as nearby.
[1.6.107] - 2026-07-14
Searching a whole country now searches where its businesses are
A country-sized search area is no longer covered as if people lived in it
evenly. The Actor covers a search area by laying a grid of map viewports over
it. For a city, a region, or a country the size of Germany that is right —
every cell opens onto somebody. For a continent-sized country it is not: an
even grid over a country like Russia or Canada spends most of its cells on
forest, tundra and ocean, and hands the one city that holds the businesses a
single cell. Runs came back with a fraction of what was there.
Now a country-scale area sends its seed viewport in alone first, and lays the
grid on what comes back. Google ranks by prominence, so even a search centred
on empty land returns the capital and the big cities — that answer is a map of
where the supply is, and the grid follows it: a metro-sized viewport on each
cluster of results, and whatever the clusters do not claim is still searched
blind, so nothing is given up. Same number of searches, same cost per run.
A Location of "USA" searched nowhere in particular. Every other country is
sent to Google as part of the query — <term> Germany, <term> India — which
is what keeps a search on the country it was asked about. The United States was
stripped out by a stop-word list that only ever meant to trim the redundant
, USA off the end of a city, so the search went out as a bare term from a
viewport in the middle of the country and came back with businesses across the
border and rows with no city at all. USA / US / United States now works
like every other country. A city that names its country (Anchorage, USA) is
unaffected.
A country is now bounded by its border, not by a rectangle around the
planet. A search area larger than ~6° across used to be refused its exact
outline and filtered by a bounding box instead, on the reasoning that for
something that big a box is a fair approximation. For a country that crosses
the antimeridian it is not an approximation of anything: the box runs from
−180° to 180°. Every business on Earth in the right band of latitude counted as
inside, so a country search could label businesses in entirely different
countries insideSearchArea: true, and geoStrictMatch had nothing to enforce.
Country-sized areas now get their real boundary, simplified in transit so it
stays small (a country's outline costs about 50 KB). What this changes for
you:insideSearchArea tells the truth for country searches; geoStrictMatch=true
over a country now actually keeps the country; the "somewhere else nearby"
count on the results map is meaningful again. In the default (non-strict) mode
the number of rows you get is unchanged — out-of-area places were kept before
and are kept now, they are simply labelled honestly.
Limits and billing are unchanged.
[1.6.104] - 2026-07-14
Strict means strict: the fallback rows are gone
A strict run no longer saves places from outside the search area — ever.
When strict filtering would have saved nothing, the Actor used to save the
outside-area places it had already fetched (geoStrictFallback=true) so that
work already paid for produced something. What it produced was the one thing
the user had ruled out, charged as dataset rows.
Now: nothing outside the area is saved and nothing outside it is billed. A run
that finds nothing returns one diagnostic row, exactly like every other run
with no places to show — and names which towns the results were actually in
(OUTPUT.searchStats.nearbyCitiesWithResults, and in the log). A city's
official boundary is often far smaller than the city people mean (Mendoza's is
54 km², its suppliers are in Godoy Cruz, Guaymallén and Maipú), so that list is
usually the answer: search those towns as Location(s) of their own.
The geoStrictFallback and geoStrictFallbackReason dataset fields no longer
exist. Nothing else about strict mode changed: in-area places are saved,
out-of-area places are dropped, and one in-area place is enough to keep the run
going as before.
An empty area is abandoned, not searched to the end. A strict run sends its
seed in alone. If the seed comes back with a full page of results and not one
of them inside the area, the run stops there instead of opening viewport after
viewport that can only answer the same way: a hopeless search now costs one
map view instead of fifteen. If the seed does find something inside, the grid
is laid over the area at once.
[1.6.102] - 2026-07-14
A small run stops being a coin flip
The same search returned 5 places one time and 19 the next. A map view is
looked at more closely when Google fills it — that is Google saying it has more
than it will show. But "full" means "as many places as this run still wants",
so a run asking for 20 called a view full at 20 results, and whether Google put
25 or 6 on its first page is luck. A run that lost that toss never looked
closer anywhere, and returned a third of what was there.
A run that asks for fewer places than one map view can hold now covers its
search area from the start, instead of waiting for a signal it cannot trust.
Measured on one such search, four runs each: 5, 5, 18, 19 places before —
19, 19, 20, 20 after, and each place costs a third less than it did. Runs
that ask for more (the default is 50 per search term, and 250 or 400 is
common) are untouched: their first view fills up whenever the ground has
anything on it, so they already find their way. Nothing about them changed —
the same places, for the same money.
[1.6.100] - 2026-07-14
A city with an accent in its name is no longer searched as its whole province
"Neuquen, Argentina" could still resolve to the 5°-wide province, and a
strict run then filtered against half of Patagonia and saved six places. The
rule that takes the city when a name belongs to both the city and the region
around it only ever ran on the main geocoder. That one allows a single request
a second, so when it is busy the fallback answers instead — and the fallback
ranked the province first, because its name matches the query exactly. The
fallback now reads a location the same way, and the main geocoder is asked a
second time before it is given up on: it is the only one of the two with real
boundaries, and a miss from it usually means "busy", not "no such place".
Names are compared without their accents. The geocoders do not agree with
themselves about them: for "Córdoba" the fallback spells the city without its
accent and the province with it. Those read as two different names, so the
rule above never fired and Córdoba was searched as a 612 km-tall province.
Córdoba, Neuquén, São Paulo, Salta, Mendoza and Santa Fe now all resolve to the
city, as Google Maps reads them, while "California, United States" stays the
state.
The fallback no longer answers with a building when it does not know the
place: "Provincia del Neuquén" used to come back as a racetrack at street zoom.
A search area has to be somewhere people live, not an address.
[1.6.98] - 2026-07-14
The Actor tunes itself — the advanced settings are gone
Nine settings removed from the input: places per map view, subdivision
on/off and depth, zoom, zoom passes, worker count, request timeout, request
spacing, and address reverse-geocoding. Nobody should have to know what a
viewport or a subdivision depth is to get a good run, and a wrong guess at any
of them quietly cost coverage or money.
The Actor now decides all of it from the job in front of it: the grid over the
search area picks its own zoom from the size of the area, workers scale with
the queue, and map views are split where Google truncates them. Address
reverse-geocoding is gone for good — it invented addresses for businesses that
have none (see 🚚 Keep businesses with no address instead).
Runs saved before this that still pass those fields keep working exactly as
they did.
[1.6.97] - 2026-07-14
Keep the businesses that have no address, if you want them
New input: 🚚 Keep businesses with no address (keepUnverifiedLocations).
A mobile plumber, an electrician who drives to you, a studio run from home —
Google publishes no address for a business with no storefront, and fills its
coordinates with a placeholder that several unrelated firms can share. Strict
area matching has nothing to check such a place against, so it was dropped: one
real run dropped 7,315 of them.
They are not bad rows. Name, phone, website, category and reviews are all
there — for lead generation they are often the best rows in the dataset, and
they are exactly the leads a strict run could never see. Turn this on to keep
them while the rest of the run stays strict. They are saved with
locationUnverified=true, no insideSearchArea value,
and an empty address rather than an invented one.
Measured on a strict run of one city: 351 places without it, 372 with — and 91
of the 92 extra rows carry a phone number or a website. Off by default, so no
run changes and no bill changes unless you ask.
[1.6.96] - 2026-07-14
A map view is read in waves, not page by page
Google returns a view's places twenty at a time, and the Actor asked for each
twenty only after the last had arrived. The pages of one view are now read side
by side — but only once Google has filled the first, since most views hold
nothing else and reading ahead into an empty view is traffic paid for nothing.
Same places, same cost, a shorter run.
Pages are read on the same proxy IP that opened the view: Google hands the rest
of a result list to the session that started it, and pages asked for from other
IPs come back empty (which would look like a view with nothing left in it, and
quietly cost a quarter of the places).
[1.6.95] - 2026-07-14
Big runs stop queueing
Covering an area means many more searches than splitting one seed did, and
a run of 5 search terms across 10 cities now queues around 850 of them. On the
8 workers it was configured with, such a run spends most of its life waiting in
its own queue — the work is network-bound and the Actor's CPU sits at about 4%.
A run whose queue dwarfs its workers is now given more of them, up to three
times what it was configured with. Every search already holds its own
residential IP and requests are spaced per IP, so this asks no more of any one
exit: measured on the same input, 241s on 8 workers, 159s on 16 and 130s on 24
— the same places, the same cost, and no failed searches. A run configured with
very few workers on purpose stays that way.
[1.6.94] - 2026-07-14
Same places, half the time and half the cost
1.6.92's second look was paying full price. Where Google truncates a map
view, the Actor asks again from another IP — but it was re-reading the whole
view, page after page, every time. Two answers from Google differ in how they
are ranked, so a place missing from the first shows up near the top of the
second: reading the top of the list finds the same places for a fifth of the
traffic. The second look is also skipped where it cannot pay — when the search
term has nearly filled its maxCrawledPlacesPerSearch quota (whatever it found
would be dropped anyway), and deep in the map where a view is a few streets
across and there is no held-back tail.
On a run of 5 search terms across 2 cities capped at 250 places each: the same
work now takes 217s instead of 421s and costs half as much, for 9% fewer
places. Against the build before this coverage work: +33% places for +60%
time, where it had been +48% for +250%.
[1.6.93] - 2026-07-14
Sprawling cities are searched properly
A city that reaches out in arms was searched as if the arms were empty.
Oklahoma City's limits cross 1,600 km² of them. A map view laid over one arm is
mostly city, but wide enough to take in the countryside beside it — and Google,
which ranks a view's results outward from its center, fills it from the nearest
town: 120 results, 8 of them in the city. The Actor read those 8 as "there is
nothing here" and never looked closer, so the arm was never searched at a zoom
that could see it.
A view standing on the city is now looked at more closely even when it comes
back with a thin catch, as long as Google truncated it. A view that merely
reaches into the city from outside still has to show real local supply first,
so a search that has drifted into the next town does not start exploring it.
Measured on Oklahoma City, four runs: 146 → 207 places per run (+42%), and
36% more across the runs. Compact cities are unaffected — they have no arms.
[1.6.92] - 2026-07-14
The search now covers the city, and a run stops depending on luck
A run used to find 130 places or 300 for the same input, and which one was
chance. The search started from the viewport the geocoder returns, which is
framed around the city rather than on it — for a large city it reaches well
past the boundary. Quartering that viewport sent three of its four quarters
into the countryside, and Google, which ranks a map's results outward from its
center, answered them with the neighbouring towns. Those searches found almost
nothing in the city, so they were never taken further, and the whole search
then rested on whether one of them happened to land on something.
The search now starts from a grid laid over the requested area itself — as
close-up as it can be while still covering the city, spaced to reach its edges,
with every viewport centered inside it. Nothing starts out in a field.
A place Google leaves out of one answer is asked for again. Google does not
answer the same map view with the same list twice. Where it truncates a view —
telling us it has more than it will show — the Actor now asks a second time
from a different IP and keeps whatever the first answer left out.
Measured on two cities, four runs each: Nottingham 134 → 360 places per run
(and no more collapsing runs: 355-368 every time, against 130-318 before);
Oklahoma City +40% on everything found across the runs. Sprawling cities
whose limits reach out in arms still vary more than compact ones.
[1.6.91] - 2026-07-14
More places inside the search area, for the same money
The map is now cut where Google is holding places back. A viewport was
split into four smaller ones only when it showed a large number of rows from
the requested area — a guess at "there is more here" that refused areas Google
had plainly filled to its limit, while spending four searches on areas that
had already shown everything they had. Filling the viewport to the limit is
Google saying it has more than it will show at this zoom, so that is the
condition now. Measured on one city: +18% places found, and the extra
places cost a fraction of what they earn.
A search area no longer costs a search page. Every viewport fetched
Google's ~52 KB search page just to read one url out of it — a url that
carries the viewport inside it and can be re-pointed at any other. The page is
now fetched once per search term and reused, which is most of the cost of the
small viewports the Actor opens the most of. Same places, ~30% less proxy
traffic and a noticeably faster run; a term whose url stops working falls back
to fetching the page, so this can never cost a result.
Together: on the same input, +21% more places at the same cost per place.
[1.6.89] - 2026-07-13
Fixed: a long search term could lose a whole search area to the retry meant to save it
The retry added in 1.6.87 built a proxy session id the proxy would not
take. A search area is pinned to one residential exit IP by a session id
built from the search term, and when Google's first page came back thin, the
retry appended a counter to that id to ask for a fresh IP. For a long search
term the id was already at the 49-character mark, so the counter pushed it to
51 — one past what Apify Proxy accepts. The request was rejected before it
left the Actor with "session_id is longer than maximum allowed length 50",
and the search area died on the retry that existed to rescue it.
A search term long enough to fill the id was exposed whenever Google happened
to answer thin — a search that hit both could fail its viewport and save no
places at all.
Session ids are now brought into the proxy's limits at the single point where
they are handed over, so no caller can build one that overflows. An id too
long to keep whole keeps a readable head plus a digest of the rest, so the
same search area still maps to the same IP and two areas never collapse onto
one.
[1.6.87] - 2026-07-13
Fixed: a viewport could report "no places" while Google was handing them over
One odd reply from Google threw away a whole search area. Google normally
puts a viewport's results in one slot of its search response. Every so often
it answers the same query at the same viewport with a thin envelope
instead: that slot is absent, and a place sits deeper in the tree. The parser
read only the usual slot, so it saw nothing — and "nothing" is how a viewport
reports that it is empty, which then stopped its pagination and its
subdivision too. A search that would have returned a full page of places
returned none, and the run told the user to check their spelling.
Replaying one such search against Google 14 times, it answered with 13–18
places 11 times and with the thin envelope twice — the reply Google sends is
not deterministic, so this was quietly costing places in every run, not just
the ones that ended at zero.
The thin envelope is now read, and a first page that comes back thin or empty
is retried on a fresh proxy IP before the viewport is written off. Replaying
the same search 10 more times after the fix: 14–17 places every time, no
zeroes.
[1.6.86] - 2026-07-13
Fixed: a city that shares its province's name searched the whole province
"Mendoza, Argentina" resolved to the 619 km-wide province, not the city.
Nominatim ranks by importance, and a province always outranks its own
capital, so the Actor searched — and, under geoStrictMatch, filtered
against — half a province. Google Maps reads such a query as the city, and so
do users. When a name is both a region and a city inside it, the city now
wins: Mendoza, Neuquén, Córdoba, Salta, Santa Fe, San Juan.
Guarded in both directions. A capital whose name genuinely differs leaves its
province alone — Tucumán's capital is San Miguel de Tucumán, so "Tucumán"
still means the province. And a same-named place elsewhere cannot hijack a
region: "California, United States" is still the state, not the town of
California, Kentucky. Say "Provincia de Mendoza" (its local form) or pass
customGeolocation to search a whole province on purpose.
An oversized boundary is now simplified, not discarded. Boundaries above
25,000 points were thrown away, leaving a rectangle — Neuquén province has
44,927, so a strict run there matched against a 5°-wide box, i.e. half of
Patagonia. Such boundaries are now thinned to fit (44,927 → 22,464 points),
which costs tens of metres of accuracy and buys back the actual area.
Clearer
The run diagnosis no longer claims that dropped places "had no coordinates".
They have coordinates — a placeholder, because they are businesses with no
storefront. The hint now says so, and points out they are real businesses you
can keep by turning geoStrictMatch off.
additionalLanguages was undersold as uncovering "slightly different
translated categories and rankings". Measured, three extra languages find
20–35% more places than English alone, because each pass reshuffles
Google's ranking and surfaces a different long tail from under the
~120-per-viewport cap. The field now says that, and advises adding the local
language of the target city, which contributes the most.
[1.6.85] - 2026-07-13
Fixed: strict mode saved places from neighbouring towns
A province that shares its capital's name no longer lets other towns in.
A place outside the boundary counts as inside when Google files it under the
requested place name — but the check also read the state field, and a
province usually carries its city's name while covering far more ground.
Google writes Alcorcón's addresses as "28925 Alcorcón, Madrid", so a
strict Madrid search saved 87 places from Alcorcón, Leganés, Alcobendas and
other municipalities. The name match now reads only the settlement fields
(city/town/municipality). Verified against the reported 8,109-place run: 97
such rows are now correctly dropped, and Nottingham's postal suburbs still
count as Nottingham.
The biggest metropolises get their real boundary. Exact OpenStreetMap
boundaries were only fetched for areas spanning under 1.0° (~111 km), which
excluded Sydney (1.08°), Singapore (1.01°) and Dubai (1.49°) — so the cities
people search most were filtered by a rectangle while smaller ones got a real
outline, and geoStrictMatch was effectively box-matching for them. The cap
is now 6.0°; the 25,000-point limit is what protects CPU, and country-scale
areas still fall back to a box, where one is a fair approximation anyway.
Point-in-polygon now rejects a place against the boundary's own bounding box
before walking its vertices — one comparison instead of 13,000 for the places
that are nowhere near it. Measured worst case (London, 12,919 points): 4 ms
per place.
[1.6.84] - 2026-07-13
New: a map of the results
Every run now writes an interactive 🗺️ Map of the results, available from
the run's Output tab (key-value store record results-map). It draws the
resolved search area — the exact OpenStreetMap boundary where one exists,
otherwise the bounding box, marked as approximate — and plots every place on
top of it, so a glance answers the question a table cannot: did it search
where I meant, and where did the results land?
The map is published before the first place is scraped and refreshed as
rows arrive. A map that only appears when the run ends cannot tell you the run
is searching the wrong city — which is most of what a map is for.
Pins are coloured by how they relate to that area, which is also the honest
way to show this release's area work: inside the boundary, outside it
but filed by Google under the place you searched (an NG postcode in Beeston
is Nottingham — a green pin outside the blue outline is correct, and the
legend says why), and somewhere else nearby.
Clicking a pin opens the place: name, category, address, rating and review
count, phone, and links to Google Maps, the business's website, and
the complete dataset JSON for that exact row — so the map is a way into
the data, not just a picture of it.
Places whose coordinates are only a placeholder (locationUnverified) are
not pinned. Dropping a confident marker on a location that does not exist
is exactly the lie the rest of this release removes; they are counted in the
legend instead, so nothing goes silently missing.
Search area: fewer junk rows, far better coverage
A place is now judged against the requested place, not just its polygon.
Administrative boundaries are tighter than postal cities: Google files a
Beeston or Burton Joyce address under Nottingham with an NG postcode, but
OSM's City-of-Nottingham polygon excludes both. Those rows were being marked
insideSearchArea=false. A place outside the boundary now counts as inside
when Google's own city/state field names the requested location and the place
is close enough (≤25 km) for that name to be plausible. The match reads the
structured address fields, never the raw string, so "Nottingham Road, Derby"
is still Derby, and a Bristol in Tennessee is still not Bristol, UK.
This was not a cosmetic flag. The subdivision gate counts in-area results to
decide whether an area is dense enough to split, so misreading half the city
as out-of-area stopped exploration early. Measured on the platform, same input
(Nottingham, plumber + electrician, limit 500): 113 → 290 in-area places
(+157%) in the same 75 seconds, for the same searching. On a 6-term Bristol
Nottingham run, rows flagged
out-of-area dropped from 56% to 14% — the flag now means what it says.
Strict runs (geoStrictMatch=true) gain the most: they were dropping those
real in-city places outright.
Results hundreds of kilometres away are dropped, not saved. Google
sometimes answers a São Paulo search with restaurants that really are in São
José dos Campos, address and all. Rows more than 50 km outside the search
area — with an address of their own that puts them there — are now dropped
in every mode, because nobody asked for them and every saved row is billable.
If a run finds nothing but far-away rows, they are still saved (flagged)
rather than leaving an empty dataset that looks like a failure. New
searchStats counter: farOutlierPlacesDropped.
Stopped inventing addresses for businesses that have none. ⚠️ Changes a
default. About 22.6% of Google Maps results are businesses with no
storefront — a mobile plumber, a service-area contractor. They have no street
address, Google publishes none, and the coordinates it returns for them are a
placeholder: twelve unrelated companies shared a single point in Radford,
another twelve shared one house on Station Road in Bristol, and some
placeholders land in another country.
The Actor was reverse-geocoding those placeholders into confident,
fully-formed addresses. A Nottingham search returned "B&G Duct Cleaning
Services - East Midlands" (.co.uk website, UK mobile number) living at a
street address in Bavaria with countryCode=DE; other rows were given a high
school, a bridleway, a road number, and a private house as their business
address. That is not recovering a fact, it is inventing one.
So reverseGeocodeMissingAddress now defaults to false. These places keep
everything that is real about them — name, phone, website, category — with an
empty address and the new locationUnverified=true field, and
insideSearchArea left unset, because a placeholder coordinate proves
nothing. They are never dropped as far-away junk either: no-storefront
businesses are the hardest leads to find, not the ones to throw away. Strict
mode does refuse to save what it cannot verify, counting them under
unknownAreaPlacesDropped — so a strict run returns fewer, better-verified
rows than before. New searchStats counter: unverifiedLocationPlaces.
The flag is now marked deprecated, because it has no case where it helps.
It only ever fires on results with no address, and that population turns out
to be exactly one thing: businesses with no premises. Google's own place page
has no address for them either (16 of 16 checked), and OpenStreetMap does not
know the business at all (0 of 16) — there is nothing to look up. Places that
physically exist always come with an address, even a waterfall or a beach, so
the option never touches them. It is kept only so existing inputs that set it
keep working.
Several locations in one run are judged as one search area. Places are
deduplicated globally, so whichever location's search happened to surface a
place first was the only one that ever judged it — and it judged it on its own
behalf. Asking for Delft and The Hague returned twelve plumbers with literal
"Den Haag" addresses stamped insideSearchArea=false, because Delft's search
found them first. A place is now inside the search area when it is inside
any of the requested areas, and is only junked when it is far from all
of them (so a Bristol place is not thrown away for being 170 km from
Nottingham). Measured on the platform: mislabelled rows went 12 → 0.
The biggest cities now use their real boundary. Exact OpenStreetMap
boundaries were capped at 5,000 points, and London's is 12,919 and São
Paulo's 7,075 — so the cities people search most were the ones silently
filtered by a loose rectangle instead. The cap is now 25,000 points. A
point-in-polygon test against London's full boundary costs ~3 ms per place,
the same order as parsing the search response itself.
Coverage and cost
Places per map viewport (maxPlacesPerViewport) now defaults to 120
instead of 80. 120 is Google's own cap for a single viewport, so the old
default threw away up to a third of what Google was already willing to show.
Paging deeper inside a viewport the Actor has already opened costs ~11 KB of
proxy traffic per place, while opening another viewport carries the fixed
cost of a fresh SSR page plus a first search page — so the higher default is
both more complete and cheaper per place. Measured on the platform against
the previous default: Lisbon restaurants 109 → 126 places (35s → 25s, proxy
traffic per place halved), Manchester hair salon 98 → 201 places, Lisbon
with three terms 299 → 446 places. Runs also subdivide dense areas more
readily, because a fuller first page is what proves an area is dense.
Concurrent workers stays at 8. Measured on the platform, a default-shaped
run queues only 6-18 search tasks and finishes in well under a minute, so the
workers already idle: raising the default to 24 changed nothing (299 places in
42s vs 43s). Raise it only for runs with many terms, locations, or a deep
subdivision tree.
Correctness
maxCrawledPlacesPerSearch now defaults to 50 in code, matching the input
schema. A run started through the API without that field used to get a limit
of 500 — ten times the documented default, and ten times the billable rows.
The accepted minimum (10) now matches the schema as well.
The last website contact-page parse that still ran on the event loop now runs
in a worker thread, like every other contact parse. On a large contact page it
could stall the loop, and a stalled loop cannot fire request timeouts.
Search performance
Request spacing is now applied per proxy exit IP instead of once for the whole
run. minRequestIntervalMs used to be a single lock and a single timestamp
shared by every request in the Actor, so all Google requests queued behind one
another and the run was capped at roughly 1.9 requests per second no matter
how many workers were configured — 4 workers reached 1.60 req/s, 12 reached
1.88. Spacing is what protects a residential IP from Google's per-IP rate
limiting, so it is now keyed by proxy session (one exit IP, one search task)
and by host for direct website fetches. Measured against the live proxy after
the change: 3.5 req/s at 12 workers and 8.3 at 24, at unchanged per-request
latency (~1.7s median) and with no blocks. Concurrent workers now actually
scales the run.
Delay between requests to the same IP (minRequestIntervalMs) keeps its
250 ms default and only paces the pages of a single search task. It no longer
slows the run as a whole, and lowering it rarely helps.
[1.6.83] - 2026-07-13
Run log
Reviews no longer log two lines per place. Places are enriched in small
batches, so a run with reviews on printed a "fetching" and an "attached" line
for almost every place; the totals are already reported in the Result section,
and the progress heartbeat now carries a running review count. A systematic
review failure warns once per run instead of once per place.
[1.6.82] - 2026-07-13
Website contact enrichment
Fixed the quadratic bracketed-obfuscation regexes ((at), [dot], …) that
scanned every whitespace run in a page from every position inside it. On large
pretty-printed pages they burned ~18s of CPU while matching nothing, holding
the GIL and freezing the whole Actor: search requests then outlived their
timeouts and the run lost minutes. The patterns now use bounded whitespace and
are skipped entirely unless the page actually contains a bracketed form.
Worst real page measured: 18.9s to 0.8s; contact extraction is unchanged.
[1.6.81] - 2026-07-12
Run log
The run log is now organised into sections (Setup, Run plan, Search, Result)
with one fact per aligned line. Search tasks log a single compact line each
instead of three verbose ones, and a progress heartbeat is emitted every
minute so a long search never looks stuck.
Website enrichment statistics are logged as a readable block. They used to be
a single record long enough for Apify to truncate it ([line-too-long]),
which hid the last counters.
Run diagnostics are logged once, at the end, instead of twice.
Retry lines report what actually failed (timeout, proxy error, HTTP status)
without the repeated boilerplate.
Diagnostics
New event-loop watchdog: if the Actor's event loop stalls (no timers fire,
in-flight requests silently outlive their timeouts), the run log now reports
the stall and its duration instead of going quiet. Seen in production as a
~10-minute freeze where requests reported 595s elapsed against a 30s timeout.
Website contact enrichment
One keep-alive connection per site is reused for the homepage and its contact
pages, and heavy contact parsing runs off the event loop.
[1.6.80] - 2026-07-09
Website contact enrichment
Website contact enrichment now preserves homepage contacts that were already
found when later contact-page probing is cancelled by the per-site timeout.
This prevents social/profile links and phone numbers from being lost on sites
whose contact pages respond slowly.
Extra phone deduplication now compares local trunk formats against
international/E.164 formats for all countries covered by the Actor's phone
normalization table, not only France.
Facebook extraction now rejects additional non-profile paths such as
/pages/category and /search/....
[1.6.79] - 2026-07-09
Documentation
Reworked the Actor README to follow Apify Store README guidance more closely:
clearer first-screen explanation, use cases, quick-start steps, input recipes,
pricing/cost expectations, strict-area behavior, output views, diagnostics,
and troubleshooting.
Website contact enrichment
Website contact enrichment keeps the production-proven 4-site default
concurrency after Apify A/B testing showed that more aggressive site-level
parallelism can increase website timeouts and reduce recovered emails on some
business sites.
Contact-page extraction now reads the same capped slice size as the extended
homepage pass and uses the extended homepage HTML for contact-link discovery.
This recovers emails on heavy Wix/WordPress-style contact pages where the
address appears after the previous 220 KB contact-page cap.
Website contact extraction now filters font-license attribution emails found
inside CSS/style blocks, such as font author Gmail addresses, so they are not
saved as business contacts.
Phone extraction now prefers the visible phone text inside tel: links when
it disagrees with the hidden href, and also scans already-fetched extended
homepage slices for phone links without adding extra network requests.
Social/profile extraction now rejects common content/share URLs such as
Instagram posts/reels, Facebook share links, and Pinterest pins so they are
not mistaken for business profiles.
Instagram invite links are no longer saved as business profiles, and TikTok
short links from business website social bars are now kept as TikTok links.
Website enrichment now records business-profile links found on the business
website in separate tripadvisors, yelps, and foursquares fields. These
are lead-qualification links, not direct contact details.
Added a hidden contactConcurrency override for controlled diagnostics and
tuning without changing the public input form.
[1.6.74] - 2026-07-08
Website contact enrichment
Website contact enrichment now streams finished places to the dataset as soon
as each website is processed, instead of waiting for the whole search batch.
If maxTotalChargeUsd or the soft timeout is reached, already-ready rows are
saved and unfinished website-enrichment tasks are cancelled. If the pricing
budget only allows part of a contact-enrichment batch, the Actor now stops
gracefully after that streamed partial batch instead of starting another
search task.
Changed website enrichment to fetch the homepage first and then try
high-confidence contact-style pages only when the homepage has no email.
Added capped website page downloads for contact enrichment, so large HTML
pages are cut off once enough text has been received for contact extraction.
Kept the fast homepage contact slice at 180 KB, but added a short extended
homepage pass for truncated CMS pages with no email. This recovers real
footer emails found around 250-410 KB on some Wix/WordPress sites without
making every website fetch heavier.
The extended homepage pass now runs an email-only scan instead of the full
social/phone parser, avoiding slow regex work on large pages that still do
not expose an email.
Kept the runtime enrichment path focused on homepage and contact-like pages;
framework JSON, sitemap, and metadata discovery are no longer part of the
fast path.
Improved Portuguese contact-page routing: Portugal-style .pt sites now try
contactos / contacto / contact-us without changing Brazil-style
contato priority.
Removed the early guessed contact-endpoint pass from the default runtime path.
Live A/B testing showed it added many short no-response waits without
increasing email yield on the representative contact corpus.
Direct social profile URLs such as Instagram, Facebook, LinkedIn, TikTok,
YouTube, X/Twitter, and Pinterest are now recorded as social links without
downloading those platforms as business websites. This avoids slow blocked
social-page fetches that do not produce reliable business emails.
Fixed capped website downloads that returned HTTP 206 Partial Content.
Some business sites correctly honor the Actor's lightweight byte-range
request, and those partial HTML responses are now parsed instead of being
treated as failed website fetches.
Reordered website URL fallback variants so stale http://www... business
URLs try the modern HTTPS apex host earlier. This reduces wasted timeout time
on sites that only answer on https://example.com.
Reduced default website-enrichment concurrency from 16 to 4 sites at once.
Google Maps scraping still runs concurrently, but arbitrary business websites
are now contacted less aggressively to reduce timeouts and missed contacts
caused by direct-route overload.
Contact-page probing now stops before the per-site timeout would cancel the
whole website enrichment attempt. If the homepage already yielded social links
or phone numbers, those partial contacts are returned instead of being lost
because later contact pages were slow.
Added a narrow residential homepage retry when the direct homepage response
contains no contact data at all. This targets blocked or incomplete direct
website responses without enabling deep crawling.
Improved email-domain filtering for businesses that publish a location page
on one domain but show a real parent/brand-domain email in an explicit
contact block. Template/vendor placeholders such as companyname.com,
tudominio.com, and form-service attribution emails remain filtered.
Added run-summary diagnostics for no-email contact endpoints, byte-limited
website fetches, and website/contact-page timing.
[1.6.73] - 2026-07-06
Proxy country selection
Fixed automatic residential proxy country selection for special territories
where the formal geocoder country code is not the most reliable Google Maps
proxy route. The Actor now auto-routes Hong Kong to HK, Macau/Macao to
MO, and Greenland / Faroe Islands to DK unless the user explicitly set a
proxy country.
[1.6.72] - 2026-06-29
Direct place targets
Fixed raw placeIds input resolution. Raw Google Place IDs are now searched
using an explicit place_id:<id> token together with query_place_id, instead
of a generic placeholder query, so valid Place IDs resolve like equivalent
Google Maps place URLs.
[1.6.71] - 2026-06-29
Documentation clarity
Made the main Actor Information / README strict-area documentation explicit
about the geoStrictMatch=true fallback: Google Maps uses the requested area
as a discovery viewport, and if Google requests were already made but strict
filtering would save 0 in-area places, the Actor may save a limited fallback
set of already-fetched outside-area candidates instead of returning an empty
dataset.
Clarified in README, input schema, and dataset schema that fallback rows are
marked with geoStrictFallback=true and insideSearchArea=false, are not
inside the requested area, and may be charged as dataset results.
Clarified that if at least one in-area place is found, strict mode remains
strict and outside-area places are dropped as before.
Clarified zero-place diagnostic rows: when written, they are not Google Maps
places, but they are still default dataset items and may be charged as one
result under pay-per-event pricing.
[1.6.70] - 2026-06-27
Input and warning clarity
Made geoStrictMatch and customGeolocation descriptions more explicit:
Google Maps discovery uses the map area as a viewport, and the strict
fallback activates only when Google requests were already made but 0 in-area
places were found. Fallback rows are still marked with
geoStrictFallback=true and insideSearchArea=false.
Reworded strict-fallback log and Action needed messages so high-volume
users can see that tiny grid cells should be applied after a larger-area
discovery run, not as thousands of independent discovery searches.
Renamed the structured geolocation county input label to
County / district so it is not confused with the Country field.
Clarified that structured geolocation fields such as countryCode, state,
county, city, and postalCode are an alternative to Location(s) and
customGeolocation, not fields that should normally be mixed with a custom
GeoJSON area.
Invalid hidden/API proxyConfiguration values are now ignored with a clear
input warning and replaced by the default Apify Residential proxy instead of
failing the run before a diagnostic summary can be saved.
[1.6.67] - 2026-06-24
Run summary reliability
Fixed the final result summary for direct URL/placeId runs and early skipped
zero-place runs where no search statistics exist. These runs now finish
cleanly instead of failing after the dataset row was already written.
[1.6.66] - 2026-06-24
Zero-place run handling
Added one clearly marked diagnostic dataset row for runs that save zero
Google Maps places. The row uses recordType="run-diagnostic",
isDiagnostic=true, and isPlace=false, and includes the reason plus
concrete actionNeeded steps.
OUTPUT now reports both totalPlaces and diagnosticRows, so exports can
distinguish real place rows from zero-result diagnostics.
This follows Apify's PPE guidance to make charged work visible in dataset
output instead of leaving costly zero-result runs with an empty dataset.
[1.6.65] - 2026-06-24
Log clarity
Replaced low-level per-task log labels such as internal task keys and
viewport coordinates with user-facing search task labels that show the search
text, location, language, and map depth.
Reworded per-task progress and completion lines so users can understand how
many places Google returned, how many were kept, and why any candidates were
dropped.
[1.6.64] - 2026-06-24
Log clarity
Added a clearer Run plan block that spells out the requested limits,
locations, filters, reviews, website contact enrichment, subdivision, and
concurrency in user-facing language.
Added a final Result summary block with saved places, strict-area counts,
website-contact counts, review counts, and runtime before the Action needed
section.
Changed website enrichment diagnostics from warning-style output to
informational output when they are normal run statistics rather than a user
action item.
[1.6.63] - 2026-06-24
Log clarity
Reduced duplicate warnings for runs where search terms include a city/country
but no Location(s) field is provided. These runs now skip before Google/proxy
requests with one clear input warning and a short Action needed step,
instead of also logging a misleading error and extra pipeline warnings.
[1.6.62] - 2026-06-24
Log clarity
Reworked run warnings so users see a direct next step instead of low-level
network/parser details. Recoverable retries are now logged as informational
messages, while warnings are reserved for input issues, partial coverage,
strict-area drops, charge limits, or missing optional add-ons.
Added a final Action needed section that summarizes what the user should
change to avoid the warnings in future runs.
Clarified direct URL / Place ID, customGeolocation, strict-area, charge
limit, review, and legacy filter messages.
[1.6.60] - 2026-06-24
Strict-area fallback
Added a transparent rescue path for geoStrictMatch=true searches that would
otherwise save 0 places after spending Google Maps requests. If strict area
filtering removes every candidate in the search run, the Actor now saves the
outside-area candidates instead of returning an empty dataset.
Rescued rows are clearly marked with geoStrictFallback=true and
insideSearchArea=false, and the run log/output explains that strict
filtering was relaxed because no in-area places were found.
If at least one normal in-area place is saved, strict mode stays strict and
outside-area places are still dropped as before.
[1.6.58] - 2026-06-24
Cost protection
Changed startup subdivision to be adaptive for strict-area and active-filter
searches. The Actor now starts with the parent seed viewport first and only
adds child viewports after Google shows enough target-area supply, reducing
wasted work on 0-result runs where all candidates are outside the requested
area or removed by filters.
Kept eager startup subdivision for low-risk broad searches so large normal
runs can still start fast when results are expected to be saved.
[1.6.52] - 2026-06-24
Cost protection
Added pay-per-event budget checks before optional website contact enrichment
and review extraction. The Actor now estimates the full planned row cost
(place scraped + contact add-on + requested review add-ons) before spending
proxy/network requests on rows that cannot fit inside maxTotalChargeUsd.
Capped oversized input arrays for search terms, extra languages, direct
Google Maps targets, and search URLs to prevent accidental or abusive runaway
runs. Larger jobs should be split into separate runs.
Added Actor memory limits in actor.json so users cannot start the Actor
with unnecessarily high memory and inflate platform cost.
[1.6.51] - 2026-06-21
Review extraction correctness
Replaced runtime review extraction with Google's BOQ local-search reviews
endpoint. This endpoint returns real review pages directly and does not need
browser UI replay, SSR warm-up, or the old listugcposts fallback.
Fixed review pagination for high maxReviewsPerPlace values by following
BOQ continuation tokens page by page.
Added regression coverage for BOQ review parsing, pagination, timestamp
normalization, text cleanup, and the no-SSR/no-listugc runtime path.
[1.6.50] - 2026-06-21
Review extraction correctness
Internal build that tested listentitiesreviews pagination. Live testing
showed that endpoint can return an empty payload for places that do have
reviews, so it was superseded by 1.6.51.