---
title: "Native-Voice Recovery and AI-Search Infrastructure on bilvio.com: An End-to-End SEO + AEO Case Study"
seoTitle: "Bilvio.com Multilingual SEO + AEO Case Study | Iron Goo"
description: "Multi-week SEO and AEO engagement on bilvio.com, 134 URL surfaces rewritten in native voice across four languages, 552 Yoast metadata field updates, a single-mu-plugin AEO baseline, all shipped in 14 days."
datePublished: "2026-05-09T14:00:00.000Z"
dateModified: "2026-05-09T14:00:00.000Z"
client: "Bilvio"
clientDomain: "bilvio.com"
summary: "Bilvio is a multilingual B2B export platform. We replaced translated SEO copy with native-voice content in four languages and shipped a working AI-search baseline (llms.txt, JSON-LD, /raw endpoints) before the next quarterly review."
metrics:
- { value: "134", label: "URLs rewritten" }
- { value: "4", label: "Languages" }
- { value: "14", label: "Days, full ship" }
imageAlt: "Iron Goo case study cover image documenting the Bilvio multilingual SEO and AEO engagement outcomes."
tags: ["seo", "aeo", "multilingual", "wordpress", "case-study"]
---
## Background
**Bilvio** is a Turkish B2B export-customer-acquisition platform serving Turkish exporters who sell into international markets. The platform's primary site at **bilvio.com** runs on multilingual WordPress (Polylang) with five active languages: Turkish (original), English, Russian, Simplified Chinese, and Arabic. The translated languages target Western European procurement managers, US importers, Russian-speaking CIS buyers, mainland Chinese B2B sourcing teams, and Arabic-speaking MENA exporters.
In May 2026 we ran a multi-week SEO and AEO engagement on bilvio.com. The brief: the translated content (EN, RU, ZH, AR) was technically correct but unmistakably translated, with DeepL artifacts, idioms that read literally, and metadata still in Turkish on pages that were supposed to be English. Inbound conversion from non-Turkish search was underperforming relative to traffic. The fix had to happen in place (no replatform, no schema migration, no DNS work) and could not touch the Turkish original content.
This case study walks through what we found, what we shipped, and what we learned. We are publishing the post-mortem because most of the lessons are general: about multilingual content recovery on WordPress, about AEO infrastructure on legacy stacks, about why subagent self-validation lies on stylistic constraints, and about the operational discipline that lets you ship 134 URLs of native-voice content rewrites across four languages plus a full AEO baseline inside two weeks.
---
## Where we started
### The legacy stack
`bilvio.com` runs on a fairly conventional Turkish corporate WordPress stack:
- **WordPress 6.x** on **nginx + PHP 7.4 FPM** (we discovered the 7.4 detail mid-engagement, and it cost us a deploy; more on that below).
- **Polylang free version** for multilingual routing. Default language (Turkish) has no URL prefix; non-default languages (EN, RU, ZH, AR) use `/<lang>/`.
- **Yoast SEO** for on-page metadata and schema.
- **Elementor** + the **Hello Elementor** parent theme for page composition.
- **Cloudflare** in front of the origin for DNS, CDN, and edge TLS.
The platform side of bilvio (the actual customer-acquisition application) runs on a **separate Python/Django/gunicorn service** on the same server, mounted at a different path. The WordPress install is the marketing surface; the platform is the product. The two coexist but do not share a runtime, and the engagement scope was strict: WordPress install only, never touch the platform app.
### What the multilingual surface actually looked like
The frozen pre-engagement snapshot (`wget` mirror of the public site, sitemap-bounded, served from a Cloudflare-proxied subdomain we control as the "before" reference) captured **391 sitemap URLs** resolving to **381 unique HTML files** across the five languages.
The translation quality across the non-Turkish languages followed a predictable pattern:
- **Turkish (TR):** original, native, untouched. Out of scope for our work.
- **English (EN):** mostly manual translation, often word-for-word from Turkish. Idioms came through literally. Headlines like "Home - Export Customer Finding" instead of "Home - Export Customer Acquisition". Service page H1s mixed Turkish phrases with English. Some `<title>` tags were still in Turkish on URLs at `/en/...`. Meta descriptions where they existed often read as transliterated Turkish.
- **Russian, Chinese, Arabic:** DeepL or comparable machine-translation output. Technically correct grammar. No idiomatic register. No marketing voice. The "translated" tell that a procurement professional native to that language can spot in five seconds.
The structural surfaces (Yoast titles, meta descriptions, OpenGraph tags, alt text, slugs) were even worse than the body content because they had been edited least often.
---
## The audit: six clusters of issues
A pre-engagement audit using Search Console data, a fresh full crawl, the frozen `wget` mirror at our control subdomain, and a per-language manual sample of pages surfaced six clusters of issues.
### 1. Yoast metadata still in Turkish on translated URLs
The single most visible problem. The English homepage `<title>` read `"Home - İhracat Müşteri Bulma"` (Turkish phrase). OG titles, alt text, meta descriptions across roughly **138 items × 4 Yoast fields** (title, meta description, OG title, OG description) were either Turkish, transliterated Turkish, or missing entirely.
This is the kind of defect that costs CTR in the SERPs every single day and is invisible to anyone reviewing the site in a browser. It only surfaces when you read the head of a translated URL in a SERP snippet preview, or when an AI agent retrieves the URL and the first thing it reads is a Turkish title for an English page.
### 2. DeepL-shaped body content across RU, ZH, AR
Roughly 31 English posts and 7 service pages had been translated more or less manually, with the issues described above. Roughly 33 Russian posts, 21 Chinese posts, and 21 Arabic posts had been run through DeepL or a comparable tool. The body content was grammatically correct, structurally intact, and unmistakably machine-translated to anyone reading in those languages with commercial intent.
This is the silent killer for B2B export sites: procurement professionals are highly attuned to corporate translation quality, and a translated-feeling page reads as "this vendor does not actually operate in our market". The cost is not measurable in any single metric. It compounds across every visit by a serious buyer.
### 3. Polylang free-version slug collisions
A handful of legitimate cross-language content sharing patterns were broken by Polylang free's same-slug-per-language limitation. EN had `blog-2` (Polylang's automatic disambiguator) instead of `blog`. Two posts had duplicate canonicals pointing at sibling posts in different languages. Several post titles were lower-case in places where they should have been title-case.
### 4. AEO infrastructure: absent
- **`llms.txt`:** 404 across all five languages.
- **`robots.txt`:** Yoast default, no explicit AI agent allowlist (which means in 2026 the major AI crawlers are blocked by implicit-default for the platform's most commercially interesting audience: international procurement teams who increasingly start research in ChatGPT, Perplexity, and Claude).
- **`Organization` schema with `@inLanguage` per language:** not present. Yoast emits `Article` and `WebPage` per-page; it does not generate a per-language `Organization` node.
- **AI traffic logging:** no infrastructure. We had no way to count which bots were already crawling and which had given up.
- **/raw endpoints:** none. Token-efficient Markdown surfaces for AI agents did not exist.
Estimated audit-script posture score baseline: roughly **5-10 / 100** on our internal AEO scoring rubric.
### 5. Operational risk surface
The site shares a server with a separate Python/Django platform application. SSH access is gated through a credentials file that an unfamiliar contractor would not know existed. Any heavyweight intervention (theme edits, plugin installs, DB modifications) carries the risk of touching the platform app or breaking the slug-share workaround that was already in place. The engagement plan needed to be aggressively scoped to the WordPress install path only, with rollback-ready commits at every step.
### 6. No structural before/after capture
Most replatform engagements skip baseline capture and then have nothing to show the client at the 30-day or 60-day review. We treated baseline capture as a launch-blocking deliverable: a frozen `wget` mirror at a Cloudflare-proxied control subdomain, with defense-in-depth `noindex` at both per-page `<meta>` and `X-Robots-Tag` HTTP header so the snapshot itself never competes in the SERPs. The mirror is the durable "before" reference for the rest of the engagement and for the client review at the end.
---
## Strategy
We made several deliberate strategic calls upfront. Each one shaped the rest of the engagement.
### English is the canonical pivot for translations
Rather than translate from Turkish into each target language, we translated **from English into Russian, Chinese, and Arabic**, after first rewriting English to native-procurement-grade quality. The reasoning:
- Our author capacity for native-grade output is highest in English across all four target audiences.
- Translating EN → RU/ZH/AR yields more idiomatic output than TR → RU/ZH/AR (which would double-distance through Turkish comprehension at each step).
- The Western procurement audience served by the English version is the highest-commercial-value cohort, so English deserves source-of-truth status.
Turkish stayed read-only. Read it for semantic ground-truth (especially where the current English translated an idiom literally), never edit it.
### Per-language voice contracts
Each target-language pass adapted register and voice for that market, not as a literal English clone. We wrote and committed per-language style guides at `notes/_subagent-style-guide-{en,ru,zh,ar}.md` covering:
- **Russian:** formal commercial register; no transliterated Anglicisms where a native Russian B2B phrase exists; "Bilvio" stays Latin-script as the brand name.
- **Chinese (Mandarin Simplified):** mainland business register; no Taiwanese or Hong Kong variants; standard B2B vocabulary for trade-platform features.
- **Arabic (MSA, Modern Standard Arabic):** formal business register; right-to-left layout considerations preserved; `dir="rtl"` attributes added to package-table widgets where missing.
- **English:** native procurement-grade voice; concrete-over-vague; preserve all facts, brand names, and HTML tags from source content.
The voice contracts also carried the universal AI-tells ban list (no em-dashes, no `leverage`/`utilize`/`seamless`/`robust`/`comprehensive`/`delve` in the AI-flavor sense) and an instruction to scan output for the per-language flavored equivalents.
### Edits go through WP-CLI over SSH or mu-plugin file deploys
No direct DB writes. No WP admin browser editing for anything that could be batched. The methodology:
- **Yoast metadata fixes (title, meta description, OG):** WP-CLI commands over SSH, batched per language, with per-post meta backups to `/tmp/backup_meta_<id>_pre.json` before any update. Roughly 552 metadata field updates in a single SSH session.
- **Body content (Elementor-managed):** server-side PHP scripts that walk the `_elementor_data` JSON tree, locate the largest text-editor widget by widget_id, replace its content with the rewritten markup, and blank smaller redundant widgets. Re-save via `update_post_meta`.
- **Critical structural fixes (blog slug, canonicals, title casing):** one-off `wp eval` invocations with backup/restore wrappers.
- **AEO infrastructure:** single mu-plugin file at `wp-content/mu-plugins/bilvio-aeo.php`, removable in one `rm` command.
This approach has two material benefits. First, every change is recorded in a per-language `.txt` change log with before/after pairs (the client uses these for review). Second, the entire engagement state can be re-applied by a single shell script (`restore-all.sh`, ~3 minutes end-to-end) which is the engagement's durable rollforward artifact.
### Don't touch what we cannot test
Three hard rules carried forward at every step:
1. **Don't touch Turkish content.** TR is original, read-only.
2. **Don't touch the platform backend.** The Python/Django app at `/root/bilvioBackend/` is a separate concern.
3. **Mu-plugin must be removable in one command.** `rm wp-content/mu-plugins/bilvio-aeo.php` reverts the entire AEO ship. No DB writes, no theme edits, no custom tables.
---
## Execution
### Yoast metadata phase: 552 field updates across 4 languages
The Yoast metadata phase ran first because it is the highest-impact intervention with the smallest blast radius. Every Yoast `<title>`, meta description, OG title, and OG description on every translated URL was rewritten and verified live through Cloudflare.
- **English:** 39 of 41 items updated (excluding `kvkk` legal page + `deneme` test page).
- **Russian:** 41 of 42 (excluding `kvkk`), EN-canonical-pivot translations.
- **Chinese:** 29 of 30 (excluding `kvkk`), EN-canonical-pivot translations in Mandarin Simplified B2B register.
- **Arabic:** 29 of 30 (excluding `kvkk`), EN-canonical-pivot translations in MSA B2B register.
Total: **138 items × 4 Yoast fields = 552 metadata field updates**, all verified live with a curl matrix that fetched each URL through Cloudflare and grep'd the `<title>` and `<meta name="description">` against the expected post-update strings. 100% match.
Per-post backups of pre-update Yoast meta saved to `/tmp/bilvio_backups_<lang>_posts/` with a small `restore.php` to reverse any single update if the client objected to a specific framing.
Per-language change logs at `notes/changes-{en,ru,zh,ar}.txt` in the Turkish-labeled before/after format the client requested. Each entry: page URL, list of before/after pairs, numbered by page.
### Structural fixes (one-time)
Bundled into the metadata phase because they touched the same infrastructure:
- **EN blog slug `blog-2` → `blog`**: Polylang free-version disambiguation artifact. Fixed by writing a small mu-plugin (`bilvio-pll-slug-share.php`) that handles Polylang's same-slug-per-language limitation cleanly. The plugin coexists with the AEO mu-plugin we later shipped.
- **Redirection plugin rule** (`/en/blog-2/` → `/en/blog/`) for legacy URL preservation.
- **Duplicate canonicals fixed:** post 7188 (EN, an unfortunate "what-is-a-potential-customer-2") canonicalized to its sibling 7184. Post 7189 (RU duplicate) canonicalized to 7185.
- **Post title casing:** EN "academy" → "Academy", RU "академия" → "Академия", RU "блог" → "Блог". One-off `wp post update` per fix.
- **Pages flagged but not modified (client decision):** post 7151 (empty body), posts 7188/7189 (canonical duplicates the client may want to merge or delete), KVKK pages across all languages (per client directive).
### EN body content phase: 7 pages + 31 posts
This is where the engagement's most distinctive operational pattern showed up.
The 7 EN pages (homepage, about-us, exports, contact, academy, export-marketing-system, research-china) were rewritten by Vertex directly: 169 widget-level rewrites across the 7 pages, applied via a server-side PHP script that walked `_elementor_data` and replaced the largest text-editor widget per page with the rewritten markup. Three widgets were deferred for client decision (homepage SEO keyword cloud with mixed-language tags, exports pricing table with TR/EN mix, research-china search form HTML).
The 31 EN posts were rewritten via **four parallel `opus` subagents**, each handling a batch of 7-8 posts. Per-batch dispatch carried the EN-canonical voice contract, the universal ban list, and a fact-preservation rule (preserve all facts, brand names, HTML tags from the source content).
Validation: a controller pre-apply scan over the subagent output specifically for em-dashes and the per-language AI-tell wordlist. Result on the EN batch: zero em-dashes, zero AI-tells across all 31 posts. One subagent (batch 4) emitted a generic "robust" CTA boilerplate on five posts. The controller scan flagged it. Manually fixed in-place before applying.
The application path:
- 21 posts written into both `_elementor_data` (largest text-editor widget) and `post_content` (the WP body fallback).
- 8 posts had corrupt `_elementor_data` JSON (Turkish unicode escape issues on the legacy import). Fell back to `post_content` only.
- 1 post had empty `_elementor_data`. `post_content` only.
- 1 post used a theme-post-content widget rather than Elementor. `post_content` only.
Restore wrapper updated with Step 4b for re-application. Idempotent.
### RU + ZH + AR posts body phase: 75 posts across 3 languages
The same pattern, scaled up.
**Nine parallel `opus 4.7` subagents**, three per language, each handling a batch of 7-11 posts. Per-batch dispatch carried the per-language style guide and the universal ban list.
Subagent self-validation results, captured at apply time:
- **RU (33 posts across 3 batches):** subagent self-validation said "clean". A pre-apply controller scan caught **9 em-dashes and 2 AI-tells**. All fixed before apply.
- **ZH (21 posts):** subagent self-validation said "clean". The controller scan caught **1 Chinese double em-dash** (`, , `). Fixed before apply.
- **AR (21 posts):** clean across both subagent and controller scan.
This is the load-bearing lesson of the engagement, and we now treat it as a standing rule across every multi-subagent content pipeline we run: **subagent self-validation lies on stylistic constraints**. The implementer is too close to its own output to notice the dashes it just emitted. Always run an independent controller pass against the ban list before applying.
Apply infrastructure: a language-parameterized PHP walker at `notes/_apply-lang-posts-bodies.php` that reads `_apply-<lang>-posts-payload.json`, locates the post by ID, walks `_elementor_data`, replaces the largest text-editor widget with new content, blanks redundant smaller widgets, and re-saves. Falls back to `post_content`-only for corrupt-Elementor posts.
Per-language Elementor coverage:
- **RU:** 21 Elementor + post_content, 12 post_content only.
- **ZH:** 20 Elementor + post_content, 1 post_content only.
- **AR:** 20 Elementor + post_content, 1 post_content only.
**The Elementor-walker bug worth flagging publicly.** The first apply attempt found zero widgets across all 75 posts. The recursive walker assumed the top-level `_elementor_data` JSON was a single node. It is a **list of sections** at the top level. The fix was a one-line `array_is_list()` check that branches into a list-iteration path when the top-level is a list, and a single-node path otherwise. Re-applied successfully on the second pass.
### RU + ZH + AR pages body phase: 21 pages (3 languages × 7 pages)
The page-body rewrite used a different pattern from the post-body rewrite because pages are more visually structured and have widget-level translations that need exact id-matching.
A parallel-walker script extracted EN-rewritten and target-language-original Elementor JSON tree pairs by tree position, producing a per-language translation worksheet of **roughly 185 widget-pair entries per language** (`{page, widget_id, field, en_text, target_text}`).
Six parallel `opus` subagents (two per language) translated `en_text` to native target voice for each widget, returning flat `{page, widget_id, field, new_text}` arrays.
An apply script walked Elementor JSON, located each widget by `widget_id`, set `settings[field] = new_text`, and re-saved. The tabs/accordion pattern (`tabs[N].field`) was handled with a small special case.
Coverage: all **558 widget translations** (187 RU + 186 ZH + 185 AR) applied with zero misses. Live-verified on /ru/, /zh/, /ar/, /ru/контакты/, /zh/学院/ through Cloudflare.
Apply infrastructure: `notes/_extract-page-text-pairs.py`, `notes/_apply-page-translations.py`, `notes/_apply-page-translations.php`. All 21 rewritten Elementor JSONs saved to `notes/_elementor-{ru,zh,ar}-{label}-rewritten.json` as a durable artifact.
Sub-agents also did some structural cleanups in flight that were worth surfacing: the ZH batch stripped Mozilla `simple-translate` extension residue plus ChatGPT-export `data-start`/`data-end` artifacts from a few widgets (the client had previously used those tools to evaluate translations and the residue had been committed by accident). The AR batch added `dir="rtl"` to package-table widgets where it was missing.
### AEO V1 phase: single mu-plugin, five surfaces
After the content quality phase landed, AEO infrastructure went on top.
**Single mu-plugin** at `wp-content/mu-plugins/bilvio-aeo.php`. **861 lines of PHP**, PHP 7.4-compatible by construction, single file with no Composer dependency. Removable in one `rm` command.
Internal organization, all classes within `Bilvio\AEO` namespace, all in one file:
- **`AiAgents`:** constant arrays for 12 AI agent UA regex patterns (GPTBot, ChatGPT-User, OAI-SearchBot, ClaudeBot, anthropic-ai, Claude-Web, PerplexityBot, Perplexity-User, Google-Extended, Applebot-Extended, cohere-ai, Bytespider) and 5 AI referrer hostnames (chatgpt.com, claude.ai, perplexity.ai, gemini.google.com, copilot.microsoft.com).
- **`TrafficLogger`:** hooks `init` priority 1, matches request UA and referrer against `AiAgents`, writes a structured JSON line to PHP `error_log` via `error_log("[BILVIO_AEO] " . json_encode($entry))`. The PHP-FPM stderr passthrough lands these entries in `/var/log/nginx/error.log` on this server, which we discovered during deploy.
- **`RobotsTxt`:** hooks `robots_txt` filter priority 20, appends 12-agent allowlist. (This hook turned out to be a fallback because of the static-file bypass we hit during deploy. More below.)
- **`RewriteRules`:** hooks `init` priority 11 (after Polylang priority 10 has installed its rules), registers `/llms.txt` rewrite rules per language and an EN-only `/<en>/<slug>/raw/?` rewrite rule for posts and pages.
- **`Router`:** hooks `parse_request` priority 5, dispatches matched query vars to the right handler, sends response, exits cleanly.
- **`LlmsTxtHandler`:** generates per-language `llms.txt` content from the DB (org description in target language, top 10 recent posts, 7 service pages, available-formats note, refresh timestamp). Cached as a WP transient for 5 minutes. `?nocache=1` bypasses the cache for ad-hoc verification.
- **`RawHandler`:** resolves slug to a published EN post or page (via `pll_get_post` for Polylang language disambiguation), walks `_elementor_data` to extract text-bearing widget content, runs the result through `HtmlToMd`, prepends frontmatter (title, source URL, language, as-of timestamp, token estimate), returns Markdown with correct headers.
- **`HtmlToMd`:** custom 100-line DOMDocument-based HTML→Markdown converter. Handles headings (h1-h6), paragraphs, lists (ul/ol/li), links (a), images (img), strong/em, blockquote, code/pre, br, hr. Strips Elementor wrapper divs. No Composer dependency.
- **`Schema`:** hooks `wpseo_schema_graph` (Yoast's filter), adds an `Organization` node with `@inLanguage` set to the BCP47 code matching `pll_current_language()` (tr-TR / en-US / ru-RU / zh-CN / ar-SA). Merges into Yoast's existing graph, does not replace.
**Surface coverage after deploy:**
- **`/llms.txt`** at 5 URLs (root for TR, `/en/llms.txt`, `/ru/llms.txt`, `/zh/llms.txt`, `/ar/llms.txt`). Each returns 200 with dynamically generated Markdown content sized between 525 and 974 tokens depending on language byte-expansion.
- **38 EN `/raw` endpoints** (31 EN posts + 7 EN service pages). Each returns 200 with `Content-Type: text/markdown; charset=utf-8`, `Cache-Control: public, max-age=3600`, `X-Data-As-Of: <ISO>`, `X-Robots-Tag: noindex, follow`. The `noindex, follow` keeps `/raw` out of search engine results (search engines should index the HTML version) but lets agents follow links.
- **JSON-LD `Organization` schema** on each language homepage with `@inLanguage` BCP47, merged into Yoast's existing schema graph without duplication. Validates clean on schema.org's validator.
- **AI traffic logging** active. Test curls with ClaudeBot, GPTBot, and PerplexityBot user-agent strings produce structured JSON entries in `/var/log/nginx/error.log` tagged `[BILVIO_AEO]` for monthly review.
- **`robots.txt` 12-agent allowlist** appended directly to the static file at `/var/www/html/proje/robots.txt` with `# BILVIO_AEO` / `# END BILVIO_AEO` marker brackets so the change is idempotent and reversible.
**Posture jump** captured at `notes/aeo-audit.md`:
| Surface | Baseline | Post-deploy |
|---|---|---|
| `/llms.txt` × 5 langs | 404 across all | 200 + per-language content (525-974 tokens each) |
| `robots.txt` AI allowlist | absent | present (after Cloudflare cache purge) |
| `/raw` endpoint | 404 | 200, text/markdown, ~240 tokens (sample) |
| JSON-LD `Organization @inLanguage` | absent | present on all 5 lang homepages |
| AI traffic logging | absent | active (verified via test curls) |
Internal AEO scoring rubric: baseline approximately **5-10 / 100**; post-deploy approximately **70-80 / 100**. The jump took roughly a day of focused work plus the deploy hardening described below.
---
## Specific bugs and lessons worth documenting
A few issues we hit and fixed are worth flagging publicly because we see them on other multilingual WordPress sites all the time.
### PHP version mismatch: a target-host lint is mandatory
The first deploy attempt of `bilvio-aeo.php` **500'd the entire site**. The mu-plugin used PHP 8+ syntax (`match` expressions, `str_ends_with()`, `array_is_list()`) which PHP 7.4 rejects with a parse error. Local PHP-lint had passed because our default machine runs PHP 8.3.
Rolled back in roughly 3 minutes by `rm`'ing the mu-plugin. Rewrote in 7.4-compatible syntax (`match` to array-plus-`isset` lookups, `str_ends_with` to `substr` checks, `array_is_list` to a manual `isset($node[0]) && is_array($node[0])` pattern). Re-deployed clean.
The lesson, now an enforced rule: **lint against the target host's PHP binary before scp**, every time. Specifically:
```bash
scp aeo.php client:/tmp/aeo-lint-test.php
ssh client "php7.4 -l /tmp/aeo-lint-test.php"
```
If the target lints clean, ship. If not, fix before scp. Skipping this step is what caused the site-wide 500.
### Static robots.txt bypasses the WP filter
The mu-plugin's `RobotsTxt::augment()` filter was registered correctly, would have fired if reached, and produced the expected output in standalone testing. Public-facing curl of `bilvio.com/robots.txt` showed the file unchanged.
The cause: nginx serves `/var/www/html/proje/robots.txt` directly from disk via a `location = /robots.txt { try_files $uri =404; }` block. The WordPress `robots_txt` filter never fires for the public URL because WordPress is never invoked. Even if nginx routed the URL through PHP, Yoast's `robots_txt` filter at priority 99999 would have steamrolled our priority 20 augmentation.
Fix: append the AI allowlist directly to the static file at `/var/www/html/proje/robots.txt` with `# BILVIO_AEO` and `# END BILVIO_AEO` marker brackets. Idempotent, grep-able, reversible (`sed -i '/# BILVIO_AEO/,/# END BILVIO_AEO/d' robots.txt`).
The lesson: **probe the actual serving path of `/robots.txt` before relying on WordPress filters to augment it.** The in-PHP filter chain is a fallback. The static-file append is the reliable path.
### Cloudflare's 4-hour robots.txt TTL
After the origin robots.txt change, public clients saw the stale version until Cloudflare's cache expired. Origin-direct curl (`curl --resolve bilvio.com:443:<origin-ip> https://bilvio.com/robots.txt`) showed the change immediately; public-facing curl through Cloudflare lagged for hours.
Fix: purge `robots.txt` (and all `/llms.txt` URLs across the five languages) from the Cloudflare dashboard immediately after origin changes. If you have a CF API token, a one-line `curl` against `/zones/$ZONE_ID/purge_cache` handles it without dashboard work.
### The Elementor walker top-level-list bug
The first apply attempt of the RU/ZH/AR body rewrites found zero widgets across all 75 posts. The recursive walker we had built assumed the top-level `_elementor_data` JSON tree was a single node. It is a **list of sections** at the top level (each section contains columns, each column contains widgets, recursive).
Fix: a one-line `array_is_list()` check at the top of the recursive walker. If the input is a list, iterate and recurse into each element. If it is a single node, walk it directly. Re-applied successfully on the second pass with zero misses.
The lesson: **the top of any Elementor JSON tree is a list, not a node.** Defensive list-handling at the entry point of any Elementor walker.
### Subagent self-validation lies on stylistic constraints
This was the most consistent observation of the engagement. Every batch of subagent output that we asked to self-validate against the AI-tells ban list reported "clean". On a controller pre-apply scan, we caught:
- 9 em-dashes across the RU output
- 2 AI-flavor adjectives across the RU output
- 1 Chinese double em-dash (`, , `) across the ZH output
- 0 issues across the AR output
This is not a subagent capability problem. It is a writer-can't-edit-their-own-prose problem. The implementer is too close to the text it just emitted to notice the dashes it just produced.
Defense: always run an **independent controller-side scan** against the ban list before applying any subagent's content output. The scan is grammar-blind regex; the false-positive rate is acceptable because the cost of a false negative (an em-dash shipped to production) is real.
We have promoted this to a project-wide rule, now part of every multi-subagent content pipeline we run.
### Don't trust engagement memory about server stack
Bilvio's engagement intake had captured the server stack as "LiteSpeed Cache" (a common stack on shared Turkish hosting). Reality, probed at deploy time: nginx + PHP 7.4 FPM + Cloudflare. `php -i | grep error_log` returned empty (no explicit `error_log` directive in `php.ini`), which meant PHP-FPM stderr passthrough sends `error_log()` output to `/var/log/nginx/error.log` rather than a `php-error.log` we had assumed existed.
The lesson, now a pre-deploy gate: **probe the server stack before writing platform-specific code.** Specifically:
```bash
curl -sI "https://CLIENT/" | head -10
ssh client "ls /etc/nginx/sites-enabled/ /etc/apache2/sites-enabled/ 2>/dev/null"
ssh client "systemctl status nginx php-fpm apache2 litespeed 2>&1 | head -5"
ssh client "ls /var/run/php/*.sock"
ssh client "php -i | grep error_log"
```
The output tells you which web server, which PHP version, which FPM socket, and where logs land. Plan the deploy against the actual stack, not the intake-form stack.
---
## Results and what we are tracking
We are deliberately not publishing specific traffic-recovery percentages in this post yet. The content quality and AEO ship landed in May 2026; we want a full Google reindexing cycle and at least 60 days of AI agent crawl before quoting numbers we can stand behind.
What we can say now, with confidence:
- **134 unique URL surfaces** with native-voice rewrites across English, Russian, Chinese, and Arabic, all live and Cloudflare-verified.
- **552 Yoast metadata field updates** across 138 items in 4 languages, all verified live through Cloudflare with 100% match against the expected post-update strings.
- **558 page-body widget translations** across the RU, ZH, and AR service pages, applied with zero widget-id misses.
- **AEO V1 surfaces** complete: `/llms.txt` × 5 languages, 38 EN `/raw` endpoints, JSON-LD `Organization` with `@inLanguage` on all language homepages, 12-agent `robots.txt` allowlist, AI traffic logging active.
- **Posture jump** from approximately 5-10 / 100 on our internal AEO scoring rubric to approximately 70-80 / 100.
- **Idempotent restore script** at `restore-all.sh` that re-applies the entire engagement (Yoast meta + body content + AEO infrastructure) in approximately 3 minutes if any of it ever needs to be re-deployed.
- **Per-language change logs** (`notes/changes-{en,ru,zh,ar}.txt`, 1,100+ lines each) in the Turkish-labeled before/after format the client requested.
- **No regressions** on the Turkish original content. A 5-page spot-check post-deploy confirmed structural parity with the frozen pre-engagement snapshot at our control subdomain.
The +30 and +60 day AI-client query rerun is the metric that actually tells us whether the AEO bet pays off. We run three standard queries ("What does bilvio.com offer?", "How do I find international export buyers?", "Compare platforms for finding export customers") across Claude Desktop, ChatGPT, Perplexity, and Gemini at baseline, +7d, +30d, and +60d, and log results in `notes/aeo-real-world-checks.md`. The trajectory of citation rate and discovery rate across those checkpoints is the engagement's real KPI.
---
## Lessons we would give to anyone doing a similar engagement
1. **Probe the server stack before writing platform-specific code.** Don't trust intake-form claims. Linting against the wrong PHP version is what causes site-wide 500s on the first deploy. Probing takes 30 seconds; recovering from a bad deploy takes 30 minutes and burns client trust.
2. **Static `robots.txt` is the public-facing surface, regardless of what WordPress thinks.** If nginx (or Apache, or LiteSpeed) serves `/robots.txt` from disk, your WP filter chain is a fallback, not the path. Append directly to the static file with marker brackets. Idempotent and reversible.
3. **Subagent self-validation lies on stylistic constraints.** Em-dashes, AI-tells, voice-contract violations: never trust an implementer's self-report. Always run an independent controller-side scan against the ban list before applying.
4. **English as the canonical translation pivot is the right call for B2B export sites.** Translate EN to RU/ZH/AR rather than TR to RU/ZH/AR. The cost is one extra hop of human review on the English; the benefit is markedly higher idiomatic quality across the target languages.
5. **Per-language voice contracts are non-optional.** Formal Russian commercial register reads completely differently from formal English; mainland Mandarin business register is not Taiwanese business register; MSA Arabic is not colloquial Arabic. Commit the per-language style guides at engagement start, reference them in every subagent dispatch, scan output against them before apply.
6. **The Elementor JSON top-level is a list, not a node.** Build the walker defensively. The one-line `array_is_list()` check at the entry of any recursive Elementor walker prevents the most common debug-time waste on these engagements.
7. **A single-file removable-in-one-command mu-plugin is the correct AEO shape for WordPress engagements.** No Composer. No theme edits. No DB writes. `rm` reverts everything. The tradeoff (no shared utility library across mu-plugins on different clients) is worth the safety.
8. **Capture baseline before you touch anything.** A frozen `wget` mirror at a control subdomain, served with defense-in-depth `noindex` (both `<meta>` and `X-Robots-Tag` HTTP header), is the reference for the rest of the engagement and the only durable artifact you have at the 30 and 60 day review. Skip this and you have no story at the end.
9. **AI client query checks are the real KPI, not the audit-script posture score.** Server-side green lights confirm the infrastructure is shipped. Citation rate at +30 and +60 days confirms the AEO bet is paying off. Don't conflate the two. Don't skip the +30 / +60 rerun because the audit looks green at +0.
---
## Where the site is now
`bilvio.com` is live with 134 URLs of native-voice content rewrites across English, Russian, Simplified Chinese, and Arabic; 552 Yoast metadata field updates; a single removable mu-plugin delivering five AEO surfaces (`llms.txt`, `robots.txt` allowlist, `/raw` endpoints, JSON-LD `Organization @inLanguage`, AI traffic logging); and a fully idempotent restore script that re-applies the entire engagement in approximately three minutes.
The Turkish original content is untouched. The platform application is untouched. The mu-plugin is removable in one `rm` command.
If you are running a multilingual WordPress B2B site and would like to compare notes, our door is open.