Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Roboti, robots.txt a sitemapy

Roboti, robots.txt a sitemapy#

Co smí robot číst a co ne, jak se mu to řekne a jak se vyhledávači předá seznam adres. Tři věci, které spolu souvisí a které dřív nefungovaly: roboti spouštěli akční odkazy, robots.txt byl statický soubor s cizí sitemapou a sitemapa stránek byla vždy prázdná.

Co robot smí#

Robot smí číst každou obsahovou stránku. Klikat (spouštět signál ?do=…) nesmí: oblíbené, košík, hodnocení článku, hlášení příspěvku. Roboti by takové adresy volali ve velkém a plnili databázi i relace.

Vrstva Co dělá Kde
#[Requires(ajax: true)] zapisující handler odmítne holý GET (403); AJAX z JavaScriptu projde Rating…HandleTrait, Discussion VoteTrait (hlas, nahlášení)
CrawlerDetector signál od klienta, který se hlásí jako robot, končí 403 dřív, než se cokoli načte Front\BasePresenter::startup()
rel="nofollow" 50 akčních odkazů v šablonách má rel="nofollow" šablony témat
robots.txt zakazuje akční adresy Seo\Presenters\RobotsPresenter

Oblíbené a košík zůstávají GET

Zapisují jen do session, takže je hlídá CrawlerDetector a robots.txt. Funguje to lidem s JavaScriptem i bez něj. Převod na POST/AJAX-only je další krok.

CrawlerDetector#

App\Core\Utils\Helpers\CrawlerDetector::isCrawler(?string $userAgent): bool. Poznává bot, crawl, spider, slurp, scrap, fetch, headless, preview, monitor, archiver, jména crawlerů, které tyhle značky nemají (Facebook, Bing preview, Google-InspectionTool, ChatGPT-User, OAI-SearchBot, Perplexity, Bytespider, PetalBot, ia_archiver), a obecné klienty (curl, wget, python-requests, go-http-client, okhttp, libwww). Prázdný User-Agent je vždy robot.

Je to zdvořilostní filtr, ne bezpečnostní hranice

Zastaví klienty, kteří se sami hlásí. Cokoli, co zapisuje do databáze, musí mít vlastní ochranu (oprávnění, #[Requires(ajax: true)], isSameSite()).

Při testování signálů přes curl posílej prohlížečový User-Agent

Jinak dostaneš 403 a budeš hledat chybu v kódu. curl/… a prázdná hlavička jsou pro detektor robot.

Čítače zobrazení#

Zobrazení výpisu a detailu (inzeráty a katalog firem, příspěvky diskuze, články) robot nezvyšuje: sedm míst ve Front presenterech volá CrawlerDetector::isCrawlerRequest() a zápis čítače přeskočí. Čítač kliků zůstal beze změny. Přidáváš-li nový čítač zobrazení, obal zápis stejnou podmínkou.

Postup: přidávám zapisující signál na frontu#

  1. Handler označ #[\Nette\Application\Attributes\Requires(ajax: true)] — holý GET je pak 403 a odkaz nepouštějí roboti ani předběžné načtení.
  2. Odkaz na signál dej rel="nofollow".
  3. Má-li akce fungovat i bez JavaScriptu, nepoužívej ajax: true; spoléhej na CrawlerDetector a robots.txt, a zapisuj jen do session.
  4. Hodnoty, které zapisuješ do databáze, ověř v manageru; ID z adresy se jen porovnává s tím, co patří komponentě (vzor: hlášení příspěvku handleReport()).
  5. Zkus signál z AJAXu, bez AJAXu a s curl/… — dostaneš postupně ✓, 403 a 403.

robots.txt#

Generuje ho App\UI\Seo\Presenters\RobotsPresenter (routa robots.txt v RouterDispatcher). Je to obyčejný Nette presenter — bez motivu, lokalizace a databáze. Statický www/robots.txt byl odstraněn: Sitemap: potřebuje absolutní adresu a jeden kód obsluhuje víc domén (starý soubor ukazoval všechny weby na cizí host).

Pevná pravidla:

User-agent: *
Allow: /
Disallow: /*?do=
Disallow: /*&do=
Disallow: /*?_fid=
Disallow: /*&_fid=
Disallow: /admin/
Disallow: /api/
Disallow: /cron/

Řádek Sitemap: <host>/sitemaps/sitemap.xml se přidá jen tehdy, když soubor indexu skutečně existuje (www/sitemaps/sitemap.xml, adresář z parametru sitemapDir). Host se bere z aktuálního požadavku.

Bez indexu sitemapy robots.txt na sitemapu neukazuje

Dokud na serveru neproběhne /cron/system/sitemap/index?save=1, v souboru řádek Sitemap: není. Nic neselže, jen vyhledávač nemá odkaz.

Sitemapy#

Generují je cron presentery (app/UI/Cron/<Modul>/Presenters/SitemapPresenter.php) přes SitemapBuilderTrait. Bez ?save=1 vrátí JSON náhled (počty), s ?save=1 zapíše soubor do www/sitemaps/.

Adresy v sitemapě staví SitemapBuilderTrait přes službu SiteBaseUrl: je-li vyplněné nastavení site.baseUrl, platí vždy ono, ne Host, pod kterým cron web zavolal. Prázdné nastavení znamená adresu požadavku (viz Odesílání e-mailů).

Úloha Co dělá
/cron/system/sitemap/generate stránky všech jazykových variant (hreflang)
/cron/blog/sitemap/{article,news,event} a …-category blog
/cron/bazaar/…, /cron/comcat/…, /cron/discussion/… moduly
/cron/eshop/sitemap/category všechny kategorie e-shopu
/cron/eshop/sitemap/product produkty, které ukazuje výpis (ProductSetEligibilityFilters), do souborů podle rozsahů ID (velikost viz níže)
/cron/system/sitemap/index index všech sitemap-*.xml; spustit až po ostatních

Úlohy se plánují crontabem serveru a samy neběží, viz Naplánované úlohy. Frekvence: denně v noci.

Adresář www/sitemaps/ patří do .gitignore i do deploy/rsync-exclude.txt

Soubory vznikají za běhu na serveru. Kdyby je rsync při nasazení (--delete) viděl jako cizí, smazal by je a robots.txt by zůstal bez sitemapy do další noci. Obojí je už zapsáno; při nové instalaci to ověř.

Index ze starých souborů

Index skládá odkazy z toho, co v adresáři právě je. Poběží-li před ostatními úlohami, sestaví se z výsledku minulého běhu.

Dělení do souborů#

Velké sady (inzeráty, firmy, produkty, články…) se zapisují po dávkách podle id rovnou do souboru (KeysetBatches, SitemapBucketWriter, SitemapUrlsetWriter), ne přes pole v paměti. Do jednoho souboru patří 45000 / počet jazyků položek (SitemapBucketWriter::bucketSizeFor()), protože každá položka dává jednu adresu na jazyk a soubor sitemapy smí mít nejvýš 50 000 adres. Soubor se jmenuje <předpona>-<bucket>.xml, bucket = intdiv(id - 1, velikost); hranice id jsou pevné, takže smazání položky ostatní mezi soubory neposune. Kdyby další položka přetáhla pojistku (50 000 adres nebo 45 MiB na soubor), pokračuje se v <předpona>-<bucket>-1.xml, -2, … Změna počtu jazyků změní velikost bucketu, a tím rozdělení id do souborů. Staré číslované soubory sady, které nová generace nepřepsala, se smažou (vzor <předpona>-[0-9]*.xml, takže cizí soubor se stejným začátkem, např. soubor kategorií, zůstane).

Zápis je atomický přes StagedFileSet:

  1. každý soubor se píše do náhodného .tmp vedle cíle (reserve(), stage()) — stejný filesystem, takže rename je atomický;
  2. commit() teprve po zapsání všech souborů přejmenuje dočasné na finální jména, nastaví 0664 (web server je vydává staticky i přes umask cronu) a smaže staré soubory podle vzoru;
  3. abort() (i selhání zápisu) smaže jen vlastní dočasné soubory; stará sada zůstane.

Robot tak u každého jména vidí buď celou starou, nebo celou novou verzi.

Nemaž starou sadu předem a nepiš rovnou do finálního jména

Selhání druhého souboru by nechalo web bez sitemapy a robot čtoucí soubor během zápisu by dostal useknuté XML. Vlastní generátor souborů pro roboty publikuj přes StagedFileSet.

Položky musí jít vzestupně podle id

SitemapBucketWriter::add() při id menším nebo stejném jako předchozí vyhodí výjimku. Loader pro KeysetBatches proto řadí ORDER BY id a vrací jen id > poslední.

Postup: přidat sitemapu nového modulu#

  1. V cron presenteru modulu použij SitemapBuilderTrait (buildSitemapXml(), respondSitemap(), u velkých sad respondBucketedSitemap()).
  2. Cíl odkazu skládej přes routing (presenter->link(…) / buildPageUrl()), ne ručně. Sitemapa stránek byla dřív prázdná, protože cíl odkazu byl složený jako Front:Front:System:Page a neexistoval; po opravě je v ní kolem stovky adres.
  3. Do crontabu přidej úlohu před index.
  4. Ověř: bez save JSON s počtem, s save=1 soubor; curl …/robots.txt ukáže Sitemap:; soubor se servíruje s application/xml.

Jedna kanonická adresa#

www/.htaccess sjednocuje adresy na jednu variantu (301, cesta i dotaz se zachovají):

Vstup Výsledek
www.<host>/… <host>/…
/www/… (doslovný prefix složky) /…; existující soubory se nepřesměrují

Cíl přesměrování se skládá z $1 pravidla, ne z %{REQUEST_URI}

Web běží přes kořenový .htaccess (^(.*)$ → /www/$1), takže REQUEST_URI už nese vnitřní /www/. První verze proto přesměrovávala na /www/inzerce. Duplicitní cestu /www/… rozlišuje %{THE_REQUEST} — původní řádek požadavku, který vnitřní přepis nemění.

Web, který má žít na www, tyto dva řádky smaže

Přesměrování www → bez www je ve výchozím .htaccess zapnuté. Na webu, který má kanonický host www, se musí odstranit.

Kam sáhnout#

Chci Kde
detekci robotů app/Core/Utils/Helpers/CrawlerDetector.php
bránu v prezenteru app/Core/Base/Front/BasePresenter.php → startup()
robots.txt app/UI/Seo/Presenters/RobotsPresenter.php, routa v RouterDispatcher
generování sitemap app/Core/Traits/Cron/Presenters/SitemapBuilderTrait.php
e-shopové sitemapy app/UI/Cron/Eshop/Presenters/SitemapPresenter.php
dělení a atomický zápis souborů app/Core/Utils/Helpers/Sitemap/, app/Core/Utils/Helpers/StagedFileSet.php
kanonický host www/.htaccess

Související: CORS, rate limit a audit · Naplánované úlohy · Routování

Nastavení cookies