|
| 1 | +# 2GIS Scraper PHP |
| 2 | + |
| 3 | +A PHP client library for scraping data from [2GIS](https://2gis.ru) — the largest business directory and map service across Russia, Central Asia, and the Middle East. Extract places, reviews, real estate listings, and job vacancies with typed DTOs. |
| 4 | + |
| 5 | +Powered by [Apify](https://apify.com/) actors under the hood. |
| 6 | + |
| 7 | +## Installation |
| 8 | + |
| 9 | +```bash |
| 10 | +composer require scraper-apis/2gis-parser |
| 11 | +``` |
| 12 | + |
| 13 | +## Quick Start |
| 14 | + |
| 15 | +```php |
| 16 | +use TwoGisParser\Client; |
| 17 | + |
| 18 | +$client = new Client('your_apify_api_token'); |
| 19 | + |
| 20 | +// Search for restaurants in Moscow |
| 21 | +$places = $client->scrapePlaces( |
| 22 | + query: ['restaurant'], |
| 23 | + location: 'Moscow', |
| 24 | + maxResults: 50, |
| 25 | +); |
| 26 | + |
| 27 | +foreach ($places as $place) { |
| 28 | + echo "{$place->name} — {$place->address}\n"; |
| 29 | + echo "Rating: {$place->rating} ({$place->reviewCount} reviews)\n"; |
| 30 | + |
| 31 | + if ($place->hasContactInfo()) { |
| 32 | + echo "Phone: {$place->getFirstPhone()}\n"; |
| 33 | + echo "Email: {$place->getFirstEmail()}\n"; |
| 34 | + } |
| 35 | + |
| 36 | + if ($place->hasWebsite()) { |
| 37 | + echo "Website: {$place->website}\n"; |
| 38 | + } |
| 39 | +} |
| 40 | +``` |
| 41 | + |
| 42 | +## Available Methods |
| 43 | + |
| 44 | +The client wraps 4 specialized Apify actors, each optimized for a specific data type. |
| 45 | + |
| 46 | +### 1. Places / Businesses |
| 47 | + |
| 48 | +Search the 2GIS catalog by keyword and city. Returns rich records with 60+ fields including contacts, ratings, schedule, photos, and more. Covers 207 cities across 20 countries. |
| 49 | + |
| 50 | +```php |
| 51 | +use TwoGisParser\Language; |
| 52 | +use TwoGisParser\Country; |
| 53 | + |
| 54 | +$places = $client->scrapePlaces( |
| 55 | + query: ['dentist', 'dental clinic'], |
| 56 | + location: 'Saint Petersburg', |
| 57 | + maxResults: 200, |
| 58 | + language: Language::Russian, |
| 59 | + country: Country::Russia, |
| 60 | + options: [ |
| 61 | + 'categoryFilter' => ['Dentistry'], |
| 62 | + 'filterRating' => 'excellent', // 4.5+ rating |
| 63 | + 'skipClosedPlaces' => true, |
| 64 | + 'filterCardPayment' => true, |
| 65 | + 'maxReviews' => 10, // fetch up to 10 reviews per place |
| 66 | + 'maxPhotos' => 5, // fetch up to 5 photos per place |
| 67 | + ], |
| 68 | +); |
| 69 | +``` |
| 70 | + |
| 71 | +**Available options for `scrapePlaces()`:** |
| 72 | + |
| 73 | +| Option | Type | Description | |
| 74 | +|--------|------|-------------| |
| 75 | +| `categoryFilter` | `string[]` | Filter by business categories (fuzzy-matched against 731 categories) | |
| 76 | +| `filterRating` | `string` | Min rating: `perfect` (4.9+), `excellent` (4.5+), `pretty_good` (4.0+), `nice` (3.5+), `not_bad` (3.0+) | |
| 77 | +| `sortBy` | `string` | Sort by: `rating`, `opened_time`, `name` | |
| 78 | +| `filterWebsite` | `string` | `all`, `withWebsite`, `withoutWebsite` | |
| 79 | +| `skipClosedPlaces` | `bool` | Skip permanently closed places | |
| 80 | +| `searchMatching` | `string` | `all`, `only_includes`, `only_exact` | |
| 81 | +| `filterHasPhotos` | `bool` | Only places with photos | |
| 82 | +| `filter24h` | `bool` | Only 24/7 places | |
| 83 | +| `filterNewPlaces` | `bool` | Only recently opened places | |
| 84 | +| `filterDelivery` | `bool` | Has delivery | |
| 85 | +| `filterTakeaway` | `bool` | Has takeaway | |
| 86 | +| `filterCardPayment` | `bool` | Accepts card payment | |
| 87 | +| `filterWifi` | `bool` | WiFi available | |
| 88 | +| `filterHasGoods` | `bool` | Has price list/goods on 2GIS | |
| 89 | +| `filterAvgPriceMin` | `int` | Min average check (local currency) | |
| 90 | +| `filterAvgPriceMax` | `int` | Max average check (local currency) | |
| 91 | +| `maxReviews` | `int` | Max reviews per place (0 = disabled, 99999 = all) | |
| 92 | +| `reviewsRating` | `string` | `all`, `positive` (4-5), `negative` (1-2) | |
| 93 | +| `reviewsWithAnswer` | `bool` | Only reviews with business reply | |
| 94 | +| `reviewsStartDate` | `string` | Only reviews after YYYY-MM-DD | |
| 95 | +| `reviewsTopic` | `string` | Server-side topic filter | |
| 96 | +| `reviewsFilterKeyword` | `string` | Client-side text search within reviews | |
| 97 | +| `reviewsSource` | `string` | `all`, `2gis`, `flamp`, `booking` | |
| 98 | +| `maxPhotos` | `int` | Max photos per place (0 = disabled, 99999 = all) | |
| 99 | +| `photoCategories` | `string[]` | Photo categories: `food_and_drinks`, `interior`, `outside`, `price_list_image`, etc. | |
| 100 | +| `scrapeOrgBranches` | `bool` | Scrape all branches of found organizations | |
| 101 | + |
| 102 | +**Place DTO helpers:** |
| 103 | + |
| 104 | +```php |
| 105 | +$place->hasContactInfo(); // true if phones or emails exist |
| 106 | +$place->getFirstPhone(); // first phone number or null |
| 107 | +$place->getFirstEmail(); // first email or null |
| 108 | +$place->hasWebsite(); // true if website is set |
| 109 | +$place->getCoordinates(); // ['lat' => float, 'lng' => float] or null |
| 110 | +``` |
| 111 | + |
| 112 | +### 2. Reviews |
| 113 | + |
| 114 | +Extract reviews from specific 2GIS places. Accepts place URLs, search URLs, or branch IDs. Returns flat one-row-per-review records, ideal for CSV/Excel export. Aggregates reviews from 2GIS, Flamp, and Booking. |
| 115 | + |
| 116 | +```php |
| 117 | +use TwoGisParser\ReviewsRating; |
| 118 | +use TwoGisParser\ReviewsSource; |
| 119 | + |
| 120 | +$reviews = $client->scrapeReviews( |
| 121 | + startUrls: [ |
| 122 | + 'https://2gis.ru/moscow/firm/70000001057394703', |
| 123 | + '70000001057394704', // plain branch ID also works |
| 124 | + ], |
| 125 | + maxReviews: 500, |
| 126 | + reviewsRating: ReviewsRating::Negative, |
| 127 | + reviewsSource: ReviewsSource::TwoGis, |
| 128 | + options: [ |
| 129 | + 'reviewsStartDate' => '2025-01-01', |
| 130 | + 'reviewsWithAnswer' => true, |
| 131 | + ], |
| 132 | +); |
| 133 | + |
| 134 | +foreach ($reviews as $review) { |
| 135 | + echo "{$review->authorName}: {$review->rating}/5\n"; |
| 136 | + echo "{$review->text}\n"; |
| 137 | + |
| 138 | + if ($review->hasOfficialAnswer()) { |
| 139 | + echo "Reply: {$review->getOfficialAnswerText()}\n"; |
| 140 | + } |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +**Available options for `scrapeReviews()`:** |
| 145 | + |
| 146 | +| Option | Type | Description | |
| 147 | +|--------|------|-------------| |
| 148 | +| `reviewsWithAnswer` | `bool` | Only reviews with business reply | |
| 149 | +| `reviewsStartDate` | `string` | Only reviews after YYYY-MM-DD | |
| 150 | +| `reviewsTopic` | `string` | Server-side topic filter | |
| 151 | +| `reviewsFilterKeyword` | `string` | Client-side keyword filter | |
| 152 | + |
| 153 | +**Review DTO helpers:** |
| 154 | + |
| 155 | +```php |
| 156 | +$review->hasOfficialAnswer(); // true if business replied |
| 157 | +$review->getOfficialAnswerText(); // reply text or null |
| 158 | +$review->hasPhotos(); // true if review has photos |
| 159 | +$review->isPositive(); // true if rating >= 4 |
| 160 | +$review->isNegative(); // true if rating <= 2 |
| 161 | +``` |
| 162 | + |
| 163 | +### 3. Real Estate / Property |
| 164 | + |
| 165 | +Scrape property listings from 2GIS across Russia, Kazakhstan, and Kyrgyzstan. Supports sale/rent for residential and commercial properties, plus daily rentals. |
| 166 | + |
| 167 | +```php |
| 168 | +use TwoGisParser\PropertyCategory; |
| 169 | +use TwoGisParser\PropertySort; |
| 170 | + |
| 171 | +$properties = $client->scrapeProperties( |
| 172 | + location: 'Kazan', |
| 173 | + maxResults: 500, |
| 174 | + category: PropertyCategory::SaleResidential, |
| 175 | + sort: PropertySort::PriceAsc, |
| 176 | + options: [ |
| 177 | + 'rooms' => ['2', '3'], |
| 178 | + 'priceMax' => 15000000, |
| 179 | + 'areaMin' => 50, |
| 180 | + 'notFirstFloor' => true, |
| 181 | + 'notLastFloor' => true, |
| 182 | + ], |
| 183 | +); |
| 184 | + |
| 185 | +foreach ($properties as $property) { |
| 186 | + echo "{$property->name}\n"; |
| 187 | + echo "Price: {$property->getPriceFormatted()}\n"; |
| 188 | + echo "Area: {$property->area} m², floor {$property->floor}\n"; |
| 189 | + echo "Address: {$property->address}\n"; |
| 190 | +} |
| 191 | +``` |
| 192 | + |
| 193 | +**Available options for `scrapeProperties()`:** |
| 194 | + |
| 195 | +| Option | Type | Description | |
| 196 | +|--------|------|-------------| |
| 197 | +| `rooms` | `string[]` | Room count: `studio`, `1`, `2`, `3`, `4`, `5` | |
| 198 | +| `propertyType` | `string[]` | `flat`, `house`, `land`, `cottage`, `room`, `townhouse`, `share`, `part_house` | |
| 199 | +| `newBuilding` | `string[]` | `secondary` (resale), `new` (new building) | |
| 200 | +| `priceMin` | `int` | Minimum price | |
| 201 | +| `priceMax` | `int` | Maximum price | |
| 202 | +| `pricePerMeterMin` | `float` | Min price per m2 | |
| 203 | +| `pricePerMeterMax` | `float` | Max price per m2 | |
| 204 | +| `areaMin` | `float` | Min area in m2 | |
| 205 | +| `areaMax` | `float` | Max area in m2 | |
| 206 | +| `floorMin` | `int` | Min floor number | |
| 207 | +| `floorMax` | `int` | Max floor number | |
| 208 | +| `notFirstFloor` | `bool` | Exclude first floor | |
| 209 | +| `notLastFloor` | `bool` | Exclude last floor | |
| 210 | +| `floorsInBuildingMin` | `int` | Min building floors | |
| 211 | +| `floorsInBuildingMax` | `int` | Max building floors | |
| 212 | +| `metroTime` | `string` | Metro proximity: `5`, `10`, `20` (minutes walking) | |
| 213 | +| `provider` | `string[]` | Listing providers: `cian`, `domclick` | |
| 214 | + |
| 215 | +**Property DTO helpers:** |
| 216 | + |
| 217 | +```php |
| 218 | +$property->hasImages(); // true if images array is not empty |
| 219 | +$property->getCoordinates(); // ['lat' => float, 'lng' => float] or null |
| 220 | +$property->getPriceFormatted(); // "1,500,000 RUB" or null |
| 221 | +``` |
| 222 | + |
| 223 | +### 4. Jobs / Vacancies |
| 224 | + |
| 225 | +Scrape job vacancies from 2GIS by city. Supports filtering by 224 job categories and salary range. |
| 226 | + |
| 227 | +```php |
| 228 | +$jobs = $client->scrapeJobs( |
| 229 | + location: 'Novosibirsk', |
| 230 | + maxResults: 300, |
| 231 | + salaryMin: 80000, |
| 232 | + categoryId: '200', // Developer |
| 233 | +); |
| 234 | + |
| 235 | +foreach ($jobs as $job) { |
| 236 | + echo "{$job->name} at {$job->orgName}\n"; |
| 237 | + echo "Salary: {$job->salaryLabel}\n"; |
| 238 | + echo "Address: {$job->address}\n"; |
| 239 | + |
| 240 | + if ($job->hasApplyUrl()) { |
| 241 | + echo "Apply: {$job->applyUrl}\n"; |
| 242 | + } |
| 243 | +} |
| 244 | +``` |
| 245 | + |
| 246 | +**Job DTO helpers:** |
| 247 | + |
| 248 | +```php |
| 249 | +$job->hasApplyUrl(); // true if apply URL is set |
| 250 | +$job->getCoordinates(); // ['lat' => float, 'lng' => float] or null |
| 251 | +``` |
| 252 | + |
| 253 | +## Enums Reference |
| 254 | + |
| 255 | +### Language |
| 256 | + |
| 257 | +Supported across all 4 actors. 14 languages covering the 2GIS service area. |
| 258 | + |
| 259 | +| Case | Value | Language | |
| 260 | +|------|-------|----------| |
| 261 | +| `Auto` | `auto` | Auto-detect | |
| 262 | +| `Russian` | `ru` | Russian | |
| 263 | +| `English` | `en` | English | |
| 264 | +| `Arabic` | `ar` | Arabic | |
| 265 | +| `Kazakh` | `kk` | Kazakh | |
| 266 | +| `Uzbek` | `uz` | Uzbek | |
| 267 | +| `Kyrgyz` | `ky` | Kyrgyz | |
| 268 | +| `Armenian` | `hy` | Armenian | |
| 269 | +| `Georgian` | `ka` | Georgian | |
| 270 | +| `Azerbaijani` | `az` | Azerbaijani | |
| 271 | +| `Tajik` | `tg` | Tajik | |
| 272 | +| `Czech` | `cs` | Czech | |
| 273 | +| `Spanish` | `es` | Spanish | |
| 274 | +| `Italian` | `it` | Italian | |
| 275 | + |
| 276 | +### Country |
| 277 | + |
| 278 | +21 countries where 2GIS operates. |
| 279 | + |
| 280 | +| Case | Value | |
| 281 | +|------|-------| |
| 282 | +| `Auto` | *(auto-detect)* | |
| 283 | +| `Russia` | `ru` | |
| 284 | +| `Kazakhstan` | `kz` | |
| 285 | +| `UAE` | `ae` | |
| 286 | +| `Uzbekistan` | `uz` | |
| 287 | +| `Kyrgyzstan` | `kg` | |
| 288 | +| `Armenia` | `am` | |
| 289 | +| `Georgia` | `ge` | |
| 290 | +| `Azerbaijan` | `az` | |
| 291 | +| `Belarus` | `by` | |
| 292 | +| `Tajikistan` | `tj` | |
| 293 | +| `SaudiArabia` | `sa` | |
| 294 | +| `Bahrain` | `bh` | |
| 295 | +| `Kuwait` | `kw` | |
| 296 | +| `Qatar` | `qa` | |
| 297 | +| `Oman` | `om` | |
| 298 | +| `Iraq` | `iq` | |
| 299 | +| `Chile` | `cl` | |
| 300 | +| `Czechia` | `cz` | |
| 301 | +| `Italy` | `it` | |
| 302 | +| `Cyprus` | `cy` | |
| 303 | + |
| 304 | +### Other Enums |
| 305 | + |
| 306 | +| Enum | Values | |
| 307 | +|------|--------| |
| 308 | +| `RatingFilter` | `None`, `Perfect` (4.9+), `Excellent` (4.5+), `PrettyGood` (4.0+), `Nice` (3.5+), `NotBad` (3.0+) | |
| 309 | +| `ReviewsRating` | `All`, `Positive` (4-5 stars), `Negative` (1-2 stars) | |
| 310 | +| `ReviewsSource` | `All`, `TwoGis`, `Flamp`, `Booking` | |
| 311 | +| `PropertyCategory` | `SaleResidential`, `SaleCommercial`, `RentResidential`, `RentCommercial`, `DailyRent` | |
| 312 | +| `PropertySort` | `Default`, `PriceAsc`, `PriceDesc`, `AreaAsc`, `AreaDesc` | |
| 313 | + |
| 314 | +## Configuration |
| 315 | + |
| 316 | +```php |
| 317 | +use TwoGisParser\Client; |
| 318 | +use TwoGisParser\Config; |
| 319 | + |
| 320 | +// Simple — just pass the API token |
| 321 | +$client = new Client('your_apify_api_token'); |
| 322 | + |
| 323 | +// Advanced — override actor IDs, timeout, or base URL |
| 324 | +$client = new Client('token', new Config( |
| 325 | + apiToken: 'token', |
| 326 | + placesActorId: 'zen-studio/2gis-places-scraper-api', |
| 327 | + reviewsActorId: 'zen-studio/2gis-reviews-scraper', |
| 328 | + propertyActorId: 'zen-studio/2gis-property-scraper', |
| 329 | + jobsActorId: 'zen-studio/2gis-jobs-scraper', |
| 330 | + baseUrl: 'https://api.apify.com/v2', |
| 331 | + timeout: 900, |
| 332 | +)); |
| 333 | +``` |
| 334 | + |
| 335 | +## Error Handling |
| 336 | + |
| 337 | +```php |
| 338 | +use TwoGisParser\Exception\ApiException; |
| 339 | +use TwoGisParser\Exception\RateLimitException; |
| 340 | + |
| 341 | +try { |
| 342 | + $places = $client->scrapePlaces(query: ['cafe'], location: 'Almaty'); |
| 343 | +} catch (RateLimitException $e) { |
| 344 | + // Retry after the suggested delay |
| 345 | + sleep($e->retryAfter); |
| 346 | +} catch (ApiException $e) { |
| 347 | + echo "API error: {$e->getMessage()}\n"; |
| 348 | +} |
| 349 | +``` |
| 350 | + |
| 351 | +## Requirements |
| 352 | + |
| 353 | +- PHP 8.3+ |
| 354 | +- [Apify API token](https://console.apify.com/account/integrations) |
| 355 | + |
| 356 | +## License |
| 357 | + |
| 358 | +MIT |
0 commit comments