Przykłady scrapera

skrypty lokalne + API przez HTTPS (curl, Python)
← Pomoc

Przykłady: API przez HTTPS i skrypty lokalne

Ten dokument (do pobrania w całości jako plik .md — przycisk niżej na stronie /przyklady) zbiera gotowy, kopiuj-wklej kod:

Korzystanie z API przez HTTPS (WAF)

Wszystkie przykłady niżej łączą się przez publiczny adres WAF-a — https://scrap-1.syspir.pl — bo to jedyny właściwy sposób dostępu spoza sieci netbird (surowego IP netbirda nie podawaj nikomu spoza tej sieci). Każde żądanie do /api/* wymaga dwóch nagłówków naraz:

Nagłówek Skąd wziąć Sprawdza
Authorization: Bearer <klucz> /api-keys ta aplikacja (FastAPI)
X-Scrap-Key: <sekret> SCRAP_WAF_KEY z .env na serwerze (albo od administratora WAF-a) sam WAF, zanim ruch dotrze do serwera

Brak X-Scrap-Key → żądanie nie dotrze do aplikacji (błąd po stronie WAF-a). Brak/zły Authorization → aplikacja odpowie 401. W przykładach niżej X-Scrap-Key jest tylko placeholderem — realną wartość weź z .env (SCRAP_WAF_KEY) na serwerze albo od administratora WAF-a; nie wklejaj jej do tego dokumentu ani do repozytorium. Poniższe przykłady zakładają Pythona z zainstalowanym requests (pip install requests — to zależność Twojego środowiska, nie tego projektu).

1. Stan na żywo — GET /api/status

curl -s https://scrap-1.syspir.pl/api/status \
     -H "Authorization: Bearer <klucz z /api-keys>" \
     -H "X-Scrap-Key: <sekret WAF-a (SCRAP_WAF_KEY z .env)>"
import requests

API_KEY = "<klucz z /api-keys>"
WAF_KEY = "<sekret WAF-a (SCRAP_WAF_KEY z .env)>"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "X-Scrap-Key": WAF_KEY}

resp = requests.get(
    "https://scrap-1.syspir.pl/api/status", headers=HEADERS, timeout=10
)
resp.raise_for_status()
stan = resp.json()
print(stan["state"], "—", stan["seconds_since_last_event"], "s temu")

Pole state to jedno z: running, success, error, offline, no_data, unknown — znaczenie każdego jest opisane w /pomoc.

2. Liczniki, historia i ostatnie zdarzenia — GET /api/stats

curl -s "https://scrap-1.syspir.pl/api/stats?range=7d" \
     -H "Authorization: Bearer <klucz z /api-keys>" \
     -H "X-Scrap-Key: <sekret WAF-a (SCRAP_WAF_KEY z .env)>" \
     | python3 -m json.tool
import requests

API_KEY = "<klucz z /api-keys>"
WAF_KEY = "<sekret WAF-a (SCRAP_WAF_KEY z .env)>"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "X-Scrap-Key": WAF_KEY}

for zakres in ("24h", "7d", "30d"):
    resp = requests.get(
        "https://scrap-1.syspir.pl/api/stats",
        params={"range": zakres},
        headers=HEADERS,
        timeout=10,
    )
    resp.raise_for_status()
    dane = resp.json()
    print(zakres, "-> punktów w historii:", len(dane["history"]))

# `range` w zapytaniu steruje TYLKO długością `history` (wykresu).
# `counters` wraca zawsze z tymi samymi trzema oknami, niezależnie od `range`:
print(dane["counters"]["last_24h"])
print(dane["counters"]["last_7d"])
print(dane["counters"]["all_time"])

3. Sam szereg czasowy do wykresu — GET /api/history

curl -s "https://scrap-1.syspir.pl/api/history?range=30d" \
     -H "Authorization: Bearer <klucz z /api-keys>" \
     -H "X-Scrap-Key: <sekret WAF-a (SCRAP_WAF_KEY z .env)>" \
     -o historia_30d.json
import csv

import requests

API_KEY = "<klucz z /api-keys>"
WAF_KEY = "<sekret WAF-a (SCRAP_WAF_KEY z .env)>"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "X-Scrap-Key": WAF_KEY}

resp = requests.get(
    "https://scrap-1.syspir.pl/api/history",
    params={"range": "30d"},
    headers=HEADERS,
    timeout=10,
)
resp.raise_for_status()
seria = resp.json()["history"]  # lista {"bucket": ts, "items": int, "errors": int}

with open("historia_30d.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["bucket", "items", "errors"])
    writer.writeheader()
    writer.writerows(seria)

4. Zlecenie scrapowania na żądanie — POST /api/scrape

curl -X POST https://scrap-1.syspir.pl/api/scrape \
     -H "Authorization: Bearer <klucz z /api-keys>" \
     -H "X-Scrap-Key: <sekret WAF-a (SCRAP_WAF_KEY z .env)>" \
     -H "Content-Type: application/json" \
     -d '{"targets": ["https://example.com", "https://example.org"]}'
import requests

API_KEY = "<klucz z /api-keys>"
WAF_KEY = "<sekret WAF-a (SCRAP_WAF_KEY z .env)>"
HEADERS = {"Authorization": f"Bearer {API_KEY}", "X-Scrap-Key": WAF_KEY}

resp = requests.post(
    "https://scrap-1.syspir.pl/api/scrape",
    json={"targets": ["https://example.com", "https://example.org"]},
    headers=HEADERS,
    timeout=200,  # żądanie CZEKA na wynik — do ok. 3 minut przy wielu celach
)
resp.raise_for_status()
wynik = resp.json()
for r in wynik["results"]:
    # `http_status`, `body_bytes` i `duration_ms` ma KAŻDY wynik (None = nieznane)
    print(r["target"], "->", r["http_status"], r["body_bytes"], "B", r["duration_ms"], "ms")
    if r["status"] != "ok":
        print("  BŁĄD:", r["error"])
        # Uwaga: kod >= 400 to też błąd, ale treść jest wtedy pobrana —
        # `title`, `head` i `body` są wypełnione, więc da się ich użyć.
        if r["http_status"] is None:
            continue
    print("  tytuł:", r["title"])
    print("  meta:", r["head"]["meta"])       # np. opis, tagi Open Graph
    print("  canonical:", r["head"]["canonical"])
    print("  body:", len(r["body"]), "znaków HTML")

Każdy udany cel niesie oprócz title też head (wyciągnięte metadane: title, lang, meta — w tym Open Graph, bo łapane po name i property, oraz canonical) i body (surowy outerHTML całego <body> — potrafi być spory przy dużych stronach). Pełny opis kształtu odpowiedzi jest w /pomoc.

Niezależnie od wyniku każdy cel ma http_status (kod odpowiedzi strony), body_bytes (rozmiar body w bajtach) i duration_ms (czas pobrania) — te same liczby widać w rozwinięciu tabeli „Przebiegi” w panelu. null znaczy „nieznane”: brak odpowiedzi, odrzucenie przez ochronę adresów albo cel pominięty z braku czasu. Pole load_complete (true/false/null) mówi, czy strona doczekała się końca wczytywania — false to wciąż udany cel z treścią, tylko bez dodatków, które nie zdążyły w ok. 10 s (zob. /pomoc).

Cel potrafi zostać odrzucony także wtedy, gdy sama strona wczytała się poprawnie: jeśli któryś jej podzasób (obrazek, skrypt, XHR) został pobrany z adresu w sieci wewnętrznej — zwykle przez przekierowanie — cel kończy się błędem i nie zwraca treści. Komunikat jest wtedy celowo ogólny, bez adresu.

Ograniczenia (pełny opis w /pomoc): maksymalnie 10 celów na żądanie, tylko jedno scrapowanie naraz (drugie równoległe dostaje 429), odpowiedź przychodzi dopiero po zakończeniu całego przebiegu.

Skrypty lokalne: pobranie HTML-a i zdjęć

Poniższe dwa skrypty uruchamia się bezpośrednio na serwerze (nie przez HTTP) — do szybkiego podejrzenia, co widzi scraper na danej stronie.

Pobranie pełnego HTML-a strony

./.venv/bin/python example_fetch_html.py https://www.twoje-miasto.pl > strona.html

Domyślny URL (bez argumentu) to https://www.twoje-miasto.pl. HTML — po wykonaniu JS, dokładnie taki, jaki widzi przeglądarka — trafia na stdout; > strona.html zapisuje go do pliku.

Pobranie serii zdjęć z artykułu

./.venv/bin/python example_fetch_photos.py https://www.twoje-miasto.pl/art-.../tytul-i123456

Zapisuje zdjęcia do katalogu ./photos/ (można zmienić przez --out-dir DIR). Szuka zdjęć w pełnym rozmiarze przez link lightboxa (<a data-lity href="...">), którego używa twoje-miasto.pl do powiększania zdjęć — działa identycznie dla artykułu z jednym zdjęciem głównym i dla prawdziwej wieloelementowej galerii.

Jak są „ucharakteryzowane” (żeby wyglądały jak realny użytkownik)

Oba skrypty korzystają z tej samej konfiguracji przeglądarki co produkcyjny scraper (scraper_core.py):

Element Wartość
Przeglądarka realny, systemowo zainstalowany Google Chrome (nie zbundlowany Chromium)
System (deklarowany) Windows 11
Rozdzielczość 1920×1080 (najpopularniejsza w Polsce — StatCounter, 2026)
Język polski (pl-PL), strefa czasowa Europe/Warsaw
Sesja cookies/localStorage przetrwałe między uruchomieniami (data/browser_state.json)

Znane ograniczenie: starsze API navigator.platform w przeglądarce i tak zdradza, że fizyczny serwer działa na Linuksie, nie na Windows — to architektoniczne ograniczenie samego Chromium, którego nie da się obejść z poziomu tego stacku. W praktyce: myli to proste sprawdzenia oparte na nagłówkach/User-Agencie, ale nie przetrwa dokładnego zestawienia z tym starszym API. Pełne techniczne uzasadnienie tych wyborów (i pomiary fingerprintu TLS/HTTP2) jest w README.md w repo.


Instrukcja obsługi całego systemu (dashboard, API, klucze) jest pod /pomoc.