Skip to content

About

2GIS scraper in Python: search any 2GIS city (Moscow, Almaty, Dubai) by category and pull rubrics, WGS84 coordinates, rating, review totals and weekday hours into pandas, a notebook or a nightly cron export. Python example for the Devil Scrapes Apify Actor.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

2GIS Places Scraper — Python example

2GIS Scraper in Python: city business listings for pandas and notebooks

Run on Apify Python MIT license CI

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

🔥 What you get

  • Cities times queries, in one run: two city slugs and three keywords become six separate searches, and every row records which searchCity and query produced it.
  • Branch and brand kept apart: placeId identifies one physical outlet, while organizationId is the id its whole chain shares, so counting stores and counting companies are two different nunique calls.
  • 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 includeContacts on, one extra request per outlet fills phone, phones, website and email where 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.

🎯 Use cases

Measure category density by district

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.

Plot a retail footprint on a map

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.

Follow ratings over months

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.

Audit opening hours for a delivery zone

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.

⚡ Quick start

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 2

The 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.

🧩 The core call

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)

📥 Input

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 — —

📤 Output

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

💰 Pricing

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.

🛠️ Working with the data in Python

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
)

❓ FAQ

Is this the official 2GIS API?

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.

Why do I never get more than 60 rows for one keyword in one city?

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.

Can I schedule it and keep history?

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.

Does it return reviews or photos?

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.

🧯 Troubleshooting

  • Set APIFY_TOKEN first: export the token in the same shell, or put it in a local .env file your shell loads. Never commit it.
  • Run finished with FAILED or ABORTED: open the printed Console link; the run log says why. Input validation errors name the field.
  • Fewer rows than expected: a --max-charge cap 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.

🔗 More from Devil Scrapes

Related Actors:


MIT licensed example code. The Actor itself runs on Apify and is maintained by Devil Scrapes. Not affiliated with 2GIS or its parent company.

About

2GIS scraper in Python: search any 2GIS city (Moscow, Almaty, Dubai) by category and pull rubrics, WGS84 coordinates, rating, review totals and weekday hours into pandas, a notebook or a nightly cron export. Python example for the Devil Scrapes Apify Actor.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages