I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads 2GIS — the business directory that covers Russia, Kazakhstan, the UAE, Uzbekistan, Czechia, Chile and a dozen more countries, and that most Western scraping stacks have never heard of. It is a Google Maps substitute in places where Google Maps is thin, and unlike Maps it hands out e-mails.
This week I went to do the boring thing everybody does with review data: pull a category, pull the reviews, recompute the average, see who is really good. It did not reconcile, and the reason is a field pair that looks redundant and is not.
The two counters
Every place row comes back with both of these:
"rating": 4.6, "reviewCount": 30, "ratingCount": 41 reviewCount is 2GIS's general_review_count. ratingCount is general_review_count_with_stars. The second one is always the larger of the two, because on 2GIS you can tap stars without writing anything — and the reviews feed only serves the ones with text.
On a 40-row pull of кофейня in Novosibirsk (438 results exist; I took the first 40):
Places where ratingCount > reviewCount | 40 of 40 |
Sum of reviewCount | 34,509 |
Sum of ratingCount | 72,030 |
| Readable share of the vote base | 47.9% |
| Median readable share per place | 61.1% |
| Worst place | 19.4% |
| Best place | 87.7% |
So across this sample, slightly more than half of the opinions that move a 2GIS star rating have no text attached and are not in any feed you can page through. That is not a scraper limitation I can fix with a better proxy; the endpoint does not carry them.
Then the arithmetic stopped working
To check whether the readable half is at least a fair sample, I took the smallest place in the set — Breakfast house, Красный проспект 31, 30 text reviews against 41 star votes — and pulled every review it has.
{ "placeUrls": ["70000001117226436"], "includeReviews": true, "maxReviewsPerPlace": 500 } 31 reviews came back (the counter said 30 — it lags; one arrived that morning). All 31 had text. 26 of 31 carried an owner reply, 18 carried photos, and they spanned 25 August to 4 November 2026.
The star distribution of those 31: 24 fives, 1 four, 1 three, 3 twos, 2 ones. Sum 135, mean 4.355.
The page says 4.6.
Before blaming the ten invisible voters, I checked whether they could close the gap. Give every one of the ten unseen votes a perfect 5:
(135 + 50) / 41 = 4.512 Still not 4.6. The displayed rating is not the arithmetic mean of all 41 votes under any assignment of the ten I cannot see — the maximum reachable value is 4.51. 2GIS is weighting something: recency, reviewer history, something it does not publish. I cannot tell you which, and neither can anyone reading the API. What I can tell you is that it is settled, not a rounding artefact.
Practical consequence: do not recompute the rating from scraped reviews and present it as the rating. They are two different numbers measuring overlapping but unequal populations, and on a real place they came out 0.25 stars apart in my sample — in the direction that makes the business look worse than its page does.
If you want a defensible metric from review text, compute it and name it for what it is — "mean of 31 readable reviews" — and keep rating beside it as the platform's own figure.
The other number that is not about this place
Chains make the same mistake available in a second flavour. Every row carries orgRating and orgBranchCount next to rating, and orgRating is the brand's score across all its branches.
Of my 40 coffee shops, 24 were branches of a multi-location brand. In 21 of those 24 the brand score was higher than the branch's own, mean gap +0.29, worst case +0.9: Куликов on улица Кирова sits at 3.8 with 1,002 reviews while the 41-branch brand averages 4.7.
The sample also contained the same brand several times over, which makes it very visible:
| Brand | branch ratings in the sample | orgRating |
|---|---|---|
| Академия Кофе | 4.6, 4.5, 4.5, 4.5 | 4.7 |
| Skuratov Coffee | 4.5, 5.0, 5.0, 4.7 | 4.8 |
| Brewsell | 4.1, 4.4, 4.6 | 4.7 |
| Чашка кофе | 3.4, 3.8 | 4.3 |
Pick the wrong column and every branch of Чашка кофе scores 4.3, including the one the neighbourhood rates 3.4. Sort a lead list by orgRating and you have sorted brands, not locations.
Running it
The Actor is plain HTTP against the two public JSON APIs the 2GIS web app uses — no browser, no login, no API key of your own. 2GIS Places Scraper, one row per place, reviews as an optional second row type.
curl -X POST "https://api.apify.com/v2/acts/lergassy~2gis-places-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"queries":["кофейня"],"cities":["Новосибирск"],"maxPlacesPerQuery":2,"includeReviews":true,"maxReviewsPerPlace":3}' That exact call, this morning: 8 rows in 6.6 s, charged place ×2 and review ×6 = $0.0082.
Reading it back, with the two traps handled:
rows = [r for r in items if r["type"] != "notice"] # the free summary row places = [r for r in rows if r["type"] == "place"] reviews = [r for r in rows if r["type"] == "review"] for p in places: readable = p["reviewCount"] / p["ratingCount"] # how much of the rating you can read mine = [r["rating"] for r in reviews if r["placeId"] == p["id"]] print(p["name"], p["rating"], round(readable, 2), round(sum(mine) / len(mine), 2) if mine else None, "chain" if (p["orgBranchCount"] or 1) > 1 else "solo") Three small things that bite if you skip them:
- The last row of every run has
type: "notice"and is a free plain-English summary of what you were charged. It is not data. Filter it. - Reviews are a separate row type in the same dataset, joined by
placeId, not nested inside the place. Export to CSV without splitting bytypeand you get a sheet with two different column sets stacked. -
emailswas filled on 18 of my 40 places,phoneson 36,websiteson 34,socialson 36. For contact data that is a good hit rate by directory standards, but it is a directory, not a verifier — the e-mail is whatever the business typed into its listing.
One thing that genuinely needs a proxy
While writing this I fired the same reviews endpoint straight from a plain datacenter host with curl, to grab a raw response for the article. It never answered — still hanging after 120 seconds, when the identical request through the Actor on Apify's datacenter proxy had returned in under seven. The catalog API does this too; it is why the Actor retries twice and then falls back to a residential address instead of simply timing out. If you build your own 2GIS client and it "works on my laptop, hangs in CI", that is the thing you are hitting, not rate limiting.
When not to use it
2GIS is organised by city, not by country — Москва, Алматы, Dubai work, Russia does not, and an uncovered city comes back as one free error row. Reviews are $1 per 1,000 on top of $1.50 per 1,000 places, so turning them on across a 5,000-place sweep is a real bill; sample instead. And if the number you actually need is the vote count behind the star rating rather than the readable reviews, take ratingCount and stop — the other 52% is not coming.
