Files
odoo-expertos.com/seo-audit-2026-05-27/REVIEW.ps1
T
2026-08-28 10:11:11 -03:00

246 lines
13 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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,'<loc>(https[^<]+)</loc>')){
$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,'<title>(.*?)</title>',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,'<meta\s+name="description"\s+content="([^"]*)"',1).Groups[1].Value.Trim()
if($md){ if($metas.ContainsKey($md)){$metas[$md]++}else{$metas[$md]=1} }
}
$dupT = ($titles.GetEnumerator() | Where-Object {$_.Value -gt 1}).Count
$dupM = ($metas.GetEnumerator() | Where-Object {$_.Value -gt 1}).Count
$ok = ($long -eq 0 -and $dupM -eq 0)
Res 'W5' $ok "titles>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)<style[^>]*>(.*?)</style>') | 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,'<title>(.*?)</title>',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,'<meta\s+name="description"\s+content="([^"]*)"',1).Groups[1].Value.Trim()
if($md){ if($metas.ContainsKey($md)){$metas[$md]++}else{$metas[$md]=1}; if($md.Length -gt 165){$longM++}; if($md.Length -lt 130){$shortM++} }
}
$dupT = ($titles.GetEnumerator() | Where-Object {$_.Value -gt 1}).Count
$dupM = ($metas.GetEnumerator() | Where-Object {$_.Value -gt 1}).Count
$ok = ($dupT -eq 0 -and $dupM -eq 0 -and $long -eq 0 -and $longM -eq 0)
Res 'W5b' $ok "dup-titles=$dupT(want 0); dup-metas=$dupM(want 0); titles>65=$long; titles<40=$short(report); metas>165=$longM; metas<130=$shortM(report)"
}
function BodyWords($html){
$m = [regex]::Match($html,'(?s)<article[^>]*>(.*?)</article>',1)
if(-not $m.Success){ return 0 }
$body = $m.Groups[1].Value
$body = $body -replace '(?s)<script.*?</script>',''
$body = $body -replace '(?s)<style.*?</style>',''
$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)<table[^>]*>'){ $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 <table>=$($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'." }