Bunny-Agency-Italy/scripts/validate-seo.mjs
BigMarc 67ea5d48be Initial commit: Bunny Agency Italia (it-IT) marketing site
Italian-market site for the Bunny Agency network, built on the shared
Next.js App Router template with content driven from site-content.json.

- 50 routes: home, 5 services, pricing, FAQ, about/apply/contact,
  32 blog answers and guides, 5 legal pages
- Research-driven Italian copy citing ISTAT, INPS and Agenzia delle Entrate
  (forfettario 15%/5%, ATECO 73.11.03, INPS 26,07%, tassa etica, DAC7)
- 6 long-form guides targeting high-volume Italian creator searches
- "Milanese Editoriale" design system with stats band, editorial article
  layout, creator gallery and footer language switcher to 18 sibling sites
- Full SEO/AEO/GEO: JSON-LD entity graph, hreflang cluster, llms.txt,
  IndexNow, sitemap, AI-crawler-friendly robots
- validate-seo.mjs passes with 0 errors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:00:28 +02:00

13 lines
3.7 KiB
JavaScript

import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url";
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),"..");
const data=JSON.parse(fs.readFileSync(path.join(root,"src/content/site-content.json"),"utf8"));
const marketing=data.pages.filter((p)=>p.category!=="legal"); const errors=[];
const moneyPaths=new Set(["/","/services/account-management","/pricing","/blog/questions/creator-taxes-guide","/blog/questions/how-to-start-onlyfans","/blog/questions/how-to-choose-a-creator-agency"]);
const wordCount=(value)=>{try{return [...new Intl.Segmenter(data.site.locale,{granularity:"word"}).segment(value)].filter((item)=>item.isWordLike).length}catch{return value.trim().split(/\s+/).length}};
if(marketing.length<38)errors.push("fewer than 38 marketing/content pages");
for(const field of ["title","description","h1"]){const seen=new Map();for(const p of data.pages){const key=p[field].trim().toLocaleLowerCase();if(seen.has(key))errors.push("duplicate "+field+": "+p.path+" and "+seen.get(key));else seen.set(key,p.path)}}
for(const p of data.pages){for(const field of ["path","title","description","h1","summary","image","alt","keyword"]){if(!p[field])errors.push(p.path+" missing "+field)}if(!p.faq?.length)errors.push(p.path+" missing visible FAQ");if(p.title.length>60)errors.push(p.path+" title exceeds 60 characters");if(p.description.length>155)errors.push(p.path+" description exceeds 155 characters");if(p.citations.length<2)errors.push(p.path+" has fewer than 2 citations");if(!/\d/.test(p.summary))errors.push(p.path+" summary has no quotable number or date");for(const faq of p.faq){if(!/\d/.test(faq.answer))errors.push(p.path+" FAQ answer has no quotable number or date: "+faq.question)}if(p.category==="service"&&!p.h1.toLocaleLowerCase().includes(p.keyword.toLocaleLowerCase()))errors.push(p.path+" service keyword missing from H1");if(p.category==="blog"&&![p.h1,...p.sections.map((section)=>section.heading)].some((value)=>value.toLocaleLowerCase().includes(p.keyword.toLocaleLowerCase())))errors.push(p.path+" blog keyword missing from H1/H2");if(moneyPaths.has(p.path)){if(p.sections.length<5||p.sections.length>7)errors.push(p.path+" money page needs 5-7 sections");if(p.faq.length<5)errors.push(p.path+" money page needs at least 5 FAQs");const rendered=[p.summary,...p.sections.flatMap((section)=>[section.heading,section.body,...(section.items??[]),...(section.steps??[])]),...p.faq.flatMap((faq)=>[faq.question,faq.answer])].join(" ");if(["zh-TW","th-TH","ko-KR"].includes(data.site.locale)){if(rendered.length<1400)errors.push(p.path+" money page is too short")}else{const words=wordCount(rendered);if(words<580||words>1000)errors.push(p.path+" money page outside 580-1000 words: "+words)}}const image=path.join(root,"public",p.image);if(!fs.existsSync(image))errors.push(p.path+" missing image "+p.image)}
if(!data.site.hreflangXDefault)errors.push("missing cluster-wide hreflangXDefault");if(data.site.foundingDate!=="2019")errors.push("missing verified foundingDate");if(!data.site.aggregateRating?.ratingValue||!data.site.aggregateRating?.reviewCount)errors.push("missing aggregate rating evidence");if((data.site.schemaSameAs?.length??0)<15)errors.push("entity sameAs graph is incomplete");
for(const file of ["public/llms.txt","public/llms-full.txt","src/app/robots.ts","src/app/sitemap.ts","src/components/TrackedHubSpotForm.tsx","src/components/SiteJsonLd.tsx","src/components/Breadcrumbs.tsx"]){if(!fs.existsSync(path.join(root,file)))errors.push("missing "+file)}
if(errors.length){console.error(errors.join("\n"));process.exit(1)}console.log(data.site.code+": "+marketing.length+" marketing pages, "+data.pages.length+" total pages; SEO registry valid");