433 lines
15 KiB
JavaScript
433 lines
15 KiB
JavaScript
import { createServer } from 'http';
|
|
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
|
|
import { join, extname, dirname } from 'path';
|
|
import { launch } from 'puppeteer';
|
|
|
|
const DIST_DIR = join(import.meta.dirname, '..', 'dist');
|
|
const INDEX_PATH = join(DIST_DIR, 'index.html');
|
|
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
|
|
const OG_PLACEHOLDER = '<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__"/>';
|
|
|
|
const ROUTES = [
|
|
{
|
|
path: '/',
|
|
output: 'index.html',
|
|
title: 'Perfect Postcode - Find where to buy before browsing listings',
|
|
description:
|
|
'Search every postcode by budget, commute, schools, safety, noise, broadband, prices and more. Build a better home-buying shortlist before viewings.',
|
|
},
|
|
{
|
|
path: '/learn',
|
|
output: 'learn/index.html',
|
|
title: 'How Perfect Postcode works - Data sources, FAQ and support',
|
|
description:
|
|
'Learn how Perfect Postcode combines property prices, EPC records, travel times, crime, schools, broadband, noise, amenities and open data for postcode research.',
|
|
},
|
|
{
|
|
path: '/pricing',
|
|
output: 'pricing/index.html',
|
|
title: 'Perfect Postcode pricing - Lifetime property search map access',
|
|
description:
|
|
'Get lifetime access to the postcode property search map for England, including filters, saved searches, exports, and future data updates.',
|
|
},
|
|
{
|
|
path: '/property-price-map',
|
|
output: 'property-price-map/index.html',
|
|
title: 'Property price map for England - Compare postcodes before viewing',
|
|
description:
|
|
'Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.',
|
|
},
|
|
{
|
|
path: '/postcode-property-search',
|
|
output: 'postcode-property-search/index.html',
|
|
title: 'Postcode property search - Find areas that match your criteria',
|
|
description:
|
|
'Search every postcode by budget, property type, floor area, tenure, commute, schools, crime, broadband, noise, parks and local amenities.',
|
|
},
|
|
{
|
|
path: '/commute-property-search',
|
|
output: 'commute-property-search/index.html',
|
|
title: 'Commute property search - Find places to live by travel time',
|
|
description:
|
|
'Filter postcodes by commute time, then compare price, schools, safety, broadband, road noise, parks and property data on one map.',
|
|
},
|
|
{
|
|
path: '/school-property-search',
|
|
output: 'school-property-search/index.html',
|
|
title: 'School property search - Compare postcodes for family moves',
|
|
description:
|
|
'Compare nearby schools, property size, prices, parks, safety, commute and local amenities before building a viewing shortlist.',
|
|
},
|
|
{
|
|
path: '/postcode-checker',
|
|
output: 'postcode-checker/index.html',
|
|
title: 'Postcode checker - Property, crime, broadband, noise and schools',
|
|
description:
|
|
'Check postcode-level property prices, EPC data, crime, broadband, road noise, schools, council tax, amenities and travel-time context.',
|
|
},
|
|
{
|
|
path: '/property-search/birmingham',
|
|
output: 'property-search/birmingham/index.html',
|
|
title: 'Birmingham property search - Compare postcodes by price and commute',
|
|
description:
|
|
'Use postcode-level data to compare Birmingham property prices, commute trade-offs, schools, crime, broadband and local amenities before viewings.',
|
|
},
|
|
{
|
|
path: '/property-search/manchester',
|
|
output: 'property-search/manchester/index.html',
|
|
title: 'Manchester property search - Compare postcodes before viewing',
|
|
description:
|
|
'Compare Manchester-area postcodes by budget, commute, property type, schools, broadband, crime, noise and amenities before booking viewings.',
|
|
},
|
|
{
|
|
path: '/property-search/bristol',
|
|
output: 'property-search/bristol/index.html',
|
|
title: 'Bristol property search - Compare postcodes by commute and price',
|
|
description:
|
|
'Compare Bristol postcodes by price, commute, property size, schools, broadband, crime, road noise, parks and amenities before viewings.',
|
|
},
|
|
{
|
|
path: '/data-sources',
|
|
output: 'data-sources/index.html',
|
|
title: 'Perfect Postcode data sources - Property, schools, commute and local context',
|
|
description:
|
|
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.',
|
|
},
|
|
{
|
|
path: '/methodology',
|
|
output: 'methodology/index.html',
|
|
title: 'Perfect Postcode methodology - How to interpret postcode property data',
|
|
description:
|
|
'Understand how to use postcode filters, property estimates, travel-time data, school context and local signals as a home-buying shortlist tool.',
|
|
},
|
|
{
|
|
path: '/privacy-security',
|
|
output: 'privacy-security/index.html',
|
|
title: 'Perfect Postcode privacy and security - Saved searches and account data',
|
|
description:
|
|
'Learn how Perfect Postcode treats saved searches, account data and property research workflows with privacy and security in mind.',
|
|
},
|
|
];
|
|
|
|
const FAQ_SCHEMA_ITEMS = [
|
|
{
|
|
question: 'Where should I look once the obvious areas are too expensive?',
|
|
answer:
|
|
'Set your budget, property type, floor area, commute, schools, crime, noise, broadband, parks, and other must-haves. The map removes postcodes that fail those tests, so overlooked areas can surface before you start searching listings.',
|
|
},
|
|
{
|
|
question: 'What should I do when my search returns too many or too few areas?',
|
|
answer:
|
|
'Start with hard limits, then colour the map by a trade-off such as price per sqm, road noise, school score, or commute time. If the map gets too narrow, relax one slider and you can see exactly which compromise opens up more options.',
|
|
},
|
|
{
|
|
question: 'How are the travel times calculated?',
|
|
answer:
|
|
'Travel times are precomputed with Conveyal R5, a routing engine used for transport analysis. For each supported destination we route to reachable postcodes over the street and transit network, then store sparse postcode travel-time files for car, cycling, walking, and public transport.',
|
|
},
|
|
{
|
|
question: 'How does the estimated current price algorithm work?',
|
|
answer:
|
|
'The estimate starts with the last HM Land Registry sale price, adjusts it to current-market terms using repeat-sales modelling and fallback models, then blends that result with a nearest-neighbour estimate from nearby, recently sold, same-type homes.',
|
|
},
|
|
{
|
|
question: 'Should I use this before or after checking Rightmove?',
|
|
answer:
|
|
'Use Perfect Postcode before and alongside listing portals. Rightmove, Zoopla, and OnTheMarket are still where you check live availability, photos, agent contact, viewings, and alerts.',
|
|
},
|
|
];
|
|
|
|
const MIME_TYPES = {
|
|
'.html': 'text/html',
|
|
'.js': 'application/javascript',
|
|
'.css': 'text/css',
|
|
'.json': 'application/json',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.mp4': 'video/mp4',
|
|
'.webm': 'video/webm',
|
|
'.svg': 'image/svg+xml',
|
|
'.ico': 'image/x-icon',
|
|
};
|
|
|
|
function escapeAttr(value) {
|
|
return value
|
|
.replaceAll('&', '&')
|
|
.replaceAll('"', '"')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>');
|
|
}
|
|
|
|
function routeUrl(pathname) {
|
|
return `${PUBLIC_URL}${pathname === '/' ? '/' : pathname}`;
|
|
}
|
|
|
|
function jsonLd(data) {
|
|
return `<script type="application/ld+json">${JSON.stringify(data).replaceAll(
|
|
'<',
|
|
'\\u003c'
|
|
)}</script>`;
|
|
}
|
|
|
|
function structuredDataForRoute(route) {
|
|
const url = routeUrl(route.path);
|
|
const base = [
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'WebPage',
|
|
'@id': `${url}#webpage`,
|
|
url,
|
|
name: route.title,
|
|
description: route.description,
|
|
isPartOf: { '@id': `${PUBLIC_URL}/#website` },
|
|
},
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'BreadcrumbList',
|
|
itemListElement: [
|
|
{
|
|
'@type': 'ListItem',
|
|
position: 1,
|
|
name: 'Perfect Postcode',
|
|
item: `${PUBLIC_URL}/`,
|
|
},
|
|
...(route.path === '/'
|
|
? []
|
|
: [
|
|
{
|
|
'@type': 'ListItem',
|
|
position: 2,
|
|
name: route.title.split(' - ')[0],
|
|
item: url,
|
|
},
|
|
]),
|
|
],
|
|
},
|
|
];
|
|
|
|
if (route.path === '/') {
|
|
base.push(
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Organization',
|
|
'@id': `${PUBLIC_URL}/#organization`,
|
|
name: 'Perfect Postcode',
|
|
url: `${PUBLIC_URL}/`,
|
|
logo: `${PUBLIC_URL}/favicon.svg`,
|
|
},
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'WebSite',
|
|
'@id': `${PUBLIC_URL}/#website`,
|
|
name: 'Perfect Postcode',
|
|
url: `${PUBLIC_URL}/`,
|
|
publisher: { '@id': `${PUBLIC_URL}/#organization` },
|
|
},
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'SoftwareApplication',
|
|
name: 'Perfect Postcode',
|
|
applicationCategory: 'BusinessApplication',
|
|
operatingSystem: 'Web',
|
|
url: `${PUBLIC_URL}/`,
|
|
description: route.description,
|
|
}
|
|
);
|
|
}
|
|
|
|
if (route.path === '/learn') {
|
|
base.push({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'FAQPage',
|
|
mainEntity: FAQ_SCHEMA_ITEMS.map((item) => ({
|
|
'@type': 'Question',
|
|
name: item.question,
|
|
acceptedAnswer: {
|
|
'@type': 'Answer',
|
|
text: item.answer,
|
|
},
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (route.path === '/pricing') {
|
|
base.push({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Product',
|
|
name: 'Perfect Postcode lifetime access',
|
|
description: route.description,
|
|
brand: { '@type': 'Brand', name: 'Perfect Postcode' },
|
|
});
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
function updateHead(indexHtml, route) {
|
|
const structuredData = structuredDataForRoute(route).map(jsonLd).join('\n ');
|
|
return indexHtml
|
|
.replace(/<title>.*?<\/title>/, `<title>${escapeAttr(route.title)}</title>`)
|
|
.replace(
|
|
/<meta name="description" content="[^"]*" ?\/?>/,
|
|
`<meta name="description" content="${escapeAttr(route.description)}" />`
|
|
)
|
|
.replace(OG_PLACEHOLDER, `${OG_PLACEHOLDER}\n ${structuredData}`);
|
|
}
|
|
|
|
function cleanBaseIndexHtml(indexHtml) {
|
|
const withoutStructuredData = indexHtml.replace(
|
|
/<script type="application\/ld\+json">.*?<\/script>\s*/gs,
|
|
''
|
|
);
|
|
const rootStart = withoutStructuredData.indexOf('<div id="root">');
|
|
const bodyEnd = withoutStructuredData.indexOf('</body>', rootStart);
|
|
|
|
if (rootStart === -1 || bodyEnd === -1) {
|
|
return withoutStructuredData;
|
|
}
|
|
|
|
return `${withoutStructuredData.slice(0, rootStart)}<div id="root"></div>${withoutStructuredData.slice(bodyEnd)}`;
|
|
}
|
|
|
|
function startServer() {
|
|
return new Promise((resolve) => {
|
|
const server = createServer((req, res) => {
|
|
const url = new URL(req.url, 'http://localhost');
|
|
let filePath = join(DIST_DIR, url.pathname === '/' ? 'index.html' : url.pathname);
|
|
|
|
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
// SPA fallback
|
|
filePath = INDEX_PATH;
|
|
}
|
|
|
|
const ext = extname(filePath);
|
|
const mime = MIME_TYPES[ext] || 'application/octet-stream';
|
|
const content = readFileSync(filePath);
|
|
res.writeHead(200, { 'Content-Type': mime });
|
|
res.end(content);
|
|
});
|
|
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const port = server.address().port;
|
|
resolve({ server, port });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function prerender() {
|
|
console.log('Starting prerender...');
|
|
|
|
const { server, port } = await startServer();
|
|
console.log(`Static server on port ${port}`);
|
|
|
|
const browser = await launch({
|
|
headless: true,
|
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
});
|
|
|
|
try {
|
|
const baseIndexHtml = cleanBaseIndexHtml(readFileSync(INDEX_PATH, 'utf-8'));
|
|
|
|
for (const route of ROUTES) {
|
|
const page = await browser.newPage();
|
|
|
|
// Intercept API requests to prevent real fetches and retry loops.
|
|
await page.setRequestInterception(true);
|
|
page.on('request', (req) => {
|
|
const url = req.url();
|
|
if (url.includes('/api/features')) {
|
|
req.respond({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ groups: [] }),
|
|
});
|
|
} else if (url.includes('/api/poi-categories')) {
|
|
req.respond({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ groups: [] }),
|
|
});
|
|
} else if (url.includes('/api/pricing')) {
|
|
req.respond({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
licensed_count: 120,
|
|
current_price_pence: 999,
|
|
tiers: [
|
|
{ up_to: 50, price_pence: 99, slots: 50 },
|
|
{ up_to: 150, price_pence: 999, slots: 100 },
|
|
{ up_to: 250, price_pence: 2999, slots: 100 },
|
|
{ up_to: 350, price_pence: 4999, slots: 100 },
|
|
{ up_to: null, price_pence: 9999, slots: 0 },
|
|
],
|
|
}),
|
|
});
|
|
} else if (url.includes('/api/')) {
|
|
req.respond({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: '{}',
|
|
});
|
|
} else {
|
|
req.continue();
|
|
}
|
|
});
|
|
|
|
await page.goto(`http://127.0.0.1:${port}${route.path}`, {
|
|
waitUntil: 'networkidle0',
|
|
timeout: 30000,
|
|
});
|
|
|
|
await page.waitForSelector('h1', { timeout: 10000 });
|
|
|
|
// Extract and clean the rendered HTML.
|
|
const html = await page.evaluate(() => {
|
|
const root = document.getElementById('root');
|
|
if (!root) return '';
|
|
|
|
// Strip fade-in-visible classes (added by IntersectionObserver effects).
|
|
root.querySelectorAll('.fade-in-visible').forEach((el) => {
|
|
el.classList.remove('fade-in-visible');
|
|
});
|
|
|
|
// Clean canvas elements (dimensions set by ResizeObserver effect).
|
|
root.querySelectorAll('canvas').forEach((canvas) => {
|
|
canvas.removeAttribute('width');
|
|
canvas.removeAttribute('height');
|
|
canvas.style.removeProperty('width');
|
|
canvas.style.removeProperty('height');
|
|
});
|
|
|
|
return root.innerHTML;
|
|
});
|
|
|
|
if (!html || html.length < 100) {
|
|
throw new Error(`Prerender produced too little HTML for ${route.path}`);
|
|
}
|
|
|
|
const updated = updateHead(baseIndexHtml, route).replace(
|
|
'<div id="root"></div>',
|
|
`<div id="root" data-prerender-path="${escapeAttr(route.path)}">${html}</div>`
|
|
);
|
|
|
|
if (updated === baseIndexHtml) {
|
|
throw new Error('Could not find <div id="root"></div> in index.html');
|
|
}
|
|
|
|
const outputPath = join(DIST_DIR, route.output);
|
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
writeFileSync(outputPath, updated);
|
|
await page.close();
|
|
console.log(`Prerendered ${route.path} (${html.length} chars) into ${route.output}`);
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
server.close();
|
|
}
|
|
}
|
|
|
|
prerender().catch((err) => {
|
|
console.error('Prerender failed:', err);
|
|
process.exit(1);
|
|
});
|