Conclusion first: pipeline-type branching — writing different prose for text-generation models versus image models — produces unique pages between pipeline categories but not within them. All 300+ text-generation model pages on my AI tools directory were structurally identical. Three techniques cut this back a long way across all three sites without rewriting anything by hand in bulk or fabricating data: name interpolation in boilerplate, rewriting comparison notes to vary entry angles, and mining per-entry metadata tags to generate unique fact clauses.
Why pipeline branching isn't enough
When I first rebuilt slug pages after AdSense flagged them for scaled content abuse, the strategy was to generate different prose blocks depending on the model's pipeline_tag. A text-generation model page describes a different use case than an image-classification page. That's real differentiation.
The problem is that pipeline_tag is a category, not an identity. The AI tools directory indexes about 1,400 models. 302 of them are tagged text-generation, and every text-generation page gets the same branched copy. The pages look different from an image model page, but they look identical to each other. A crawler sampling at scale sees that.
The same structural problem appeared in two other forms across the other two sites:
- OSS alternatives: the curated alternative pages converged on two constructions. Fifty-three of the 54 comparison notes carried a sentence beginning "The main gap", and fourteen used a "[repo] is the closest / most direct OSS alternative" phrasing — twelve of those in the note's first sentence.
-
Indie games: roughly 60 of 120 game pages — those where the ETL fallback fired — had identical
similar_gamestriples: "Other titles in the same genre", "Popular indie releases", "Games with similar mechanics". Byte-for-byte.
Three sites, three different manifestations of the same underlying mistake: shared prose without per-item differentiation.
Technique 1: Name interpolation in boilerplate
The quickest fix with the lowest ceiling: inject the item name into shared text that was previously static.
On the OSS alternatives site, two paragraphs appeared identically across all 32 curated SaaS pages — a decision tree intro and a license notes intro. Neither was wrong. Both were just the same HTML block on every page.
The fix was a one-line change in the Astro component:
<!-- Before -->
<p>The sections below help you weigh the tradeoffs between options in this category.</p>
<!-- After -->
<p>The sections below help you weigh the tradeoffs between {saas.name} alternatives specifically.</p>
Every page now contains the SaaS product name in that sentence. The information is the same, but the rendered HTML differs between pages. Combined with per-alternative comparison tables and FAQ blocks, this eliminates the byte-identical boilerplate.
The limit is real though: name substitution is shallow. A crawler doing semantic analysis detects proper noun substitution in otherwise identical sentences. This technique is useful for structural prose that's genuinely generic — policy text, navigation, section intros — but it doesn't create content that differs in substance. Do it, but treat it as the floor, not the solution.
Technique 2: Vary comparison note entry angles
Fifty-four comparison notes, each describing how one OSS tool differs from the SaaS it replaces — and 53 of them contained a sentence beginning "The main gap". Not as the opening sentence — none of them opened with it. The notes started by naming the alternatives ("NocoDB is the closest OSS alternative…") and then pivoted to a "The main gaps vs. Airtable:" sentence for the differences. That's my fault from how I framed the original generation prompt: "describe the main difference" reliably produced "The main gap" as the pivot sentence.
The rewrite dropped both formulaic constructions across all 54 entries. The replacement strategy was to vary five things: entry point, sentence order, vocabulary, focus angle, and sentence length. Specifically:
- Entry angles: lead with a feature difference / lead with an operational concern / lead with migration context / lead with the license situation / lead with community scale
- Focus angles: functional capability / self-hosting complexity / commercial restrictions / project maturity
Two alternatives in the same category — say, two self-hosted analytics tools for Plausible — now open differently, focus on different considerations, and read differently even when they cover similar ground.
The constraint I kept: every sentence had to cite real data. If a note mentions a license term, that term is in the actual SPDX field. If it references project scale, that's from the star count. Nothing invented.
This is more labor than name interpolation — 54 notes needed individual passes — but the result is substantively different content. It's also the technique that's hardest to automate, because "vary the entry angle" requires understanding what the most salient fact about each entry actually is.
Technique 3: Mine per-entry metadata for unique fact clauses
The approach with the highest ceiling: extract facts from each entry's own data fields and build a fact clause specific to that entry.
For the AI tools directory, every HuggingFace model card has a tags array containing things like arxiv:2305.12345, base_model:mistralai/Mistral-7B-v0.1, gguf, int4, multilingual, deploy:spaces. Most models have a distinctive combination, though not all of them: across the 1,431 model records, 87 tag combinations are shared by two or more models (209 models total, with the biggest cluster at 7). The Astro page component frontmatter now parses these tags into conditional sentence generators:
const arxivIds = model.tags
.filter((t) => /^arxiv:/i.test(t))
.map((t) => t.replace(/^arxiv:/i, "").trim());
const hasGguf = model.tags.some((t) => /gguf/i.test(t));
const baseModelTag = model.tags.find((t) => /^base_model:/i.test(t));
const modelFactClauses: string[] = [];
if (arxivIds.length > 0) {
modelFactClauses.push(
`it references a paper (arXiv:${arxivIds[0]}), so the training recipe is documented rather than folklore`
);
}
if (hasGguf) {
modelFactClauses.push(
`a GGUF build is published, meaning you can run ${model.name} through llama.cpp on CPU or Apple Silicon without a Python stack`
);
}
Each clause is gated on a tag actually present in the model's data. A model without a GGUF tag doesn't get the GGUF sentence. About 88% of the 1,431 model records — 1,256 of them — acquired at least one fact clause. (The build reports 1,533 pages for this site, but that count includes category and index routes — the model pages are the 1,431.)
"At least one clause" is not the same as "a clause nobody else has", and I want to be honest about the gap. Only 904 distinct clause sets get rendered across those 1,256 pages: 468 models still share their clause set with at least one other model, and the single worst cluster is 110 models that all render nothing but the same one-click-Azure-deploy sentence. So this technique moved the needle a long way, but it did not finish the job — HuggingFace tag combinations are specific enough to differentiate most entries, not every entry.
The remaining 12% — 175 models — have tagging too sparse to trigger any clause at all. They don't get one. That's the correct outcome — better to say nothing than to invent a differentiator.
For the indie-games site, the fix was different: drop the fallback strings from the ETL generator entirely and handle the no-similar-games case in the page component using real fields:
const PLACEHOLDER_SIMILAR = [
"Other titles in the same genre",
"Popular indie releases",
"Games with similar mechanics",
];
const hasRealSimilar =
game.similar_games.length > 0 &&
!game.similar_games.every((s) => PLACEHOLDER_SIMILAR.includes(s));
// When no real similar list exists, build varied fact-only text from game fields.
// Rotate entry angle by appid for deterministic variation across pages.
const similarFallbackAngles = [genreLed, developerLed, yearLed];
const similarFallbackText =
similarFallbackAngles[game.appid % similarFallbackAngles.length];
Using game.appid % angles.length gives deterministic rotation without runtime randomness. The same game always gets the same entry angle; different games with different appids get different angles even within the same genre bucket.
What I should have built into the ETL from day one
Looking back across three rounds of humanization work, the pattern is consistent: every piece of shared prose becomes a convergence risk when it doesn't interpolate per-item fields.
The three places this bit me:
-
Static fallback strings in ETL generators —
similar_games,good_for,avoid_iffields hardcoded to identical text when the AI call fails - Prompt constructions that consistently produce the same phrasing — asking for "the main difference" returned "The main gap" in 53 of 54 notes
- Shared Astro prose blocks without per-item interpolation — boilerplate paragraphs copied verbatim to every page
The three-tier content quality approach handles the generated content layer, but the static layer — prose written directly into the page component — needs its own audit: every paragraph that doesn't interpolate at least one per-item field is a convergence candidate.
What I'd do differently: after generating any ETL category, build two same-category pages and diff the rendered HTML. Any shared text block longer than a navigation element is boilerplate worth checking before launch. Catching this at initial design is far cheaper than rewriting 54 comparison notes post-hoc. The noindex gate buys time while you fix it, but it's a deferral.
Limits
These techniques only work when there's real per-item data to mine. A model with no tags, a game with no reviews, a SaaS with no GitHub repository — those pages are thin regardless of how varied the boilerplate is. I don't currently have an upstream gate for that — fetch-models.ts calls the HuggingFace list endpoint sorted by downloads and inserts every record that comes back with an id, no metadata filtering. What actually keeps the thinnest pages out of the index is downstream: the model page sets noindex when the entry still carries the default template pros. The selection criteria I've written about belong in the ETL, and moving them there is the fix I still owe this pipeline.
The data-mining approach also doesn't produce the same quality as hand-edited editorial copy. It produces per-page uniqueness derived from public data, which is a different thing. The goal isn't to simulate human curation; it's to ensure no two pages are byte-identical when the underlying entries are genuinely different.
FAQ
Why not generate unique prose for every page with Claude Haiku?
Cost and build frequency. I removed ANTHROPIC_API_KEY from the refresh pipeline on May 2 — the nightly ETL now fills new entries with built-in fallback templates at zero API cost, and a weekly polish routine running on my Claude Code subscription upgrades fallback entries to real content. The shared Haiku client with prompt caching I built earlier isn't in that path anymore. Paying per call wouldn't scale here anyway: ~1,400 model entries would mean ~1,400 API calls every time the catalogue updates. The tag-mining approach runs at build time from already-fetched data with no API involved at all, which is why it's the layer doing the differentiation work.
Does name interpolation in boilerplate actually matter?
It removes byte-identical blocks from rendered HTML. Whether a quality scorer treats proper-noun substitution as meaningful differentiation or detects the pattern is unknowable without access to the scoring function. It's the weakest of the three techniques — but it's also free and takes 10 minutes to implement. Do it and move on to the more substantive fixes.
How do I detect convergence before a site goes live?
Build two same-category pages and compare the rendered HTML output. Any shared substring over roughly 80 characters that isn't a navigation element or a script block is a boilerplate candidate. There's no packaged tool for this; I run a quick Node script over the dist/ output. The point is to do it before the first index request, not after.
What's the difference between this and the pipeline-branching approach?
Pipeline branching segments pages by category — text-generation versus image versus audio. Within a category, all pages used the same branch copy. The techniques here operate within a category, using per-item fields to differentiate entries that share a pipeline type. They're complementary layers, not alternatives.
Aren't FAQ sections also templated?
Yes. The JSON-LD FAQ blocks need per-item grounding the same way the prose does — questions should reference the entry name or a field-specific detail. A generic "What is this? / Is it free?" FAQ is as bad as any other boilerplate. Each FAQ entry should be gated on real data.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)