Four gaps, ranked by cost
GEO — Generative Engine Optimization — is not SEO with a new name. SEO competes for a position in a result list; GEO competes for one sentence inside an answer. A search engine sends you a click. A generative engine reads you, extracts a claim, and cites it. It will never send the click.
So the question stops being how do I rank and becomes what can be lifted off this page. That reframing is what makes the audit useful: it does not ask whether the writing is good, it asks whether a machine can tell what the page claims, who is making the claim, and whether that person is a real entity.
The audit scores four dimensions. Weights matter more than the numbers: citability carries 35%, brand 25%, technical and schema 20% each. A 10-point gain in citability is worth more than 10 points anywhere else. My first report looked like this:
| Dimension | Weight | Blog | Repo | What it measures |
|---|---|---|---|---|
| Technical | 20% | 73 | 81 | Can a crawler fetch and parse it |
| Citability | 35% | 71 | 66 | Is there a sentence worth quoting |
| Structured data | 20% | 12 | 28 | Does a machine know what this is |
| Entity & brand | 25% | 40 | 35 | Does anyone else say you exist |
| Total | 52 | 54 | Both grade C |
Two numbers pointed straight at the real problem. Schema at 12 and 28 does not mean bad schema — it means the machine is guessing. And brand at 40 was the biggest weighted hole even then. Everything else was noise by comparison.
The English half did not exist
The site has been bilingual for months. Chinese lives in the HTML, English lives in data-en and data-i18n attributes, and a small script swaps them when a visitor clicks EN. This is a reasonable design: it halves the bytes, keeps one source of truth, and works with JavaScript off for the Chinese reader.
It is also, from a crawler's point of view, a Chinese-only site. GPTBot, ClaudeBot and PerplexityBot do not execute JavaScript. They receive the HTML, see Chinese text, and index that. An English query like validate DBC against ARXML had nothing on the page to match, because the English was sitting in attributes the crawler never reads.
Google and Bing do render JavaScript. So this problem is completely invisible in traditional search: your English pages rank fine, and you conclude the setup works. Only the generative engines miss it. I would not have found this without an audit that specifically fetches as a non-JS user agent.
The fix is to materialise the English at build time. A script reads each page, pulls the English out of the attributes, and writes a static mirror: index.en.html, blog/index.en.html, and one per post. The two declare each other with hreflang, each carries its own canonical, and the sitemap lists both.

Two decisions in that script are worth stealing. First, it slices the source at parser-reported offsets instead of re-serialising the DOM, so CSS, script bodies, whitespace and attribute order stay byte-identical — the diff is only the text that changed, which makes it reviewable. Second, it removes the runtime language switcher from the mirrors and replaces it with a plain 中文 link. Without that, localStorage remembers the visitor's preference and flips the static English page back to Chinese on first paint, which defeats the entire exercise.
The mirror also has to carry an English FAQ schema, not just an English language tag. A FAQPage whose text is Chinese on an English page is worse than no FAQPage: the schema contradicts the page, and the extractor notices when it lifts the quote.
Schema was 12 because it was zero
12 out of 100 is not a badly written schema, it is an absent one. There was no JSON-LD anywhere on the site. Every signal a machine could have used — that this is a person, that these pages are theirs, that this is an article rather than a listing — had to be inferred from prose.
The important part is not adding schema, it is making it one graph. Six disconnected blocks — Person here, WebSite there, TechArticle on each post — tell a machine about six strangers. A single @graph where every node points at the same @id for the person tells it about one entity with several faces.
So the homepage defines the person once, at https://me.tech-harry.top/#person, with name, job title, employer, sameAs links to GitHub, CSDN and the PyPI project. Every other page references that @id instead of repeating a name string. The blog index is a Blog whose blogPost entries point at the articles. Each article is a TechArticle with that person as author and publisher.
The GitHub side had a harder problem: GitHub emits no JSON-LD for repositories. That is a platform limit, not a mistake — you cannot add markup to github.com. The only way to make a repository machine-readable as a software entity is to host a page about it yourself. Hence a small docs site on GitHub Pages, where index.html declares SoftwareSourceCode plus SoftwareApplication with softwareVersion, and offers.price 0.
offers.price 0 is not a gimmick. It answers a question an AI cannot answer from prose: is this thing free. A tool page that says MIT licensed in paragraph nine gets filtered out of a free-tool recommendation; a page that declares price 0 in machine-readable form gets included.
Then FAQPage. Both posts are structured as question-then-answer, and neither was marked up as such, so Perplexity and AI Overviews had nothing to lift. Four questions per post, written as the queries people actually type: why does ARXML disagree with the DBC, how do you compare an AUTOSAR bit position with a DBC start bit, skill versus prompt. The schema took the easiest points in the whole audit — 12 to 90.
One constraint worth stating: the visible Q&A and the schema must have the same number of questions. I added a check that parses both and fails if they differ, then broke one on purpose to confirm it fails. Schema that describes content the page does not have is not an optimization, it is a lie a machine can catch.
s-maxage that did nothing
The site runs as a Cloudflare Worker, and the first version returned cache-control: no-store on everything. Every request rebuilt the page. TTFB was 2.1 to 3.0 seconds — slow for a static site, and slow matters: a crawler with a budget will come back less often.
The obvious fix is to send max-age=60, s-maxage=300 and let the edge cache it. I did that. Nothing happened. No cf-cache-status header appeared, TTFB did not move. The reason is that for a Worker response, s-maxage is advisory to the edge, not an instruction — the Worker is itself the origin, so there is no separate origin cache for the edge to consult.
What actually works is the Cache API: put the built response into caches.default explicitly, and design the key yourself. The key is the asset's content hash, which has a useful property — a new deployment changes the hash, so a stale page is not merely unlikely, it is unreachable.
Result: TTFB 2.1–3.0s down to 0.58–0.78s. This is the one place where a purely technical change moved a scoring dimension, because crawl budget is a technical-accessibility input.
Not by reading the code, and not by curl. Both would have shown the header and looked correct. I checked that cf-cache-status actually appears on a second request, and that the key changes when the asset changes. A caching change you have not observed hitting is a caching change you do not have.
A real bug that scored zero
The third round added an author card and FAQ blocks to both posts. The markup shipped, the deploy verified, the live pages had the new sections. And in the browser, the author card rendered as a block with no avatar, no border radius, wrong width — completely collapsed.
The cause was a cache interaction nobody had looked at. blog.css is served with max-age=86400, and every page referenced it by a bare relative path. New CSS classes go out, but a returning visitor keeps the old stylesheet for up to a day. The HTML is fresh, the CSS is stale, and the two disagree.
<!-- before: one URL, forever -->
<link rel="stylesheet" href="blog.css">
<!-- after: the URL changes when the file does -->
<link rel="stylesheet" href="blog.css?v=17a9da41d6">
The fix appends the stylesheet's own content hash at build time, then recomputes the page ETag. The one-day cache stays; a stale sheet becomes impossible. This is the standard pattern and I had not applied it, because the site is small enough that I had never hit it.
Here is the part that belongs in a post about GEO: this fix moved the score by zero points. It is not a technical-accessibility input, it is not citability, it is not schema. The audit does not care. It was still the most valuable change of the round, because it was the difference between a rendered page and a broken one.
The bug only exists when CSS changed and the visitor has a cache. curl has no cache, and the source HTML is correct either way. The only way to see it is to load the page in a browser and read the computed style of the new element. A lesson with teeth: after touching CSS, look at the rendered result, not at whether the class exists in the HTML.
The 17 points code cannot buy
After three rounds the blog is at 83 and the repository at 74. Here is where the remaining 17 points actually are, and this is the most useful chart in the whole exercise, because it is the one that says stop.

Entity and brand is missing 38 points. At a 25% weight that is 9.5 points of drag — more than citability, schema and technical combined, 6.05. And look at what that dimension measures: how many stars the repository has, whether a Wikidata entry exists, whether a LinkedIn profile exists, whether any third party has mentioned the thing. None of that is a property of my code.
The repository was created the day before this audit. Zero stars, zero forks, zero issues. That is not a signal failure, that is an accurate reading of a one-day-old project. You cannot trick it and you should not want to: the metric exists to stop generated content from ranking on nothing.
What I did do in that direction is all slow: published the package to PyPI so the project has an external identity besides a git URL; opened pull requests to three awesome-lists so other people's curated pages point at it; made the repository citeable with a CITATION.cff that states its scope boundary, because a judgeable scope gets cited more than a vague claim. Every one of those is a step where the outcome is not mine to control — a maintainer can simply not merge.
Code fixes took the blog from 52 to 83. The last 17 points are a distribution problem wearing a metrics costume. If you are doing this for a project nobody has heard of, expect the technical work to be the fast part and the recognition to be the slow part — and do not spend another week in the CSS when the score stopped moving.
The order I would use next time
-
Fetch the page the way a crawler does. Not with a browser, not with curl and a normal UA — with a non-JS fetch, and read only what comes back in the HTML. Half the problems in this audit are invisible from any other vantage point.
-
Separate two failures that feel identical. A crawler that cannot see your content and a crawler that sees it and does not care both show up as no traffic. The first is a build problem and takes an afternoon. The second is a content problem and takes months.
-
Rank fixes by weight times gap, not by how satisfying they are. Schema was the most fun and worth 20%. Brand was the least fun and worth 25% of the total. Doing the fun one first is fine — believing it is the important one is not.
-
Verify in the medium the claim lives in. A cache claim needs an observed cache hit. A rendering claim needs a browser. A schema claim needs the parser you are actually feeding, not a JSON linter — and a validated file can still describe content the page does not have.
-
Write down which points are not yours before you start. Otherwise you will keep opening the editor at 83 and wondering why the number does not move.
And a correction against myself, because it belongs here: the first version of my own round-three report said 82. The weights give 83.45. I had summed the weighted parts wrong — the same class of error as trusting a build that passes in the repository but was never installed in a clean environment. A score you did not recompute from its parts is a score you are guessing at.
Data and reproduction
| Item | Before | After |
|---|---|---|
| Blog score | 52 (C) | 83 (B) |
| — Technical | 73 | 94 |
| — Citability | 71 | 89 |
| — Structured data | 12 | 90 |
| — Entity & brand | 40 | 62 |
| Repo score | 54 (C) | 74 (B) |
| TTFB | 2.1–3.0s | 0.58–0.78s |
| English URLs | 0 | 4 |
| JSON-LD nodes | 0 | 6 types / 12 pages |
| FAQ questions | 0 | 12 (6 per language) |
Both sites are open source and the generators are in the repository, so these numbers can be checked rather than believed: the English mirrors are produced by a script, the charts above are drawn by another script from the audit numbers, and the deploy refuses to ship if any of the assertions fail — including the one that compares visible FAQ count against schema count, and the one that checks the stylesheet reference carries a content hash.
Common questions
How is GEO different from SEO?
SEO competes for a position in a result list; GEO competes for one sentence inside an answer. A search engine sends you a click — a generative engine reads the page, extracts a claim and cites it, and never sends the click. So the question stops being how do I rank and becomes what can be lifted off this page: does each paragraph carry a complete conclusion, is each fact a number someone can check, and is each entity stably identified.
Why could AI search not find my English content?
If the English only exists in JavaScript data attributes, the crawler never sees it. GPTBot, ClaudeBot and PerplexityBot do not execute JavaScript, so an English query matched nothing on the page. Google and Bing do render JavaScript, which is why the problem is invisible in traditional search. The fix is to materialise the English at build time into static pages, not to switch language at runtime.
Why does s-maxage do nothing on Cloudflare Workers?
Because for a Worker response, s-maxage is advice to the edge rather than an instruction, and the Worker is itself the origin — there is no separate origin cache for the edge to consult. Nothing happens: no cf-cache-status, no TTFB change. You have to call the Cache API and put the response into caches.default explicitly, designing the key yourself. Using the asset content hash as the key means a stale page is unreachable rather than merely unlikely.
Why does the score stop moving even after schema is fixed?
Because the largest weighted gap is entity and brand, at 25% weight: 38 missing points there drag 9.5 points off the total, more than the other three dimensions combined. It measures whether a third party says you exist — stars, a Wikidata entry, a LinkedIn profile, an external citation. None of those are properties of your code, so no amount of editing will move it.