Cutover tasks were scattered across three sources that disagreed with each other and with reality: planning/parity-checklist.md (git-ignored, content only), docs/seo-aeo-optimization.md (SEO only, listed the Zod schema, seo-lint and breadcrumbs as open when all three are done, and called for Uptime Kuma which ADR-0004 replaced with monylog), and session notes. Add docs/launch-checklist.md as the single source of truth, verified against the working tree, the cluster and ArgoCD rather than inherited from the older docs. Point the SEO doc's two checkbox blocks at it and keep only reference detail there. Also records blockers that were not tracked anywhere: TLS never wired on the ingress, developer-facing copy rendered on the contact page, the contact form having no endpoint, and the draft insight post building into the sitemap because the draft flag is declared in Zod but never filtered on.
242 lines
15 KiB
Markdown
242 lines
15 KiB
Markdown
# SEO, AEO & AI-Crawler Optimization Plan
|
||
|
||
Requirements and rules for optimizing the BlueCap Strategies rebuild for classic search (SEO),
|
||
answer engines / AI citation (AEO/GEO), and AI-crawler acceptance. Written **2026-07-15** against
|
||
mid-2026 best practices (see Sources). Where a rule can be machine-enforced, it is defined as a
|
||
**Zod schema constraint** or a **build-time check** so the build fails when copy violates it —
|
||
this is the TypeScript/Astro equivalent of Pydantic validation (do **not** add Python/Pydantic to
|
||
a JS build; Astro Content Collections already run on Zod).
|
||
|
||
## 0. Guiding principles (2026 reality check)
|
||
|
||
1. **HTML is the source of truth, not `llms.txt`.** AI search crawlers (GPTBot, ClaudeBot,
|
||
PerplexityBot, OAI-SearchBot, Google-Extended) overwhelmingly **skip `/llms.txt` and crawl
|
||
rendered HTML directly**; Google confirmed it does not support llms.txt. Keep our `llms.txt`
|
||
(low cost, ~10% of sites have one) but invest first in semantic HTML, structured data, and
|
||
crawlable copy. Our Astro output is **fully static** — 100% of meaningful copy is in initial
|
||
HTML, which is the single biggest AEO advantage over the old JS-heavy WordPress/Elementor site.
|
||
2. **Structured data drives citation.** Schema lets answer engines verify *who we are*, *what a
|
||
page covers*, and *which facts are reliable to cite*. FAQ schema has among the highest AI
|
||
citation rates (question/answer mirrors how engines present answers).
|
||
3. **Answer-ready structure wins.** Clear headings, direct definitional first sentences, and
|
||
self-contained paragraphs get extracted; buried or paraphrased marketing prose does not.
|
||
4. **E-E-A-T via the principal.** Boutique-advisory credibility is entity-driven — a real
|
||
`Person` entity (Brad Rodgers) with `knowsAbout`, linked to the `Organization`, is a core GEO
|
||
asset. (Gated on owner-approved bio — see parity plan Phase 5.)
|
||
|
||
## 1. Programmatic enforcement (Zod + build linter)
|
||
|
||
### 1a. Content-collection SEO schema (fails the build)
|
||
|
||
Extend `src/content.config.ts`. Every collection (pages, services, focusAreas, insights) shares
|
||
an `seo` shape with enforced limits:
|
||
|
||
```ts
|
||
import { z } from "astro:content";
|
||
|
||
export const seo = z.object({
|
||
// <title> renders as "`title` | BlueCap Strategies" — keep the full SERP title ≤ ~60 chars
|
||
title: z.string().min(10).max(55),
|
||
// meta description + OG description; 120–158 is the safe SERP/AI-snippet window
|
||
description: z.string().min(120).max(158),
|
||
// canonical is derived from slug, but allow override for cross-posted insights
|
||
canonicalUrl: z.string().url().optional(),
|
||
// every page must ship a social share image (absolute path under /assets)
|
||
ogImage: z.string().startsWith("/assets/").default("/assets/og/default.jpg"),
|
||
// primary entity/topic — powers internal linking + schema keywords
|
||
primaryTopic: z.string(),
|
||
noindex: z.boolean().default(false),
|
||
});
|
||
```
|
||
|
||
Rules encoded here (build fails on violation): title length, description length window, OG image
|
||
present, canonical is a valid URL. This is the "rules are being followed" guarantee you asked
|
||
about — enforced on every `npm run build` in CI before ArgoCD ever sees the image.
|
||
|
||
### 1b. Build-time SEO linter (what Zod can't see)
|
||
|
||
Zod validates frontmatter, not rendered HTML. Add a small check (a Node script run in CI, and/or
|
||
Playwright assertions in `tests/`) that fails the build if any built page in `dist/` violates:
|
||
|
||
- Exactly **one `<h1>`** per page.
|
||
- Heading hierarchy has no skipped levels (no `<h4>` before an `<h2>`).
|
||
- `<title>`, `<meta name="description">`, `<link rel="canonical">` all present and non-empty.
|
||
- Every `<img>` has non-empty `alt` (parity plan already requires this).
|
||
- At least one JSON-LD block present, and **every** JSON-LD block `JSON.parse`s.
|
||
- Internal links resolve (no 404s to our own routes).
|
||
- Canonical host matches the chosen production host (apex vs `www` — see §5).
|
||
|
||
Recommended: a `scripts/seo-lint.mjs` that walks `dist/**/*.html` with a lightweight parser, plus
|
||
keep the existing Playwright suite for link/CTA coverage.
|
||
|
||
## 2. Per-page technical SEO requirements
|
||
|
||
Already implemented in `src/layouts/BaseLayout.astro` (keep + enforce):
|
||
|
||
- `<title>`, `<meta name="description">`, `<link rel="canonical">`
|
||
- Open Graph (`og:type/title/description/url/image`) + Twitter `summary_large_image`
|
||
- JSON-LD injection slot (Organization + WebSite globally, page schema per route)
|
||
- `lang="en"`, viewport, skip-link, RSS `<link rel="alternate">`
|
||
|
||
To add/verify:
|
||
|
||
> **Tracked in [`launch-checklist.md`](./launch-checklist.md) §4.** The list that used to live
|
||
> here went stale (breadcrumbs are done). This section keeps the *reference* detail below; the
|
||
> live task state is in the launch checklist only.
|
||
|
||
- **Per-page OG images** — every page currently falls back to one shared image
|
||
(`BaseLayout.astro` emits a single `og:image`). Generate topic-specific 1200×630 images for
|
||
services, focus areas, and insights.
|
||
- **`robots` meta** wired to a `noindex` frontmatter flag (for `/thank-you/`, staging). Not wired
|
||
today — `/thank-you/` is sitemap-excluded but emits no `noindex`.
|
||
- **`article:published_time` / `modified_time`** OG tags on insights posts.
|
||
- **Breadcrumbs** — DONE. `BreadcrumbList` in `src/utils/schema.ts`, applied on detail pages.
|
||
- **Sitemap** — via `@astrojs/sitemap`, excludes `/thank-you/`. Still to add: `lastmod` from
|
||
content dates for insights.
|
||
- **Trailing-slash consistency** — config uses `trailingSlash: "always"`; ensure canonical +
|
||
internal links all match to avoid duplicate-URL dilution.
|
||
|
||
## 3. Structured data (JSON-LD) matrix
|
||
|
||
| Page / type | Required schema | Notes |
|
||
|-------------|-----------------|-------|
|
||
| Global | `Organization`, `WebSite` | Already in `src/utils/schema.ts`. Add `sameAs` (real socials), `logo`, `contactPoint` (phone/email), `areaServed`. |
|
||
| Homepage | `Organization` + `WebSite` (+ optional `SearchAction`) | — |
|
||
| `/services/` + each service | `Service` (with `provider` → Organization, `serviceType`, `areaServed`) | One per detail page. |
|
||
| `/focus-areas/` + each focus | `Service` or `DefinedTerm`/`Article` | Focus areas double as topic-authority hubs. |
|
||
| About / Brad Rodgers | `Person` + `AboutPage`; `Person.worksFor` → Organization; `knowsAbout[]` | **Owner-approved facts only.** Core GEO/E-E-A-T asset. |
|
||
| Insights posts | `Article`/`BlogPosting` (author=Person, publisher=Organization, dates, `headline`, `image`) | Powers AI citation of thought leadership. |
|
||
| Insights index | `Blog` / `CollectionPage` | — |
|
||
| Contact | `ContactPage` + `Organization.contactPoint` | Use real values, never the WP placeholders. |
|
||
| FAQ sections | `FAQPage` — **only where the same Q&A is visibly rendered** | Highest AI-citation rate. Never add invisible FAQ schema. |
|
||
| Breadcrumbs | `BreadcrumbList` | Detail pages. |
|
||
|
||
Rule: **structured data must reflect content that is visibly present on the page.** No hidden-text
|
||
schema, no unsupported claims, no fabricated review/rating markup.
|
||
|
||
## 4. AEO / GEO content rules
|
||
|
||
Applied during Phase 2 copy restoration and Phase 5+ enhancements (must not violate the
|
||
no-paraphrasing parity rule on restored copy):
|
||
|
||
- **Answer-first**: each section's opening sentence should stand alone as a direct answer /
|
||
definition (e.g., "Marine conservation finance is …"). Extractable by answer engines.
|
||
- **Semantic headings** phrased as the questions/topics people ask ("What makes PFAS remediation a
|
||
capital formation problem?").
|
||
- **FAQ modules** on high-intent authority pages (PFAS economics, marine conservation finance) —
|
||
visible Q&A + `FAQPage` schema. Owner-approved answers only.
|
||
- **Entity consistency**: use canonical terminology everywhere — *catalytic capital*, *aquatic
|
||
resources*, *marine & freshwater ecosystems*, *PFAS remediation*, *marine conservation finance*,
|
||
*environmental markets*, *coastal resilience*. Feeds `primaryTopic` + internal linking.
|
||
- **Internal linking graph**: services ↔ focus areas ↔ insights ↔ principal bio. Dense, relevant
|
||
internal links are a top GEO signal.
|
||
- **Freshness**: insights carry `publishDate`/`modifiedDate`; surface "Updated" dates.
|
||
- **Statistics & specifics**: the homepage metrics (27 years, $10B+, $3.5B+, 275+) are exactly the
|
||
kind of concrete, citable facts answer engines favor — keep them prominent and, where true,
|
||
attributable.
|
||
|
||
## 5. AI-crawler policy (`robots.txt`) — **owner decision required**
|
||
|
||
Current `public/robots.txt` = `Allow: /` for everyone. 2026 best practice is **explicit per-agent
|
||
rules**, splitting *search/answer* bots (that can cite and send traffic) from *training* bots
|
||
(that ingest content into model weights). Decision matrix:
|
||
|
||
| Bot | Job | Recommendation |
|
||
|-----|-----|----------------|
|
||
| `OAI-SearchBot`, `ChatGPT-User` | ChatGPT search index + live citation fetch | **Allow** |
|
||
| `Claude-SearchBot` | Claude web-search citations | **Allow** |
|
||
| `PerplexityBot` | Perplexity answer citations | **Allow** |
|
||
| `Google-Extended` | Gemini + Google AI Overviews | **Allow** (maximizes AI visibility) |
|
||
| `Googlebot`, `Bingbot` | Classic search | **Allow** |
|
||
| `GPTBot` | OpenAI model *training* | Owner choice — allow = max reach, disallow = opt out of training |
|
||
| `ClaudeBot` | Anthropic model *training* | Owner choice (same trade-off) |
|
||
| `Bytespider`, stealth scrapers | Known to ignore robots.txt | Consider **edge/WAF block** (Cloudflare) — robots.txt won't stop them |
|
||
|
||
**Recommended default for an authority-seeking advisory firm: allow all search/answer + classic
|
||
bots (maximize citation and discoverability); training bots are a values call.** Because BlueCap
|
||
wants to be cited as an authority, allowing everything is the simplest high-visibility posture.
|
||
Non-compliant scrapers are handled at the Cloudflare edge (see gitops doc §4.2), not robots.txt.
|
||
|
||
Keep `llms.txt` (already present, low cost) but treat it as supplemental, not load-bearing.
|
||
|
||
## 6. Measurement & Monitoring — **recommendation, needs sign-off**
|
||
|
||
Three distinct layers; today none is decided.
|
||
|
||
### 6a. Uptime / infrastructure — reuse the existing `monylog` stack (do NOT deploy Uptime Kuma)
|
||
|
||
The homelab already runs a full self-hosted **LGTM + Alertmanager** stack on the `monylog` box
|
||
(`10.66.15.21`, Ansible role `pglta`): Prometheus (15d retention, remote-write receiver), Grafana,
|
||
Loki, Tempo, Alertmanager → **Pushover** (creds from OpenBao) with **ntfy** fallback → phone. It
|
||
runs **outside k8s on purpose** so it still alerts when the cluster is down. Grafana Alloy runs as
|
||
a k3s DaemonSet and remote-writes cluster metrics + ships all pod logs to monylog.
|
||
|
||
**Already flowing for BlueCap (zero work):**
|
||
- **Pod/deployment health** — Alloy scrapes kube-state-metrics + annotated pods → Prometheus.
|
||
`kube_deployment_status_replicas_unavailable`, restarts, OOMKills are queryable now.
|
||
- **nginx logs** — Alloy ships all pod logs to Loki. 5xx rate / errors available via LogQL now.
|
||
|
||
**To add (small):**
|
||
1. **blackbox_exporter** — the one gap (not in the monylog compose). Add as a compose service +
|
||
a Prometheus scrape job. Probe the in-cluster Service (app health) **and** the public URL (full
|
||
edge-path uptime + TLS expiry). monylog is off-cluster → probing the public URL is a genuine
|
||
external vantage. Metrics: `probe_success`, `probe_http_status_code`,
|
||
`probe_ssl_earliest_cert_expiry`, latency.
|
||
2. **`web-bluecap.yml` alert rules** (mirror the existing host-infra rules pattern): `SiteDown`
|
||
(`probe_success==0` for 2m), `CertExpiringSoon` (<14d), `High5xx` (Loki/blackbox),
|
||
`DeploymentDegraded`, `PodRestarting`.
|
||
3. **Alertmanager** already routes to Pushover — just add a `service: bluecap` label; no new
|
||
channel. **Grafana** already present → build a BlueCap uptime/latency/5xx/pod-health dashboard.
|
||
- **ArgoCD** health/sync status as an additional deploy-level signal.
|
||
|
||
### 6b. Visitor & behavior analytics — PostHog + Umami side-by-side trial (ADR-0005)
|
||
|
||
Decided: **run PostHog and Umami side-by-side initially** and keep whichever fits best. This is
|
||
**product/visitor analytics**, distinct from the `monylog` ops telemetry in §6a. Trial setup:
|
||
|
||
- **PostHog — Cloud free tier** (1M events/mo) for the trial, not self-host (self-hosting needs
|
||
ClickHouse/Kafka/etc.; PostHog's docs steer small users to Cloud). Reverse-proxy ingest through
|
||
our domain to reduce ad-blocker loss. Gives autocapture, funnels, session replay.
|
||
- **Umami — self-hosted** in k3s: cookieless, ~2 KB, no consent banner, always-on baseline
|
||
(pageviews, sources, devices, custom events). Aligns with the Digital-ESG positioning.
|
||
- Both wired through one config-driven `Analytics.astro` in `BaseLayout`, gated by env vars, so
|
||
either can be toggled and the winner kept without code churn. Same event set in both:
|
||
consultation-CTA clicks, per-service/focus CTA source (ties to the segmented-CTA strategy),
|
||
insights reads, contact submits.
|
||
- **Consent/privacy must cover the superset** — PostHog (esp. session replay) likely triggers
|
||
cookie-consent + privacy-policy obligations; add these **before** enabling PostHog in production.
|
||
Umami alone needs no banner.
|
||
- **Avoid GA4** unless Google Ads attribution is later required.
|
||
- Evaluate on insight value vs. weight/consent/ESG fit; record the winner in a follow-up ADR.
|
||
|
||
### 6c. Search & AI-citation visibility
|
||
- **Google Search Console** + **Bing Webmaster Tools** (free, essential) — submit sitemap, monitor
|
||
impressions/queries/coverage, Core Web Vitals.
|
||
- **AI-citation tracking** (emerging, optional): periodically test brand/topic prompts across
|
||
ChatGPT/Perplexity/Google AI Overviews and log whether BlueCap is cited. Lightweight manual
|
||
tracker to start; dedicated GEO tools are immature — don't over-invest yet.
|
||
- **Performance budget**: Lighthouse in CI; target 95+ SEO/Perf/Best-Practices/Accessibility.
|
||
Static Astro should hit this easily; guard against regressions from images (use Astro `Image`).
|
||
|
||
## 7. Pre-launch SEO checklist
|
||
|
||
> **Moved.** This list is no longer maintained here — it duplicated, and disagreed with, reality
|
||
> (it listed the Zod schema, `seo-lint`, and breadcrumbs as open when all three are done, and
|
||
> called for Uptime Kuma, which ADR-0004 replaced with `monylog`).
|
||
>
|
||
> **The maintained checklist is [`launch-checklist.md`](./launch-checklist.md).** SEO items are
|
||
> in §3 (owner decisions: canonical host, robots policy) and §4 (post-cutover: OG images,
|
||
> `noindex` meta, article times, `lastmod`, Lighthouse, trailing slashes). What is already
|
||
> complete is recorded in §5.
|
||
|
||
## Sources (verified 2026-07-15)
|
||
|
||
- [The State of llms.txt in 2026 — aeo.press](https://www.aeo.press/ai/the-state-of-llms-txt-in-2026)
|
||
- [llms.txt Explained (May 2026) — Codersera](https://codersera.com/blog/llms-txt-complete-guide-2026/)
|
||
- [Structured Data for AEO and GEO: Schema Markup Guide 2026 — Kurieta](https://kurieta.com/schema-for-aeo-geo/)
|
||
- [Answer Engine Optimization: Complete AEO Guide [2026] — Frase](https://www.frase.io/blog/what-is-answer-engine-optimization-the-complete-guide-to-getting-cited-by-ai)
|
||
- [Are FAQ Schemas Important for AI Search, GEO & AEO? — Frase](https://www.frase.io/blog/faq-schema-ai-search-geo-aeo)
|
||
- [The AI User-Agent Landscape in 2026: A Complete Reference — No Hacks](https://nohacks.co/blog/ai-user-agents-landscape-2026)
|
||
- [AI Crawlers Explained: GPTBot, ClaudeBot, PerplexityBot (2026) — Anagram](https://www.anagram.ai/blog/ai-crawlers-explained-gptbot-claudebot-perplexitybot-and-how-to-let-them-in-2026)
|
||
- [Robots.txt for AI Crawlers in 2026 — Cubitrek](https://cubitrek.com/blog/robots-txt-2026-managing-ai-crawler-budgets)
|