Przejdź do głównej zawartości

Wskazówki SEO i GEO (AEO) dla projektów WordPress — bez wtyczek SEO

· 14 min aby przeczytać
Przemysław Majdak
Full-Stack Developer, Automation Engineer & Web Security Specialist

Szczegółowa analiza technik zastosowanych w motywie FSE BitBiz.pl — do przeniesienia na inne projekty WordPress. Wszystkie techniki działają bez wtyczek SEO (Yoast/RankMath), czysto w functions.php motywu.

SEO = klasyczna optymalizacja pod Google. GEO / AEO (Generative Engine Optimization / Answer Engine Optimization) = optymalizacja pod systemy AI (ChatGPT Search, Perplexity, Google AI Overviews), które cytują treści i budują odpowiedzi na ich podstawie.


1. Dynamiczna meta description (bez wtyczki SEO)

Meta description to krótki opis strony w <head> HTML — Google często pokazuje go jako szary tekst pod linkiem w wynikach wyszukiwania. Dobry opis nie podnosi bezpośrednio pozycji, ale decyduje, czy ktoś kliknie. Hook wp_head to mechanizm WordPressa, który pozwala "dokleić" własny kod do sekcji <head> każdej strony — a priorytet (liczba) określa kolejność: im niższy, tym wcześniej kod się wykona.

Jeden hook wp_head z priorytetem 1 generuje description dla każdego typu strony:

Typ stronyŹródło description
Wpis / strona (is_singular)Excerpt, a gdy pusty — pierwsze 20 słów treści (wp_trim_words)
Kategoria / tag (is_category, is_tag)term_description(), fallback: szablon „Artykuły powiązane z: {nazwa}"
Strona autora (is_author)Ręcznie napisany bio-opis z tytułem zawodowym i afiliacjami
Strona głównaget_bloginfo('description')
add_action('wp_head', 'moj_seo_meta_description', 1);
function moj_seo_meta_description() {
if (is_singular()) {
global $post;
$excerpt = strip_tags($post->post_excerpt);
if (empty($excerpt)) {
$excerpt = strip_tags(wp_trim_words($post->post_content, 20, ''));
}
$excerpt = esc_attr(trim($excerpt));
if (!empty($excerpt)) {
echo '<meta name="description" content="' . $excerpt . '">' . "\n";
}
} elseif (is_category() || is_tag() || is_tax()) {
$desc = strip_tags(term_description());
if (empty($desc)) {
$term = get_queried_object();
$desc = 'Artykuły i aktualności powiązane z: ' . $term->name;
}
echo '<meta name="description" content="' . esc_attr(trim($desc)) . '">' . "\n";
} elseif (is_front_page() || is_home()) {
$desc = get_bloginfo('description', 'display');
if (!empty($desc)) {
echo '<meta name="description" content="' . esc_attr($desc) . '">' . "\n";
}
}
}

Zasady:

  • Zawsze esc_attr() + trim() + strip_tags().
  • Fallback-chain: excerpt → treść → szablon generyczny. Nigdy pusta description.
  • Priorytet 1 na wp_head — meta description wysoko w <head>.

2. Open Graph + Twitter Cards

Tagi Open Graph (OG) decydują o tym, jak wygląda podgląd linku po wklejeniu go na Facebooka, LinkedIn, Slacka czy Discorda — tytuł, opis i obrazek w "kafelku". Bez nich serwisy społecznościowe zgadują (często źle), co pokazać. Twitter Cards to odpowiednik tego samego mechanizmu dla X/Twittera.

Drugi hook (wp_head, priorytet 2) generuje pełny zestaw OG dla każdego typu strony:

  • og:site_name, og:title, og:description, og:url, og:type, og:image
  • twitter:card = summary_large_image + twitter:title/description/image

Kluczowe decyzje:

  • og:type zależny od kontekstu: article dla wpisów, profile dla strony autora, website dla reszty. To sygnał typologiczny również dla crawlerów AI.
  • Fallback obrazka: get_site_icon_url() ?: get_template_directory_uri() . '/favicon.png' — każda strona MA og:image, nawet bez obrazka wyróżniającego.
  • Obrazek wyróżniający w pełnym rozmiarze: get_the_post_thumbnail_url($post->ID, 'full').
  • Description współdzielona z meta description (ta sama logika excerpt → treść).

3. Schema.org JSON-LD — serce SEO i GEO

Schema.org to słownik ustrukturyzowanych danych, a JSON-LD to sposób ich zapisu — niewidoczny dla użytkownika blok <script type="application/ld+json"> w kodzie strony, w którym mówimy maszynom wprost: "to jest artykuł, napisał go ten człowiek, opublikowano go tego dnia". Google używa tych danych do rich results (gwiazdki, breadcrumbs, daty w wynikach), a systemy AI — do rozumienia, kto i o czym pisze. "Encja" w tym kontekście to po prostu konkretny, jednoznaczny obiekt w bazie wiedzy: ta osoba, ta firma, ten artykuł.

Generowane ręcznie w PHP (json_encode z flagami JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), hook wp_head priorytet 5. Typy schema per widok:

WidokSchema
Każda stronaOrganization (z founder, address, contactPoint, sameAs)
Strona głównaWebSite + SearchAction (sitelinks searchbox)
WpisNewsArticle + BreadcrumbList
Strona autoraProfilePage z mainEntity = Person
Kategoria / tagCollectionPage + BreadcrumbList

3.1. Konsolidacja encji Person przez @id (najważniejszy trik E-E-A-T)

Autor pojawia się w schema w kilku miejscach (jako autor artykułu, założyciel organizacji, właściciel strony profilowej). Bez @id Google widzi trzy osobne wzmianki o kimś o tym samym nazwisku i nie ma pewności, że to ta sama osoba. Nadanie węzłowi Person stałego identyfikatora @id i odwoływanie się do niego wszędzie skleja te wzmianki w jedną encję — a im mocniejsza encja autora, tym wyższy E-E-A-T, czyli ocena Google'a: czy autor ma doświadczenie (Experience), ekspertyzę (Expertise), autorytet (Authoritativeness) i wiarygodność (Trust) w temacie, o którym pisze.

Jedna funkcja zwraca pełny węzeł Person z ustalonym @id:

function moj_get_author_person_schema() {
$author_url = home_url('/author/nazwa-autora/');
return array(
'@type' => 'Person',
'@id' => $author_url . '#person', // ← stały identyfikator węzła
'name' => 'Imię Nazwisko',
'jobTitle' => 'Tytuł zawodowy',
'url' => $author_url,
'image' => get_template_directory_uri() . '/assets/autor.jpg',
'description' => 'Bio z doświadczeniem, afiliacjami, specjalizacją…',
'worksFor' => array('@type' => 'Organization', 'name' => '…', 'url' => '…'),
'memberOf' => array('@type' => 'Organization', 'name' => '…', 'url' => '…'),
'knowsAbout' => array('temat 1', 'temat 2', '…'), // ← mapa ekspertyz dla AI
'sameAs' => array('https://linkedin.com/…', 'https://github.com/…'),
);
}

Ten sam węzeł jest potem spinany referencjami w jeden graf wiedzy:

  • Organization.founder → pełny węzeł Person (drukowany na każdej stronie),
  • NewsArticle.author → tylko referencja array('@id' => $author_url . '#person'),
  • ProfilePage.mainEntity → pełny węzeł Person.

Dzięki temu Google i systemy AI widzą jedną encję autora, a nie trzy rozproszone. To bezpośrednio wzmacnia E-E-A-T (Experience, Expertise, Authoritativeness, Trust).

3.2. NewsArticle — pola ważne dla GEO

$schema = array(
'@context' => 'https://schema.org',
'@type' => 'NewsArticle', // lub 'Article'/'BlogPosting' zależnie od serwisu
'mainEntityOfPage' => array('@type' => 'WebPage', '@id' => get_permalink()),
'headline' => get_the_title(),
'description' => $description,
'inLanguage' => 'pl-PL', // ← jawny język treści
'datePublished' => get_the_time('c'),
'dateModified' => get_the_modified_time('c'), // ← świeżość, sygnał dla AI
'wordCount' => str_word_count(strip_tags($post->post_content)),
'author' => $author_schema, // referencja @id — patrz 3.1
'publisher' => array('@type' => 'Organization', 'name' =>, 'logo' =>),
'keywords' => $keywords, // ← z tagów wpisu: implode(', ', tag names)
// speakable — asystenci głosowi i AI wybierają kluczowe fragmenty
'speakable' => array(
'@type' => 'SpeakableSpecification',
'cssSelector' => array('h1', 'h2', '.entry-content > p:first-of-type'),
),
);

Uwagi:

  • speakable to sygnał czysto GEO/AEO — wskazuje AI, które selektory zawierają esencję artykułu.
  • keywords generowane automatycznie z tagów WP — zero ręcznej pracy redakcji.
  • wordCount — sygnał głębokości treści.

3.3. BreadcrumbList

Dla wpisu: Strona główna → Kategoria → Artykuł (pozycje 1-2-3, pierwsza kategoria wpisu). Dla kategorii/tagu: Strona główna → Termin. Drukowany jako osobny blok <script type="application/ld+json">.

3.4. ProfilePage (strona autora)

Typ wprowadzony przez Google specjalnie do walidacji encji twórców:

$schema = array(
'@context' => 'https://schema.org',
'@type' => 'ProfilePage',
'dateModified' => get_lastpostmodified('server'),
'mainEntity' => moj_get_author_person_schema(),
);

3.5. Organization — sameAs i dane adresowe

sameAs (LinkedIn, GitHub, strona firmowa) wzmacnia Brand Mention Rate — AI kojarzy markę z profilami zewnętrznymi. address (PostalAddress) i contactPoint dodają sygnał realnego podmiotu (trust).


4. E-E-A-T w warstwie treści (nie tylko schema)

Schema to za mało — encja autora musi istnieć też w widocznym HTML:

  1. Dedykowany szablon strony autora (templates/author-{nicename}.html) z sekcją hero: zdjęcie, H1 z imieniem i nazwiskiem, tytuł zawodowy, 2 akapity bio (doświadczenie, afiliacje, specjalizacje), linki social (rel="me" na link do własnej strony).
  2. Boks autora pod każdym artykułem — zdjęcie, nazwisko linkujące do strony autora, tytuł zawodowy, krótkie bio, link „Wszystkie artykuły autora →". Rejestrowany jako register_block_pattern() w PHP (bo URL zdjęcia różni się między środowiskami dev/prod).
  3. Tytuł strony autora celowany w zapytanie o nazwisko (encja Person):
add_filter('document_title_parts', function ($parts) {
if (is_author()) {
$author = get_queried_object();
if ($author && $author->user_nicename === 'nazwa-autora') {
$parts['title'] = 'Imię Nazwisko — Tytuł Zawodowy';
}
}
return $parts;
});
  1. Spójność encji wszędzie: to samo imię i nazwisko, tytuł zawodowy, afiliacje i linki w: schema Person, meta description strony autora, hero, boksie autora i llms.txt. Powtarzalność = konsolidacja encji w grafach wiedzy AI.

5. GEO: wirtualny endpoint /llms.txt

Plik llms.txt to rodzący się standard informowania systemów AI o zawartości serwisu i zasadach cytowania. Serwowany wirtualnie (bez fizycznego pliku) przez hook init:

add_action('init', 'moj_serve_llms_txt', 1);
function moj_serve_llms_txt() {
$path = rtrim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
$home_path = rtrim((string) parse_url(home_url(), PHP_URL_PATH), '/');
if ($path !== $home_path . '/llms.txt') return;

header('Content-Type: text/plain; charset=utf-8');
echo "# " . get_bloginfo('name') . "\n\n";
echo "> " . get_bloginfo('description') . "\n";
echo "> Wydawca i redaktor: Imię Nazwisko — tytuł (email)\n\n";
echo "## Treści dostępne do cytowania\n\n";
echo "Dozwolone: wyszukiwanie, cytowanie, synteza odpowiedzi.\n";
echo "Niedozwolone: trening modeli bez zgody właściciela.\n\n";
echo "## Sekcje\n\n";
echo "- Artykuły: " . home_url('/') . "\n";
echo "- Profil autora: " . home_url('/author/…/') . "\n";
echo "- Kontakt: " . home_url('/kontakt/') . "\n\n";
echo "## Autor i właściciel\n\n";
echo "Bio autora z afiliacjami i kontaktem…\n";
exit;
}

Zawartość llms.txt: nazwa serwisu, opis, kto wydaje, polityka cytowania (dozwolone/niedozwolone), mapa kluczowych sekcji, bio autora z linkami. To wizytówka serwisu dla LLM-ów.


6. GEO: selektywny robots.txt — boty wyszukujące vs trenujące

Kluczowe rozróżnienie strategiczne: boty AI-search cytują i odsyłają ruch (wpuszczamy), boty treningowe konsumują treść bez zwrotu (blokujemy). Filtr robots_txt:

add_filter('robots_txt', function ($output, $public) {
if (!$public) return $output;

$output .= "\n# AI Search Bots — cytują treści i generują ruch (dozwolone)\n";
$output .= "User-agent: OAI-SearchBot\nAllow: /\n\n"; // ChatGPT Search
$output .= "User-agent: PerplexityBot\nAllow: /\n\n"; // Perplexity
$output .= "User-agent: GoogleOther\nAllow: /\n\n"; // Google AI

$output .= "# AI Training Bots — trening bez zwrotu ruchu (blokowane)\n";
$output .= "User-agent: GPTBot\nDisallow: /\n\n"; // trening OpenAI
$output .= "User-agent: CCBot\nDisallow: /\n\n"; // Common Crawl
$output .= "User-agent: anthropic-ai\nDisallow: /\n\n"; // trening Anthropic
$output .= "User-agent: Omgilibot\nDisallow: /\n\n";

return $output;
}, 10, 2);

Spójne z deklaracją w llms.txt („trening bez zgody niedozwolony").


7. Google Indexing API — automatyczny ping przy publikacji

Cały przepływ w skrócie: publikujesz wpis → WordPress w tle (nie blokując zapisu) wysyła do Google sygnał "mam nowy URL, wejdź i zaindeksuj" → Google odwiedza stronę w ciągu minut zamiast dni czekania na naturalny crawl. Service account to "konto techniczne" Google Cloud, którym serwer uwierzytelnia się w API bez udziału człowieka, a WP-Cron to wbudowany w WordPressa mechanizm zadań w tle.

Architektura:

  1. Trigger: transition_post_status — tylko przy pierwszej publikacji ($new_status === 'publish' && $old_status !== 'publish'), żeby nie spamować API przy każdej edycji literówki.
  2. Asynchronicznie przez WP-Cron: wp_schedule_single_event(time(), 'moj_async_google_ping', [$post_ID]) — publikacja wpisu nie czeka na odpowiedź API Google.
  3. Autoryzacja: service account JSON (WP_CONTENT_DIR . '/service_account.json'), ręczne JWT RS256 (openssl_sign) → token OAuth → POST na https://indexing.googleapis.com/v3/urlNotifications:publish z type: URL_UPDATED.
  4. Logowanie: każdy ping (URL, status HTTP, body odpowiedzi) do WP_CONTENT_DIR . '/google_indexing.log' — diagnostyka bez debuggera.

Wymagania: projekt w Google Cloud z włączonym Indexing API, service account dodany jako właściciel w Search Console. Plik klucza NIGDY w repo — wgrywany osobno na serwer.


8. Techniczne SEO — wydajność i struktura

8.1. Semantyczny HTML w szablonach FSE

Bloki grupy z jawnym tagName: <main>, <article>, <header>, <aside> (sidebar), <footer>. Jeden <h1> na stronę (post-title level 1). Struktura czytelna dla parserów AI.

8.2. Optymalizacja zapytań (crawl budget + UX)

add_action('pre_get_posts', function ($query) {
if (!is_admin() && $query->is_main_query()) {
if ($query->is_search()) {
$query->set('post_type', 'post'); // szukaj tylko wpisów
$query->set('posts_per_page', 20);
}
if ($query->is_archive()) {
$query->set('posts_per_page', 20);
}
}
});

8.3. Cache w .htaccess (Lighthouse 100/100)

  • Obrazy (jpg/png/webp/avif/svg) i fonty: Cache-Control: public, max-age=31536000 (fonty dodatkowo immutable),
  • CSS/JS: 1 tydzień (max-age=604800),
  • Options -Indexes (bez listowania katalogów),
  • ExpiresByType przez mod_expires jako uzupełnienie.

8.4. Core Web Vitals

Core Web Vitals to trzy metryki, którymi Google mierzy odczuwalną szybkość strony i które wprost wpływają na ranking. LCP (Largest Contentful Paint) — jak szybko pojawia się największy element widoczny na starcie, zwykle zdjęcie hero. CLS (Cumulative Layout Shift) — czy treść "skacze" podczas ładowania. FOIT (Flash of Invisible Text) — moment, w którym tekst jest niewidoczny, bo czeka na doładowanie webfonta.

  • LCP: zdjęcie hero z loading="eager" fetchpriority="high"; pozostałe obrazy loading="lazy" z jawnymi width/height (zapobiega CLS).
  • CLS: rezerwowanie przestrzeni pod dynamiczną treść; scroll-padding-top kompensujący sticky header.
  • FOIT: font-display: swap.
  • Above the fold: redukcja paddingów, żeby treść nie spadała pod linię zgięcia.
  • Obrazy wyróżniające w proporcji 16/9 (spójny layout, brak przeskoków).

9. Kolejność hooków wp_head (istotna!)

PriorytetCo
1meta description
2Open Graph + Twitter Cards
5Schema.org JSON-LD

Meta najważniejsze dla snippetów wyżej w <head>, schema po nich.


10. Uzupełnienia z audytu projektu Next.js: canonical, hreflang, sitemap, local SEO

Techniki, które wyszły przy audycie bliźniaczego projektu multi-site w Next.js — uniwersalne, choć w WordPressie część załatwia core.

10.1. Canonical URL

Tag <link rel="canonical"> mówi Google, która wersja adresu jest "tą właściwą" — bez niego parametry URL, paginacja czy wersje z/bez ukośnika potrafią rozmyć ranking między duplikaty. WordPress od wersji 2.9 dodaje canonical sam, ale tylko na stronach singular — archiwa, strony paginowane i wyniki wyszukiwania trzeba obsłużyć samodzielnie (hook wp_head) albo świadomie zostawić z noindex.

10.2. Hreflang — wersje językowe

Przy więcej niż jednym języku każda strona musi linkować wszystkie swoje wersje językowe (w obie strony!) plus x-default:

<link rel="alternate" hreflang="pl" href="https://example.pl/oferta/">
<link rel="alternate" hreflang="en" href="https://example.pl/en/offer/">
<link rel="alternate" hreflang="x-default" href="https://example.pl/">

Bez tego Google potrafi pokazać polską stronę anglojęzycznemu użytkownikowi. Pamiętaj też o atrybucie lang na <html> — musi zgadzać się z językiem treści na każdej stronie, także w podkatalogu /en/.

10.3. Sitemap — i pułapka lastmod

WordPress od 5.5 generuje /wp-sitemap.xml z core'a. Jedna zasada, której łatwo nie doceniać: pole lastmod musi być prawdziwe. Ustawianie bieżącej daty (np. daty builda przy stronach statycznych) na wszystkich URL-ach naraz sprawia, że Google po kilku cyklach przestaje ufać temu polu dla całej domeny — lepiej nie podawać lastmod wcale, niż podawać fałszywy.

10.4. Meta robots: max-snippet i max-image-preview

add_filter('wp_robots', function ($robots) {
$robots['max-image-preview'] = 'large';
$robots['max-snippet'] = -1;
$robots['max-video-preview'] = -1;
return $robots;
});

max-image-preview:large pozwala Google pokazywać duże miniatury (Discover!), a max-snippet:-1 zdejmuje limit długości cytowanego fragmentu — co dotyczy także AI Overviews: zbyt niski limit snippetu ogranicza, ile AI może zacytować z Twojej strony. To sygnał GEO, nie tylko SEO.

10.5. Local SEO — schema dla firm usługowych

Dla biznesu z lokalnym zasięgiem Organization warto zastąpić typem LocalBusiness lub konkretniejszym (ProfessionalService, ArtGallery…) i dodać pola, których "zwykłe" SEO nie potrzebuje:

  • geo (GeoCoordinates) + areaServed (lista City/AdministrativeArea) — zasięg działania,
  • openingHoursSpecification, priceRange, telephone — dane do panelu wizytówki,
  • katalog usług jako osobne węzły Service z provider: {"@id": "...#organization"} — ta sama konsolidacja przez @id, co przy encji Person (sekcja 3.1).

Ostrzeżenie: nie dodawaj aggregateRating ani review o własnej firmie na własnej stronie — Google od 2019 traktuje to jako self-serving reviews: markup jest ignorowany, a przy nadużyciu grozi ręczna kara. Opinie w markup tylko wtedy, gdy dotyczą innych podmiotów (np. recenzje produktów, które sprzedajesz).


11. Checklist wdrożenia w nowym projekcie WordPress

Fundamenty SEO:

  • Dynamiczna meta description z fallback-chain (excerpt → treść → szablon)
  • Open Graph + Twitter Cards z fallbackiem og:image (site icon → favicon motywu)
  • document_title_parts dla stron specjalnych (autor, landing)
  • Semantyczne tagi w szablonach (main, article, header, aside), jeden H1
  • Canonical na archiwach/paginacji (singular załatwia WP core)
  • Hreflang + poprawny lang na <html> przy wersjach językowych
  • wp_robots: max-image-preview:large, max-snippet:-1

Schema.org (JSON-LD):

  • Organization na każdej stronie (sameAs, address, contactPoint, founder)
  • WebSite + SearchAction na stronie głównej
  • NewsArticle/Article z datePublished/dateModified/inLanguage/wordCount/keywords/speakable
  • BreadcrumbList (wpis: Home → Kategoria → Artykuł; termin: Home → Termin)
  • ProfilePage + centralna encja Person z @id, referencjonowana w author/founder/mainEntity
  • CollectionPage dla kategorii i tagów
  • Firmy lokalne: LocalBusiness/ProfessionalService z geo, areaServed, openingHours, priceRange + usługi jako Service z provider @id (bez self-serving reviews!)

E-E-A-T:

  • Dedykowany szablon strony autora z hero (zdjęcie, bio, afiliacje, social z rel="me")
  • Boks autora pod każdym artykułem (block pattern rejestrowany w PHP)
  • knowsAbout + memberOf + worksFor w schema Person
  • Spójna encja autora we wszystkich miejscach (schema, meta, HTML, llms.txt)
  • Strony „O nas" i „Kontakt" (trust signals)

GEO / AEO:

  • Wirtualny endpoint /llms.txt (opis serwisu, polityka cytowania, sekcje, autor)
  • robots.txt: Allow dla botów AI-search (OAI-SearchBot, PerplexityBot, GoogleOther), Disallow dla botów treningowych (GPTBot, CCBot, anthropic-ai, Omgilibot)
  • speakable w schema artykułów
  • sameAs w Organization (Brand Mention Rate)

Indeksacja i wydajność:

  • Google Indexing API: ping przy pierwszej publikacji, async przez WP-Cron, z logiem
  • Sitemap z prawdziwym lastmod (albo bez tego pola — nigdy z datą builda)
  • Cache headers w .htaccess (obrazy/fonty 1 rok, CSS/JS 1 tydzień)
  • LCP: fetchpriority="high" na hero, loading="lazy" + wymiary na reszcie
  • font-display: swap, scroll-padding-top pod sticky header
  • Limit wyników wyszukiwania/archiwów przez pre_get_posts

12. Zasady bezpieczeństwa przy generowaniu meta/schema

  • Zawsze esc_attr() / esc_url() przy echo do HTML.
  • json_encode(..., JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) — czytelne polskie znaki i URL-e w JSON-LD.
  • Klucze API (service account) poza repo, w wp-content/, z walidacją file_exists().
  • Generuj własne meta tylko gdy nie ma wtyczki SEO — inaczej duplikaty tagów.