DEV Community

Cover image for What I learned fixing in-category template convergence across three programmatic sites
MORINAGA
MORINAGA

Posted on

What I learned fixing in-category template convergence across three programmatic sites

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 fixed this 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 over 1,500 models. If 300 of them are tagged text-generation, then 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: every curated alternative page opened with the same "The main gap" sentence. Fifty-three of 54 comparison notes used identical phrase patterns, and fourteen used "[repo] is the closest OSS match" as a closing construction.
  • Indie games: roughly 60 of 120 game pages — those where the ETL fallback fired — had identical similar_games triples: "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>
Enter fullscreen mode Exit fullscreen mode

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, all starting with "The main gap." That's my fault from how I framed the original generation prompt: "describe the main difference" consistently produced "The main gap" as a sentence opener.

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 unique facts from each entry's own data fields and build a fact clause that appears only on that entry's page.

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. No two models have the same combination. 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`
  );
}
Enter fullscreen mode Exit fullscreen mode

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 80% of the 1,533 pages acquired at least one unique fact clause — not because 80% of models are especially distinctive, but because HuggingFace tag combinations are specific enough to differentiate most entries.

The remaining 20% are models with sparse tagging. They don't get a unique clause. 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];
Enter fullscreen mode Exit fullscreen mode

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:

  1. Static fallback strings in ETL generatorssimilar_games, good_for, avoid_if fields hardcoded to identical text when the AI call fails
  2. Prompt constructions that consistently produce the same openers — asking for "the main difference" returns "The main gap" every time
  3. 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. The four filters I apply when pulling HuggingFace models into the directory are the upstream gate: entries without enough metadata to differentiate shouldn't be indexed in the first place.

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. A batch generation pass runs once at ETL time, and the shared Haiku client with prompt caching makes that affordable. But 1,533 model pages would need 1,533 API calls every time the catalogue updates. The tag-mining approach runs at build time from already-fetched data with zero additional API cost. Claude handles entries where the data is too sparse; the template handles the rest.

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)