#requires -version 5.1 <# REVIEW.ps1 - local verifier for the odoo-expertos SEO fix workstreams. Each /goal agent runs: powershell -File seo-audit-2026-05-27\REVIEW.ps1 -Only W# The printed "W#: PASS" line is what the /goal evaluator (Haiku) reads from the transcript. Checks run against LOCAL repo files (pre-deploy). Live/GSC verification is post-deploy. #> param([string]$Only = "all") $ErrorActionPreference = "Stop" $Audit = $PSScriptRoot $RepoRoot = Split-Path -Parent $Audit $BASE = "https://www.odoo-expertos.com" function Raw($p){ try{ $t=[System.IO.File]::ReadAllText($p); if($null -eq $t){''}else{$t} }catch{''} } function Res($id,$pass,$msg){ $tag = if($pass){"PASS"}else{"FAIL"}; Write-Host ("{0}: {1} - {2}" -f $id,$tag,$msg) } function HtmlFiles { Get-ChildItem $RepoRoot -Recurse -Filter index.html -File -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch '\\(\.git|node_modules|seo-audit-2026-05-27|website|directory)\\' } } function UrlToLocal($url){ $p = $url -replace '^https?://www\.odoo-expertos\.com','' $p = $p.Split('#')[0].Split('?')[0] if($p -eq '' -or $p -eq '/'){ return (Join-Path $RepoRoot 'index.html') } if($p -match '\.html?$'){ return (Join-Path $RepoRoot ($p.TrimStart('/').Replace('/','\'))) } $p = $p.TrimEnd('/') return (Join-Path $RepoRoot ($p.TrimStart('/').Replace('/','\') + '\index.html')) } function Check-W1 { $sm = Get-ChildItem $RepoRoot -Filter 'sitemap-*.xml' -File -ErrorAction SilentlyContinue if(-not $sm){ Res 'W1' $false 'no sitemap-*.xml found'; return } $rslugs='bestes-odoo-hosting-2026|cheap-odoo-hosting-2026|kostenloses-odoo-hosting-2026|odoo-hosting-deutschland-2026|odoo-hosting-oesterreich-2026|odoo-hosting-schweiz-2026|best-odoo-hosting-2026|free-odoo-hosting-2026|odoo-hosting-germany-2026|odoo-hosting-uk-2026|odoo-sh-alternatives-2026|odoo-vps-hosting-2026' $missing=@(); $bad=@(); $redir=@(); $total=0 foreach($file in $sm){ $c = Raw $file.FullName; if(-not $c){continue} foreach($m in [regex]::Matches($c,'(https[^<]+)')){ $total++; $u=$m.Groups[1].Value if($u -match '/index\.html$'){ $bad+=$u; continue } if($u -match "/(en|fr|pt|ar)/odoo-hosting/($rslugs)/"){ $redir+=$u; continue } if(-not (Test-Path (UrlToLocal $u))){ $missing+=$u } } } $ok = ($missing.Count -eq 0 -and $bad.Count -eq 0 -and $redir.Count -eq 0) $eg = if(!$ok){ ' e.g. ' + ((@($missing)+@($bad)+@($redir)) | Select-Object -First 3) -join ' | ' } else { '' } Res 'W1' $ok ("$total locs; missing-local=$($missing.Count); index.html-in-sitemap=$($bad.Count); redirecting-loc=$($redir.Count)$eg") } function Check-W2 { $r = Raw (Join-Path $RepoRoot 'robots.txt') $star = ([regex]::Matches($r,'(?m)^User-agent:\s*\*\s*$')).Count $bing = ([regex]::Matches($r,'(?im)^User-agent:\s*Bingbot\s*$')).Count $cc = ([regex]::Matches($r,'(?im)^User-agent:\s*CCBot\s*$')).Count $frag = [bool]($r -match '(?m)^Disallow:\s*/\*#') $q = [bool]($r -match '(?m)^Disallow:\s*/\*\?') $ok = ($star -eq 1 -and $bing -eq 1 -and $cc -eq 1 -and -not $frag -and $q) Res 'W2' $ok "UA*=$star(want 1); Bingbot=$bing(want 1); CCBot=$cc(want 1); has /*#=$frag(want False); has /*?=$q(want True)" } function Check-W3 { $deadDomain=0; $nonwww=0 foreach($file in HtmlFiles){ $c = Raw $file.FullName; if(-not $c){continue} if($c -match 'odooexpertos\.com'){ $deadDomain++ } if($c -match '"(?:url|item|@id|logo)":\s*"https://odoo-expertos\.com'){ $nonwww++ } } $ok = ($deadDomain -eq 0 -and $nonwww -eq 0) Res 'W3' $ok "files w/ dead domain odooexpertos.com=$deadDomain(want 0); files w/ non-www JSON-LD url=$nonwww(want 0)" } function Check-W4 { $cats = @('odoo','odoo-hosting','odoo-ia') | ForEach-Object { Join-Path $RepoRoot "$_\index.html" } $catNoHreflang=0 foreach($cf in $cats){ if(Test-Path $cf){ $cc=Raw $cf; if($cc -and ($cc -notmatch 'hreflang=')){ $catNoHreflang++ } } } $mislocated=0 foreach($file in HtmlFiles){ $c = Raw $file.FullName; if(-not $c){continue} foreach($m in [regex]::Matches($c,'hreflang="es"[^>]*href="([^"]+)"')){ if($m.Groups[1].Value -match '/(de|fr|pt|ar|en)/'){ $mislocated++ } } } $ok = ($catNoHreflang -eq 0 -and $mislocated -eq 0) Res 'W4' $ok "category pages without hreflang=$catNoHreflang(want 0); es-hreflang pointing into a /lang/ subtree=$mislocated(want 0)" } function Check-W5 { $long=0; $titles=@{}; $metas=@{} foreach($file in HtmlFiles){ $c = Raw $file.FullName; if(-not $c){continue} $t = [regex]::Match($c,'(.*?)',1).Groups[1].Value.Trim() if($t.Length -gt 65){ $long++ } if($t){ if($titles.ContainsKey($t)){$titles[$t]++}else{$titles[$t]=1} } $md = [regex]::Match($c,'65chars=$long(want 0); duplicate meta descriptions=$dupM(want 0); duplicate titles=$dupT" } function Check-W6 { $jsNav=0; $noStaticNav=0 foreach($file in HtmlFiles){ $c = Raw $file.FullName; if(-not $c){continue} if($c -match 'createHeader\(\)'){ $jsNav++ } if($c -notmatch 'href="[^"]*/odoo-hosting/"'){ $noStaticNav++ } } $sitemapPage = Test-Path (Join-Path $RepoRoot 'mapa-del-sitio\index.html') $ok = ($jsNav -eq 0 -and $sitemapPage) Res 'W6' $ok "pages still JS-injecting nav (createHeader)=$jsNav(want 0); pages missing static hosting nav link=$noStaticNav; HTML sitemap page exists=$sitemapPage(want True)" } function Check-W7 { $dupFile = Join-Path $Audit 'gsc\duplicate-canonical.txt' if(-not (Test-Path $dupFile)){ Res 'W7' $false 'gsc/duplicate-canonical.txt missing'; return } $urls = (Get-Content $dupFile) | Where-Object { $_ -match '^https://' } | Select-Object -First 40 $unfixed=0; $checked=0 foreach($u in $urls){ $checked++; $lf = UrlToLocal $u if(Test-Path $lf){ $c = Raw $lf; if($c -notmatch 'name="robots"[^>]*noindex'){ $unfixed++ } } } $ok = ($unfixed -eq 0) Res 'W7' $ok "of $checked sampled duplicate URLs, still-indexable (no noindex, file still present)=$unfixed(want 0)" } function Check-W8 { $homes = @('index.html','en\index.html','de\index.html','fr\index.html','pt\index.html','ar\index.html') | ForEach-Object { Join-Path $RepoRoot $_ } $bad = @('Robarte','DESTRUIR','DESTRUIDOS','CONQUISTA','DOMINACI') $hits=0 foreach($h in $homes){ if(Test-Path $h){ $c=Raw $h; foreach($b in $bad){ if($c -match [regex]::Escape($b)){ $hits++ } } } } $ok = ($hits -eq 0) Res 'W8' $ok "combat/hype tokens (Robarte/DESTRUIR/CONQUISTA...) remaining in homepages=$hits(want 0). MANUAL GATE: re-screenshot desktop+mobile, fix run-together H1 spacing, score >=8/10 before merge." } function Check-W9 { $known = @( '/odoo-ai/odoo-18-ai-nuevas-features-2025/','/odoo-ai/odoo-withstruccion-ai-projects-2025/', '/pt/odoo/consultoria-erp','/mes','/consultoria-erp', '/Odoo-Hosting/digitalocean-Hosting-Odoo-deployment-2025/','/de/odoo/consultoria-erp', '/en/odoo-hosting/odoo-sh-official-hosting/','/odoo/consultoria-erp', '/recursos/guia-aws-odoo.pdf','/en/odoo-hosting/odoo-hosting-schweiz-2026/','/recursos-implementacion-odoo' ) $vj = Raw (Join-Path $RepoRoot 'vercel.json') $uncovered=0 foreach($k in $known){ $hasFile = Test-Path (UrlToLocal ($BASE + $k)) $hasRedirect = [bool]($vj -match [regex]::Escape($k.TrimEnd('/'))) if(-not ($hasFile -or $hasRedirect)){ $uncovered++ } } $ok = ($uncovered -eq 0) Res 'W9' $ok "of 12 known 404s, still uncovered (no local page AND no vercel redirect)=$uncovered(want 0)" } function Check-W10 { $c = Raw (Join-Path $RepoRoot 'index.html') $inline = ([regex]::Matches($c,'(?s)]*>(.*?)') | ForEach-Object { $_.Groups[1].Value.Length } | Measure-Object -Sum).Sum $d3 = Test-Path (Join-Path $RepoRoot 'js\d3.v7.min.js') $vj = Raw (Join-Path $RepoRoot 'vercel.json') $cache = [bool]($vj -match 'max-age=31536000') $ok = ($inline -lt 14336 -and $d3 -and $cache) Res 'W10' $ok "homepage inline CSS bytes=$inline(want <14336); self-hosted D3 exists=$d3; immutable cache header=$cache" } function Check-W11 { $vj = Raw (Join-Path $RepoRoot 'vercel.json') $x1 = [bool]($vj -match 'X-Content-Type-Options') $x2 = [bool]($vj -match 'X-Frame-Options') $x3 = [bool]($vj -match 'Referrer-Policy') $ok = ($x1 -and $x2 -and $x3) Res 'W11' $ok "X-Content-Type-Options=$x1; X-Frame-Options=$x2; Referrer-Policy=$x3 (want all True)" } function Check-W5b { $long=0; $short=0; $shortM=0; $longM=0; $titles=@{}; $metas=@{} foreach($file in HtmlFiles){ $c = Raw $file.FullName; if(-not $c){continue} $t = [regex]::Match($c,'(.*?)',1).Groups[1].Value.Trim() if($t){ if($titles.ContainsKey($t)){$titles[$t]++}else{$titles[$t]=1}; if($t.Length -gt 65){$long++}; if($t.Length -lt 40){$short++} } $md = [regex]::Match($c,'65=$long; titles<40=$short(report); metas>165=$longM; metas<130=$shortM(report)" } function BodyWords($html){ $m = [regex]::Match($html,'(?s)]*>(.*?)',1) if(-not $m.Success){ return 0 } $body = $m.Groups[1].Value $body = $body -replace '(?s)','' $body = $body -replace '(?s)','' $body = $body -replace '<[^>]+>',' ' $body = $body -replace '&[a-z]+;',' ' $words = ($body -split '\s+') | Where-Object { $_ -match '[A-Za-zÀ-ſ]{2,}' } return $words.Count } function Check-W12 { $marked = @(); $maria = 0 foreach($file in HtmlFiles){ $c = Raw $file.FullName; if(-not $c){continue} if($c -match 'name="content-version"\s+content="W12-deep-rewrite"'){ $w = BodyWords $c $q = ([regex]::Matches($c,'"@type":\s*"Question"')).Count $hasMaria = ($c -match 'Maria Elena Rodriguez') $marked += [pscustomobject]@{File=$file.FullName; Words=$w; QA=$q; Maria=$hasMaria} if($hasMaria){ $maria++ } } } $count = $marked.Count $thin = ($marked | Where-Object { $_.Words -lt 1200 }).Count $lowQA = ($marked | Where-Object { $_.QA -lt 6 }).Count $ok = ($count -ge 20 -and $thin -eq 0 -and $lowQA -eq 0 -and $maria -eq 0) Res 'W12' $ok "marked-rewrites=$count(want >=20); body-words<1200=$thin(want 0); FAQ Q&A<6=$lowQA(want 0); pages still naming Maria Elena Rodriguez among marked=$maria(want 0)" } function Check-W13 { $targets = @( 'odoo-hosting\odoo-vs-sap-hosting-2026','odoo-hosting\odoo-vs-zoho-books-hosting-2025', 'odoo-hosting\cloudpepper-vs-odoo-sh-2026','odoo-hosting\alternativas-odoo-sh-2026', 'odoo-hosting\odoo-community-vs-enterprise-hosting-2026','odoo\odoo-gratis-vs-pago-diferencias-2025', 'odoo-hosting\comparativa-precios-odoo-2026','odoo-hosting\mejor-hosting-odoo-2026', 'odoo-hosting\opiniones-hosting-odoo-2026','odoo-hosting\odoo4projects-hosting-review-2026' ) $deepened=0; $lowFAQ=@(); $noTable=@(); $stillMaria=@() foreach($t in $targets){ $p = Join-Path $RepoRoot "$t\index.html" if(-not (Test-Path $p)){ continue } $c = Raw $p if($c -match 'name="content-version"\s+content="W13-ai-search"'){ $deepened++ } $q = ([regex]::Matches($c,'"@type":\s*"Question"')).Count if($q -lt 8){ $lowFAQ += $t } if($c -notmatch '(?is)]*>'){ $noTable += $t } if($c -match 'Maria Elena Rodriguez'){ $stillMaria += $t } } $ok = ($deepened -ge 10 -and $lowFAQ.Count -eq 0 -and $noTable.Count -eq 0 -and $stillMaria.Count -eq 0) Res 'W13' $ok "marked-deepened=$deepened(want >=10); pages with <8 FAQ Q&A=$($lowFAQ.Count); pages missing comparison =$($noTable.Count); pages still naming Maria Elena Rodriguez=$($stillMaria.Count)" } $map = [ordered]@{ W1='Check-W1';W2='Check-W2';W3='Check-W3';W4='Check-W4';W5='Check-W5';W6='Check-W6';W7='Check-W7';W8='Check-W8';W9='Check-W9';W10='Check-W10';W11='Check-W11';W5b='Check-W5b';W12='Check-W12';W13='Check-W13' } Write-Host "REVIEW.ps1 repo=$RepoRoot" if($Only -eq 'all'){ foreach($k in $map.Keys){ & $map[$k] } } elseif($map.Contains($Only)){ & $map[$Only] } else{ Write-Host "Unknown -Only '$Only'. Use one of: $($map.Keys -join ', '), or 'all'." }