Jak AI widzi twoją stronę: treść, której crawler nie znajdzie
Strona produktu w sklepie internetowym: nad zdjęciem nazwa i cena, pod nim trzy zakładki – „Opis”, „Specyfikacja”, „Dostawa” – a niżej sekcja opinii, która doładowuje się, gdy użytkownik do niej przewinie. Opis ma dwa tysiące słów, specyfikacja trzydzieści parametrów, opinii jest dwieście. Wszystko, czego potrzeba, żeby odpowiedzieć na pytanie „czy ta lampa nadaje się do małego biura?”.
Ktoś zadaje dokładnie to pytanie asystentowi AI z wyszukiwaniem w sieci. Odpowiedź cytuje twoją stronę – i opiera się wyłącznie na dwóch zdaniach z meta description oraz cenie. O wymiarach, natężeniu światła i opiniach ani słowa, choć na stronie są.
Pierwsze podejrzenie pada zwykle na CSS: „zakładki mają display: none, więc bot ich nie widzi”. To prawie nigdy nie jest przyczyna. Żeby zrozumieć, gdzie naprawdę ginie treść, trzeba wiedzieć, co crawler w ogóle robi ze stroną.
Crawler nie ogląda strony, tylko czyta HTML
Przeglądarka pobiera HTML, potem CSS i JavaScript, buduje drzewo DOM, wykonuje skrypty, liczy layout i maluje piksele. Użytkownik widzi wynik tego całego procesu. Crawler robi zwykle tylko pierwszy krok: pobiera HTML i wyciąga z niego tekst.
Googlebot jest tu wyjątkiem – od lat renderuje strony w przeglądarce, łącznie z JavaScriptem, choć z opóźnieniem. Ale crawlery, które zasilają asystentów AI i ich wyszukiwanie, w większości tego nie robią. Analizy ruchu takich botów, publikowane m.in. przez Vercel, pokazywały, że pobierają one pliki JavaScript, ale ich nie wykonują. W praktyce oznacza to jedną prostą regułę, od której zależy wszystko dalej: crawler AI widzi to, co jest w HTML-u wysłanym przez serwer – i nic więcej.
Z tej reguły wynikają dwie rzeczy naraz. Treść ukryta stylem, ale obecna w HTML-u, jest dla crawlera tak samo widoczna jak każda inna. A treść, która powstaje dopiero w przeglądarce – po kliknięciu, po przewinięciu, po wykonaniu skryptu – dla crawlera nie istnieje.
Pułapka: display: none to nie problem
Crawler, który nie liczy layoutu, nie wie nawet, że jakaś reguła CSS ukrywa element. Dla niego zakładka z display: none, hidden czy visibility: hidden to po prostu kolejny fragment tekstu w HTML-u. Wyszukiwarki ewentualnie ważą taką treść inaczej, ale ją widzą.
Prawdziwy problem leży o krok wcześniej – w tym, czy ukryta treść w ogóle trafiła do HTML-a. I tu decyduje nie CSS, tylko sposób, w jaki komponent ją ukrywa. Porównaj dwie implementacje tych samych zakładek w React:
tsx
// Zakładki „warunkowe”: w HTML-u jest tylko aktywny panelfunction ProductTabs({ tabs }: { tabs: Tab[] }) {const [active, setActive] = useState(tabs[0].id)const current = tabs.find((t) => t.id === active)!return (<div><TabList tabs={tabs} active={active} onChange={setActive} /><div role="tabpanel">{current.content}</div></div>)}
Wygląda niewinnie i działa dobrze dla użytkownika. Ale serwer renderuje tylko pierwszą zakładkę. Specyfikacja i informacje o dostawie powstają w przeglądarce dopiero po kliknięciu – a crawler nigdy nie klika. Dla niego strona produktu ma opis i nic więcej.
tsx
// Zakładki ukrywane stylem: w HTML-u są wszystkie panelefunction ProductTabs({ tabs }: { tabs: Tab[] }) {const [active, setActive] = useState(tabs[0].id)return (<div><TabList tabs={tabs} active={active} onChange={setActive} />{tabs.map((t) => (<divkey={t.id}role="tabpanel"id={"panel-" + t.id}aria-labelledby={"tab-" + t.id}hidden={t.id !== active}>{t.content}</div>))}</div>)}
Ta sama zakładka dla użytkownika, ten sam stan, ta sama obsługa kliknięć. Różnica jest tylko jedna: wszystkie panele są wyrenderowane, a nieaktywne ukrywa atrybut hidden. Serwer wysyła w HTML-u cały opis, całą specyfikację i informacje o dostawie. Crawler czyta wszystko.
To samo dotyczy akordeonów w FAQ, rozwijanych sekcji „Więcej informacji” i każdego komponentu, który pokazuje treść na żądanie. Zasada jest jedna: ukrywaj stylem albo atrybutem, nie warunkiem. {open && <Panel />} usuwa treść z HTML-a, hidden={!open} tylko ją chowa.
hidden="until-found": ukryte, ale do znalezienia
Jest wariant, który idzie jeszcze krok dalej. W artykule o content-visibility opisywałem hidden="until-found" – atrybut, który ukrywa element tak jak hidden, ale pozwala przeglądarce go odsłonić, gdy użytkownik szuka w nim tekstu przez Ctrl+F albo trafia na niego przez link z fragmentem tekstu.
html
<div role="tabpanel" id="panel-spec" hidden="until-found"><table>…</table></div>
Dla crawlera to dokładnie to samo co zwykłe hidden – tekst jest w HTML-u. Dla użytkownika to rozwiązanie problemu, który zakładki mają od zawsze: wyszukiwanie w przeglądarce nie znajduje słowa „waga”, bo tabela parametrów jest w zakładce, której nikt nie otworzył. Z until-found przeglądarka sama odsłania panel i wysyła na elemencie zdarzenie beforematch, w którym możesz przełączyć aktywną zakładkę, żeby interfejs pozostał spójny. Przeglądarka, która nie zna tej wartości, potraktuje ją jak zwykłe hidden – czyli nic nie tracisz.
Ten sam efekt za darmo daje natywny <details>: zawartość zamkniętego elementu jest w HTML-u, a nowsze przeglądarki odsłaniają ją przy wyszukiwaniu.
Tekst, którego nie ma w HTML-u: content w CSS i obrazki
Druga kategoria to treść, która jest widoczna na stronie, ale w ogóle nie jest tekstem w dokumencie. Najbardziej podstępny przypadek to content w pseudo-elementach:
css
.price::after {content: " zł brutto";}.product-card--new::before {content: "Nowość";}.shipping-info::before {content: "Darmowa dostawa od 200 zł";}
Użytkownik widzi „Darmowa dostawa od 200 zł”. Crawler – nie, bo tego tekstu nie ma w HTML-u, istnieje tylko w arkuszu stylów, a crawler nie stosuje CSS. Nie ma go też w textContent, nie da się go skopiować, a wklejony do asystenta fragment strony go nie zawiera. Pseudo-elementy są dobre do dekoracji – ikon, strzałek, cudzysłowów. Każda informacja, którą ktoś mógłby zacytować, powinna być prawdziwym tekstem w HTML-u.
Ta sama zasada dotyczy tekstu na obrazkach. Baner „−30% na całą kolekcję do niedzieli” wyeksportowany jako jeden JPG to dla crawlera tylko alt – jeśli w ogóle jest. Lepiej zrobić to samo prawdziwym tekstem nałożonym na zdjęcie przez CSS: position: absolute na tekście, object-fit: cover na obrazku i gradient pod spodem dla kontrastu. Wygląda identycznie, skaluje się z ekranem, da się przetłumaczyć – i da się przeczytać.
Doładowywane treści: opinie, „pokaż więcej”, nieskończony scroll
Trzecia kategoria to treść, która trafia na stronę dopiero po akcji użytkownika albo po przewinięciu. W e-commerce to najczęściej opinie – i jest to strata podwójna, bo właśnie opinie są tym, o co ludzie pytają asystentów AI: „czy ten produkt jest dobry?”, „na co narzekają kupujący?”.
tsx
// Opinie pobierane, gdy sekcja wjedzie na ekranfunction Reviews({ productId }: { productId: string }) {const [reviews, setReviews] = useState<Review[]>([])const ref = useInView(() => {fetch("/api/reviews/" + productId).then((r) => r.json()).then(setReviews)})return <section ref={ref}>{reviews.map(renderReview)}</section>}
Dla wydajności to rozsądne: nie pobierasz dwustu opinii, zanim ktoś do nich przewinie. Ale crawler nie przewija, więc dla niego sekcja opinii jest pusta. Kompromis, który zwykle działa najlepiej: pierwsze kilka opinii i podsumowanie (średnia ocena, liczba opinii, najczęstsze plusy i minusy) w HTML-u z serwera, resztę doładowuj przy scrollu albo po kliknięciu „Pokaż więcej”. Dla użytkownika różnica jest niezauważalna, dla crawlera to różnica między „brak opinii” a konkretnymi argumentami.
To samo dotyczy obcinania tekstu. Opis skrócony stylem – line-clamp, max-height z gradientem i przyciskiem „Czytaj więcej” – jest w całości w HTML-u. Opis skrócony w kodzie – description.slice(0, 300) z pobieraniem reszty po kliknięciu – w HTML-u ma trzysta znaków.
css
/* Cały opis jest w HTML-u, widać pierwsze 4 linie */.description:not(.is-expanded) {display: -webkit-box;-webkit-line-clamp: 4;-webkit-box-orient: vertical;overflow: hidden;}
Next.js: co trafia do HTML-a, a co nie
W Next.js z App Routerem sporo z tego dostajesz za darmo, ale nie wszystko. Warto znać cztery przypadki:
- Server Components renderują się na serwerze, a ich wynik trafia do HTML-a. Dane pobrane w Server Component – opis produktu, specyfikacja, pierwsze opinie – są widoczne dla każdego crawlera.
- Client Components (
"use client") też są renderowane na serwerze przy pierwszym żądaniu. To częste nieporozumienie:"use client"nie oznacza „tylko w przeglądarce”. Zakładki z przykładu wyżej mogą być komponentem klienckim i nadal wyślą wszystkie panele w HTML-u. - Dane pobrane w
useEffectalbo przez bibliotekę działającą tylko w przeglądarce nie trafią do HTML-a – efekty nie wykonują się na serwerze. Tak samo komponenty ładowane przezdynamic(..., { ssr: false }). - Streaming z Suspense – opisany w artykule o
use()i Suspense – wysyła zawartość w tej samej odpowiedzi HTTP, tylko później. Crawler, który pobiera całą odpowiedź, dostaje także treść strumieniowaną, choć umieszczoną na końcu dokumentu i przenoszoną na miejsce małym skryptem. Tekst jest w HTML-u, więc crawler go przeczyta.
Najbezpieczniejszy wzorzec dla strony produktu: dane pobierane w Server Component, interaktywność w małych komponentach klienckich, które dostają treść jako props albo children, a nie pobierają jej same.
Jak sprawdzić, co widzi crawler
Nie trzeba zgadywać. Najprostszy test to pobranie strony bez przeglądarki i poszukanie w niej konkretnej frazy z ukrytej treści:
bash
# Czy fraza ze specyfikacji jest w HTML-u wysłanym przez serwer?curl -s https://example.com/produkt/lampa-biurowa | grep -c "Natężenie światła"# To samo z nagłówkiem User-Agent jednego z botówcurl -s -A "GPTBot" https://example.com/produkt/lampa-biurowa | grep -c "Natężenie światła"
0 oznacza, że crawler tej frazy nie zobaczy. Drugi sposób nie wymaga terminala: w devtools wyłącz JavaScript (w Chrome: paleta poleceń, „Disable JavaScript”) i odśwież stronę. To, co wtedy widzisz – albo czego nie widzisz – jest bardzo bliskie temu, co dostaje crawler. Trzeci: „Pokaż źródło strony” zamiast zakładki Elements. Elements pokazuje DOM po wykonaniu skryptów, źródło – HTML, który wysłał serwer.
Pułapki i dobre praktyki
- Ukrycie stylem nie szkodzi, brak w HTML-u – tak.
display: noneihiddenzostawiają tekst w dokumencie.{open && <Panel />}usuwa go z HTML-a wysłanego przez serwer. - Zakładki i akordeony renderuj w całości. Nieaktywne panele ukrywaj atrybutem
hiddenalbohidden="until-found", który dodatkowo pozwala znaleźć treść przez Ctrl+F. - Informacje to tekst, nie
contentw CSS ani obrazek. Pseudo-elementy zostaw dekoracjom, tekst z banerów nałóż na zdjęcie przez CSS. - Najważniejsze opinie i podsumowanie w HTML-u z serwera. Doładowywanie przy scrollu jest dobre dla reszty, ale crawler nigdy nie przewinie.
- Obcinaj tekst stylem, nie w kodzie.
line-clampzostawia cały opis w HTML-u,slice()– nie. "use client"nie wyklucza SSR,useEffectissr: false– tak. Pobieraj treść w Server Components i przekazuj ją do komponentów interaktywnych.- Testuj
curlalbo z wyłączonym JavaScriptem. Zakładka Elements w devtools pokazuje stronę po skryptach, a crawler widzi ją przed nimi.
Podsumowanie
Crawlery zasilające asystentów AI w większości nie wykonują JavaScriptu i nie stosują CSS – czytają HTML wysłany przez serwer i wyciągają z niego tekst. Dlatego ukrycie treści stylem, którego tak się obawiamy, prawie nigdy nie jest problemem: zakładka z display: none jest dla crawlera tak samo czytelna jak reszta strony. Problemem jest treść, której w tym HTML-u nie ma – panele renderowane warunkowo, opinie pobierane przy scrollu, opisy obcinane w kodzie, informacje wstawione przez content w CSS albo zapisane na obrazku. Rozwiązania są proste i zwykle poprawiają też dostępność i wyszukiwanie w przeglądarce: renderuj wszystko, a ukrywaj atrybutem, najważniejsze treści pobieraj na serwerze, informacje zapisuj tekstem. A gdy nie masz pewności, curl i jedno grep powiedzą ci w sekundę, co z twojej strony zobaczy crawler.