# 2GIS Scraper — Business Search in 206 Cities (`thenetaji/2gis-search-scraper`) Actor

Search 206 cities across Russia, the CIS, the Gulf, Czechia, Italy and Chile for businesses by name or category. Rows carry the address, coordinates, categories, opening hours and ratings — and a flag on every one saying whether 2GIS matched your query or quietly returned something else.

- **URL**: https://apify.com/thenetaji/2gis-search-scraper.md
- **Developed by:** [The Netaji](https://apify.com/thenetaji) (community)
- **Categories:** Business, Lead generation, Automation
- **Stats:** 3 total users, 2 monthly users, 95.2% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.17 / 1,000 business listeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## 2GIS Search Scraper

Business search across the 206 cities 2GIS covers — Russia and the CIS, plus the UAE, Saudi
Arabia, Qatar, Kuwait, Bahrain, Oman, Iraq, Czechia, Italy, Cyprus and Chile. Each row
carries the branch id, the name, the street address with its city, map coordinates, every
category the business is filed under, opening hours, its own rating and rating count with
the parent chain's beside them, whether the row is a paid placement, and 2GIS's own relevance
score.

And one flag that matters more than any of them.

**A query 2GIS cannot match does not come back empty.** It answers with a healthy response
and a list of unrelated real businesses, in identical row shapes, at a relevance around
`1e-09` instead of the 10 to 8,800 a real match scores. Every row here carries
`is_partial_match` saying which it is. Measured over 15 queries in 5 cities the flag
separated them perfectly: false on 10 of 10 real queries, true on 5 of 5 nonsense ones.

### Accepted input

`queries` is required and takes one search term per line, in the city's own language — `кафе`,
`pharmacy`, `kavárna`. A term is **never split on a comma**, because a business name contains
one: `Му-Му, кафе` is a single search term, not a chain and a category.

`cities` is required and takes one 2GIS city slug per line — the segment in 2GIS's own
address, such as `moscow`, `spb`, `dubai`, `almaty` or `praha`. A pasted 2GIS link is reduced
to the city in it. The slug is the whole key: 2GIS is organised by city rather than by
country, and its internal id for a city cannot be derived from a name — Moscow is 32,
Novosibirsk is 1, Dubai is 99. [2gis-scraper](https://apify.com/thenetaji/2gis-scraper) (`coveredCities` mode) publishes all 206.

**Every term is searched in every city.** Three terms across five cities is fifteen searches,
and the run log says so before it makes any of them.

`startPage` defaults to `1` and is where each search's walk begins, not the page it fetches.
Use it to resume a harvest.

`locale` is optional and takes one of the city's own locales, written as `ru_RU` or `en_AE`.
Leave it empty for the city's default. This is **not** a translation layer: a locale a city
does not offer is refused by 2GIS outright rather than falling back — Dubai serves `en_AE`
and refuses `en_US` — and each city's accepted set is on its row in the Covered Cities export.

`maxItems` defaults to `100` and caps **each search separately**, so three terms across five
cities with a cap of 100 returns up to 1,500 rows rather than 100 shared between them. `0`
means no limit.

There is no page-size input. It is always the maximum 2GIS allows, because a row here is
priced as a share of one request and a smaller page divides that share among fewer rows for
nothing in return.

```json
{
  "queries": ["кафе", "аптека"],
  "cities": ["moscow", "spb"],
  "startPage": 1,
  "maxItems": 100
}
```

### Response fields

```json
{
  "query": "кафе",
  "requested_city": "moscow",
  "page": 1,
  "is_partial_match": false,
  "match_type": "discovery",
  "relevance": 1137.25,
  "max_relevance": 2151.38,
  "total_results": 8616,
  "id": "70000001007179642",
  "search_context_id": "70000001007179642_ku4dspBmdBdB9A82AJ7H…",
  "name": "Му-Му, кафе",
  "type": "branch",
  "url": "https://2gis.ru/moscow/firm/70000001007179642",
  "street_address": "Манежная площадь, 1 ст2",
  "address_comment": "-2 этаж; Средний уровень",
  "building_name": "Охотный ряд",
  "postcode": "125009",
  "country": "Россия",
  "region": "Москва",
  "address_city": "Москва",
  "district": "Тверской",
  "latitude": 55.755301,
  "longitude": 37.614586,
  "region_id": "32",
  "city_alias": "moscow",
  "primary_rubric": "Кафе",
  "rubrics": [{ "id": "161", "name": "Кафе", "alias": "kafe", "kind": "primary" }],
  "schedule": { "Mon": { "working_hours": [{ "from": "09:00", "to": "22:00" }] } },
  "phones": [],
  "websites": [],
  "emails": [],
  "chain_name": "Му-Му, кафе",
  "chain_branch_count": 18,
  "rating": 4.8,
  "review_count": 1090,
  "org_rating": 4.3,
  "is_advertisement": false
}
```

Fields absent from a record are returned as null rather than omitted, so every row has the
same shape.

`is_partial_match` is the column to filter on before anything else. `relevance` and
`max_relevance` are the numeric corroboration: real matches measured 10.98 to 8,827.83 and
unmatched ones 4.8e-16 to 2.9e-06, seven orders of magnitude apart with no overlap. No
threshold is published here, because fifteen samples is not a threshold.

`match_type` is **not** that signal, and treating it as one is the mistake to avoid. It names
which search mode answered: `discovery` means 2GIS resolved the term to one of its own
categories and browsed it, `recovery` means it ran a literal text search instead. `recovery`
is not a warning — four of the ten good measured queries took that path and every one
answered well.

`phones`, `websites` and `emails` are **empty on every row here**, and that is a property of
the search endpoint rather than of the businesses. 2GIS's own search request omits contact
fields entirely, so no amount of paging produces a phone number. Pass a row's `id` to [2GIS
Business Scraper](https://apify.com/thenetaji/2gis-place-scraper) for those.

`id` is the branch id and it is exactly what the Business and Reviews Actors take. 2GIS's own
search response does not publish it: its id is this value plus a 130-character per-request
context blob that works nowhere else on the site. The tail is stripped here and kept beside
it as `search_context_id`.

`street_address` is the **street line only** — the city is not in it. That is 2GIS's own
shape, and the city is lifted out of its administrative division list into `address_city`,
because flattening to the street line would lose it.

`rating` and `org_rating` are both published because a branch of a chain frequently disagrees
with its parent, and quoting either alone misstates the other.

`is_advertisement` is true when the row is a paid placement. 2GIS mixes advertised
organisations into the ranked list, so anyone measuring ranking needs to be able to exclude
them.

### Behaviour on partial results

A term 2GIS cannot match produces rows, flagged. They are returned rather than hidden — a
near miss is occasionally what a caller wants — but never unlabelled, and the run log warns
about it at the top of the search.

A city slug that is not a slug stops the run before any request is made, with a message
naming the shape. A city 2GIS does not serve is skipped with a line in the run log, and the
rest of the cross product still runs.

The walk goes to the end of the stated total, which is unusual: pages run to exactly
`ceil(total / 50)` and the last one is short, rather than the walk dying early. A query
stating 8,616 results served 173 pages and page 174 came back empty.

Rows repeat a little across pages — 2GIS honours its own page exclusion loosely, and over six
five-page walks 0 to 12 rows of 250 repeated an organisation already returned. This Actor
drops the repeats before they reach the dataset and says how many it dropped, so a run that
fetched five pages can legitimately save fewer than 250 rows.

### Frequently asked questions

**Why is there no phone number on these rows?**
Because 2GIS's own search request does not ask for one. The same organisation returns no
contact fields at all from search and four phone numbers, a website and two email addresses
from the detail endpoint. It is a property of which lookup you made, not of the business, so
paging further will never produce one. [2GIS Business
Scraper](https://apify.com/thenetaji/2gis-place-scraper) takes a row's `id` directly.

**My query returned businesses that have nothing to do with it. Is that a bug?**
No, and the row already told you. Check `is_partial_match` — true means 2GIS could not match
the term and returned unrelated real businesses anyway, under a perfectly healthy response.
The fix is usually the language: search in the city's own, and use 2GIS's own category names.

**Where do I get city slugs?**
From [2gis-scraper](https://apify.com/thenetaji/2gis-scraper) (`coveredCities` mode), which returns
all 206 with their region ids, countries and accepted locales in a single request. They are
not guessable from a country, because 2GIS is organised by city.

**Why did I get fewer rows than pages × 50?**
Because repeats are dropped. See Behaviour on partial results. The run log states the number
it skipped per search.

**Can I sort the results?**
No. 2GIS returns them in its own relevance order and offers no ordering parameter on this
surface, so an input for one would be a control that does nothing. `relevance` is on every
row if you want to re-sort them yourself.

**Is a 2GIS account or API key required?**
No. No account, session cookie or key of any kind is supplied to the Actor or needed by it.
Open the `url` on any row and you see the same record.

### Related Actors

[2GIS Business Scraper](https://apify.com/thenetaji/2gis-place-scraper) takes the `id` from
any row here and returns the phone numbers, websites and email addresses this surface cannot
carry, plus the full structured attributes. It costs one request per business, so it is the
second pass over a shortlist rather than a way to read a market.

[2GIS Reviews Scraper](https://apify.com/thenetaji/2gis-reviews-scraper) takes the same `id`
and returns the written reviews, each naming which source it came from.

[2gis-scraper](https://apify.com/thenetaji/2gis-scraper) (`coveredCities` mode) is the city vocabulary
this Actor's `cities` input needs.

[2GIS Scraper](https://apify.com/thenetaji/2gis-scraper) puts this search and the reviews
export behind one mode picker, if you would rather run one Actor than two.

# Actor input Schema

## `queries` (type: `array`):

What to search for, one per line, in the city's own language — `кафе`, `pharmacy`, `kavárna`. Every term is searched in every city listed below, so three terms across five cities is fifteen searches. A term is never split on commas: `Му-Му, кафе` is one business name.

## `cities` (type: `array`):

The 2GIS cities to search, one slug per line. A city is a slug — the segment in 2GIS's own URL, such as `moscow`, `spb`, `dubai`, `almaty` or `praha`. It is not guessable from a country, because 2GIS is organised by city; the Covered Cities Actor publishes all 206. A pasted 2GIS link is reduced to the city in it.

## `startPage` (type: `integer`):

Which page of 50 results to begin each search at. Useful for resuming a harvest; leave it at 1 to start from the top. Pages run to exactly `ceil(total / 50)` and then stop.

## `locale` (type: `string`):

One of the city's own locales, written as `ru_RU` or `en_AE`. Leave it empty for the city's default. This is not a translation layer: a locale a city does not offer is refused by 2GIS outright — Dubai serves `en_AE` and refuses `en_US` — and the Covered Cities Actor lists each city's set.

## `maxItems` (type: `integer`):

Maximum organisations to save for each search term in each city. Set 0 for no limit. Results come 50 to a page and a search walks to the end of its stated total, so a common query genuinely reaches thousands.

## Actor input object example

```json
{
  "queries": [
    "кафе"
  ],
  "cities": [
    "moscow"
  ],
  "startPage": 1,
  "locale": "ru_RU",
  "maxItems": 20
}
```

# Actor output Schema

## `dataset` (type: `string`):

All records scraped by this run

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "queries": [
        "кафе"
    ],
    "cities": [
        "moscow"
    ],
    "startPage": 1,
    "maxItems": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("thenetaji/2gis-search-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "queries": ["кафе"],
    "cities": ["moscow"],
    "startPage": 1,
    "maxItems": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("thenetaji/2gis-search-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "queries": [
    "кафе"
  ],
  "cities": [
    "moscow"
  ],
  "startPage": 1,
  "maxItems": 20
}' |
apify call thenetaji/2gis-search-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,thenetaji/2gis-search-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/hcdiGMWtJXMcbzm8d/builds/d6j9WhhdYlRfdW6kl/openapi.json
