diff --git a/boker/app.py b/boker/app.py index bd8fdce..4be0978 100644 --- a/boker/app.py +++ b/boker/app.py @@ -51,11 +51,18 @@ def create_app(config_overrides: dict | None = None) -> Flask: @app.context_processor def inject_globals() -> dict: + base_url = app.config.get("APP_BASE_URL", "https://myboker.org").rstrip("/") return { "app_version": app.config["APP_VERSION"], "current_user_id": current_user_id(), "is_logged_in": is_logged_in(), "current_user_is_site_admin": is_site_admin(), + "seo_site_url": base_url, + "seo_canonical_url": f"{base_url}{request.path}", + "seo_default_description": ( + "Free home poker league tracker for sessions, ledgers, leaderboards, " + "settlement, buy-ins, cashouts, and private game records." + ), } app.register_blueprint(public_bp) diff --git a/boker/routes/public.py b/boker/routes/public.py index 063b7fb..0cf144f 100644 --- a/boker/routes/public.py +++ b/boker/routes/public.py @@ -1,8 +1,11 @@ #!/usr/bin/env python3 from __future__ import annotations -from flask import Blueprint, flash, redirect, render_template, request, url_for -from flask import current_app +from datetime import date +from xml.sax.saxutils import escape as xml_escape + +from flask import Blueprint, Response, current_app, flash, redirect, render_template, request, url_for +from sqlalchemy.exc import SQLAlchemyError from boker.charts import cumulative_profit_series, player_session_series, session_breakdown_series from boker.config import ELIGIBLE_MIN_SESSIONS @@ -18,11 +21,74 @@ from boker.utils import session_label, session_sort_key public_bp = Blueprint("public", __name__) +def _absolute_url(path: str) -> str: + base_url = current_app.config.get("APP_BASE_URL", "https://myboker.org").rstrip("/") + return f"{base_url}{path}" + + +def _sitemap_url(path: str, priority: str, changefreq: str = "weekly") -> str: + return ( + " \n" + f" {xml_escape(_absolute_url(path))}\n" + f" {date.today().isoformat()}\n" + f" {changefreq}\n" + f" {priority}\n" + " " + ) + + @public_bp.get("/") def home(): return render_template("landing.html") +@public_bp.get("/robots.txt") +def robots_txt(): + body = "\n".join( + [ + "User-agent: *", + "Allow: /", + "Disallow: /internal/", + "Disallow: /account/", + "Sitemap: " + _absolute_url(url_for("public.sitemap_xml")), + "", + ] + ) + return Response(body, mimetype="text/plain") + + +@public_bp.get("/sitemap.xml") +def sitemap_xml(): + urls = [ + _sitemap_url(url_for("public.home"), "1.0", "weekly"), + _sitemap_url(url_for("public.explore"), "0.8", "daily"), + _sitemap_url(url_for("public.help"), "0.7", "monthly"), + _sitemap_url(url_for("public.privacy"), "0.3", "yearly"), + _sitemap_url(url_for("public.terms"), "0.3", "yearly"), + ] + + from boker.db import database_extensions_available + + if database_extensions_available(): + try: + from boker.league_repositories import list_public_leagues + + for league in list_public_leagues(): + urls.append(_sitemap_url(url_for("leagues.dashboard", league_ref=league.url_ref), "0.6", "weekly")) + urls.append(_sitemap_url(url_for("leagues.leaderboard", league_ref=league.url_ref), "0.5", "weekly")) + urls.append(_sitemap_url(url_for("leagues.sessions", league_ref=league.url_ref), "0.5", "weekly")) + except SQLAlchemyError: + current_app.logger.warning("Sitemap public league query failed", exc_info=True) + + body = ( + '\n' + '\n' + + "\n".join(urls) + + "\n\n" + ) + return Response(body, mimetype="application/xml") + + @public_bp.get("/help") def help(): return render_template("support.html") diff --git a/templates/base.html b/templates/base.html index 52e4214..be15448 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,12 +1,25 @@ + {% set page_title %}{% block title %}myboker.org{% endblock %}{% endset %} + {% set page_description %}{% block meta_description %}{{ seo_default_description }}{% endblock %}{% endset %} - {% block title %}myboker.org{% endblock %} + {{ page_title | trim }} + + + + + + + + + + + {% block structured_data %}{% endblock %} diff --git a/templates/explore.html b/templates/explore.html index 68ce088..5ba57c2 100644 --- a/templates/explore.html +++ b/templates/explore.html @@ -1,5 +1,6 @@ {% extends "base.html" %} -{% block title %}Explore leagues · myboker.org{% endblock %} +{% block title %}Explore Public Home Poker Leagues · myboker.org{% endblock %} +{% block meta_description %}Browse public home poker league results, leaderboards, sessions, and player stats from groups using myboker.org.{% endblock %} {% block content %}
diff --git a/templates/landing.html b/templates/landing.html index 0b1ae2c..715b0a8 100644 --- a/templates/landing.html +++ b/templates/landing.html @@ -1,5 +1,33 @@ {% extends "base.html" %} -{% block title %}myboker.org · Poker league records{% endblock %} +{% block title %}Free Home Poker Tracker, Ledger & League Leaderboards · myboker.org{% endblock %} +{% block meta_description %}Track home poker sessions, buy-ins, cashouts, settlements, player stats, and league leaderboards with a free append-only poker ledger built for private games.{% endblock %} +{% block structured_data %} + + +{% endblock %} {% block page_class %}page--landing{% endblock %} {% block content %} diff --git a/templates/privacy.html b/templates/privacy.html index 0d50e43..ec49ffc 100644 --- a/templates/privacy.html +++ b/templates/privacy.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block title %}Privacy Policy - myboker.org{% endblock %} +{% block meta_description %}Privacy policy for myboker.org, including account data, home poker league records, session ledgers, cookies, security, and data rights.{% endblock %} {% block page_class %}page--public{% endblock %} {% block content %} diff --git a/templates/support.html b/templates/support.html index c7b29c1..12027df 100644 --- a/templates/support.html +++ b/templates/support.html @@ -1,5 +1,6 @@ {% extends "base.html" %} -{% block title %}Help - myboker.org{% endblock %} +{% block title %}Home Poker League Tracker Help · myboker.org{% endblock %} +{% block meta_description %}Learn how myboker.org tracks home poker leagues, sessions, players, ledger events, settlements, leaderboards, roles, and public or private league visibility.{% endblock %} {% block content %}
diff --git a/templates/terms.html b/templates/terms.html index 0788162..d064a51 100644 --- a/templates/terms.html +++ b/templates/terms.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block title %}Terms of Service - myboker.org{% endblock %} +{% block meta_description %}Terms of Service for myboker.org, a free record-keeping tool for private home poker leagues, ledgers, sessions, and statistics.{% endblock %} {% block page_class %}page--public{% endblock %} {% block content %} diff --git a/tests/test_seo.py b/tests/test_seo.py new file mode 100644 index 0000000..e0967f2 --- /dev/null +++ b/tests/test_seo.py @@ -0,0 +1,65 @@ +import tempfile +import unittest +from pathlib import Path + +from app import create_app +from boker.db import db + + +class SeoTests(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + db_path = Path(self.tmpdir.name) / "test.sqlite3" + self.app = create_app( + { + "TESTING": True, + "APP_BASE_URL": "https://myboker.org", + "SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}", + } + ) + self.client = self.app.test_client() + with self.app.app_context(): + db.create_all() + + def tearDown(self): + with self.app.app_context(): + db.session.remove() + db.drop_all() + db.engine.dispose() + self.tmpdir.cleanup() + + def test_landing_page_has_search_metadata(self): + response = self.client.get("/") + html = response.get_data(as_text=True) + + self.assertEqual(response.status_code, 200) + self.assertIn("Free Home Poker Tracker, Ledger & League Leaderboards", html) + self.assertIn('name="description"', html) + self.assertIn("Track home poker sessions, buy-ins, cashouts, settlements", html) + self.assertIn('rel="canonical" href="https://myboker.org/"', html) + self.assertIn('application/ld+json', html) + + def test_robots_txt_allows_public_crawling_and_points_to_sitemap(self): + response = self.client.get("/robots.txt") + body = response.get_data(as_text=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.mimetype, "text/plain") + self.assertIn("Allow: /", body) + self.assertIn("Disallow: /internal/", body) + self.assertIn("Disallow: /account/", body) + self.assertIn("Sitemap: https://myboker.org/sitemap.xml", body) + + def test_sitemap_xml_lists_public_static_pages(self): + response = self.client.get("/sitemap.xml") + body = response.get_data(as_text=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.mimetype, "application/xml") + self.assertIn("https://myboker.org/", body) + self.assertIn("https://myboker.org/explore", body) + self.assertIn("https://myboker.org/help", body) + + +if __name__ == "__main__": + unittest.main()