Integracja z Google Places API wygląda prosto tylko na pierwszy rzut oka. Wysyłasz zapytanie, dostajesz listę, bierzesz pierwszy wynik i idziesz dalej. W praktyce okazuje się, że Google raz zwraca pięć trafionych wyników, raz trzydzieści losowych, a czasem zamiast wizytówki firmy dostajesz adres ulicy i dwa sklepy spożywcze. Żeby to ogarnąć, musisz wejść poziom wyżej: paginacja, filtrowanie LINQ-iem, mapowanie DTO na Twoje encje, a na końcu rozsądna obsługa limitów i ponawianie zapytań. W tym odcinku kursu WP Importera dokładnie to robimy — porządkujemy kod tak, żeby wyciskał z Google Places API maksimum użytecznych danych.
Paginacja next_page_token
Google Places API zwraca maksymalnie 20 wyników na stronę. Jeśli chcesz więcej, musisz iść po next_page_token. Wygląda to prosto — dopóki token istnieje, pobierasz kolejną stronę. Haczyk jest jeden: token potrzebuje chwili, zanim stanie się aktywny. Google explicite pisze w dokumentacji, że między odebraniem tokena a jego użyciem trzeba odczekać kilka sekund. Jeśli tego nie zrobisz, dostaniesz INVALID_REQUEST i paginacja się zatrzyma.
W praktyce najczystsze rozwiązanie to do...while, który kończy pętlę, gdy token znika:
public async Task<List<PlaceDto>> FindAllNearby(double lat, double lng, int radius, string type)
{
var all = new List<PlaceDto>();
string? pageToken = null;
do
{
var url = BuildUrl(lat, lng, radius, type, pageToken);
var response = await _http.GetFromJsonAsync<PlacesResponse>(url);
all.AddRange(response!.Results);
pageToken = response.NextPageToken;
// Google wymaga chwili zanim next_page_token staje się aktywny
if (pageToken != null) await Task.Delay(2000);
}
while (pageToken != null);
return all;
}
Zwróć uwagę na dwie rzeczy. Po pierwsze — Task.Delay(2000) wykonujesz tylko wtedy, gdy token istnieje, czyli wiesz, że będzie kolejna iteracja. Bez tego ostatnia iteracja niepotrzebnie zawiesza Ci metodę na dwie sekundy. Po drugie — sprawdzasz status odpowiedzi. W kursie pokazuję, że Google w polu status zwraca OK, ZERO_RESULTS albo komunikat błędu. Jeśli zapytanie było zbyt wąskie, status nie będzie OK i nawet nie warto próbować wyciągać wyników. Obsłuż to wcześniej, zanim polecisz po kolejną stronę.
Jeśli po pierwszej iteracji zapomnisz poczekać dwie sekundy, Google grzecznie powie „nie ma takiego tokena". To nie jest bug w Twoim kodzie — to kontrakt API, o którym łatwo zapomnieć.
Filtrowanie w LINQ
Jak masz już listę 50 czy 100 miejsc, następny problem to wybór tych, które rzeczywiście chcesz przetwarzać dalej. W WP Importerze dla każdej firmy z bazy dobieram najlepiej pasującą wizytówkę z Google. Gdybyś pisał to pętlą z for i if-ami, miałbyś dwadzieścia linijek i trzy zagnieżdżone warunki. LINQ daje Ci to w kilku linijkach, które czyta się jak zdanie.
var highRated = places
.Where(p => p.Rating >= 4.0)
.Where(p => p.UserRatingsTotal >= 50)
.OrderByDescending(p => p.Rating)
.Take(20)
.ToList();
Dwa razy Where zamiast jednego z && to nie przypadek — każdy warunek opisuje osobną intencję. „Tylko dobrze oceniane" i „tylko z wystarczającą liczbą opinii" to dwa różne kryteria biznesowe. Jeśli za pół roku jedno z nich będzie trzeba zmienić, wiesz dokładnie, którą linijkę tykasz.
LINQ czyni kod czytelniejszym niż 20 linijek pętli z if-ami — i to nie jest pusta pochwała, tylko realna oszczędność czasu przy czytaniu kodu za trzy miesiące.
W odcinku idę krok dalej. Budując słownik typu Dictionary<PlaceResult, int>, do którego dla każdego wyniku wrzucam wyliczone prawdopodobieństwo trafienia, potem wyciągam najlepszy rekord też LINQ-iem:
var bestMatch = possibilities
.Where(kvp => kvp.Value >= 100)
.OrderByDescending(kvp => kvp.Value)
.Select(kvp => kvp.Key)
.FirstOrDefault();
FirstOrDefault zwraca null, gdy żaden wynik nie przekroczył progu. To mój sygnał: „żadna wizytówka z Google nie odpowiada tej firmie z bazy, pomiń ją". Dzięki temu nie wykonuję kolejnego zapytania po place_details dla firm, których i tak nie zapiszę — czysta oszczędność limitu API.
Mapowanie DTO na Entity
Odpowiedź z Google Places API to nie jest model, który chcesz wsadzać prosto do bazy. Pola są niewygodne, nazwy długie, część danych jest pusta, część zagnieżdżona w obiektach typu geometry.location. Dlatego w projekcie mam osobną klasę PlaceSearch, która służy jako pośrednik między encją Company z bazy a formatem, jaki chcę wysłać do Google.
Zobacz kluczowy moment — konstruktor formatuje dane wejściowe, a settery są prywatne:
public class PlaceSearch
{
public string Name { get; }
public string City { get; }
public string Street { get; }
public string BuildingNumber { get; }
public PlaceSearch(Company company)
{
Name = FormatName(company.Name);
City = FormatCity(company.City);
Street = FormatStreet(company.Street);
BuildingNumber = company.BuildingNumber?.Trim() ?? string.Empty;
}
public string GetSearchQuery() => $"{Name} {City} {Street} {BuildingNumber}".Trim();
private static string FormatName(string name) => name
.Replace("Spółka z ograniczoną odpowiedzialnością", "", StringComparison.OrdinalIgnoreCase)
.Trim()
.ToLowerInvariant();
}
Trzy rzeczy, które są tu ważne. Po pierwsze — tylko gettery. Z zewnątrz nikt Ci nie nadpisze BuildingNumber, bo klasę tworzysz raz, konstruktorem, z danych z bazy. Po drugie — metody pomocnicze typu FormatName siedzą wewnątrz klasy, bo dotyczą tylko tego DTO. Nie musisz zaciągać zewnętrznego helpera, co zmniejsza powierzchnię kodu. Po trzecie — GetSearchQuery zwraca string gotowy do wrzucenia do URL-a. Jeden obiekt, jedna odpowiedzialność, jeden sposób użycia.
W drugą stronę — po tym, jak Google zwróci wynik i zdecyduję, że to ten jeden prawdziwy match — mapuję PlaceResult z powrotem na encję. To też dobre miejsce na metodę statyczną FromDto, która bierze DTO, obiekt z bazy i dokleja z Google tylko te pola, których jeszcze nie mam. Dzięki temu logika mapowania jest w jednym miejscu, a nie rozsiana po SetCity, SetPhone, SetWebsite.
Czysty DTO nie ma setterów. Tworzysz go konstruktorem i żadna metoda ani middleware nie jest w stanie po drodze popsuć Ci danych.
Retry i rate limiting
Google Places API ma limity. Bezpłatnie dostajesz pewną pulę, potem każde zapytanie kosztuje. Moja baza w docelowym scenariuszu ma 55 000 firm — gdybym puścił wszystkie na raz, zapłaciłbym za to konkretną kasę. Dlatego w kodzie mam prosty mechanizm ograniczania: Skip i Take na poziomie Entity Framework.
var companies = _db.Companies
.OrderBy(c => c.Id)
.Skip(offset)
.Take(batchSize)
.ToList();
Skip odpowiada za to, ile pierwszych firm pomijam (przy wznowieniu importu), Take ustawia maksymalną liczbę firm przetwarzanych w jednym uruchomieniu. Na co dzień trzymam Take(10) — to mój bezpiecznik, żeby w razie błędu nie puścić 55 tysięcy zapytań do Google i nie spalić budżetu.
Drugi wymiar to retry przy chwilowych błędach. Google czasem zwraca OVER_QUERY_LIMIT albo UNKNOWN_ERROR — i to nie są błędy trwałe, tylko sygnał „spróbuj za chwilę". Ręcznie możesz napisać coś takiego:
private async Task<PlacesResponse?> GetWithRetry(string url, int maxAttempts = 3)
{
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
var response = await _http.GetFromJsonAsync<PlacesResponse>(url);
if (response?.Status == "OK" || response?.Status == "ZERO_RESULTS")
return response;
if (response?.Status == "OVER_QUERY_LIMIT" && attempt < maxAttempts)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
continue;
}
return response;
}
return null;
}
Exponential backoff — po pierwszej nieudanej próbie czekasz 2 sekundy, po drugiej 4, po trzeciej 8. W produkcji zamiast pisać to samemu, sięgnąłbym po Polly:
var retryPolicy = Policy
.HandleResult<PlacesResponse>(r => r.Status == "OVER_QUERY_LIMIT")
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
var response = await retryPolicy.ExecuteAsync(() =>
_http.GetFromJsonAsync<PlacesResponse>(url)!);
Polly daje Ci backoff, circuit breaker i timeout w jednej bibliotece. W projekcie konsolowym możesz sobie pozwolić na prostą pętlę z for, ale w aplikacji webowej, gdzie ten sam kod obsługuje wielu klientów jednocześnie, Polly jest bezkonkurencyjne.
Pamiętaj też o ważnym rozróżnieniu: OVER_QUERY_LIMIT ponawiaj, REQUEST_DENIED nie ma sensu ponawiać (problem z kluczem API), a ZERO_RESULTS to poprawna odpowiedź, nie błąd. Retry na wszystko bez rozróżnienia to prosta droga do spalenia limitu na zapytaniach, które nigdy się nie powiodą.
Co dalej?
To był najbardziej algorytmiczny odcinek w całej serii. W kolejnych krokach będziesz już głównie sklejać to, co mamy, w spójny flow: pobranie z bazy → dopasowanie z Google → pobranie szczegółów → wysłanie do WordPressa. Jeśli chcesz potrenować dalej, masz kilka otwartych kierunków:
- Poeksperymentować ze wzorem na liczenie prawdopodobieństwa — dodać rozpoznawanie NIP-u, dopasowanie nazw rozmytą dopasowanie stringów (Levenshtein).
- Podpiąć pod system
rejestr.io, żeby weryfikować KRS firm, które Google zwróci. - Dodać prawdziwe Polly z circuit breakerem, który wyłącza zapytania po kilku porażkach.
- Wpiąć cache — każda firma raz dopasowana do
Place IDnie musi być dopasowywana ponownie przy następnym uruchomieniu.
Źródłowy kod projektu jest na GitHubie: https://github.com/jarmatys/WPImporter. Zerknij, odpal lokalnie, pobaw się algorytmem dopasowania. A jeśli macie lepszy pomysł na scoring firm — dajcie znać w komentarzach pod filmem. Do usłyszenia w kolejnym odcinku.



