// Gotowe workflow

Scraper ogłoszeń

▸ // rozdziały lekcji

Scraper ogłoszeń - Wolniś: „Nowe mieszkanie. Wiem pierwszy”

Cały workflow na kanwie po uruchomieniu, z liczbami items na połączeniach

Dobre mieszkanie w rozsądnej cenie znika z portalu w dzień, czasem w kilka godzin. Jeśli sprawdzasz ogłoszenia wieczorem, oglądasz to, co zostało po innych. Odświeżanie tej samej strony pięć razy dziennie to robota, którą n8n zrobi co godzinę i nie zapomni.

Ten workflow pilnuje jednego wyszukiwania na Otodom. W pliku jest przykład: mieszkania na sprzedaż w Częstochowie, dzielnica Tysiąclecie, do 5400 zł za metr. Nowe ogłoszenie trafia do tabeli i na Telegram jako krótka wiadomość z ceną, metrażem, piętrem, ulicą i linkiem.

Poprzednia wersja miała 28 węzłów: dwie zdublowane gałęzie, Jinę do pobierania strony i dwa agenty AI, które robiły z tekstu tabelę. Wyleciało wszystko. Otodom trzyma dane ogłoszeń w kodzie strony jako gotowy JSON, więc wystarczy je stamtąd wyjąć.

Co robi ten workflow

  1. Co godzine - harmonogram, co godzinę w 5. minucie.
  2. Przygotuj tabele - Data Table z operacją Create zakłada tabelę ogloszenia z kolumnami url, tytul, cena, cena_m2, metraz, pokoje, pietro, ulica i znaleziono. Jeśli tabela już jest, używa istniejącej.
  3. Ustawienia - pole wyszukiwanie z adresem wyników z Otodom.
  4. Pobierz wyniki - HTTP Request z nagłówkami przeglądarki, odpowiedź jako tekst, ponawianie 3 razy co 5 sekund.
  5. Dane z kodu strony - węzeł HTML wyciąga skrypt script#__NEXT_DATA__.
  6. Lista ogloszen - Edit Fields zamienia tekst skryptu na JSON i wyjmuje z niego listę ogłoszeń.
  7. Rozbij ogloszenia - Split Out, jeden item na ogłoszenie.
  8. Dane ogloszenia - Edit Fields wybiera i tłumaczy pola.
  9. Czy nowe ogloszenie? - Row Not Exists po kolumnie url.
  10. Zapisz ogloszenie - Insert do tabeli.
  11. Wyslij na Telegram - wiadomość o każdym nowym ogłoszeniu.

Czego potrzebujesz

  • adresu wyszukiwania z Otodom,
  • bota na Telegramie założonego przez @BotFather i poświadczenia Telegram w n8n z tokenem bota (Credentials Telegram),
  • ID czatu, na który bot ma pisać. W pliku stoi zaślepka TWOJE_ID_CZATU. Jak odczytać ID, pokazuję w tej samej lekcji o Telegramie.

Jak uruchomić

  1. Zaimportuj plik z dołu lekcji (jak importować workflow).
  2. Na stronie Otodom ustaw filtry wyszukiwania: miasto, dzielnicę, cenę, metraż. Skopiuj adres z paska przeglądarki.
  3. Wklej go w pole wyszukiwanie w węźle Ustawienia.
  4. W węźle Wyslij na Telegram wybierz poświadczenie i wpisz ID czatu w miejsce TWOJE_ID_CZATU.
  5. Usuń połączenie między Zapisz ogloszenie a Wyslij na Telegram i kliknij Execute workflow. Powód jest w haczyku niżej.
  6. Sprawdź tabelę: Overview > zakładka Data tables > ogloszenia.
  7. Podłącz Telegram z powrotem i kliknij Publish (interfejs i publikacja).

Haczyk. Przy pierwszym uruchomieniu tabela jest pusta, więc każde ogłoszenie z listy jest „nowe". Z podłączonym Telegramem dostaniesz naraz tyle wiadomości, ile ogłoszeń jest na stronie wyników. Dlatego pierwszy przebieg robisz bez Telegrama: tabela się wypełnia, a od drugiego przebiegu na telefon idą tylko nowości.

Dane leżą w kodzie strony

Otodom zbudowany jest na frameworku Next.js. Taka strona wysyła w kodzie skrypt __NEXT_DATA__, a w nim JSON z danymi, z których przeglądarka rysuje listę ogłoszeń. Cena, metraż, piętro, ulica, wszystko w polach o stałych nazwach.

Węzeł Dane z kodu strony wyciąga ten skrypt selektorem script#__NEXT_DATA__, a Lista ogloszen robi z niego obiekt i schodzi do listy:

{{ JSON.parse($json.dane).props.pageProps.data.searchAds.items }}

JSON.parse zamienia tekst na obiekt, reszta to ścieżka do listy ogłoszeń. Dalej Split Out i zwykłe wyrażenia.

Wynik węzła Lista ogloszen - rozwinięte pierwsze ogłoszenie z polami totalPrice, areaInSquareMeters, roomsNumber, floorNumber

Pro-tip. Pobierz wyniki wysyła nagłówek User-Agent przeglądarki i Accept-Language z językiem polskim. Serwis dostaje zapytanie, które wygląda jak zwykła wizyta, i oddaje zwykłą stronę. Ponawianie 3 razy co 5 sekund łapie chwilowe czkawki serwera.

Tłumaczenie pól

Węzeł Dane ogloszenia wyciąga osiem pól. Większość to proste odwołania: totalPrice.value to cena, pricePerSquareMeter.value cena za metr (zaokrąglona), areaInSquareMeters metraż. Link składa się z https://www.otodom.pl/pl/oferta/ i pola slug.

Dwa pola wymagają tłumaczenia. Otodom zapisuje liczbę pokoi słowem, na przykład TWO, a piętro jako FOURTH albo GROUND. Wyrażenie ma w sobie mapę i bierze z niej wartość:

{{ ({ONE:1,TWO:2,THREE:3,FOUR:4,FIVE:5,SIX:6,SEVEN:7,EIGHT:8,NINE:9,TEN:10})[$json.o.roomsNumber] ?? 0 }}

TWO zamienia się w 2. Piętro działa tak samo: FOURTH daje „4", GROUND daje „parter", GARRET „poddasze". Jeśli wartości nie ma w mapie, wpada „brak danych".

Ulica bierze location.address.street.name. Ogłoszeniodawca nie zawsze ją podaje, więc wyrażenie ma zapas: „brak ulicy w ogłoszeniu".

Co naprawiłem względem starej wersji

  • cena za metr szła w wiadomości dwa razy, a ulicy nie było wcale. Teraz wiadomość ma cenę, cenę za metr i metraż w jednej linii, a ulicę w osobnej,
  • deduplikacja to jeden węzeł Row Not Exists po url zamiast porównywania dwóch zbiorów,
  • zniknęły klucze do Jiny i modeli AI. Workflow nie ma żadnego płatnego elementu.

Test i czego nie sprawdziłem

Na przykładowym wyszukiwaniu (Częstochowa, Tysiąclecie) workflow znalazł 12 ogłoszeń i zapisał je w tabeli. Wysyłki na Telegram nie testowałem, bo nie zakładałem bota. Wiadomość składa się z pól zapisanego wiersza, ale pierwszą prawdziwą wiadomość obejrzyj krytycznie.

Tabela ogloszenia w zakładce Data tables z 12 wierszami

Haczyk. To nieoficjalne źródło. Otodom nie obiecuje, że struktura __NEXT_DATA__ zostanie taka sama. Po zmianie ścieżka props.pageProps.data.searchAds.items przestanie trafiać, a wyrażenie w węźle Lista ogloszen nie znajdzie listy. Takiej awarii nie testowałem, ale spodziewaj się błędu właśnie w tym węźle. Podepnij alert błędów na Discord, żeby wiedzieć o tym od razu.

Haczyk. Nie odpalaj częściej niż co godzinę i przed uruchomieniem sprawdź regulamin serwisu. Jedno wyszukiwanie raz na godzinę to skala kupującego, nie hurtowego zbierania danych.

Podsumowanie

Jedenaście węzłów zamiast dwudziestu ośmiu, zero AI, zero płatnych usług. Dane leżą w kodzie strony jako JSON, węzeł HTML je wyjmuje, a mapy w wyrażeniach tłumaczą TWO na 2 i GROUND na parter. Row Not Exists po adresie pilnuje, żeby na Telegram szły tylko nowości. Ten sam układ przeniesiesz na każdy serwis, który trzyma dane w __NEXT_DATA__.

Tu kończy się dział z gotowymi workflow. W następnym zaczynamy serię SEO: NawiSEO 1 - parser mapy strony zbiera wszystkie adresy twojej strony do tabeli.

Pokaż JSON do skopiowania - scraper-ogloszen-z-powiadomieniem.json
{
  "name": "Scraper ogloszen Otodom z powiadomieniem na Telegram",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "triggerAtMinute": 5
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ],
      "id": "9e0586db-a7b1-39b5-a485-07f10da8a179",
      "name": "Co godzine"
    },
    {
      "parameters": {
        "resource": "table",
        "operation": "create",
        "tableName": "ogloszenia",
        "columns": {
          "column": [
            {
              "name": "url",
              "type": "string"
            },
            {
              "name": "tytul",
              "type": "string"
            },
            {
              "name": "cena",
              "type": "number"
            },
            {
              "name": "cena_m2",
              "type": "number"
            },
            {
              "name": "metraz",
              "type": "number"
            },
            {
              "name": "pokoje",
              "type": "number"
            },
            {
              "name": "pietro",
              "type": "string"
            },
            {
              "name": "ulica",
              "type": "string"
            },
            {
              "name": "znaleziono",
              "type": "date"
            }
          ]
        },
        "options": {
          "createIfNotExists": true
        }
      },
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        220,
        0
      ],
      "id": "be3c808f-9b42-baed-8c47-d715e9659114",
      "name": "Przygotuj tabele"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "92c967a0-c641-96a5-0828-659fb32efdbb",
              "name": "wyszukiwanie",
              "value": "https://www.otodom.pl/pl/wyniki/sprzedaz/mieszkanie/slaskie/czestochowa/czestochowa/czestochowa/tysiaclecie?limit=36&ownerTypeSingleSelect=ALL&pricePerMeterMax=5400&by=DEFAULT&direction=DESC",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        440,
        0
      ],
      "id": "3e9d0a59-f7b1-5b26-53ea-caa88cb501b9",
      "name": "Ustawienia"
    },
    {
      "parameters": {
        "url": "={{ $json.wyszukiwanie }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36"
            },
            {
              "name": "Accept-Language",
              "value": "pl-PL,pl;q=0.9"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        660,
        0
      ],
      "id": "8c02797b-6764-7030-935b-1a9aca91bc9f",
      "name": "Pobierz wyniki",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "operation": "extractHtmlContent",
        "dataPropertyName": "data",
        "extractionValues": {
          "values": [
            {
              "key": "dane",
              "cssSelector": "script#__NEXT_DATA__",
              "returnValue": "html"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.html",
      "typeVersion": 1.2,
      "position": [
        880,
        0
      ],
      "id": "6034d858-f78d-f2ea-2592-5fa2019a7987",
      "name": "Dane z kodu strony"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "1a363461-2935-5b21-66fb-fb49028f0caa",
              "name": "ogloszenia",
              "value": "={{ JSON.parse($json.dane).props.pageProps.data.searchAds.items }}",
              "type": "array"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1100,
        0
      ],
      "id": "ff62f86f-1710-d1c7-ff8d-03a7c8af3e6b",
      "name": "Lista ogloszen"
    },
    {
      "parameters": {
        "fieldToSplitOut": "ogloszenia",
        "options": {
          "destinationFieldName": "o"
        }
      },
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1,
      "position": [
        1320,
        0
      ],
      "id": "7d94dc58-2dea-2ff0-3767-0e474437f52c",
      "name": "Rozbij ogloszenia"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "51baf8e5-969e-9a51-26fd-a43f07af1b88",
              "name": "url",
              "value": "={{ 'https://www.otodom.pl/pl/oferta/' + $json.o.slug }}",
              "type": "string"
            },
            {
              "id": "13f880f2-0d6d-bc7b-f78e-d6c6a1703633",
              "name": "tytul",
              "value": "={{ $json.o.title }}",
              "type": "string"
            },
            {
              "id": "b58d22fc-7f69-b2dc-18ef-6a907a320326",
              "name": "cena",
              "value": "={{ $json.o.totalPrice?.value ?? 0 }}",
              "type": "number"
            },
            {
              "id": "9f59a87c-8b10-927f-b227-b254b9834258",
              "name": "cena_m2",
              "value": "={{ Math.round($json.o.pricePerSquareMeter?.value ?? 0) }}",
              "type": "number"
            },
            {
              "id": "2ae11e1a-c4e0-6c03-7db0-8e84536608a4",
              "name": "metraz",
              "value": "={{ $json.o.areaInSquareMeters }}",
              "type": "number"
            },
            {
              "id": "e82982a0-6cfb-4fe4-401e-ca197d5f3f96",
              "name": "pokoje",
              "value": "={{ ({ONE:1,TWO:2,THREE:3,FOUR:4,FIVE:5,SIX:6,SEVEN:7,EIGHT:8,NINE:9,TEN:10})[$json.o.roomsNumber] ?? 0 }}",
              "type": "number"
            },
            {
              "id": "88e9338e-04e4-cd27-d2c9-ce4a7540c3f9",
              "name": "pietro",
              "value": "={{ ({CELLAR:'suterena',GROUND:'parter',FIRST:'1',SECOND:'2',THIRD:'3',FOURTH:'4',FIFTH:'5',SIXTH:'6',SEVENTH:'7',EIGHTH:'8',NINTH:'9',TENTH:'10',ABOVE_TENTH:'powyżej 10',GARRET:'poddasze'})[$json.o.floorNumber] ?? 'brak danych' }}",
              "type": "string"
            },
            {
              "id": "2d7be0bd-d01d-b2e8-d464-965a37fcab68",
              "name": "ulica",
              "value": "={{ $json.o.location?.address?.street?.name || 'brak ulicy w ogłoszeniu' }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1540,
        0
      ],
      "id": "2c7d1e85-9549-1f7e-5b0d-281304808fb6",
      "name": "Dane ogloszenia"
    },
    {
      "parameters": {
        "operation": "rowNotExists",
        "dataTableId": {
          "__rl": true,
          "value": "ogloszenia",
          "mode": "name"
        },
        "filters": {
          "conditions": [
            {
              "keyName": "url",
              "condition": "eq",
              "keyValue": "={{ $json.url }}"
            }
          ]
        }
      },
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        1760,
        0
      ],
      "id": "776a82d6-dadc-eb79-5f38-128a422159fe",
      "name": "Czy nowe ogloszenie?"
    },
    {
      "parameters": {
        "operation": "insert",
        "dataTableId": {
          "__rl": true,
          "value": "ogloszenia",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "url": "={{ $json.url }}",
            "tytul": "={{ $json.tytul }}",
            "cena": "={{ $json.cena }}",
            "cena_m2": "={{ $json.cena_m2 }}",
            "metraz": "={{ $json.metraz }}",
            "pokoje": "={{ $json.pokoje }}",
            "pietro": "={{ $json.pietro }}",
            "ulica": "={{ $json.ulica }}",
            "znaleziono": "={{ $now.toISO() }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "url",
              "displayName": "url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "tytul",
              "displayName": "tytul",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "cena",
              "displayName": "cena",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "cena_m2",
              "displayName": "cena_m2",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "metraz",
              "displayName": "metraz",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "pokoje",
              "displayName": "pokoje",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "pietro",
              "displayName": "pietro",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "ulica",
              "displayName": "ulica",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "znaleziono",
              "displayName": "znaleziono",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "date",
              "readOnly": false,
              "removed": false
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        1980,
        0
      ],
      "id": "2175dd00-c247-59d7-c1ad-02d05fa1cb41",
      "name": "Zapisz ogloszenie"
    },
    {
      "parameters": {
        "chatId": "TWOJE_ID_CZATU",
        "text": "={{ $json.tytul }}\n{{ $json.cena.toLocaleString('pl-PL') }} zł | {{ $json.cena_m2.toLocaleString('pl-PL') }} zł/m² | {{ $json.metraz }} m²\n{{ $json.pokoje }} pok. | piętro: {{ $json.pietro }}\n{{ $json.ulica }}\n{{ $json.url }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        2200,
        0
      ],
      "id": "2d480577-5b76-d738-0523-dd1ed6e6ec38",
      "name": "Wyslij na Telegram"
    }
  ],
  "connections": {
    "Co godzine": {
      "main": [
        [
          {
            "node": "Przygotuj tabele",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Przygotuj tabele": {
      "main": [
        [
          {
            "node": "Ustawienia",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ustawienia": {
      "main": [
        [
          {
            "node": "Pobierz wyniki",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pobierz wyniki": {
      "main": [
        [
          {
            "node": "Dane z kodu strony",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dane z kodu strony": {
      "main": [
        [
          {
            "node": "Lista ogloszen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lista ogloszen": {
      "main": [
        [
          {
            "node": "Rozbij ogloszenia",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rozbij ogloszenia": {
      "main": [
        [
          {
            "node": "Dane ogloszenia",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Dane ogloszenia": {
      "main": [
        [
          {
            "node": "Czy nowe ogloszenie?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Czy nowe ogloszenie?": {
      "main": [
        [
          {
            "node": "Zapisz ogloszenie",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Zapisz ogloszenie": {
      "main": [
        [
          {
            "node": "Wyslij na Telegram",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false
}