← PORTFOLIO · 03 / DATA QUALITY & PROCESSING

Od niespójnych adresów
do danych gotowych na mapę.

Interaktywna mapa szkół w WordPressie. Analiza jakości, normalizacja i geokodowanie danych — od 34 do 124 zlokalizowanych placówek.

Python · JupyterLab · pandas · GeoJSON · Leaflet · WordPress

CASE STUDY / PATRYK SZADKOWSKI

OPIS PROJEKTU

Projekt przedstawia drogę od surowych danych pobranych z katalogu szkół do działającej warstwy danych przygotowanej pod interaktywną mapę w WordPressie.

Skupiam się zarówno na rezultacie, jak i na sposobie myślenia: analizie jakości danych, diagnostyce problemów, świadomym doborze narzędzi oraz iteracyjnym dochodzeniu do rozwiązania.

01 / PROCES

Cel projektu

Celem projektu było stworzenie w WordPressie interaktywnej mapy szkół na podstawie danych pobranych z katalogu internetowego. Mapa miała umożliwiać przeglądanie placówek na terenie Polski, przybliżanie mapy, klikanie w punkty i odczytywanie informacji o szkole. Od początku projektowałem rozwiązanie tak, aby można było później dodać filtrowanie po województwie, klastry markerów, wyszukiwarkę miasta i listę placówek.

Projekt potraktowałem jako pełny przepływ danych: źródło → analiza jakości → czyszczenie → geokodowanie → walidacja → GeoJSON → WordPress → Leaflet → interakcja użytkownika. Był to praktyczny projekt łączący data processing, web development i myślenie produktowe.

02 / PROCES

Dane wejściowe i pierwsza diagnoza

Punktem wyjścia był zbiór 146 placówek z polami: name, wojewodztwo, miasto, adres, strona, lat i lng. Pierwsza analiza wykazała, że tylko 34 rekordy miały współrzędne geograficzne, a 112 ich nie posiadało.

Kluczowy wniosek: problemem nie była sama mapa. Bez poprawnych współrzędnych nie da się wiarygodnie narysować punktów. Najpierw należało przygotować dane. W JupyterLab sprawdziłem ich strukturę i liczbę rekordów z geolokalizacją.

python

df.info()
df["lat"].notna().sum()
Rekordów ogółem
146
Z lat/lng
34
Bez współrzędnych
112

03 / PROCES

Dlaczego automatycznie udało się zlokalizować tak mało szkół

Niska liczba współrzędnych nie wynikała wyłącznie z ograniczeń geokodera. Głównym problemem była jakość danych pobranych ze strony źródłowej. Adresy miały różne formaty: nadmiarowe spacje, skróty „ul.”, „al.” i „pl.”, numery lokali, oznaczenia budynków, powtórzone nazwy miejscowości, literówki oraz niepełne nazwy ulic.

Dane wejściowe

ul. Powstańców 7A lok. U2, 05-800 Pruszków
al. Aleja Komisji Edukacji Narodowej 95 lok. U29
ul. Barskich 33, lok 2
ul.Szkolna 14, 38-481 Rymanów
ul. Szkona 4

Takie dane są czytelne dla człowieka, ale geokoder może mieć problem z ich jednoznacznym dopasowaniem. Zamiast uznać niski wynik za ograniczenie narzędzia, przeanalizowałem wzorce w błędnych rekordach i zaprojektowałem etap normalizacji.

04 / PROCES

Wybór narzędzi

Do obróbki danych wykorzystałem Python, pandas i JupyterLab. JupyterLab pozwolił mi pracować iteracyjnie: wykonywać kolejne etapy w osobnych komórkach, sprawdzać wyniki, poprawiać dane i ponawiać tylko potrzebne fragmenty procesu.

Do geokodowania użyłem biblioteki geopy oraz usługi Nominatim opartej o OpenStreetMap. Do wizualizacji na stronie wybrałem Leaflet i podkład OpenStreetMap. GeoJSON posłużył jako format wymiany danych pomiędzy ich przygotowaniem a frontendem WordPressa.

Rozwiązanie miało być lekkie, łatwe do rozwijania, niezależne od płatnego kreatora map i kompatybilne z WordPressem.

05 / PROCES

Czyszczenie i normalizacja adresów

Zachowałem pełny adres do wyświetlenia użytkownikowi, a równolegle utworzyłem uproszczoną wersję przeznaczoną tylko do geokodowania.

Przed / po

adres: ul. Powstańców 7A lok. U2, 05-800 Pruszków
adres_geocode: Powstańców 7A, 05-800 Pruszków

python

import re
import pandas as pd

def clean_for_geocode(addr):
    if pd.isna(addr):
        return ""

    addr = re.sub(r"lok\.?\s*[\w/-]+", "", addr, flags=re.IGNORECASE)
    addr = re.sub(r"bud\.?\s*[\w/-]+", "", addr, flags=re.IGNORECASE)
    addr = re.sub(r"\b(ul|al|pl)\.?\s*", "", addr, flags=re.IGNORECASE)
    addr = re.sub(r"\s+", " ", addr)

    return addr.strip(" ,")

df["adres_geocode"] = df["adres"].apply(clean_for_geocode)

To rozdzielenie było ważne: dane zoptymalizowane dla algorytmu nie muszą być identyczne z danymi prezentowanymi użytkownikowi.

06 / PROCES

Geokodowanie brakujących adresów

Dla rekordów bez lat/lng utworzyłem zapytania do geokodera na podstawie oczyszczonego adresu, miasta i kraju.

python

from geopy.geocoders import Nominatim
import time

geolocator = Nominatim(user_agent="szkoly_map")

for i, row in df.iterrows():
    if pd.isna(row["lat"]) or pd.isna(row["lng"]):
        query = f"{row['adres_geocode']}, {row['miasto']}, Polska"

        try:
            location = geolocator.geocode(query)

            if location:
                df.at[i, "lat"] = location.latitude
                df.at[i, "lng"] = location.longitude

        except Exception:
            pass

        time.sleep(1)

Opóźnienie time.sleep(1) zostało dodane celowo, aby ograniczyć częstotliwość zapytań do publicznej usługi. Po normalizacji i ponownym geokodowaniu liczba poprawnie zlokalizowanych placówek wzrosła z 34 do 124.

Ten fragment dokumentuje przebieg projektu. W rozwijanym procesie obsługę wyjątków warto uzupełnić o zapis przyczyny błędu zamiast cichego pass; sam odstęp między zapytaniami nie potwierdza spełnienia wszystkich aktualnych warunków usługi.

07 / PROCES

Ręczna obsługa wyjątków

Po automatycznym etapie pozostały 22 rekordy bez współrzędnych. Zamiast komplikować system dla niewielkiej grupy wyjątków, wyświetliłem je osobno i zacząłem weryfikować ręcznie.

python

df[df["lat"].isna()][["name", "miasto", "adres"]]

Przykładowe ręczne korekty:

python

df.loc[34, "adres"] = "ul. Szkolna 4"
df.loc[25, "adres"] = "ul. Energetyków 1, Lublin"
df.loc[9, "adres"] = "ul. Barskich 33, Wrocław"

Automatyzuję większość procesu, ale przy nietypowych wyjątkach wybieram prostszy i bardziej wiarygodny sposób zamiast budować nadmiernie złożoną logikę.

08 / PROCES

Walidacja jakości danych

Po kolejnych etapach sprawdzałem liczbę rekordów ze współrzędnymi i analizowałem te, które nadal pozostawały bez geolokalizacji.

python

df[["lat", "lng"]].notna().all(axis=1).sum()

df[df[["lat", "lng"]].isna().any(axis=1)][
    ["name", "miasto", "adres"]
]

df[["lat", "lng"]].describe()

Dodatkowo kontrolowałem, czy uzyskane współrzędne mieszczą się w realistycznym zakresie dla Polski. Sam fakt, że API zwróciło współrzędne, nie był dowodem poprawności. Wyniki wymagały weryfikacji.

09 / PROCES

Pierwszy błąd GeoJSON i jego diagnoza

Pierwszy wygenerowany GeoJSON nie ładował się w przeglądarce. W konsoli pojawił się błąd parsowania JSON. Analiza pliku wykazała wartości:

Niepoprawny fragment JSON

"coordinates": [
  NaN,
  NaN
]

NaN jest wartością spotykaną w pandas/NumPy, ale nie jest poprawną wartością standardu JSON. Jeden taki rekord uniemożliwiał przeglądarce sparsowanie całego pliku. Rozwiązaniem było odrzucenie rekordów bez współrzędnych przed eksportem:

python

df_clean = df.dropna(subset=["lat", "lng"])

Nie zmieniałem losowo kodu mapy. Najpierw odseparowałem warstwę danych od warstwy wizualizacji i znalazłem źródło problemu w niepoprawnym eksporcie.

10 / PROCES

Generowanie poprawnego GeoJSON

Po oczyszczeniu danych wygenerowałem kolekcję GeoJSON. Kolejność współrzędnych to [lng, lat], a nie [lat, lng].

python

import json

df_clean = df.dropna(subset=["lat", "lng"])
features = []

for _, row in df_clean.iterrows():
    features.append({
        "type": "Feature",
        "geometry": {
            "type": "Point",
            "coordinates": [
                float(row["lng"]),
                float(row["lat"])
            ]
        },
        "properties": {
            "name": row["name"],
            "miasto": row["miasto"],
            "wojewodztwo": row["wojewodztwo"],
            "adres": row["adres"],
            "strona": row["strona"]
        }
    })

geojson = {
    "type": "FeatureCollection",
    "features": features
}

with open("szkoly_map.geojson", "w", encoding="utf-8") as f:
    json.dump(geojson, f, ensure_ascii=False, indent=2)

Jawna konwersja do float zapobiegała problemom z typami NumPy podczas serializacji. ensure_ascii=False zachowywało polskie znaki.

Dalsze zabezpieczenie eksportu to allow_nan=False oraz kontrola wszystkich właściwości, nie tylko współrzędnych. Wówczas niedozwolona wartość zatrzyma eksport zamiast trafić do przeglądarki.

11 / PROCES

Integracja z WordPressem

Plik GeoJSON umieściłem na serwerze jako niezależną warstwę danych:

Ścieżka danych

/wp-content/maps/szkoly_map.geojson

Dzięki temu można go podmieniać i w przyszłości automatycznie generować bez uzależnienia od biblioteki mediów WordPressa. Mapę zbudowałem na Leaflet.

HTML / JavaScript

<div id="map" style="height:600px;"></div>

<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css">
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>

<script>
const map = L.map("map").setView([52.1, 19.3], 6);

L.tileLayer(
  "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
  { attribution: "© OpenStreetMap" }
).addTo(map);
</script>

Fragment pokazuje mechanikę osadzenia. Przy utrzymaniu wdrożenia zasoby należy ładować mechanizmami enqueue WordPressa, z ustaloną wersją biblioteki. Kod w tym case study jest wyświetlany jako tekst — nie uruchamia mapy ani zewnętrznych usług na tej stronie.

12 / PROCES

Dynamiczne wczytywanie danych i popupy

GeoJSON jest ładowany dynamicznie przez fetch() i przekazywany do Leaflet. Poniższy fragment pokazuje pierwotną logikę popupu:

JavaScript

fetch("/wp-content/maps/szkoly_map.geojson")
    .then(response => response.json())
    .then(data => {
        L.geoJSON(data, {
            onEachFeature(feature, layer) {
                const school = feature.properties;

                layer.bindPopup(`
                    <strong>${school.name}</strong><br>
                    ${school.miasto}<br>
                    ${school.adres}<br>
                    <a href="${school.strona}" target="_blank">
                        Przejdź na stronę szkoły
                    </a>
                `);
            }
        }).addTo(map);
    });

W popupie zachowuję pełny adres, nawet jeśli do geokodowania używana była jego uproszczona wersja.

Przy rozwijaniu tej implementacji dane źródłowe wymagają walidacji. Popup należy budować bez wstrzykiwania niezaufanego HTML, np. przez elementy DOM i textContent, a adres strony dopuścić tylko dla protokołów HTTP/HTTPS. Wczytywanie danych wymaga też obsługi błędnego statusu HTTP, JSON i niedostępności pliku.

13 / PROCES

Projekt filtrowania po województwie

Każdy rekord zawiera pole wojewodztwo, dlatego dane są przygotowane do filtrowania bez zmiany źródłowego zbioru. Przykładowa logika:

JavaScript

const filteredFeatures = schoolsData.features.filter(
    feature =>
        feature.properties.wojewodztwo === selectedRegion
);

const filteredCollection = {
    type: "FeatureCollection",
    features: filteredFeatures
};

To kierunek rozwoju, nie deklaracja wdrożonego filtra. Docelowo użytkownik może wybrać województwo, a mapa pokaże odpowiadające mu placówki. Rozwiązanie można rozszerzyć o liczniki szkół w regionie, klikane granice województw, klastry markerów, wyszukiwarkę miasta i synchronizowaną listę wyników.

14 / PROCES

Debugging i sposób rozwiązywania problemów

Do diagnostyki wykorzystałem konsolę przeglądarki i zakładkę Network. Sprawdzałem, czy plik GeoJSON jest pobierany, czy zwraca poprawny status HTTP oraz czy parser JSON może go odczytać.

Do oddzielenia problemu mapy od problemu danych można użyć prostego markera testowego:

JavaScript

L.marker([52.2297, 21.0122]).addTo(map);

Jeśli marker pojawia się poprawnie, podstawowa warstwa Leaflet działa. Kolejny krok to sprawdzenie danych, ich ładowania i konfiguracji warstwy GeoJSON. Takie podejście ogranicza przypadkowe zmiany i pozwala debugować system warstwa po warstwie.

15 / PROCES

Rezultat

  • Analiza zbioru 146 placówek i identyfikacja 112 brakujących geolokalizacji.
  • Rozpoznanie problemu jakości i niespójności adresów.
  • Przygotowanie procesu normalizacji danych w Pythonie.
  • Zwiększenie liczby poprawnie zlokalizowanych szkół z 34 do 124.
  • Zdiagnozowanie wartości NaN blokujących parser JSON i wygenerowanie poprawnego GeoJSON.
  • Przygotowanie integracji WordPress + Leaflet + OpenStreetMap oraz popupów.
  • Zaprojektowanie filtrowania po województwie i architektury do dalszej automatyzacji.

124 zlokalizowane placówki to wynik po normalizacji i ponownym geokodowaniu. Opis ręcznej weryfikacji pozostałych 22 rekordów nie oznacza, że wszystkie zostały ostatecznie zlokalizowane.

16 / PROCES

Co ten projekt pokazuje w moim portfolio

Potrafię przeprowadzić problem przez kolejne warstwy: od jakości danych źródłowych, przez ich przetwarzanie i walidację, po interfejs użytkownika. Wybieram narzędzia do zadania, sprawdzam hipotezy i rozdzielam problemy danych od problemów wizualizacji. Automatyzację uzupełniam ręczną weryfikacją tam, gdzie jest to prostsze i bardziej wiarygodne.

W projekcie wykorzystałem Python, pandas, JupyterLab, geopy, Nominatim, OpenStreetMap, GeoJSON, JavaScript, Leaflet, WordPress i DevTools oraz podstawy pracy z danymi przestrzennymi przy wsparciu modeli AI.

DANE / SYSTEMY / WDROŻENIA

Zobacz pozostałe projekty.