# Shale Experts - https://www.shaleexperts.com/
#
# Editorial content (articles, news, plays) is meant to be crawled, indexed and
# shared. Account, admin and unfinished surfaces are not.
#
# Note on filtered/duplicate URLs: they are deliberately NOT disallowed here.
# Blocking a URL stops crawlers reading its rel=canonical, which is what
# actually consolidates the duplicates. Let them be crawled and let the
# canonical tag do the consolidating.
#
# WHEN A Disallow HERE IS THE RIGHT INSTRUMENT, because that rule has already
# been misapplied once (see the /store/ note below):
#
# yes the URL has no unique content of its own AND/OR requesting it mutates
# state (carts, checkout, logout, form posts), or it is a private or
# admin surface;
# yes the URL belongs to an UNBOUNDED space -- a catch-all route that answers
# 200 for every path anyone can invent -- and the pages in it carry no
# rel=canonical. Nothing consolidates what has no canonical, and an
# infinite soft-404 space cannot be crawled to exhaustion. This is the
# /store/ case below and it was missed the first time round;
# no the URL is a duplicate or near-duplicate of something indexable AND it
# carries a rel=canonical. Use the canonical, stay crawlable;
# no the URL is real content that simply should not rank. Use
# on a page that stays crawlable
# -- a blocked URL can still be indexed URL-only from an external link,
# and blocking guarantees the crawler never sees the noindex.
#
# Every status quoted in this file was measured anonymously against the local
# replica on 2026-07-27. Re-measure before trusting a claim; the last set of
# justifications here was wrong.
User-agent: *
#
# There is deliberately no `Allow: /` in this group, though there used to be.
# Absent any matching rule a crawler allows by default, so it bought nothing
# under RFC 9309 / Google / Bing (all of which resolve conflicts by LONGEST
# match) -- while under the older first-match semantics that Python's stdlib
# robotparser and various small crawlers still implement, a leading `Allow: /`
# matches every URL before any Disallow below is ever considered, silently
# turning this entire group into a no-op. Verified with both parsers: with
# `Allow: /` present, a first-match parser reported /administration/index,
# /store/checkout and /dev3/latestnews as all crawlable. Do not put it back.
#
# RELATEDLY, AND THIS IS WHY THE SEPARATORS BELOW ARE `#` AND NOT BLANK LINES:
# RFC 9309's grammar permits empty lines inside a group, but the older
# record-based parsers treat the first blank line as the end of the record and
# silently drop every rule after it -- a single blank line directly under
# `User-agent: *` discards the whole group. Comment lines do not have that
# effect (they are stripped after the blank-line test, then skipped). Measured
# on five variants; the only spelling that yields the intended answer under
# BOTH parser families is: no `Allow: /`, and no blank line between
# `User-agent: *` and the last rule of the group. Keep it that way.
#
# The paywall's destination page, and the one /user/ URL that must stay
# crawlable. Measured: /user/plans is 200, 38 KB, carries a self-referencing
# rel=canonical, and is a real "Plans & Pricing" page with plan names and
# prices. It is also where the paywall sends anonymous visitors: every one of
# the 11,915 gated articles 302s to /user/plans?utm_source=premium_article.
# Blocking the destination of a sitewide redirect is the worst available
# outcome -- Googlebot follows 11,915 redirects into a URL it is forbidden to
# fetch, and the highest commercial-intent page on the site can never rank.
# The Allow is longer than the Disallow below, so it wins in Google's and
# Bing's most-specific-rule matching, and the prefix covers the query string.
Allow: /user/plans
#
# Account, admin and transactional surfaces. The statuses are recorded because
# the previous version of this block justified it with "crawling them just
# burns budget on redirects to the login/plans pages" and that was false for
# most of them:
#
# 302 /user/index, /user/account, /user/accountedit, /user/changepassword,
# /user/renew -- the only ones the "redirects" claim was ever true of
# 200 /user/login (login form), /user/subscribe, /user/company_list. The
# last two are the same "Shale Experts is a Members ONLY resource"
# interstitial with the controller method interpolated into the copy
# ("Interested in the Subscribe?" / "Interested in the Company_list?"),
# i.e. duplicates of each other with no content of their own
# 200 /administration/index -- the admin login form
# 404 /administration/, /db_tools, /webServices, /user/profile
# 200 /tellaFriend (empty body), /maprequest (3.5 KB request form)
# 500 /bookmark
#
# None of these is a duplicate of an indexable page, so nothing here depends on
# a crawler reading a canonical tag. They are blocked under the first test at
# the top of the file, not in spite of the second.
#
# TRAILING SLASHES: mostly gone, on purpose. `Disallow: /maprequest/` does not
# match `/maprequest`, and `/maprequest/` 301s to `/maprequest`, so the slashed
# form blocked only the redirect and left the page itself crawlable. Same for
# /tellaFriend (200), /bookmark (500 -- a crawlable 500 is a server-health
# signal against the whole property), /db_tools, /webServices, /administration,
# /dev3 and /articles_preview. The bare prefix covers both spellings.
#
# /user is the exception and keeps its slash: `Disallow: /user` would also
# match /user_nav, which is routes.php:699 -> frontend/userNav, a 200 "Featured
# Plays" content page. The bare /user is handled with an end-anchor instead.
# `$` is RFC 9309 and is honoured by Google and Bing; the pre-RFC parsers read
# it as a literal and so match nothing, which leaves them able to fetch exactly
# one URL, /user, which 302s to /user/account -- blocked for them too. That is
# the whole cost of the one rule in this file that is not portable.
# (/bookmark-list, routes.php:706, is caught by `Disallow: /bookmark` -- that is
# intended: it 302s to /user/login.)
Disallow: /user/
Disallow: /user$
Disallow: /administration
Disallow: /db_tools
Disallow: /tellaFriend
Disallow: /bookmark
Disallow: /maprequest
Disallow: /webServices
#
# /searchservice is the Elasticsearch proxy behind the site's own search UI, and
# it is not a page. Measured anonymously 2026-08-20: GET /searchservice answers
# 200 with 11,238 bytes of raw ES response --
# {"took":6,"timed_out":false,...,"hits":{"total":935310,...,"_index":"slides_1786135191"...
# -- with no
, no canonical and no meta robots. routes.php:46-71 maps
# thirteen routes under it and every one is machine surface: five `_search`
# endpoints, get_companies, get_categories, fetch_pres, authlevel, sign, login.
# `/searchservice/presentation?f=.pdf`, the gated PDF fetch, answers
# 307 to /user/login.
#
# This is the "no unique content of its own, or a private surface" case at the
# top of the file, and the crawl cost is not hypothetical. In one 30-minute
# production window Googlebot managed 33 requests in total -- roughly 1.1 a
# minute, against a 49,091-URL sitemap -- and spent SIXTEEN of them on
# /searchservice/presentation URLs that every single time redirected to the
# login page. Meanwhile 32,215 presentation pages sat in Search Console as
# "Discovered - currently not indexed" for want of exactly that budget. Blocking
# this reclaims about half of what Google currently spends here, and there is no
# downside: nothing under it can rank, and nothing links to it for readers.
#
# Bare prefix, per the trailing-slash note above: `/searchservice/` would not
# match `/searchservice`, and the unslashed form is the one that returns the
# JSON. There is no `/searchservice_*` route for the bare prefix to catch by
# accident (checked against routes.php), so the /user vs /user_nav problem does
# not arise here.
#
# NOT a security control. robots.txt is a crawling convention, and this endpoint
# remains publicly readable to anyone who requests it -- including the index
# names and document counts above. That it is exposed at all is a separate
# question from whether crawlers should spend budget on it.
Disallow: /searchservice
#
# /store/ is a SEPARATE CodeIgniter application (sites/store, served by
# `location /store` in docker/web/site.conf), which is why none of it carries
# the seo_helper.php markup the main site emits.
#
# THE DECIDING FACT, which an earlier link crawl of this app could not see:
# sites/store/application/config/routes.php ends in
# $route['(:any)'] = "products/index/show_cart_new/$1";
# and CI 2's (:any) is `.+`, which matches across slashes. So EVERY path under
# /store/ answers HTTP 200. Measured anonymously on the replica, 2026-07-27:
# /store/zzz-nonexistent-xyz 200, 13,763 bytes
# /store/a/b/c/d 200, 13,715 bytes
# That is an unbounded soft-404 space, and no /store/ page emits rel=canonical
# or meta robots at all (grep for either on /store/ and /store/pd/89: zero
# matches). The previous version of this block argued from "let the canonical
# tag consolidate the duplicates" -- an instrument that does not exist here --
# and from a crawl of 33 LINKED URLs, which says nothing about the infinite set
# of unlinked ones a crawler will find in logs, old links and referrer spam.
#
# So: a blanket Disallow, plus a narrow Allow list. The earlier reasoning was
# right about WHAT to keep crawlable and wrong about how to bound the rest, and
# both halves are preserved below.
#
# ORDER MATTERS. Google and Bing resolve Allow/Disallow conflicts by LONGEST
# match, so the order is irrelevant to them; the older first-match parsers take
# the first rule that matches, so the Allows must come FIRST or they never fire.
# Written this way both families reach the same answer.
#
# WHAT STAYS CRAWLABLE, and why:
# /store 200, 21,234 bytes. The catalogue landing page, linked from
# the main nav on every page of the site
# (views/includes/nav.php). Note `Disallow: /store/` does
# NOT match `/store` -- and that is the good outcome, since
# /store, /store/, /store/index.php and /store/products all
# return the identical 21,234-byte document with no
# canonical to consolidate them. Exactly one spelling stays
# crawlable; the other three are blocked.
# /store/pd/ the real product pages. Measured: /store/pd/89 200 15,749 B,
# /store/pd/149 200 15,824 B, /store/pd/168 200 16,089 B,
# /store/pd/188 200 15,618 B, /store/pd/190 200 15,682 B,
# /store/pd/205 200 15,990 B, /store/pd/208 200 15,762 B.
# Names, descriptions and prices -- the only pages on the
# site that describe what the business sells.
# /store/css/ render-blocking assets. Google renders before it ranks, so
# /store/js/ blocking a product page's stylesheet is not a neutral act.
# /store/images/ Referenced by sites/store/application/views (css, js,
# /store/fonts/ images) and from within screen.css (../images/, ../fonts/).
# These are static files served by `try_files $uri` from the
# `./sites/store:/var/store` mount, ahead of the catch-all;
# on disk: css/screen.css 68,852 B, js/jquery-1.6.min.js
# 91,671 B, fonts/fontawesome-webfont.woff 44,432 B. The
# replica cannot demonstrate that -- its web container has
# no /var/store mount at all, so these paths fall through to
# the catch-all and measure 200 text/html ~13.7 KB there.
# Stated as measured, and stated as a replica artefact.
# Crawlers do not enumerate asset directories; they fetch
# what a page references, which is a finite set.
#
# WHAT THE BLANKET NOW COVERS that the 13 targeted rules did not. Measured
# anonymously on the replica, 2026-07-27:
# /store/logout 302 -> /store/ unsets session userdata
# (the main site's /user/logout is blocked; this
# was not)
# /store/test_email 500 a crawlable error page
# /store/order_summary 500, 8,046 B
# /store/download 500
# /store/item_download 500
# /store/findState_shipping/TX 500
# /store/user/forgotpassword 200, 11,119 B password-reset surface.
# `Disallow: /user/` never matched /store/user/
# /store/user/resetPassword 200, 11,157 B
# /store/user/account 302 -> /store/index.php/
# /store/user/my-orders 302 -> /store/index.php/
# /store/user/order-detail 302 -> /store/index.php/
# /store/changepassword 302 -> /store/index.php/
# /store/account/edit 302 -> /store/index.php/
# /store/order_list 302 -> /store/
# /store/edit_billing 302 -> /store/index.php/register
# /store/edit_shipping 302 -> /store/index.php/register
# /store/update_billing 302 -> /store/index.php/register
# /store/update_shipping 302 -> /store/index.php/register
# /store/download/1 302 -> /store/
# /store/item_download/abc 200, 0 bytes an unauthenticated
# force_download wrapper
# /store/update_cart 302 -> /store/show_cart_view
# /store/remove_single_product/1 302 -> /store/
# /store/checkout 200, 21,070 B
# /store/checkout_guest 200, 59,972 B
# /store/checkout_process_guest 200, 59,803 B
# /store/processform 200, 63,089 B
# /store/processform_test 404, 1,235 B
# /store/register 200, 63,136 B
# /store/login 200, 13,721 B
# /store/login/1 200, 3,077 B
# /store/show_cart_view 200, 12,326 B
# /store/show_cart/1 200, 13,739 B
# /store/add_cart_item 200, 12,326 B
# /store/empty_cart 200, 10,441 B
# /store/continue_shopping 200, 97 B
# /store/product_description 200, 13,763 B (catch-all soft 404)
# /store/findState 200, 13,733 B (catch-all soft 404)
# /store/findState/TX 200, 187 B an AJAX fragment
# /store/search 200, 132,508 B search results
# /store/ 200, 21,234 B duplicate of /store
# /store/index.php 200, 21,234 B duplicate of /store
# /store/products 200, 21,234 B duplicate of /store
# /store/pd 200, 14,622 B product page with no id
# /store/play/2 200, 13,680 B empty category
# /store/Maps 200, 13,718 B empty category
# /store/application/... 403 (nginx)
# /store/system/... 403 (nginx)
# Not one of those is a page that should rank, and eight of them are 5xx, 302 or
# state-mutating. The 11 empty-category URLs (/store/Maps, /store/Services,
# /store/GIS-Data, /store/Digital-Data, /store/Drilling-Activity,
# /store/Drilling-Permits, /store/PDF/Digital-Maps, /store/Wall-Map,
# /store/play/{2,5,9,17,171}) are 13.7 KB each and indistinguishable from the
# catch-all soft 404; the standing fix is still a noindex on the empty-result
# branch in sites/store/application/views/includes/header.php, and when that
# ships they can be moved back out from behind the blanket. Blocking them costs
# nothing now, because with no canonical there is nothing for a crawl to
# consolidate.
#
# COST NOTE, unchanged and still true: every /store/ response sets 2-4 cookies
# and is served `Cache-Control: no-store`, so it can never enter the shared page
# cache and every crawl is a full CI + MySQL render. Worth paying for product
# pages, not for an infinite cart.
Allow: /store/pd/
Allow: /store/css/
Allow: /store/js/
Allow: /store/images/
Allow: /store/fonts/
Disallow: /store/
#
# Unfinished/duplicate article surfaces. /dev3 renders the same articles as
# /articles through an older template that does not apply the paywall, so it
# must never be crawled or indexed. (It is being removed/gated separately -
# this is defence in depth, not the fix.)
#
# This is the one place a Disallow is used on duplicates, and it survives the
# test at the top of the file on its own terms: /dev3/latestnews (200, 151 KB)
# and /dev3/epnews (200, 97 KB) emit a SELF-referencing rel=canonical, so
# crawling them would not consolidate anything -- the instrument the rule
# prefers is actively wrong here. The article leaf is now gated in code
# (dev3/controllers/articles.php:25 302s anonymous visitors to /user/plans),
# the listings are not.
#
# /articles_preview is the one rule in this file that the rule at the top does
# NOT justify, and it is left in place with eyes open. Measured:
# /articles_preview/_ returns 200 with the full 197 KB article body
# and a CROSS-canonical to the real /articles/ URL -- the tag is correct, so
# crawling really would consolidate it, and blocking really does stop Google
# reading it. It stays blocked only because it is a whole second copy of all
# 7,612 free articles on a path that does not match $se_cacheable_uri in
# docker/web/site.conf (`~^/articles/[^/?]+$`), so every one of those crawls
# would be an uncached full CI + MySQL render of a page that must never win.
# The actual fix is to delete the alias route (application/config/routes.php:488
# maps articles_preview/(:any)_(:any) onto the same controller), after which
# this line should go too. Until then this is a crawl-budget decision, not a
# consolidation one, and it should not be cited as precedent for blocking any
# other duplicate.
Disallow: /dev3
Disallow: /articles_preview
#
# ---------------------------------------------------------------------------
# CARRIED OVER FROM origin/dev's robots.txt IN THE MERGE. These were dev's
# rules, not this release's, and they are NOT re-measured to the standard the
# rest of this file holds itself to. Each is annotated with why it survived and
# what would have to be checked to keep or drop it.
# ---------------------------------------------------------------------------
#
# /mobile/ - the legacy mobile application is retired. docker/web/site.conf now
# answers `location ^~ /mobile` with a 301 onto the equivalent path on the
# responsive site, so every /mobile/* URL is a pure redirect with no content of
# its own. Passes the first test at the top of the file. Blocking it stops
# crawlers spending budget bouncing through the redirect; the destination URLs
# are all reachable and crawlable directly.
Disallow: /mobile/
#
# /resizeimage/ - the image transform endpoint. `?imgurl=` is an
# unbounded query space that runs a GD resize per request and can never enter
# the shared page cache, which is exactly the shape the second test at the top
# of the file is about.
#
# THE ONE THING TO CHECK BEFORE TRUSTING THIS: it blocks image RESOURCES, not
# pages, and application/helpers/my_helper.php:5216-5217 still emits
# `
` for ad creatives, with more
# callsites across plays/ and dev3/ views. Googlebot renders before it ranks, so
# a blocked
is a blocked render resource, and these URLs also stop being
# eligible for Google Images. That is judged acceptable here because the
# canonical pipeline is /img/ (image_helper.php calls the resizer a shim kept
# alive only for compatibility) and /img/ stays fully crawlable, and because
# AdSense's own crawlers match their more specific groups below and are
# unaffected by this line. If a measurement ever shows article hero images still
# resolving through /resizeimage/, drop this rule rather than the images.
Disallow: /resizeimage/
#
# Static/internal directories that exist on disk under /var/www but are not
# pages: sites/website/includes and sites/website/cabcache. Nothing links to
# them, nothing there carries a canonical, and a crawler that finds one gets
# either a raw asset or the CodeIgniter catch-all. /cache does not currently
# exist; the rule is harmless and forward-looking.
Disallow: /includes/
Disallow: /cabcache/
Disallow: /cache/
#
# The design-system page (sites/website/js/design-system.js and friends). A
# developer surface, not content. Bare prefix, no trailing slash, per the
# TRAILING SLASHES note above.
Disallow: /styleguide
#
# NOT CARRIED OVER: dev's `Disallow: /user/plans`. That rule predates the
# paywall work in this release and is now actively harmful - /user/plans is the
# 302 destination of all 11,915 gated articles. See the `Allow: /user/plans`
# block near the top of this group for the measurements. If you are reconciling
# this file against dev's, that omission is deliberate.
#
# Also not carried over: dev's `Disallow: /administration/` and
# `Disallow: /db_tools/`. The rules are here, but without the trailing slash,
# because the slashed form does not match the unslashed URL and both of those
# answer on the unslashed spelling. Same coverage, strictly wider.
# Google AdSense's crawler is a separate agent from Googlebot. It must be able
# to fetch any page carrying ad slots in order to serve contextually relevant
# ads; if it inherits the User-agent:* rules above it will report crawler
# errors and fall back to untargeted ads.
User-agent: Mediapartners-Google
Allow: /
# AdSense/Ad Manager fetcher for ad-serving decisions.
User-agent: AdsBot-Google
Allow: /
# YANDEX / SEMRUSH / AHREFS ARE NOW ALLOWED, and the blanket
# `User-agent: Yandex / Disallow: /` and `SemrushBot / Disallow: /` groups that
# stood here since the original file are gone.
#
# That is origin/dev's decision, taken with a stated reason, and this release
# had no counter-reason - it simply inherited the old blocks without examining
# them. Two things make it safe to adopt rather than merely accept:
#
# 1. It is paid for in infrastructure, not hope. docker/web/site.conf now runs
# a per-IP limit_req zone at 10 r/s specifically for scraper user-agents
# (bot|crawler|spider|slurp|yandex|semrush|ahrefs|mj12|dotbot), separate
# from the 30 r/s browser zone, answering 429 above that. A blanket
# Disallow was the only lever available before; it is no longer the only
# one, and a rate limit binds a crawler that ignores robots.txt too, which
# a Disallow never did.
# 2. The cost side changed underneath the old rule. Articles, /news, /epnews
# and /company/ are served from the shared fastcgi cache now, so the
# marginal cost of a crawl of the pages these tools actually want is a
# cache HIT, not a full CodeIgniter + MySQL render.
#
# Note that the courted crawlers (Googlebot, Bingbot, AdsBot, Applebot ...) are
# deliberately NOT in that 10 r/s bucket - site.conf classifies them onto the
# browser key ahead of the generic pattern. Yandex and Semrush are.
#
# If this is ever reverted, revert the site.conf rate classes with it; the two
# were decided together.
# The site's own sitemap index. Production's robots.txt advertised a
# third-party Attracta sitemap that now resolves to an empty , and
# never referenced a first-party one at all.
#
# WHY /sitemap.php AND NOT /sitemap.xml, WHICH IS THE NICER URL
#
# Because this file and the config that serves /sitemap.xml do not deploy at the
# same time, and this file wins the race.
#
# Production CD is `git fetch && git reset --hard origin/master`, once a minute,
# with no container rebuild (CRON_JOBS.md:63-70). sites/website is bind-mounted
# (`./sites/website:/var/www`), so THIS file goes live within 60 seconds of a
# merge. The location block that serves /sitemap.xml lives in
# docker/web/site.conf, which is mounted as /etc/nginx/conf.d/site.template and
# turned into the running site.conf by an envsubst that only executes in the web
# container's start command (docker-compose.yml:44-95). The generated tree is
# `./ephemeral/sitemap:/var/sitemap`, also a mount, and ephemeral/ is gitignored
# so a git reset cannot create it. Both need the containers recreated. Nothing
# in the documented CD does that, so the window is not short -- it is open until
# somebody runs a deploy by hand.
#
# Measured in that exact state (sitemap location blocks removed from the running
# nginx, /var/sitemap absent from the php container, everything else current):
#
# /sitemap.xml HTTP 404, Content-Type: text/html, 98,201 bytes of the
# CodeIgniter 404 page. The advertised sitemap is a 98 KB HTML
# document.
# /sitemap.php HTTP 200, Content-Type: application/xml, X-SE-Sitemap:
# fallback -- sitemap.php rendering the tracked
# sitemap-fallback.tpl.xml for whichever host asked, which is a
# self-contained of 12 section roots, not an index, so
# it cannot name a child that does not exist yet.
#
# /sitemap.php reaches PHP through the generic `~ \.php$` regex location, and
# sitemap.php itself needs nothing but files that git reset already delivered.
# That is what makes it the rung that answers 200 with valid XML in every
# intermediate state. (Verified by building a site.conf with the whole sitemap
# section deleted, reloading nginx and re-measuring -- not by assuming the regex
# location has always been there. If a future site.conf drops it, re-check.)
#
# One more reason the .xml URL is not merely equivalent-but-early: in the
# pre-recreate state the CHILD sitemaps 404 as HTML too (measured:
# /sitemaps/articles-free-001.xml -> HTTP 404, text/html, 98,421 bytes). An
# index whose children all 404 is worse than a small valid urlset, and only the
# /sitemap.php rung degrades to the latter.
#
# TO SWITCH TO /sitemap.xml, in a follow-up, in this order:
# 1. recreate the web and php containers so site.conf and the /var/sitemap
# mounts are live;
# 2. confirm on production: `curl -sI https://www.shaleexperts.com/sitemap.xml`
# returns 200 with `X-SE-Sitemap: generated`, and one child URL from the
# index returns 200 as well;
# 3. only then change the line below, and let it deploy on its own.
# Do not do 3 before 1. There is no ordering guarantee available to this file.
#
# Only one URL is advertised, whichever it is: Google caches robots.txt for up
# to 24 hours and pointing at both would submit the same URLs twice. Both serve
# the identical document with 200 and no redirect, so nothing is lost by
# naming the older one.
Sitemap: https://www.shaleexperts.com/sitemap.php
# Google News sitemap (key=news-sitemap). A SECOND Sitemap line, not a second
# spelling of the first: it is a different document with different content
# (only articles published in the last 48 hours, per the Google News schema),
# so the "only one URL" rule above does not apply to it. It is DYNAMIC -- a
# CodeIgniter route (routes.php -> modules/seo/controllers/News_sitemap.php),
# not part of the weekly static build, because a 48-hour sliding window cannot
# be served from a weekly file. Unlike /sitemap.xml it has no deploy-ordering
# problem: the route rides the same git checkout as this file and reaches PHP
# through `location /` -> index.php, which every intermediate nginx config
# already has. An empty news day answers 200 with a valid empty ,
# never a 404.
Sitemap: https://www.shaleexperts.com/news-sitemap.xml