This commit is contained in:
oliver
2026-08-28 10:11:11 -03:00
parent f533ff8e36
commit 585951c8ce
1380 changed files with 1540268 additions and 0 deletions
+246
View File
@@ -0,0 +1,246 @@
#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'." }