CogniPrep has a page per assessment provider answering one question: can you cheat this test? 56 of them, and the answer on every single one is no.
That is the worst possible brief for a content cluster. 56 pages, one verdict, one call to action, and a vendor name as the only obvious variable. Written carelessly it is a doorway-page farm. Here is what we did instead, and the one thing we got wrong, which I only found by counting.
The rule that makes the pages different
The registry's own header comment states the decision the cluster is built on:
Whether an assessment can be cheated is a property of the instrument, not of
the company that bought it. Arctic Shores defeats a shortcut in exactly the
same way at Thales as it does at Capita, so a page per employer would be
dozens of restatements of one argument. Keying the cluster to the provider
means each page can explain a mechanism that is genuinely its own.
That is the whole trick. Key the cluster to the thing that actually varies. A page per employer would have produced 132 pages with nothing to say; a page per instrument produces 56 pages where the reason a shortcut fails is different every time, because a behavioural profile with no marks, a timed adaptive paper and a forced-choice personality inventory fail a cheater for genuinely unrelated reasons.
Each entry in the registry carries seven strings: the meta title, the description, the standfirst, the verdict line, the one-line mechanism, the hub card blurb, and the name candidates actually type into a search box. I checked all seven across all 56 entries:
field values distinct
metaTitle 56 56
metaDescription 56 56
intro 56 56
verdict 56 56
defence 56 56
cardBlurb 56 56
searchName 56 56
Zero repeats in 392 strings. Meta titles run from 30 to 47 characters and not one is over 60, so none of them is truncated in a result. The page bodies are 56 separate page.tsx files, 8,911 lines in total, from 117 lines for the shortest to 271 for the longest.
Measuring it from the outside
Counting distinct strings in your own registry proves you did not copy and paste. It does not prove the published pages read as different documents, because the bodies could still be one template with the nouns swapped.
So measure the deployed HTML. Strip the tags, cut the text into overlapping eight-word windows, and intersect them pairwise:
def shingles(text, n=8):
words = [w.lower() for w in re.findall(r"[A-Za-z']+", text)]
return set(tuple(words[i:i+n]) for i in range(len(words) - n + 1))
Across five pages in the cluster (shl, aon, hogan, usps and train-driver), each 1,361 to 1,806 words:
aon vs hogan 15.0% of the first page's shingles
aon vs shl 20.4%
aon vs train-driver 21.3%
hogan vs usps 19.3%
train-driver vs usps 12.0%
12 to 21 percent, and the floor is the interesting number. No pair in the cluster shares fewer than 210 shingles. To find out what that floor is made of, I ran the same comparison between a cheating page and an unrelated page on the site: 162 shared shingles, with no subject matter in common at all. So 162 of the floor is the header, navigation and footer, and the remaining 48 or so is the cluster's own shared furniture, the verdict banner and the call to action. Subtract both and the bodies overlap by a few percent.
Eight words is the right window size for this. Four-word windows match on ordinary English and tell you nothing; sixteen only catch verbatim paragraphs. Eight catches a reused sentence shape, which is the thing a template produces and a writer does not.
The part we got wrong
Every page ends with three related pages, chosen by a function with a limit = 3 default:
const family = Object.values(FAMILIES).find((m) => m.includes(provider)) ?? [];
const ordered = [
...family.filter((p) => p !== provider),
...CHEATING_GUIDES.map((g) => g.provider)
.filter((p) => p !== provider && !family.includes(p)),
];
return ordered.slice(0, limit).map((p) => BY_PROVIDER[p]).filter(Boolean);
Family first, then everything else in canonical order. The families themselves are carefully argued, with comments explaining why a given provider sits with the reasoning group rather than the speeded one. The sizes are the problem:
reasoning 30 members
dutch 9
speeded 8
gamified 4
no family 5
A 30-member family and three slots means the three slots are always the same three members. Taken across all 56 pages:
distinct related-link trios 17
pages showing shl / faa-atsa / sova 27
inbound related links to shl 29
providers never offered as related 37
Two thirds of the family sees the same trio, and 37 of the 56 pages are never recommended by any page in their own cluster. You can watch it happen:
for p in train-driver usa-hire usps hogan; do
echo "--- $p"
curl -s "https://cogniprep.app/cheating/$p" | grep -o 'href="/cheating/[a-z0-9-]*"'
done
train-driver offers shl, faa-atsa and sova. So does almost every other page in the reasoning family. usps and hogan, which belong to no family, get the first three entries in registry order instead, which is an even more arbitrary answer dressed up as a recommendation.
Nothing is broken. Every link is valid, every target is a real page, the typecheck is clean and the pages render. The function does exactly what it says. It just turns a 56-node graph into a star with three centres, and no test asserts anything about the shape of the output, so it went unnoticed for as long as the families stayed small.
The fix is to deal from the family rather than slicing it: order the candidates by something page-specific, rotate so each page appears in a bounded number of other pages' slots, and then assert the distribution in a test rather than the validity of the links. Checking that the links resolve was never the hard part.
Two smaller things the count turned up
The word fourteen appears three times in that file's comments, describing the cluster's size. There are 56 pages. The comments were written when 14 providers were live and they were accurate then, which is the problem with counts in prose: they are facts with no test attached. The comments that survived are the ones stating a rule rather than a number.
And the derived list is built like this:
export const CHEATING_GUIDES = ALL_PROVIDERS.map((provider) =>
GUIDES.find((guide) => guide.provider === provider)
).filter((guide): guide is CheatingGuide => Boolean(guide));
Adding a provider without writing its guide does not fail a build. It produces one fewer page, silently, and the .filter makes the types work out perfectly. What saves us is a per-provider test asserting that the provider appears everywhere it should, which is the only reason that line is safe to keep.
If you have a cluster like this
- Key it to whatever genuinely varies, and if nothing does, write one page.
- Generate the metadata from one place so a canonical cannot drift, and hand-write every sentence a reader sees.
- Measure the published pages, not the source: eight-word shingles against the live HTML, and expect your chrome to be the floor.
- Test the shape of your internal links, not just their validity. Valid links pointing at three pages is the failure mode that looks like success.
The hub is at cogniprep.app/cheating, and the shingle script above runs against it unchanged.
Top comments (0)