← back to Beverlyhillsvideos
build.mjs
617 lines
// Build beverlyhillsvideos.com — a Beverly Hills news/blog. Wraps each
// content/*.html fragment in a shared editorial template + generates the
// homepage, article pages, and an AdSense-compliant privacy policy.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
const BRAND = 'Beverly Hills Videos';
const DOMAIN = 'beverlyhillsvideos.com';
const ADSENSE_PUB = 'ca-pub-5278231299883833';
const ADSENSE = `<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${ADSENSE_PUB}" crossorigin="anonymous"></script>`;
// Manual in-content ad units. These run ALONGSIDE Auto Ads ("Both" mode) — the
// <head> loader already enables Auto Ads once you toggle it on in the AdSense dash.
// To activate these MANUAL units: create a Display ad unit in AdSense
// (Ads → By ad unit → Display), copy its numeric data-ad-slot, and paste it below.
// Paste ONE id into `default` and every slot goes live at once; give a slot its own
// id later to split reporting. Until an id is set, each slot renders a visible
// "Advertisement" placeholder so the ad SLOTS are on the page and you can see where
// they'll serve.
const ADSENSE_SLOTS = {
default: '8227434793', // "BHV Content" Display unit (responsive), created 2026-08-06
home: '',
article_top: '',
in_article: '',
article_foot: '',
};
const adSlot = (name = 'default') => {
// AdSense review remediation: rely on Auto ads' content-aware placement.
// Repeated manual units made editorial pages look ad-heavy/low-value.
return '';
};
const POSTS = [
{ slug: 'beverly-hills-real-estate', file: 'real-estate', kicker: 'REAL ESTATE',
title: 'Why Beverly Hills Real Estate Sits at the Summit of Global Luxury',
blurb: 'The neighborhoods, the buyers, and the scarcity that keeps 90210 at the top of the luxury market.' },
{ slug: 'rodeo-drive-guide', file: 'rodeo-drive', kicker: 'SHOPPING',
title: 'Rodeo Drive: A Guide to the World’s Most Famous Shopping Street',
blurb: 'Three iconic blocks, Two Rodeo’s cobblestones, and why it’s free to enjoy the world’s great retail theater.' },
{ slug: 'things-to-do-beverly-hills', file: 'things-to-do', kicker: 'CITY GUIDE',
title: 'Things to Do in Beverly Hills: A Local’s Guide',
blurb: 'Gardens, Greystone, canyon trails, and a century of architecture — a full day without a single price tag.' },
{ slug: 'dining-in-beverly-hills', file: 'dining', kicker: 'DINING',
title: 'Dining in Beverly Hills: From Power Lunches to Iconic Institutions',
blurb: 'The power lunch, the hotel dining rooms, the cuisines — and how to taste the city on any budget.' },
{ slug: 'beverly-hills-nightlife', file: 'nightlife', kicker: 'NIGHTLIFE',
title: 'Beverly Hills After Dark: Bars, Lounges & the Evening Scene',
blurb: 'Legendary hotel bars, the power-dinner-that-becomes-the-evening, and where locals go when the night gets loud.' },
{ slug: 'beverly-hills-wellness-spas', file: 'wellness', kicker: 'WELLNESS',
title: 'Wellness & Spas in Beverly Hills: The Capital of Self-Care',
blurb: 'Five-star hotel spas, the medspa capital, holistic recovery — and why the city itself is part of the cure.' },
{ slug: 'staying-fit-beverly-hills', file: 'fitness', kicker: 'FITNESS',
title: 'Staying Fit in Beverly Hills: Studios, Trainers & the Outdoors',
blurb: 'The boutique-studio scene, the private-training tradition, luxury gyms — and the free canyon trails at the city’s edge.' },
{ slug: 'beverly-hills-cosmetic-doctors', file: 'doctors', kicker: 'MEDICINE', directory: 'doctor',
title: 'The Cosmetic-Medicine Capital: Beverly Hills Doctors, Dermatologists & Medspas',
blurb: 'Why the world comes to Bedford Drive — the surgeons, dermatologists, and aesthetic clinics that made 90210 the capital of cosmetic medicine, with a full directory by specialty.' },
{ slug: 'beverly-hills-attorneys', file: 'lawyers', kicker: 'LAW', directory: 'lawyer',
title: 'The Attorneys of Beverly Hills: Inside the City’s Legal Power Center',
blurb: 'Entertainment deals, high-profile divorces, and boardroom battles — inside the Wilshire Boulevard firms, with a full directory by practice area.' },
];
// Real Beverly Hills local-business/institution videos, organized by category.
// Every ID oEmbed-verified (HTTP 200 + embeddable) 2026-08-05; non-embeddable /
// off-topic candidates (fake hotel handles, out-of-town listings) were dropped.
const LOVE = 'Love Beverly Hills', CITY = 'City of Beverly Hills';
const CH_URL = {
'Love Beverly Hills': 'https://www.youtube.com/@lovebeverlyhills',
'City of Beverly Hills': 'https://www.youtube.com/@cityofbeverlyhills',
'The Beverly Hills Estates': 'https://www.youtube.com/@thebeverlyhillsestates',
'Christophe Choo': 'https://www.youtube.com/@christophechoo',
'The Maybourne Beverly Hills': 'https://www.youtube.com/@TheMaybourneBeverlyHills',
'SIXTY Hotels': 'https://www.youtube.com/@SixtyHotels',
'Gagosian': 'https://www.youtube.com/@gagosian',
};
const VIDEO_CATEGORIES = [
{ name: 'Dining & Restaurants', blurb: 'Inside the city’s celebrated kitchens — MICHELIN dining, hotel chefs, and the “Farm to Table” series at Beverly Hills restaurants.', videos: [
{ id: 'shtyIgVvTlg', title: 'MICHELIN Dining in Beverly Hills', channel: LOVE },
{ id: 'Vd9UJFhD58o', title: 'Farm to Table · Viviane', channel: CITY },
{ id: 'I-DTjVxlC-k', title: 'Farm to Table · Il Pastaio', channel: CITY },
{ id: 'cEPQwTXZ2_c', title: 'Farm to Table with Freddy Vargas', channel: CITY },
{ id: 'b0_wkRl2Psg', title: 'Executive Chef Damon Gordon · The Maybourne Beverly Hills', channel: LOVE },
{ id: 'f_b7YbQ4yeg', title: 'Executive Sous Chef Matthew Sprister · The Beverly Hilton', channel: LOVE },
] },
{ name: 'Hotels & Spas', blurb: 'The five-star hotels that define Beverly Hills hospitality.', videos: [
{ id: '2JEIBvlX_Ws', title: 'The Maybourne Beverly Hills · Forbes Travel Guide Five-Star', channel: 'The Maybourne Beverly Hills' },
{ id: 'vD0oHYkcA_0', title: 'General Manager Frédéric Zemmour · L’Ermitage Beverly Hills', channel: LOVE },
{ id: 'C3f1TffM4Fk', title: 'SIXTY Beverly Hills in Seconds', channel: 'SIXTY Hotels' },
] },
{ name: 'Arts & Galleries', blurb: 'The city’s cultural life — from the Music in the Mansion series at Greystone to Gagosian, whose landmark gallery anchors N. Camden Drive.', videos: [
{ id: 'kaw1nwnp07c', title: 'Hidden Art of Beverly Hills · The Maybourne', channel: LOVE },
{ id: 'lCCHzqzQ0KU', title: 'Music in the Mansion · Cristina Montes Mateo', channel: CITY },
{ id: 'BXOSYwQXDyo', title: 'Es Devlin & Vladimir Yavachev on Christo', channel: 'Gagosian' },
{ id: 'fe_QbTkLQ6U', title: 'Giuseppe Penone · “The Reflection of Bronze”', channel: 'Gagosian' },
] },
{ name: 'Shopping & Events', blurb: 'Rodeo Drive and the moments that fill the Beverly Hills calendar.', videos: [
{ id: 'jdcUKhEOqH4', title: 'Rodeo Drive Concours d’Elegance', channel: LOVE },
{ id: 'JfhK8qlZnKI', title: 'K-Pop Flash Mob on Rodeo Drive', channel: CITY },
{ id: 'OHLhwIOnr4A', title: 'This Month’s Events in Beverly Hills', channel: LOVE },
{ id: 'N90exTV8eNs', title: 'Festival Beverly Hills', channel: LOVE },
] },
{ name: 'On Social', blurb: 'Short-form Beverly Hills straight from the city’s official social channels.', videos: [
{ platform: 'instagram', url: 'https://www.instagram.com/lovebevhills/reel/C-lSh-cy5yV/', title: 'Richard Orlinski sculptures on Rodeo Drive', channel: 'Love Beverly Hills' },
{ platform: 'instagram', url: 'https://www.instagram.com/lovebevhills/p/Ct9uztRuRFP/', title: 'The Maybourne earns AAA Five Diamond', channel: 'Love Beverly Hills' },
{ platform: 'instagram', url: 'https://www.instagram.com/lovebevhills/p/CQJu-IstVGF/', title: '“Mr. Rodeo Drive” — the Fred Hayman tribute', channel: 'Love Beverly Hills' },
] },
{ name: 'Luxury Real Estate', blurb: 'Inside the landmark estates from the firms and agents who sell them.', videos: [
{ id: 'UgZ4P5P89-s', title: '77 Beverly Park Lane · $68,000,000', channel: 'The Beverly Hills Estates' },
{ id: 'JuP1zQJzaWc', title: '2571 Wallingford Drive · $49,995,000', channel: 'The Beverly Hills Estates' },
{ id: 'bkgggxOqIz8', title: '1122 La Altura Rd · $17,995,000', channel: 'The Beverly Hills Estates' },
{ id: '33qhhmq9-1Y', title: 'Top-Floor Primary Suite · $5.5M', channel: 'Christophe Choo' },
{ id: 'QsuX5VkIJEc', title: 'Modern Upgrades Meet Scandinavian Warmth', channel: 'Christophe Choo' },
] },
];
// Platform-aware embed. Every source verified (YouTube/Vimeo via oEmbed 200;
// Instagram permalinks HTTP-200 on @lovebevhills). YouTube/Vimeo are clean
// iframes; Instagram/TikTok use their official blockquote + embed.js.
const videoEmbed = (v) => {
const p = v.platform || 'youtube';
if (p === 'youtube') return `
<figure class="vid">
<div class="frame"><iframe src="https://www.youtube.com/embed/${v.id}" title="${v.title}" loading="lazy" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>
<figcaption>${v.title}<span class="vch">${v.channel}</span></figcaption>
</figure>`;
if (p === 'vimeo') return `
<figure class="vid">
<div class="frame"><iframe src="https://player.vimeo.com/video/${v.id}" title="${v.title}" loading="lazy" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe></div>
<figcaption>${v.title}<span class="vch">${v.channel}</span></figcaption>
</figure>`;
if (p === 'instagram') return `
<figure class="vid vid-social">
<blockquote class="instagram-media" data-instgrm-permalink="${v.url}" data-instgrm-version="14" style="width:100%;max-width:100%;min-width:260px;margin:0;border:1px solid var(--line);border-radius:6px"></blockquote>
<figcaption>${v.title}<span class="vch">${v.channel} · Instagram</span></figcaption>
</figure>`;
if (p === 'tiktok') return `
<figure class="vid vid-social">
<blockquote class="tiktok-embed" cite="${v.url}" data-video-id="${v.id}" style="max-width:100%;min-width:260px;margin:0"><section></section></blockquote>
<figcaption>${v.title}<span class="vch">${v.channel} · TikTok</span></figcaption>
</figure>`;
return '';
};
// ---- original self-hosted restaurant films (HeyGen presenter videos) ----
// Loaded here (before the home hero + article interleaving both use it).
// Manifest is written by filmgen/heygen_batch.mjs as each film completes.
let FILMS = [];
try { FILMS = JSON.parse(readFileSync('data/films.json', 'utf8')); } catch {}
// In-article video, inserted after every paragraph. Handles a self-hosted film
// object ({src,poster,name,slug,blurb}) OR a YouTube item ({id,title,channel}).
const inlineFilm = (f) => `
<figure class="p-vid p-film">
<video controls preload="none" playsinline poster="${f.poster}"><source src="${f.src}" type="video/mp4"></video>
<figcaption><a href="/restaurants/${f.slug}.html">${f.name}</a><span>${(f.blurb || '').replace(/"/g, '"')}</span></figcaption>
</figure>`;
const inlineYT = (v) => `
<figure class="p-vid p-yt">
<div class="frame"><iframe src="https://www.youtube.com/embed/${v.id}" title="${v.title}" loading="lazy" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>
<figcaption>${v.title}<span>${v.channel}</span></figcaption>
</figure>`;
const inlineVideo = (item) => item && item.src ? inlineFilm(item) : inlineYT(item);
// Every YouTube item flattened, for fallback interleaving on posts with no films.
const ALL_YT = VIDEO_CATEGORIES.flatMap(c => c.videos).filter(v => (v.platform || 'youtube') === 'youtube');
// Per-post video pool: topical items first (by content file), then the whole film
// library + all YouTube, deduped, so even a 14-paragraph article never runs dry.
const POST_VIDEO_HINTS = {
dining: ['film'],
nightlife: ['film', 'Hotels & Spas'],
'real-estate': ['Luxury Real Estate'],
'rodeo-drive': ['Shopping & Events'],
'things-to-do':['Arts & Galleries', 'Shopping & Events'],
wellness: ['Hotels & Spas'],
fitness: ['Hotels & Spas'],
doctors: ['Hotels & Spas'],
lawyers: ['Arts & Galleries'],
};
function postVideoPool(file) {
const hints = POST_VIDEO_HINTS[file] || [];
const pool = [], seen = new Set();
const add = (it) => { const k = it && (it.src || it.id); if (k && !seen.has(k)) { seen.add(k); pool.push(it); } };
for (const h of hints) {
if (h === 'film') FILMS.forEach(add);
else VIDEO_CATEGORIES.filter(c => c.name === h).forEach(c => c.videos.forEach(v => { if ((v.platform || 'youtube') === 'youtube') add(v); }));
}
FILMS.forEach(add); // fill remainder so long posts never repeat within the article
ALL_YT.forEach(add);
return pool;
}
// Insert a video after every </p>, cycling the pool; drop a manual ad unit every
// 5th paragraph so the article is monetized without an ad after literally each one.
function interleaveVideos(frag, file) {
const pool = postVideoPool(file);
if (!pool.length) return frag;
let vi = 0, pcount = 0;
return frag.replace(/<\/p>/g, (m) => {
let out = m + inlineVideo(pool[vi++ % pool.length]);
if (++pcount % 5 === 0) out += adSlot('in_article');
return out;
});
}
const nav = `
<header class="site-head">
<div class="wrap">
<button class="hamb" aria-label="Open menu" aria-expanded="false" onclick="toggleNav(this)"><span></span><span></span><span></span></button>
<a class="brand" href="/">BEVERLY HILLS<span>VIDEOS</span></a>
</div>
</header>
<div class="drawer" id="nav" aria-hidden="true">
<button class="drawer-x" aria-label="Close menu" onclick="toggleNav()">×</button>
<nav>
<a href="/">Home</a>
<a href="/videos.html">Videos</a>
<a href="/restaurants.html">Restaurants</a>
<a href="/posts/beverly-hills-real-estate.html">Real Estate</a>
<a href="/posts/rodeo-drive-guide.html">Rodeo Drive</a>
<a href="/posts/things-to-do-beverly-hills.html">City Guide</a>
<a href="/posts/dining-in-beverly-hills.html">Dining</a>
<a href="/posts/beverly-hills-nightlife.html">Nightlife</a>
<a href="/map.html">Map</a>
</nav>
</div>
<script>
function toggleNav(btn){
var d=document.getElementById('nav'), open=d.classList.toggle('open');
document.body.style.overflow=open?'hidden':'';
d.setAttribute('aria-hidden', open?'false':'true');
var h=document.querySelector('.hamb'); if(h) h.setAttribute('aria-expanded', open?'true':'false');
if(open) h&&h.classList.add('x'); else h&&h.classList.remove('x');
}
</script>`;
const footer = `
<footer class="site-foot">
<div class="wrap">
<p>© ${new Date().getFullYear()} ${BRAND} · ${DOMAIN}</p>
<p class="fine">An independent editorial guide to Beverly Hills — its neighborhoods, culture, shopping, and dining.
Not affiliated with the City of Beverly Hills. <a href="/privacy.html">Privacy</a></p>
<p class="fine">Hero image: “Via Rodeo, Beverly Hills” by <a href="https://commons.wikimedia.org/wiki/File:Via_Rodeo,_Beverly_Hills.jpg" target="_blank" rel="noopener noreferrer">Kjetil Ree</a>, <a href="https://creativecommons.org/licenses/by-sa/2.5/" target="_blank" rel="noopener noreferrer">CC BY-SA 2.5</a>.</p>
</div>
</footer>`;
const page = ({ title, desc, canonical, body }) => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title} · ${BRAND}</title>
<meta name="description" content="${desc}">
<link rel="canonical" href="https://${DOMAIN}${canonical}">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${desc}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${BRAND}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=Jost:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
${ADSENSE}
</head>
<body>
${nav}
${body}
${footer}
</body>
</html>`;
// ---- home ----
// Big rotating film montage on load: two stacked <video> layers crossfade through
// a handful of the self-hosted restaurant films (muted autoplay so browsers allow
// it; a sound toggle unmutes). Falls back to the static image hero if no films.
const HERO_FILMS = FILMS.slice(0, 6);
const homeHero = HERO_FILMS.length ? `
<section class="hero-montage" id="hero">
<div class="hm-stage">
<video class="hm-vid on" muted autoplay loop playsinline preload="auto" poster="${HERO_FILMS[0].poster}"><source src="${HERO_FILMS[0].src}" type="video/mp4"></video>
<video class="hm-vid" muted loop playsinline preload="none"></video>
</div>
<div class="hm-veil"></div>
<div class="wrap hm-copy">
<p class="eyebrow">The 90210 Edit</p>
<h1>Beverly Hills, beyond the postcard.</h1>
<p class="sub">An independent guide to the city’s real estate, shopping, dining, culture, and the quiet corners
locals love — stories worth slowing down for.</p>
<div class="hm-actions">
<a href="#guides" class="scrollcue">Explore</a>
<button class="hm-sound" type="button" aria-label="Toggle sound" onclick="hmSound(this)">🔊 Sound</button>
</div>
</div>
<div class="hm-caption" id="hmCap"></div>
</section>
<script>
(function(){
var films = ${JSON.stringify(HERO_FILMS.map(f => ({ src: f.src, poster: f.poster, name: f.name })))};
var vids = document.querySelectorAll('.hero-montage .hm-vid');
var cap = document.getElementById('hmCap');
if(!films.length || vids.length < 2) return;
var idx = 0, active = 0, wantSound = false;
function show(n){ if(cap) cap.textContent = n ? '▶ ' + n : ''; }
show(films[0].name);
vids[0].play().catch(function(){});
function advance(){
var next = (active + 1) % 2, nf = films[(idx + 1) % films.length], nv = vids[next];
nv.setAttribute('poster', nf.poster);
nv.innerHTML = '<source src="' + nf.src + '" type="video/mp4">';
nv.muted = !wantSound; nv.load();
nv.play().catch(function(){});
nv.classList.add('on'); vids[active].classList.remove('on');
idx = (idx + 1) % films.length; active = next; show(nf.name);
}
if(films.length > 1) setInterval(advance, 8000);
window.hmSound = function(btn){
wantSound = !wantSound;
vids[active].muted = !wantSound;
if(wantSound) vids[active].play().catch(function(){});
btn.textContent = wantSound ? '🔇 Mute' : '🔊 Sound';
};
})();
</script>` : `
<section class="hero home-hero">
<div class="wrap">
<p class="eyebrow">The 90210 Edit</p>
<h1>Beverly Hills, beyond the postcard.</h1>
<p class="sub">An independent guide to the city’s real estate, shopping, dining, culture, and the quiet corners
locals love — stories worth slowing down for.</p>
<a href="#guides" class="scrollcue">Explore</a>
</div>
</section>`;
const homeBody = `
${homeHero}
<section class="feed" id="guides">
<div class="wrap">
<div class="posts">
${POSTS.map((p, i) => `
<a class="post-card${i === 0 ? ' lead' : ''}" href="/posts/${p.slug}.html">
<span class="kicker">${p.kicker}</span>
<h2>${p.title}</h2>
<p>${p.blurb}</p>
<span class="read">Read →</span>
</a>`).join('')}
</div>
</div>
</section>
<div class="wrap">${adSlot('home')}</div>
<section class="home-vids">
<div class="wrap">
<div class="vch-head"><h2>From the city, on video</h2><a class="vch-link" href="/videos.html">All videos →</a></div>
<p class="vch-tag">Real footage from Beverly Hills businesses and institutions — dining, hotels, the arts, and more.</p>
<div class="vid-grid">
${VIDEO_CATEGORIES.slice(0, 3).map(c => videoEmbed(c.videos[0])).join('')}
</div>
</div>
</section>`;
// ---- build ----
mkdirSync('public/posts', { recursive: true });
writeFileSync('public/index.html', page({
title: 'Beverly Hills news, real estate, shopping & dining',
desc: 'An independent editorial guide to Beverly Hills — real estate, Rodeo Drive, city guide, and dining.',
canonical: '/', body: homeBody,
}));
// ---- specialty directory (doctors / lawyers), rendered from map-locations.json ----
let DIR_LOCS = [];
try { DIR_LOCS = JSON.parse(readFileSync('data/map-locations.json', 'utf8')).locations || []; } catch {}
function renderDirectory(type) {
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
const rows = DIR_LOCS.filter(l => l.type === type);
const groups = {};
for (const l of rows) (groups[l.category] ||= []).push(l);
return `<div class="directory">` + Object.keys(groups).sort().map(cat => `
<div class="dir-group">
<h4 class="dir-cat">${esc(cat)} <em>${groups[cat].length}</em></h4>
${groups[cat].map(l => {
const tel = l.phone ? l.phone.replace(/[^+0-9]/g, '') : '';
return `<div class="dir-item">
<span class="dir-name">${esc(l.name)}</span>
<a class="dir-addr" href="https://maps.google.com/?q=${encodeURIComponent(l.name + ' ' + l.address)}" target="_blank" rel="noopener">${esc(l.address)}</a>
${l.phone ? `<a class="dir-phone" href="tel:${tel}">${esc(l.phone)}</a>` : ''}
</div>`;
}).join('')}
</div>`).join('') + `</div>`;
}
for (const p of POSTS) {
let frag = readFileSync(`content/${p.file}.html`, 'utf8');
if (p.directory) frag = frag.replace('{{DIRECTORY}}', renderDirectory(p.directory));
const others = POSTS.filter(x => x.slug !== p.slug).slice(0, 3);
const body = `
<article class="post">
<div class="wrap">
<p class="crumb"><a href="/">Home</a> · ${p.kicker}</p>
<h1>${p.title}</h1>
${adSlot('article_top')}
${interleaveVideos(frag, p.file)}
${adSlot('article_foot')}
<div class="more">
<h3>More from Beverly Hills Videos</h3>
<ul>${others.map(o => `<li><a href="/posts/${o.slug}.html">${o.title}</a></li>`).join('')}</ul>
</div>
</div>
</article>`;
writeFileSync(`public/posts/${p.slug}.html`, page({
title: p.title, desc: p.blurb, canonical: `/posts/${p.slug}.html`, body,
}));
}
// ---- videos page (real, verified BH local-business embeds) ----
const videosBody = `
<section class="hero home-hero vids-hero">
<div class="wrap">
<p class="eyebrow">Watch</p>
<h1>Beverly Hills, on video.</h1>
<p class="sub">Real videos from the city’s own businesses and institutions — the visitors bureau, its
landmark real-estate firms, and the people who know 90210 best.</p>
</div>
</section>
${FILMS.length ? `
<section class="films"><div class="wrap">
<div class="vch-head"><h2>Restaurant Films</h2></div>
<p class="vch-tag">Original short films on the great Beverly Hills restaurants, produced for ${BRAND}.</p>
<div class="film-grid">
${FILMS.map(f => `
<figure class="film">
<video controls preload="metadata" playsinline poster="${f.poster}">
<source src="${f.src}" type="video/mp4">
</video>
<figcaption><strong><a href="/restaurants/${f.slug}.html">${f.name}</a></strong><span>${f.blurb}</span></figcaption>
</figure>`).join('')}
</div>
</div></section>` : ''}
<div class="wrap">${adSlot()}</div>
${VIDEO_CATEGORIES.map(c => `
<section class="vid-channel">
<div class="wrap">
<div class="vch-head"><h2>${c.name}</h2></div>
<p class="vch-tag">${c.blurb}</p>
<div class="vid-grid">
${c.videos.map(videoEmbed).join('')}
</div>
</div>
</section>`).join('')}
<section class="vid-note"><div class="wrap">
<p><strong>Follow Beverly Hills across platforms:</strong>
<a href="https://www.youtube.com/@lovebeverlyhills" target="_blank" rel="noopener noreferrer">YouTube</a> ·
<a href="https://www.instagram.com/lovebevhills/" target="_blank" rel="noopener noreferrer">Instagram</a> ·
<a href="https://www.tiktok.com/@lovebevhills" target="_blank" rel="noopener noreferrer">TikTok</a></p>
<p><strong>Featured channels:</strong> ${Object.entries(CH_URL).map(([n, u]) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${n}</a>`).join(' · ')}</p>
<p>Videos are embedded from their creators’ official channels (YouTube, Vimeo, Instagram, TikTok) and remain the
property of their respective owners. ${BRAND} is an independent editorial guide and is not affiliated with the
channels featured.</p>
</div></section>
<script async src="//www.instagram.com/embed.js"></script>
<script async src="https://www.tiktok.com/embed.js"></script>`;
writeFileSync('public/videos.html', page({
title: 'Beverly Hills Videos — real videos from the city’s businesses',
desc: 'Watch real Beverly Hills videos from the visitors bureau, luxury real-estate firms, and local institutions.',
canonical: '/videos.html', body: videosBody,
}));
// ---- map page (restaurants + Rodeo Drive stores, geocoded) ----
let MAP_LOCS = [];
try { MAP_LOCS = JSON.parse(readFileSync('data/map-locations.json', 'utf8')).locations || []; } catch {}
// wire each mapped restaurant to its film page (if one exists)
{ const rn = s => (s||'').toLowerCase().replace(/beverly hills|estiatorio|the /g,'').replace(/[^a-z0-9]+/g,'');
MAP_LOCS.forEach(l => { if (l.type==='restaurant') { const b=rn(l.name); const f=FILMS.find(x=>{const a=rn(x.name);return a&&b&&(a===b||a.includes(b)||b.includes(a));}); if (f) l.film=f.slug; } }); }
const mapBody = `
<section class="hero home-hero vids-hero">
<div class="wrap">
<p class="eyebrow">The Map</p>
<h1>Where to go in Beverly Hills.</h1>
<p class="sub">Fine dining, Rodeo Drive boutiques, the city’s celebrated cosmetic doctors, and its high-profile attorneys — all mapped across the Golden Triangle. Tap any pin to call.</p>
</div>
</section>
<section class="mapsec">
<div class="wrap">
<div class="maplegend">
<button class="ml on" data-t="restaurant"><span class="dot d-r"></span> Restaurants <em>${MAP_LOCS.filter(l=>l.type==='restaurant').length}</em></button>
<button class="ml on" data-t="store"><span class="dot d-s"></span> Stores <em>${MAP_LOCS.filter(l=>l.type==='store').length}</em></button>
<button class="ml on" data-t="doctor"><span class="dot d-d"></span> Doctors <em>${MAP_LOCS.filter(l=>l.type==='doctor').length}</em></button>
<button class="ml on" data-t="lawyer"><span class="dot d-l"></span> Attorneys <em>${MAP_LOCS.filter(l=>l.type==='lawyer').length}</em></button>
</div>
<div id="map"></div>
</div>
</section>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script>
const LOCS = ${JSON.stringify(MAP_LOCS)};
(function(){
const map = L.map('map',{scrollWheelZoom:false}).setView([34.0705,-118.4015],15);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',{
attribution:'© OpenStreetMap © CARTO', subdomains:'abcd', maxZoom:20}).addTo(map);
const col = {restaurant:'#1f3d30', store:'#b8935a', doctor:'#2f5d7c', lawyer:'#6d2f3d'};
const layers = {restaurant:L.layerGroup().addTo(map), store:L.layerGroup().addTo(map), doctor:L.layerGroup().addTo(map), lawyer:L.layerGroup().addTo(map)};
const bounds = [];
LOCS.forEach(l=>{
bounds.push([l.lat,l.lng]);
const m = L.circleMarker([l.lat,l.lng],{radius:7,color:'#fff',weight:2,fillColor:col[l.type]||'#333',fillOpacity:1});
const tel = l.phone ? l.phone.replace(/[^+0-9]/g,'') : '';
m.bindPopup('<div class="pop"><strong>'+l.name+'</strong>'
+'<span class="pcat">'+l.category+'</span>'
+'<a class="paddr" href="https://maps.google.com/?q='+encodeURIComponent(l.name+' '+l.address)+'" target="_blank" rel="noopener">'+l.address+'</a>'
+(l.phone ? '<a class="pcall" href="tel:'+tel+'">Call '+l.phone+'</a>' : '')
+(l.menu_url ? '<a class="pmenu" href="'+l.menu_url+'" target="_blank" rel="noopener">View current menu →</a>' : '')
+(l.film ? '<a class="pfilm" href="/restaurants/'+l.film+'.html">▶ Watch the film</a>' : '')
+'</div>');
m.addTo(layers[l.type]||layers.restaurant);
});
if(bounds.length) map.fitBounds(bounds,{padding:[40,40]});
map.on('click',()=>map.scrollWheelZoom.enable());
document.querySelectorAll('.ml').forEach(b=>b.addEventListener('click',()=>{
const t=b.dataset.t, on=b.classList.toggle('on');
if(on) map.addLayer(layers[t]); else map.removeLayer(layers[t]);
}));
})();
</script>`;
writeFileSync('public/map.html', page({
title: 'Map — Beverly Hills dining, shopping, doctors & attorneys',
desc: 'An interactive map of Beverly Hills fine dining, Rodeo Drive boutiques, cosmetic doctors, and high-profile attorneys across the Golden Triangle — with click-to-call.',
canonical: '/map.html', body: mapBody,
}));
// ---- per-restaurant film pages (SEO surface: film + cuisine + contact + menu) ----
mkdirSync('public/restaurants', { recursive: true });
let R_CUISINE = {}, R_SCRIPTS = {};
try { (JSON.parse(readFileSync('data/restaurants.json','utf8')).restaurants||[]).forEach(r => R_CUISINE[r.name]=r.cuisine); } catch {}
Object.assign(R_CUISINE, { "Marea":"Italian Seafood","Ocean Prime":"Steakhouse","Azur Beverly Hills":"French","Bad Roman":"Italian-American","88 Club":"Chinese","The Rooftop by JG":"New American","The Hideaway":"Steakhouse","Fogo de Chao":"Brazilian","The Terrace at The Maybourne":"Mediterranean","Miura":"Japanese Sushi","South Beverly Grill":"American","Piccolo Paradiso":"Italian","Kazan":"Japanese Ramen","Xi'an":"Chinese" });
try { R_SCRIPTS = JSON.parse(readFileSync('data/film-scripts.json','utf8')); } catch {}
const rnorm = s => (s||'').toLowerCase().replace(/beverly hills|estiatorio|the /g,'').replace(/[^a-z0-9]+/g,'').trim();
const findBy = (obj, name) => { const n = rnorm(name); for (const k of Object.keys(obj)) { const kn = rnorm(k); if (kn && (kn===n || kn.includes(n) || n.includes(kn))) return obj[k]; } return null; };
const contactFor = name => MAP_LOCS.filter(l=>l.type==='restaurant').find(l => { const a=rnorm(l.name), b=rnorm(name); return a===b || a.includes(b) || b.includes(a); });
const restFilms = FILMS.map(f => {
const c = contactFor(f.name);
return { ...f,
cuisine: R_CUISINE[f.name] || findBy(R_CUISINE, f.name) || (c && c.category) || 'Beverly Hills',
narration: R_SCRIPTS[f.name] || findBy(R_SCRIPTS, f.name) || f.blurb,
address: c && c.address, phone: c && c.phone, menu_url: c && c.menu_url };
});
for (const f of restFilms) {
const tel = f.phone ? f.phone.replace(/[^+0-9]/g,'') : '';
const contact = (f.address || f.phone || f.menu_url) ? `
<div class="rest-contact">
${f.address ? `<a class="paddr" href="https://maps.google.com/?q=${encodeURIComponent(f.name+' '+f.address)}" target="_blank" rel="noopener">${f.address}</a>` : ''}
${f.phone ? `<a class="pcall" href="tel:${tel}">Call ${f.phone}</a>` : ''}
${f.menu_url ? `<a class="pmenu" href="${f.menu_url}" target="_blank" rel="noopener">View current menu →</a>` : ''}
</div>` : '';
const body = `
<article class="rest"><div class="wrap">
<p class="crumb"><a href="/">Home</a> · <a href="/restaurants.html">Restaurants</a> · ${f.cuisine}</p>
<p class="eyebrow">${f.cuisine} · Beverly Hills</p>
<h1>${f.name.replace(/ Beverly Hills$/,'')}</h1>
<figure class="film rest-film"><video controls preload="metadata" playsinline poster="${f.poster}"><source src="${f.src}" type="video/mp4"></video></figure>
<p class="rest-narr">${f.narration}</p>
${contact}
${adSlot()}
<p class="rest-more"><a href="/map.html">See it on the map →</a> · <a href="/videos.html">All restaurant films →</a></p>
</div></article>`;
writeFileSync(`public/restaurants/${f.slug}.html`, page({
title: `${f.name.replace(/ Beverly Hills$/,'')} — Beverly Hills ${f.cuisine} | ${BRAND}`,
desc: f.narration.slice(0,155),
canonical: `/restaurants/${f.slug}.html`, body,
}));
}
// index
const restIndex = `
<section class="hero home-hero vids-hero"><div class="wrap">
<p class="eyebrow">The Restaurants</p><h1>Every great table in Beverly Hills.</h1>
<p class="sub">Original short films on ${restFilms.length} of the city's finest restaurants — with menus, maps, and click-to-call.</p>
</div></section>
<section class="rest-grid-sec"><div class="wrap"><div class="rest-grid">
${restFilms.map(f => `<a class="rest-card" href="/restaurants/${f.slug}.html">
<div class="rc-thumb"><img src="${f.poster}" alt="${f.name}" loading="lazy"></div>
<div class="rc-meta"><strong>${f.name.replace(/ Beverly Hills$/,'')}</strong><span>${f.cuisine}</span></div></a>`).join('')}
</div></div></section>`;
writeFileSync('public/restaurants.html', page({
title: `Beverly Hills Restaurants — ${restFilms.length} Original Films, Menus & Map | ${BRAND}`,
desc: `Original short films on ${restFilms.length} of the finest restaurants in Beverly Hills, each with its current menu, address, and click-to-call.`,
canonical: '/restaurants.html', body: restIndex,
}));
// ---- privacy (AdSense-compliant) ----
const privacyBody = `
<article class="post">
<div class="wrap">
<h1>Privacy Policy</h1>
<p class="crumb">${BRAND} · ${DOMAIN} · Last updated ${new Date().toLocaleDateString('en-US',{year:'numeric',month:'long',day:'numeric'})}</p>
<p>This policy explains what ${BRAND} collects, how we use it, and your choices.</p>
<h2>Information we collect</h2>
<p>Standard server logs (IP address, browser type, referring pages, timestamps) and information stored in
cookies. We do not sell your personal information.</p>
<h2>Cookies and advertising</h2>
<p>We use cookies to operate the site and to display advertising. <strong>Third-party vendors, including
Google, use cookies to serve ads</strong> based on your prior visits to this and other websites. Google’s use
of advertising cookies enables it and its partners to serve ads to you based on your visit to ${DOMAIN} and/or
other sites on the Internet. You may opt out of personalized advertising by visiting
<a href="https://www.google.com/settings/ads" target="_blank" rel="noopener noreferrer">Google Ads Settings</a>,
or opt out of third-party vendors’ use of cookies at
<a href="https://www.aboutads.info/choices/" target="_blank" rel="noopener noreferrer">aboutads.info</a>
(or <a href="https://www.youronlinechoices.eu/" target="_blank" rel="noopener noreferrer">youronlinechoices.eu</a>
in the EEA/UK). Where required by law, we request consent before setting non-essential advertising cookies.
See <a href="https://policies.google.com/technologies/partner-sites" target="_blank" rel="noopener noreferrer">Google’s Privacy & Terms</a>.</p>
<h2>Your choices</h2>
<p>Most browsers let you block or delete cookies through their settings, and you can use the opt-out links
above to limit personalized advertising.</p>
<h2>Contact</h2>
<p>Questions or a data request? Email <a href="mailto:privacy@${DOMAIN}">privacy@${DOMAIN}</a>.</p>
</div>
</article>`;
writeFileSync('public/privacy.html', page({
title: 'Privacy Policy', desc: `How ${BRAND} handles data, cookies, and advertising.`,
canonical: '/privacy.html', body: privacyBody,
}));
console.log('built: index + ' + POSTS.length + ' posts + privacy');