From 9c1a63b34485b7e29e3795d67953668ab362e2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Wed, 14 Oct 2026 11:39:07 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(seo):=20melhorar=20robots.txt=20de=20p?= =?UTF-8?q?rodu=C3=A7=C3=A3o=20e=20gerar=20sitemap.xml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - robots.txt em produção libera explicitamente Googlebot (incluindo /static/ para renderização) e Googlebot-Image (/static/img/) - gera dist/sitemap.xml, que já era referenciado no robots.txt mas não existia Co-Authored-By: Claude Opus 5.5 --- main.py | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index a6922bc..bfd271e 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,9 @@ import http.server import random import shutil +from datetime import date from pathlib import Path +from xml.sax.saxutils import escape from jinja2 import Environment, FileSystemLoader from typer import Typer @@ -26,6 +28,29 @@ def load_settings() -> dict: return {} +def write_sitemap(site_url: str) -> None: + """Gera dist/sitemap.xml com as páginas renderizadas (referenciado no robots.txt).""" + lastmod = date.today().isoformat() + entries = [] + for page in sorted(output_folder.glob("*.html")): + path = "/" if page.name == "index.html" else f"/{page.name}" + priority = "1.0" if path == "/" else "0.8" + entries.append( + " \n" + f" {escape(site_url + path)}\n" + f" {lastmod}\n" + " weekly\n" + f" {priority}\n" + " " + ) + (output_folder / "sitemap.xml").write_text( + '\n' + '\n' + + "\n".join(entries) + + "\n\n" + ) + + @cli.command() def render(): """Gera os arquivos HTML estáticos na pasta dist.""" @@ -57,8 +82,24 @@ def render(): robots_file.write_text("User-agent: *\nDisallow: /\n") else: site_url = settings.get("event", {}).get("url", "").rstrip("/") - sitemap_line = f"\nSitemap: {site_url}/sitemap.xml" if site_url else "" - robots_file.write_text(f"User-agent: *\nAllow: /{sitemap_line}\n") + # CSS, JS e imagens liberados explicitamente: o Googlebot precisa deles + # para renderizar a página e avaliá-la corretamente (mobile-first). + robots_lines = [ + "User-agent: *", + "Allow: /", + "", + "User-agent: Googlebot", + "Allow: /", + "Allow: /static/", + "", + "User-agent: Googlebot-Image", + "Allow: /static/img/", + "", + ] + if site_url: + robots_lines.append(f"Sitemap: {site_url}/sitemap.xml") + write_sitemap(site_url) + robots_file.write_text("\n".join(robots_lines) + "\n") # Arquivo .nojekyll para o GitHub Pages servir arquivos estáticos diretamente (output_folder / ".nojekyll").touch() From b16ea62b9561ca8a09cdf3fa065b423c67085f96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Wed, 14 Oct 2026 11:47:44 -0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(seo):=20previews=20em=20redes=20sociai?= =?UTF-8?q?s=20com=20og:image=20absoluta=20e=20configur=C3=A1vel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - og:image e twitter:image passam a usar URL absoluta (exigido por Facebook, LinkedIn, WhatsApp e X), como no site da PyNE 2025 - imagem, texto alternativo e dimensões do preview configuráveis em variables.yaml (theme.preview_image*) - adiciona og:image:secure_url e og:image:width/height - ativa o ambiente de produção com indexação liberada Co-Authored-By: Claude Opus 5.5 --- templates/base.j2 | 33 +++++++++++++++++++++++---------- variables.yaml | 10 ++++++++-- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/templates/base.j2 b/templates/base.j2 index cf98ac0..c3354ec 100644 --- a/templates/base.j2 +++ b/templates/base.j2 @@ -7,6 +7,10 @@ {% set event_desc = event.description if event and event.description else (event_description | default('')) %} {% set event_url = event.url if event and event.url else ('https://' ~ event_edition ~ '.pythonnordeste.org/') %} {% set event_preview_image = theme.preview_image if theme and theme.preview_image else (preview_image | default('static/img/logo_pyne2027.png')) %} +{# Redes sociais (Facebook, LinkedIn, WhatsApp, X) exigem URL absoluta na og:image #} +{% set event_preview_image_url = event_preview_image if event_preview_image.startswith('http') else (event_url.rstrip('/') ~ '/' ~ event_preview_image.lstrip('/')) %} +{% set event_preview_image_alt = theme.preview_image_alt if theme and theme.preview_image_alt else ('Logo oficial da ' ~ event_name ~ ' ' ~ event_edition) %} +{% set event_title = event_name ~ ' ' ~ event_edition ~ ((' | ' ~ event_city ~ ' - ' ~ event_state) if event_city else '') %} {% set event_favicon = theme.favicon if theme and theme.favicon else (favicon | default('static/img/favicon.png')) %} {% set is_indexing_allowed = (environment != 'staging') and (allow_indexing | default(true)) %} @@ -39,22 +43,31 @@ - {{ event_name }} {{ event_edition }}{% if event_city %} | {{ event_city }} - {{ event_state }}{% endif %} - - + {{ event_title }} + + + - - - + + {% if event_preview_image_url.startswith('https://') %} + + {% endif %} + {% if theme and theme.preview_image_width and theme.preview_image_height %} + + + {% endif %} + + - + + - + - - + + diff --git a/variables.yaml b/variables.yaml index d9a7392..cc165f5 100644 --- a/variables.yaml +++ b/variables.yaml @@ -21,7 +21,13 @@ theme: text_dark: "#2B1B22" # Texto contrastante logo: "static/img/pyne_emblem.png" favicon: "static/img/favicon.png" + # Imagem do preview ao compartilhar o link em redes sociais (og:image / twitter:image). + # Aceita caminho relativo (convertido para URL absoluta usando event.url) ou URL completa. + # Recomendado: 1200x630 (ou 1920x1080), JPG/PNG, até 5MB. preview_image: "static/img/logo_pyne2027.png" + preview_image_alt: "Logo oficial da Python Nordeste 2027" + preview_image_width: 1920 + preview_image_height: 1080 # Links e Ações Principais (habilitados conforme a fase do evento) actions: @@ -52,6 +58,6 @@ google_tag: "G-Z6HYH6HCL2" # - Motores de busca (Google, Bing, etc.) são bloqueados com # - O arquivo robots.txt é gerado com "Disallow: /" # - O Google Analytics é pausado para não poluir as métricas reais -environment: "staging" # "staging" ou "production" -allow_indexing: false # Se false, impede indexação no Google / motores de busca +environment: "production" # "staging" ou "production" +allow_indexing: true # Se false, impede indexação no Google / motores de busca From c78a67125e4767de74cc475af92a5bacd7e54c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Carvalho?= Date: Wed, 14 Oct 2026 11:47:44 -0300 Subject: [PATCH 3/3] =?UTF-8?q?test:=20testes=20de=20indexa=C3=A7=C3=A3o?= =?UTF-8?q?=20independentes=20do=20ambiente=20em=20variables.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cobre staging e produção forçando o ambiente via monkeypatch - valida robots.txt, sitemap.xml, meta tags e previews sociais em produção - corrige aviso DTZ011 do ruff no write_sitemap Co-Authored-By: Claude Opus 5.5 --- main.py | 4 +- tests/test_site.py | 107 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/main.py b/main.py index bfd271e..761ea81 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ import http.server import random import shutil -from datetime import date +from datetime import UTC, datetime from pathlib import Path from xml.sax.saxutils import escape @@ -30,7 +30,7 @@ def load_settings() -> dict: def write_sitemap(site_url: str) -> None: """Gera dist/sitemap.xml com as páginas renderizadas (referenciado no robots.txt).""" - lastmod = date.today().isoformat() + lastmod = datetime.now(UTC).date().isoformat() entries = [] for page in sorted(output_folder.glob("*.html")): path = "/" if page.name == "index.html" else f"/{page.name}" diff --git a/tests/test_site.py b/tests/test_site.py index 047eff9..1183cc2 100644 --- a/tests/test_site.py +++ b/tests/test_site.py @@ -1,5 +1,8 @@ from pathlib import Path +import pytest + +import main from main import load_settings, output_folder, render, settings_file @@ -21,7 +24,7 @@ def test_referenced_theme_assets_exist(): for key in ["logo", "favicon", "preview_image"]: asset_path = theme.get(key) - if asset_path: + if asset_path and not asset_path.startswith("http"): assert Path(asset_path).exists(), ( f"O arquivo de tema '{key}' apontado para '{asset_path}' não existe." ) @@ -40,27 +43,103 @@ def test_render_generates_dist_and_index_html(): assert "Pessoas > Tecnologia" in content -def test_staging_environment_blocks_search_engines(): +@pytest.fixture +def render_with(monkeypatch): + """Renderiza o site sobrescrevendo chaves do variables.yaml, sem alterar o arquivo.""" + + def _render(**overrides): + settings = {**load_settings(), **overrides} + monkeypatch.setattr(main, "load_settings", lambda: settings) + render() + return settings + + return _render + + +def test_render_generates_github_pages_files(): render() - robots_file = output_folder / "robots.txt" - assert robots_file.exists(), "O arquivo dist/robots.txt deve ser gerado." - assert "Disallow: /" in robots_file.read_text(), ( - "Em staging, o robots.txt deve bloquear todos os robôs de busca." + assert (output_folder / "robots.txt").exists(), ( + "O arquivo dist/robots.txt deve ser gerado." + ) + assert (output_folder / "favicon.ico").exists(), ( + "O arquivo dist/favicon.ico deve ser copiado." + ) + assert (output_folder / ".nojekyll").exists(), ( + "O arquivo dist/.nojekyll deve ser gerado para o GitHub Pages." + ) + + +@pytest.mark.parametrize( + "overrides", + [ + {"environment": "staging", "allow_indexing": True}, + {"environment": "production", "allow_indexing": False}, + ], + ids=["staging", "production-sem-indexacao"], +) +def test_indexing_disabled_blocks_search_engines(render_with, overrides): + render_with(**overrides) + + assert "Disallow: /" in (output_folder / "robots.txt").read_text(), ( + "Sem indexação, o robots.txt deve bloquear todos os robôs de busca." + ) + assert not (output_folder / "sitemap.xml").exists(), ( + "Sem indexação, o sitemap.xml não deve ser gerado." ) index_html = (output_folder / "index.html").read_text(encoding="utf-8") - assert 'content="noindex, nofollow' in index_html, ( - "Em staging, a meta tag robots deve ser 'noindex, nofollow'." + assert 'name="robots" content="noindex, nofollow' in index_html, ( + "Sem indexação, a meta tag robots deve ser 'noindex, nofollow'." ) assert 'name="googlebot" content="noindex, nofollow' in index_html, ( - "Em staging, a meta tag googlebot deve ser 'noindex, nofollow'." + "Sem indexação, a meta tag googlebot deve ser 'noindex, nofollow'." ) + assert 'rel="canonical"' not in index_html assert "googletagmanager.com/gtag/js" not in index_html, ( - "Em staging, o Google Analytics não deve ser injetado." + "Sem indexação, o Google Analytics não deve ser injetado." ) - assert (output_folder / "favicon.ico").exists(), ( - "O arquivo dist/favicon.ico deve ser copiado." + + +def test_production_allows_search_engines(render_with): + settings = render_with(environment="production", allow_indexing=True) + site_url = settings["event"]["url"].rstrip("/") + + robots = (output_folder / "robots.txt").read_text() + assert "Disallow: /" not in robots, ( + "Em produção, o robots.txt não deve bloquear os robôs de busca." ) - assert (output_folder / ".nojekyll").exists(), ( - "O arquivo dist/.nojekyll deve ser gerado para o GitHub Pages." + assert "Allow: /" in robots + assert f"Sitemap: {site_url}/sitemap.xml" in robots + + sitemap = output_folder / "sitemap.xml" + assert sitemap.exists(), "Em produção, o dist/sitemap.xml deve ser gerado." + assert f"{site_url}/" in sitemap.read_text() + + index_html = (output_folder / "index.html").read_text(encoding="utf-8") + assert 'name="robots" content="index, follow"' in index_html + assert 'name="googlebot" content="index, follow' in index_html + assert f'rel="canonical" href="{site_url}/"' in index_html + if settings.get("google_tag"): + assert f"gtag/js?id={settings['google_tag']}" in index_html, ( + "Em produção, o Google Analytics deve ser injetado." + ) + + +def test_social_preview_uses_absolute_image_url(): + settings = load_settings() + render() + site_url = settings["event"]["url"].rstrip("/") + preview_image = settings["theme"]["preview_image"] + expected = ( + preview_image + if preview_image.startswith("http") + else f"{site_url}/{preview_image.lstrip('/')}" + ) + + index_html = (output_folder / "index.html").read_text(encoding="utf-8") + assert f'property="og:image" content="{expected}"' in index_html, ( + "A og:image deve ser uma URL absoluta para os previews em redes sociais." ) + assert f'name="twitter:image" content="{expected}"' in index_html + assert 'property="og:title"' in index_html + assert 'name="twitter:card" content="summary_large_image"' in index_html