This repository is a short, tested Python driver for the 2GIS Places Scraper on Apify. 2GIS is the map and business directory people in Russia, Kazakhstan and the UAE actually open to find a pharmacy at 11 pm, and its search pages are a dense record of what trades where. You supply city slugs exactly as they appear in a 2GIS address (moscow, spb, almaty, dubai) and keywords in the local language, and you get back a DataFrame-ready list of outlets with rubrics, street address, district, latitude and longitude, star rating, review total and opening hours per weekday. Apify does the fetching; your notebook only reads results.json and results.csv.
Actor: 2GIS Places Scraper · Publisher: Devil Scrapes · Also in JavaScript: 2gis-scraper-javascript
- Cities times queries, in one run: two city slugs and three keywords become six separate searches, and every row records which
searchCityandqueryproduced it. - Branch and brand kept apart:
placeIdidentifies one physical outlet, whileorganizationIdis the id its whole chain shares, so counting stores and counting companies are two differentnuniquecalls. - Each outlet appears once per run: overlapping keywords such as
кафеandкофейняfind the same venues; the second sighting is dropped and never charged. - Stops where 2GIS stops: a single search tops out at five pages of twelve. When 2GIS starts handing back its first page again, the Actor notices the page number mismatch and ends that search rather than emitting copies.
- Contacts only when you ask: with
includeContactson, one extra request per outlet fillsphone,phones,websiteandemailwhere the business has published them; a failed contact page keeps the row without them. - A plain-language run summary: the status message lists every city and keyword with its row count and the reason it stopped, and a flaky page is retried with growing pauses before it is given up on.
Group pharmacies or beauty salons by district and divide by population figures you already hold. An analyst comparing Kazan with Yekaterinburg gets outlets per resident without counting pins on a map by hand.
Numeric latitude and longitude load straight into GeoPandas, Folium or kepler.gl. Filter one organizationId to see where a chain clusters, then overlay competitors from a second query in the same notebook.
A cron entry that repeats the same city and keyword list weekly, appending rows with their scrapedAt stamp, turns the rating and reviewCount columns into a time series. Merge on placeId to see which venues gain reviews fastest.
workingHours is a dict keyed Mon to Sun. Expand it into columns and you can answer questions such as how many pharmacies in a district are open late on Sunday, a classic input for courier and on-call planning.
You need an Apify account and an API token from Console → Settings → Integrations. Apify's free plan includes monthly usage credit; this Actor's event prices are listed under Pricing below.
git clone https://github.com/devilscrapes/2gis-scraper-python.git
cd 2gis-scraper-python
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
export APIFY_TOKEN=your_token_here
python main.py # uses input.json
python main.py my-input.json --out data --max-charge 2The script starts the 2GIS Places Scraper, waits for it to finish, prints a link to the run in Apify Console and saves every result to results.json and results.csv. It exits with code 1 and the run's status message if the run fails.
Reduced to essentials, the integration is the block below: apify-client starts the run, blocks until it finishes, then iterates the dataset rows.
from apify_client import ApifyClient
client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("DevilScrapes/2gis-places-scraper").call(run_input={'cities': ['moscow', 'spb'], 'queries': ['кафе', 'аптека'], 'maxItemsPerQuery': 10, 'maxPages': 2, 'maxResults': 10, 'includeContacts': False, 'proxyConfiguration': {'useApifyProxy': True}})
for item in client.dataset(run.default_dataset_id).iterate_items():
print(item)main.py hands input.json to the Actor untouched. This one asks two cities for cafes and pharmacies, reads at most two pages per search and stops at ten outlets for the whole run, which keeps the first bill to cents. City slugs come from the 2GIS address bar; Kazakh and Emirati cities route to their own country site on their own. Keywords work best in the language locals type, Russian for most cities and English in Dubai.
{
"cities": [
"moscow",
"spb"
],
"queries": [
"кафе",
"аптека"
],
"maxItemsPerQuery": 10,
"maxPages": 2,
"maxResults": 10,
"includeContacts": false,
"proxyConfiguration": {
"useApifyProxy": true
}
}| Field | Type | Required | Default | Allowed values |
|---|---|---|---|---|
cities |
array | no | ["moscow"] |
— |
queries |
array | no | ["\u043a\u0430\u0444\u0435"] |
— |
maxItemsPerQuery |
integer | no | 60 |
— |
maxPages |
integer | no | 5 |
— |
maxResults |
integer | no | 1000 |
— |
includeContacts |
boolean | no | false |
— |
proxyConfiguration |
object | no | {"useApifyProxy": true} |
— |
proxyCountryCode |
string | no | — | — |
Each record is one outlet. rating, reviewCount and the hours dict are null when 2GIS shows nothing for them, so fill or drop those before averaging. categories is a list of every rubric 2GIS assigns, while category holds the one 2GIS marks as primary. isPromoted flags paid placements, which you may want to exclude from density counts.
| Field | Type | Always present |
|---|---|---|
placeId |
string | yes |
name |
string | yes |
organizationId |
string or null | no |
category |
string or null | no |
categories |
array | no |
address |
string or null | no |
addressComment |
string or null | no |
buildingName |
string or null | no |
postcode |
string or null | no |
city |
string or null | no |
district |
string or null | no |
latitude |
number or null | no |
longitude |
number or null | no |
rating |
number or null | no |
reviewCount |
integer or null | no |
phone |
string or null | no |
phones |
array | no |
website |
string or null | no |
email |
string or null | no |
workingHours |
object or null | no |
updatedAt |
string or null | no |
isPromoted |
boolean | no |
url |
string | yes |
query |
string | yes |
searchCity |
string | yes |
scrapedAt |
string | yes |
| Event | Price (USD) |
|---|---|
| Result emitted | $0.003 |
Prices as configured on 2026-11-03. The Actor page always shows the current price. Runs are billed to the Apify account that owns the token. Use --max-charge to cap what a single run can cost.
The pandas snippet below (pip install pandas) summarises coverage per city and keyword (distinct outlets, distinct chains, median rating, summed reviews) and writes the non-promoted outlets with coordinates to map_points.csv for a mapping library.
import json
import pandas as pd
rows = pd.DataFrame(json.load(open("results.json", encoding="utf-8")))
rows["rating"] = pd.to_numeric(rows["rating"], errors="coerce")
rows["reviewCount"] = pd.to_numeric(rows["reviewCount"], errors="coerce").fillna(0)
coverage = (
rows.groupby(["searchCity", "query"])
.agg(places=("placeId", "nunique"), chains=("organizationId", "nunique"),
median_rating=("rating", "median"), reviews=("reviewCount", "sum"))
.reset_index()
)
print(coverage.to_string(index=False))
organic = rows[~rows["isPromoted"]].dropna(subset=["latitude", "longitude"])
organic[["placeId", "name", "category", "district", "latitude", "longitude", "rating", "url"]].to_csv(
"map_points.csv", index=False
)No. 2GIS licenses its own catalogue API commercially, with keys and quotas. This Actor reads the search pages any visitor sees and needs only your Apify token, which suits one-off studies and recurring notebook jobs.
That is the ceiling 2GIS itself applies to any single search. To go deeper, split the keyword into narrower ones (пиццерия, кофейня, столовая instead of just кафе) or add neighbouring cities; duplicates across those searches are removed for you.
Yes. Call python main.py my-input.json --out data/$(date +%F) from cron or Airflow and you get a dated folder per run. A non-zero exit code means the run failed, which lets the scheduler raise an alert.
No. Each row carries the aggregate star rating and the review total, not review text, menus or images. Pair it with a review scraper if you need the wording behind the score.
Set APIFY_TOKEN first: export the token in the same shell, or put it in a local.envfile your shell loads. Never commit it.- Run finished with
FAILEDorABORTED: open the printed Console link; the run log says why. Input validation errors name the field. - Fewer rows than expected: a
--max-chargecap also limits how many results can be charged. Raise the cap or lower the limits in your input. - Something looks wrong in the data: open an issue on the Actor page so the maintainers see it.
- 2GIS Places Scraper on Apify Store
- JavaScript version of this example
- All Devil Scrapes Actors
- Apify web scraping cookbook
Related Actors:
- Yandex Maps Places Scraper — the other big Russian map directory, for cross-checking coverage.
- Yandex Maps Reviews Scraper — review text behind the star ratings in the same cities.
- Zomato Restaurants Scraper — restaurant listings for markets 2GIS does not reach.
MIT licensed example code. The Actor itself runs on Apify and is maintained by Devil Scrapes. Not affiliated with 2GIS or its parent company.