feat(seo): rewrite the C1 snippets to earn the click (W8) #113
No files matched your search
@@ -1726,3 +1726,35 @@ test('a school page on staging is noindexed too, not just the homepage', async (
|
|||||||
const res = await page.request.get(`/school/${first.urn}-x`);
|
const res = await page.request.get(`/school/${first.urn}-x`);
|
||||||
expect(res.headers()['x-robots-tag']).toContain('noindex');
|
expect(res.headers()['x-robots-tag']).toContain('noindex');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* W8 — the C1 pages must ship a description, and it must differentiate.
|
||||||
|
*
|
||||||
|
* Baseline was 0.43% CTR at position 6.1 on "compare school performance",
|
||||||
|
* against 9.16% for the brand query from the same neighbourhood. The SERP is
|
||||||
|
* owned by the DfE's own service, so a description that paraphrases it earns
|
||||||
|
* nothing. Google may rewrite a snippet, but it cannot use one we never sent.
|
||||||
|
*/
|
||||||
|
test('every C1 page ships a description, and none opens its title with the brand', async ({ page }) => {
|
||||||
|
for (const path of ['/', '/compare', '/rankings', '/admissions']) {
|
||||||
|
await page.goto(path);
|
||||||
|
|
||||||
|
const desc = await page.locator('meta[name="description"]').first()
|
||||||
|
.getAttribute('content');
|
||||||
|
expect(desc, `${path} must ship a description`).toBeTruthy();
|
||||||
|
expect(desc!.length, `${path} description too short to be worth reading`)
|
||||||
|
.toBeGreaterThan(100);
|
||||||
|
|
||||||
|
const title = await page.title();
|
||||||
|
expect(title.toLowerCase().startsWith('schoolcompare'),
|
||||||
|
`${path} spends its most valuable pixels on the brand`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the homepage snippet names what gov.uk does not publish', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const desc = await page.locator('meta[name="description"]').first()
|
||||||
|
.getAttribute('content');
|
||||||
|
// Admissions distance is the one fact the DfE service has no equivalent for.
|
||||||
|
expect(desc).toMatch(/close you had to live|distance/i);
|
||||||
|
});
|
||||||
@@ -51,3 +51,80 @@ describe('/compare indexability', () => {
|
|||||||
.toBe('https://www.schoolcompare.co.uk/compare');
|
.toBe('https://www.schoolcompare.co.uk/compare');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* W8 — snippet copy for the C1 cluster.
|
||||||
|
*
|
||||||
|
* The baseline (GSC, 16 months to 2026-08-20) showed these pages ranking on
|
||||||
|
* page one and converting at a tenth of the normal rate: "compare school
|
||||||
|
* performance" at position 6.1 with 0.43% CTR, against 9.16% for the brand
|
||||||
|
* query from the same neighbourhood. The SERP is dominated by the DfE's own
|
||||||
|
* "Compare school performance" service, so the job of this copy is to say
|
||||||
|
* what that service does not offer, without losing intent match on the title.
|
||||||
|
*
|
||||||
|
* These tests guard the mechanics that make a snippet work — length, intent
|
||||||
|
* keyword, differentiator, no brand-first — not the exact wording, which
|
||||||
|
* should stay free to iterate.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Google truncates titles near 60 characters and descriptions near 155.
|
||||||
|
const TITLE_MAX = 60;
|
||||||
|
const DESC_MIN = 110;
|
||||||
|
const DESC_MAX = 155;
|
||||||
|
|
||||||
|
type Meta = { title?: unknown; description?: unknown };
|
||||||
|
const titleOf = (m: Meta): string => {
|
||||||
|
const t = m.title as string | { absolute?: string } | undefined;
|
||||||
|
return typeof t === 'string' ? t : (t?.absolute ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('C1 snippet copy', () => {
|
||||||
|
const pages: Array<[string, Meta, RegExp]> = [
|
||||||
|
['home', homeMetadata as Meta, /compare schools/i],
|
||||||
|
['rankings', rankingsMetadata as Meta, /league table/i],
|
||||||
|
['admissions', admissionsMetadata as Meta, /admission/i],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, meta, intent] of pages) {
|
||||||
|
it(`${name}: title carries the search intent and fits the SERP`, () => {
|
||||||
|
const t = titleOf(meta);
|
||||||
|
expect(t).toMatch(intent);
|
||||||
|
expect(t.length).toBeLessThanOrEqual(TITLE_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${name}: title does not open with the brand`, () => {
|
||||||
|
// The measured 0.43% CTR came from a brand-first title. The most
|
||||||
|
// valuable pixels go to the thing the searcher typed.
|
||||||
|
expect(titleOf(meta).toLowerCase().startsWith('schoolcompare')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${name}: description is long enough to be worth reading, short enough to survive`, () => {
|
||||||
|
const d = meta.description as string;
|
||||||
|
expect(d.length).toBeGreaterThanOrEqual(DESC_MIN);
|
||||||
|
expect(d.length).toBeLessThanOrEqual(DESC_MAX);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('the homepage description names what gov.uk does not publish', () => {
|
||||||
|
// Admissions distance is the one fact the DfE service has no equivalent
|
||||||
|
// for. If it ever leaves this description, the snippet is competing with
|
||||||
|
// gov.uk on gov.uk's own ground.
|
||||||
|
expect(homeMetadata.description).toMatch(/close you had to live|distance/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/compare targets the tool phrasing rather than repeating the homepage', () => {
|
||||||
|
// Two pages chasing one phrase is how a site competes with itself.
|
||||||
|
return compareMetadata({ searchParams: Promise.resolve({}) }).then((m) => {
|
||||||
|
expect(m.title).toMatch(/comparison tool/i);
|
||||||
|
expect(m.title).not.toBe(titleOf(homeMetadata as Meta));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no C1 page claims a school count that will drift', () => {
|
||||||
|
// The corpus moves with every data refresh; this repo has already shipped
|
||||||
|
// one copy bug of that kind ("three schools" against MAX_SCHOOLS = 5).
|
||||||
|
for (const [, meta] of pages) {
|
||||||
|
expect(meta.description as string).not.toMatch(/\b\d{2},\d{3}\b|\b\d{2},000\b/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,11 @@ import { AdmissionsView } from '@/components/AdmissionsView';
|
|||||||
export const dynamic = 'force-static';
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'School Admissions Guide',
|
// Deadlines and offer days are what gets searched, and what this page is
|
||||||
|
// genuinely best at — the countdowns are live.
|
||||||
|
title: { absolute: 'School Admissions Deadlines & Offer Days | schoolcompare' },
|
||||||
description:
|
description:
|
||||||
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.',
|
'Every key date for primary and secondary school admissions in England, with live countdowns to the application deadline and National Offer Day.',
|
||||||
alternates: { canonical: absoluteUrl('/admissions') },
|
alternates: { canonical: absoluteUrl('/admissions') },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ export async function generateMetadata(
|
|||||||
const { urns } = await searchParams;
|
const { urns } = await searchParams;
|
||||||
|
|
||||||
const base: Metadata = {
|
const base: Metadata = {
|
||||||
title: 'Compare Schools',
|
// Deliberately not the homepage's phrase. Two pages chasing "compare
|
||||||
|
// schools" is how a site competes with itself; this one takes the tool
|
||||||
|
// phrasing instead.
|
||||||
|
title: 'School Comparison Tool — Up to Five at Once | schoolcompare',
|
||||||
description:
|
description:
|
||||||
'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
|
'Put up to five English schools in one table: SATs and GCSE results against the England average, Ofsted grades, and the distance places were offered.',
|
||||||
keywords:
|
keywords:
|
||||||
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||||
alternates: { canonical: absoluteUrl('/compare') },
|
alternates: { canonical: absoluteUrl('/compare') },
|
||||||
|
|||||||
@@ -48,10 +48,11 @@ export const metadata: Metadata = {
|
|||||||
statusBarStyle: 'default',
|
statusBarStyle: 'default',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
default: 'schoolcompare | Compare School Performance',
|
default: 'Compare Schools Side by Side | schoolcompare',
|
||||||
template: '%s | schoolcompare',
|
template: '%s | schoolcompare',
|
||||||
},
|
},
|
||||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
description:
|
||||||
|
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
||||||
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
keywords: 'school comparison, KS2 results, KS4 results, primary school, secondary school, England schools, SATs results, GCSE results',
|
||||||
authors: [{ name: 'schoolcompare' }],
|
authors: [{ name: 'schoolcompare' }],
|
||||||
manifest: '/manifest.json',
|
manifest: '/manifest.json',
|
||||||
@@ -61,16 +62,18 @@ export const metadata: Metadata = {
|
|||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL(SITE_URL),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: 'website',
|
type: 'website',
|
||||||
title: 'schoolcompare | Compare School Performance',
|
title: 'Compare Schools Side by Side | schoolcompare',
|
||||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
description:
|
||||||
|
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||||
url: SITE_URL,
|
url: SITE_URL,
|
||||||
siteName: 'schoolcompare',
|
siteName: 'schoolcompare',
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
// summary_large_image now that there is an image worth showing.
|
// summary_large_image now that there is an image worth showing.
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
title: 'schoolcompare | Compare School Performance',
|
title: 'Compare Schools Side by Side | schoolcompare',
|
||||||
description: 'Compare primary and secondary school SATs and GCSE performance across England',
|
description:
|
||||||
|
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -34,8 +34,21 @@ interface HomePageProps {
|
|||||||
* saying the brand twice.
|
* saying the brand twice.
|
||||||
*/
|
*/
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: { absolute: 'schoolcompare | Compare every school in England' },
|
/*
|
||||||
description: 'Search and compare school performance across England',
|
* Intent in the title, differentiator in the description.
|
||||||
|
*
|
||||||
|
* These queries are owned by the DfE's own "Compare school performance"
|
||||||
|
* service, and the old title — brand first, then a near-paraphrase of that
|
||||||
|
* service's name — gave a searcher no reason to pick us over it. It drew
|
||||||
|
* 0.43% CTR at position 6.1 while the brand query drew 9.16% from the same
|
||||||
|
* neighbourhood, so the ranking was never the problem.
|
||||||
|
*
|
||||||
|
* The title now matches what people type. The description carries the one
|
||||||
|
* fact gov.uk does not publish: how close you had to live to get a place.
|
||||||
|
*/
|
||||||
|
title: { absolute: 'Compare Schools Side by Side | schoolcompare' },
|
||||||
|
description:
|
||||||
|
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place. Free, no sign-up.',
|
||||||
// This page reads eleven search params. They filter a result set; they do
|
// This page reads eleven search params. They filter a result set; they do
|
||||||
// not make a new document. Collapsing every combination onto "/" stops the
|
// not make a new document. Collapsing every combination onto "/" stops the
|
||||||
// homepage competing with itself for its own head terms.
|
// homepage competing with itself for its own head terms.
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ interface RankingsPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'School Rankings',
|
// 'School Rankings' matched nothing anyone types. League tables is the
|
||||||
description: 'Top-ranked schools by SATs and GCSE performance across England',
|
// phrase parents actually search, and it spikes each results day.
|
||||||
|
title: { absolute: 'Primary & Secondary School League Tables | schoolcompare' },
|
||||||
|
description:
|
||||||
|
'Rank English schools by SATs results, GCSEs, Progress 8 or Attainment 8, and filter by local authority or year. Built from the DfE’s own figures.',
|
||||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
||||||
// Param forms (?metric=&local_authority=&year=&phase=) collapse here for
|
// Param forms (?metric=&local_authority=&year=&phase=) collapse here for
|
||||||
// now. W3 replaces them with real indexable paths.
|
// now. W3 replaces them with real indexable paths.
|
||||||
|
|||||||
Reference in new issue
Block a user