diff --git a/CHANGELOG.md b/CHANGELOG.md
index e07688d..13db160 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
+- **SEO/GEO admin hub** — syncs search performance data from Google Search Console (service
+ account auth), Bing Webmaster Tools (API key), and Umami (bearer token) into 3 new SQLite
+ tables (`seo_search_metrics`, `seo_analytics_metrics`, `seo_sync_log`); daily background
+ sync via worker scheduler at 6am UTC; admin dashboard at `/admin/seo` with three HTMX tab
+ views: search performance (top queries, top pages, country/device breakdown), full funnel
+ (impressions → clicks → pageviews → visitors → planner users → leads), and per-article
+ scorecard with attention flags (low CTR, no clicks); manual "Sync Now" button; 12-month
+ data retention with automatic cleanup; all data sources optional (skip silently if not
+ configured)
- **Landing zone backup to R2** — append-only landing files (`data/landing/*.json.gz`)
synced to Cloudflare R2 every 30 minutes via systemd timer + rclone; extraction state
DB (`.state.sqlite`) continuously replicated via Litestream (second DB entry in existing
diff --git a/PROJECT.md b/PROJECT.md
index e3c1993..b927341 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -111,6 +111,7 @@
- [x] English legal pages (GDPR, proper controller identity)
- [x] Cookie consent banner (functional/A/B categories, 1-year cookie)
- [x] Virtual office address on imprint
+- [x] SEO/GEO admin hub — GSC + Bing + Umami sync, search/funnel/scorecard views, daily background sync
### Other
- [x] A/B testing framework (`@ab_test` decorator + Umami `data-tag`)
@@ -140,7 +141,7 @@ _Move here when you start working on it._
| Publish SEO articles: run `seed_content --generate` on prod (or trigger from admin) | First LinkedIn post |
| Wipe 5 test suppliers (`example.com` entries from `seed_dev_data.py`) | |
| Verify Resend production API key — test magic link email | |
-| Submit sitemap to Google Search Console | Set up Google Search Console + Bing Webmaster Tools |
+| Submit sitemap to Google Search Console | Set up Google Search Console + Bing Webmaster Tools (SEO hub ready — just add env vars) |
| Verify Litestream R2 backup running on prod | |
### Week 1–2 — First Revenue
diff --git a/uv.lock b/uv.lock
index dfc7159..8a25b0f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -356,6 +356,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" },
]
+[[package]]
+name = "cryptography"
+version = "46.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
+ { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
+ { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
+ { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
+ { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
+ { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
+ { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
+ { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
+ { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
+ { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
+ { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
+ { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
+ { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
+ { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
+ { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
+ { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
+ { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" },
+]
+
[[package]]
name = "cssselect2"
version = "0.9.0"
@@ -511,6 +570,77 @@ woff = [
{ name = "zopfli" },
]
+[[package]]
+name = "google-api-core"
+version = "2.30.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-auth" },
+ { name = "googleapis-common-protos" },
+ { name = "proto-plus" },
+ { name = "protobuf" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" },
+]
+
+[[package]]
+name = "google-api-python-client"
+version = "2.190.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core" },
+ { name = "google-auth" },
+ { name = "google-auth-httplib2" },
+ { name = "httplib2" },
+ { name = "uritemplate" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/4ab3e3516b93bb50ed7814738ea61d49cba3f72f4e331dc9518ae2731e92/google_api_python_client-2.190.0.tar.gz", hash = "sha256:5357f34552e3724d80d2604c8fa146766e0a9d6bb0afada886fafed9feafeef6", size = 14111143, upload-time = "2026-02-12T00:38:03.37Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/ad/223d5f4b0b987669ffeb3eadd7e9f85ece633aa7fd3246f1e2f6238e1e05/google_api_python_client-2.190.0-py3-none-any.whl", hash = "sha256:d9b5266758f96c39b8c21d9bbfeb4e58c14dbfba3c931f7c5a8d7fdcd292dd57", size = 14682070, upload-time = "2026-02-12T00:38:00.974Z" },
+]
+
+[[package]]
+name = "google-auth"
+version = "2.48.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "pyasn1-modules" },
+ { name = "rsa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
+]
+
+[[package]]
+name = "google-auth-httplib2"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-auth" },
+ { name = "httplib2" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134, upload-time = "2025-12-15T22:13:51.825Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529, upload-time = "2025-12-15T22:13:51.048Z" },
+]
+
+[[package]]
+name = "googleapis-common-protos"
+version = "1.72.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
+]
+
[[package]]
name = "greenlet"
version = "3.3.1"
@@ -607,6 +737,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
+[[package]]
+name = "httplib2"
+version = "0.31.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyparsing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" },
+]
+
[[package]]
name = "httpx"
version = "0.28.1"
@@ -1152,6 +1294,9 @@ source = { editable = "web" }
dependencies = [
{ name = "aiosqlite" },
{ name = "duckdb" },
+ { name = "google-api-python-client" },
+ { name = "google-auth" },
+ { name = "httpx" },
{ name = "hypercorn" },
{ name = "itsdangerous" },
{ name = "jinja2" },
@@ -1169,6 +1314,9 @@ dependencies = [
requires-dist = [
{ name = "aiosqlite", specifier = ">=0.19.0" },
{ name = "duckdb", specifier = ">=1.0.0" },
+ { name = "google-api-python-client", specifier = ">=2.100.0" },
+ { name = "google-auth", specifier = ">=2.23.0" },
+ { name = "httpx", specifier = ">=0.27.0" },
{ name = "hypercorn", specifier = ">=0.17.0" },
{ name = "itsdangerous", specifier = ">=2.1.0" },
{ name = "jinja2", specifier = ">=3.1.0" },
@@ -1408,6 +1556,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
+[[package]]
+name = "proto-plus"
+version = "1.27.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "6.33.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" },
+ { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" },
+ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
+]
+
[[package]]
name = "ptyprocess"
version = "0.7.0"
@@ -1476,6 +1651,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" },
]
+[[package]]
+name = "pyasn1"
+version = "0.6.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
+]
+
+[[package]]
+name = "pyasn1-modules"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
+]
+
[[package]]
name = "pycparser"
version = "3.0"
@@ -1627,6 +1823,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
+[[package]]
+name = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
[[package]]
name = "pyphen"
version = "0.17.2"
@@ -2039,6 +2244,18 @@ jupyter = [
{ name = "ipywidgets" },
]
+[[package]]
+name = "rsa"
+version = "4.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
+]
+
[[package]]
name = "ruamel-yaml"
version = "0.19.1"
@@ -2383,6 +2600,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]
+[[package]]
+name = "uritemplate"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
+]
+
[[package]]
name = "urllib3"
version = "2.6.3"
diff --git a/web/pyproject.toml b/web/pyproject.toml
index 3738fc4..0d48f41 100644
--- a/web/pyproject.toml
+++ b/web/pyproject.toml
@@ -18,6 +18,9 @@ dependencies = [
"duckdb>=1.0.0",
"pyarrow>=23.0.1",
"pyyaml>=6.0",
+ "httpx>=0.27.0",
+ "google-api-python-client>=2.100.0",
+ "google-auth>=2.23.0",
]
[build-system]
diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py
index 2b8230d..16048b0 100644
--- a/web/src/padelnomics/admin/routes.py
+++ b/web/src/padelnomics/admin/routes.py
@@ -1767,3 +1767,134 @@ async def _rebuild_article(article_id: int):
body_html = await bake_scenario_cards(body_html, lang=lang)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
+
+
+# =============================================================================
+# SEO Hub
+# =============================================================================
+
+@bp.route("/seo")
+@role_required("admin")
+async def seo():
+ """SEO metrics hub — overview + tabs for search, funnel, scorecard."""
+ from ..seo import get_search_performance, get_sync_status
+
+ date_range_days = int(request.args.get("days", "28") or "28")
+ date_range_days = max(1, min(date_range_days, 730))
+
+ overview = await get_search_performance(date_range_days=date_range_days)
+ sync_status = await get_sync_status()
+
+ return await render_template(
+ "admin/seo.html",
+ overview=overview,
+ sync_status=sync_status,
+ date_range_days=date_range_days,
+ )
+
+
+@bp.route("/seo/search")
+@role_required("admin")
+async def seo_search():
+ """HTMX partial: search performance tab."""
+ from ..seo import (
+ get_country_breakdown,
+ get_device_breakdown,
+ get_top_pages,
+ get_top_queries,
+ )
+
+ days = int(request.args.get("days", "28") or "28")
+ days = max(1, min(days, 730))
+ source = request.args.get("source", "") or None
+
+ queries = await get_top_queries(date_range_days=days, source=source)
+ pages = await get_top_pages(date_range_days=days, source=source)
+ countries = await get_country_breakdown(date_range_days=days)
+ devices = await get_device_breakdown(date_range_days=days)
+
+ return await render_template(
+ "admin/partials/seo_search.html",
+ queries=queries,
+ pages=pages,
+ countries=countries,
+ devices=devices,
+ date_range_days=days,
+ current_source=source,
+ )
+
+
+@bp.route("/seo/funnel")
+@role_required("admin")
+async def seo_funnel():
+ """HTMX partial: full funnel view."""
+ from ..seo import get_funnel_metrics
+
+ days = int(request.args.get("days", "28") or "28")
+ days = max(1, min(days, 730))
+ funnel = await get_funnel_metrics(date_range_days=days)
+
+ return await render_template(
+ "admin/partials/seo_funnel.html",
+ funnel=funnel,
+ date_range_days=days,
+ )
+
+
+@bp.route("/seo/scorecard")
+@role_required("admin")
+async def seo_scorecard():
+ """HTMX partial: article scorecard."""
+ from ..seo import get_article_scorecard
+
+ days = int(request.args.get("days", "28") or "28")
+ days = max(1, min(days, 730))
+ template_slug = request.args.get("template_slug", "") or None
+ country_filter = request.args.get("country", "") or None
+ language = request.args.get("language", "") or None
+ sort_by = request.args.get("sort", "impressions")
+ sort_dir = request.args.get("dir", "desc")
+
+ scorecard = await get_article_scorecard(
+ date_range_days=days,
+ template_slug=template_slug,
+ country=country_filter,
+ language=language,
+ sort_by=sort_by,
+ sort_dir=sort_dir,
+ )
+
+ return await render_template(
+ "admin/partials/seo_scorecard.html",
+ scorecard=scorecard,
+ date_range_days=days,
+ current_template=template_slug,
+ current_country=country_filter,
+ current_language=language,
+ current_sort=sort_by,
+ current_dir=sort_dir,
+ )
+
+
+@bp.route("/seo/sync", methods=["POST"])
+@role_required("admin")
+@csrf_protect
+async def seo_sync_now():
+ """Manually trigger SEO data sync."""
+ from ..worker import enqueue
+
+ form = await request.form
+ source = form.get("source", "all")
+
+ if source == "all":
+ await enqueue("sync_gsc")
+ await enqueue("sync_bing")
+ await enqueue("sync_umami")
+ await flash("All SEO syncs queued.", "success")
+ elif source in ("gsc", "bing", "umami"):
+ await enqueue(f"sync_{source}")
+ await flash(f"{source.upper()} sync queued.", "success")
+ else:
+ await flash("Unknown source.", "error")
+
+ return redirect(url_for("admin.seo"))
diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html
index 4f19690..c71f47c 100644
--- a/web/src/padelnomics/admin/templates/admin/base_admin.html
+++ b/web/src/padelnomics/admin/templates/admin/base_admin.html
@@ -104,6 +104,12 @@
Audiences
+
+
+
+ SEO Hub
+
+
diff --git a/web/src/padelnomics/admin/templates/admin/partials/seo_funnel.html b/web/src/padelnomics/admin/templates/admin/partials/seo_funnel.html
new file mode 100644
index 0000000..ece0800
--- /dev/null
+++ b/web/src/padelnomics/admin/templates/admin/partials/seo_funnel.html
@@ -0,0 +1,96 @@
+
+
+
+{% set max_val = [funnel.impressions, funnel.clicks, funnel.pageviews, funnel.visitors, funnel.planner_users, funnel.leads] | max or 1 %}
+
+
+
Search (GSC + Bing)
+
+
+
ImpressionsSearch results shown
+
+
+ {{ "{:,}".format(funnel.impressions | int) }}
+
+
+
+
+
ClicksCTR: {{ "%.1f" | format(funnel.ctr * 100) }}%
+
+
+ {{ "{:,}".format(funnel.clicks | int) }}
+
+
+
+
Analytics (Umami)
+
+
+
Pageviews{% if funnel.clicks %}{{ "%.0f" | format(funnel.click_to_view * 100) }}% of clicks{% endif %}
+
+
+ {{ "{:,}".format(funnel.pageviews | int) }}
+
+
+
+
+
VisitorsUnique
+
+
+ {{ "{:,}".format(funnel.visitors | int) }}
+
+
+
+
Conversions (App)
+
+
+
Planner Users{% if funnel.visitors %}{{ "%.1f" | format(funnel.visitor_to_planner * 100) }}% of visitors{% endif %}
+
+
+ {{ "{:,}".format(funnel.planner_users | int) }}
+
+
+
+
+
Lead Requests{% if funnel.planner_users %}{{ "%.1f" | format(funnel.planner_to_lead * 100) }}% of planners{% endif %}
+
+
+ {{ "{:,}".format(funnel.leads | int) }}
+
+
+
+
+{% if not funnel.impressions and not funnel.pageviews and not funnel.planner_users %}
+
+
No funnel data yet. Run a sync to populate search and analytics metrics.
+
+{% endif %}
diff --git a/web/src/padelnomics/admin/templates/admin/partials/seo_scorecard.html b/web/src/padelnomics/admin/templates/admin/partials/seo_scorecard.html
new file mode 100644
index 0000000..49071e8
--- /dev/null
+++ b/web/src/padelnomics/admin/templates/admin/partials/seo_scorecard.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+{% if scorecard %}
+
+
+
+
+ | Title |
+ Impressions |
+ Clicks |
+ CTR |
+ Pos |
+ Views |
+ Bounce |
+ Published |
+ Flags |
+
+
+
+ {% for a in scorecard %}
+
+
+ {{ a.title or a.url_path }}
+ {% if a.template_slug %}
+ {{ a.template_slug }}
+ {% endif %}
+ |
+ {{ "{:,}".format(a.impressions | int) }} |
+ {{ "{:,}".format(a.clicks | int) }} |
+ {{ "%.1f" | format((a.ctr or 0) * 100) }}% |
+ {{ "%.1f" | format(a.position_avg or 0) }} |
+ {{ "{:,}".format(a.pageviews | int) }} |
+
+ {% if a.bounce_rate is not none %}{{ "%.0f" | format(a.bounce_rate * 100) }}%{% else %}-{% endif %}
+ |
+ {{ a.published_at[:10] if a.published_at else '-' }} |
+
+ {% if a.flag_low_ctr %}
+ Low CTR
+ {% endif %}
+ {% if a.flag_no_clicks %}
+ No Clicks
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+{{ scorecard | length }} articles shown
+{% else %}
+
+
No published articles match the current filters, or no search/analytics data synced yet.
+
+{% endif %}
diff --git a/web/src/padelnomics/admin/templates/admin/partials/seo_search.html b/web/src/padelnomics/admin/templates/admin/partials/seo_search.html
new file mode 100644
index 0000000..9499a30
--- /dev/null
+++ b/web/src/padelnomics/admin/templates/admin/partials/seo_search.html
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+ Top Queries
+ {% if queries %}
+
+
+
+
+ | Query |
+ Impressions |
+ Clicks |
+ CTR |
+ Pos |
+
+
+
+ {% for q in queries[:20] %}
+
+ | {{ q.query }} |
+ {{ "{:,}".format(q.impressions | int) }} |
+ {{ "{:,}".format(q.clicks | int) }} |
+ {{ "%.1f" | format((q.ctr or 0) * 100) }}% |
+ {{ "%.1f" | format(q.position_avg or 0) }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
No query data yet. Run a sync to populate.
+
+ {% endif %}
+
+
+
+
+ Top Pages
+ {% if pages %}
+
+
+
+
+ | Page |
+ Impressions |
+ Clicks |
+ CTR |
+ Pos |
+
+
+
+ {% for p in pages[:20] %}
+
+ | {{ p.page_url }} |
+ {{ "{:,}".format(p.impressions | int) }} |
+ {{ "{:,}".format(p.clicks | int) }} |
+ {{ "%.1f" | format((p.ctr or 0) * 100) }}% |
+ {{ "%.1f" | format(p.position_avg or 0) }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+ By Country
+ {% if countries %}
+
+
+ | Country | Impressions | Clicks |
+
+ {% for c in countries[:15] %}
+
+ | {{ c.country | upper }} |
+ {{ "{:,}".format(c.impressions | int) }} |
+ {{ "{:,}".format(c.clicks | int) }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+ By Device (GSC)
+ {% if devices %}
+
+
+ | Device | Impressions | Clicks |
+
+ {% for d in devices %}
+
+ | {{ d.device | capitalize }} |
+ {{ "{:,}".format(d.impressions | int) }} |
+ {{ "{:,}".format(d.clicks | int) }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+ No device data (GSC only).
+ {% endif %}
+
+
diff --git a/web/src/padelnomics/admin/templates/admin/seo.html b/web/src/padelnomics/admin/templates/admin/seo.html
new file mode 100644
index 0000000..0b0f295
--- /dev/null
+++ b/web/src/padelnomics/admin/templates/admin/seo.html
@@ -0,0 +1,149 @@
+{% extends "admin/base_admin.html" %}
+{% set admin_page = "seo" %}
+{% block title %}SEO Hub - Admin - {{ config.APP_NAME }}{% endblock %}
+
+{% block admin_head %}
+
+{% endblock %}
+
+{% block admin_content %}
+
+
+
+
+ Last sync:
+ {% for s in sync_status %}
+
+ {{ s.source | upper }}
+ {% if s.status == 'success' %}
+ {{ s.completed_at[:16] if s.completed_at else '' }} ({{ s.rows_synced }} rows)
+ {% elif s.status == 'failed' %}
+ failed
+ {% endif %}
+
+ {% endfor %}
+ {% if not sync_status %}
+ No syncs yet
+ {% endif %}
+
+
+
+
+
+ {% for d, label in [(7, '7d'), (28, '28d'), (90, '3m'), (180, '6m'), (365, '12m')] %}
+
+ {% endfor %}
+
+
+
+
+
+
+
Impressions
+
{{ "{:,}".format(overview.total_impressions | int) }}
+
+
+
Clicks
+
{{ "{:,}".format(overview.total_clicks | int) }}
+
+
+
Avg CTR
+
{{ "%.1f" | format(overview.avg_ctr * 100) }}%
+
+
+
Avg Position
+
{{ "%.1f" | format(overview.avg_position) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/web/src/padelnomics/core.py b/web/src/padelnomics/core.py
index a85e368..4d52e4e 100644
--- a/web/src/padelnomics/core.py
+++ b/web/src/padelnomics/core.py
@@ -54,6 +54,12 @@ class Config:
UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "")
UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70"
+ # SEO metrics sync
+ GSC_SERVICE_ACCOUNT_PATH: str = os.getenv("GSC_SERVICE_ACCOUNT_PATH", "")
+ GSC_SITE_URL: str = os.getenv("GSC_SITE_URL", "")
+ BING_WEBMASTER_API_KEY: str = os.getenv("BING_WEBMASTER_API_KEY", "")
+ BING_SITE_URL: str = os.getenv("BING_SITE_URL", "")
+
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")
LEADS_EMAIL: str = _env("LEADS_EMAIL", "leads@padelnomics.io")
diff --git a/web/src/padelnomics/migrations/versions/0019_add_seo_metrics.py b/web/src/padelnomics/migrations/versions/0019_add_seo_metrics.py
new file mode 100644
index 0000000..aea6400
--- /dev/null
+++ b/web/src/padelnomics/migrations/versions/0019_add_seo_metrics.py
@@ -0,0 +1,84 @@
+"""Add SEO metrics tables for GSC, Bing, and Umami data sync.
+
+Three tables:
+ - seo_search_metrics — daily search data per page+query (GSC + Bing)
+ - seo_analytics_metrics — daily page analytics (Umami)
+ - seo_sync_log — tracks sync state per source
+"""
+
+
+def up(conn):
+ # ── 1. Search metrics (GSC + Bing) ─────────────────────────────────
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS seo_search_metrics (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source TEXT NOT NULL,
+ metric_date TEXT NOT NULL,
+ page_url TEXT NOT NULL,
+ query TEXT,
+ country TEXT,
+ device TEXT,
+ clicks INTEGER NOT NULL DEFAULT 0,
+ impressions INTEGER NOT NULL DEFAULT 0,
+ ctr REAL,
+ position_avg REAL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ # COALESCE converts NULLs to '' for unique index (SQLite treats
+ # NULL as distinct in UNIQUE constraints, causing duplicate rows)
+ conn.execute("""
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_seo_search_dedup
+ ON seo_search_metrics(
+ source, metric_date, page_url,
+ COALESCE(query, ''), COALESCE(country, ''), COALESCE(device, '')
+ )
+ """)
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_seo_search_date"
+ " ON seo_search_metrics(metric_date)"
+ )
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_seo_search_page"
+ " ON seo_search_metrics(page_url)"
+ )
+
+ # ── 2. Analytics metrics (Umami) ───────────────────────────────────
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS seo_analytics_metrics (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ metric_date TEXT NOT NULL,
+ page_url TEXT NOT NULL,
+ pageviews INTEGER NOT NULL DEFAULT 0,
+ visitors INTEGER NOT NULL DEFAULT 0,
+ bounce_rate REAL,
+ time_avg_seconds INTEGER,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.execute("""
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_seo_analytics_dedup
+ ON seo_analytics_metrics(metric_date, page_url)
+ """)
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_seo_analytics_date"
+ " ON seo_analytics_metrics(metric_date)"
+ )
+
+ # ── 3. Sync log ────────────────────────────────────────────────────
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS seo_sync_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source TEXT NOT NULL,
+ status TEXT NOT NULL,
+ rows_synced INTEGER NOT NULL DEFAULT 0,
+ error TEXT,
+ started_at TEXT NOT NULL,
+ completed_at TEXT,
+ duration_ms INTEGER
+ )
+ """)
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_seo_sync_source"
+ " ON seo_sync_log(source, started_at)"
+ )
diff --git a/web/src/padelnomics/seo/__init__.py b/web/src/padelnomics/seo/__init__.py
new file mode 100644
index 0000000..40a4e2b
--- /dev/null
+++ b/web/src/padelnomics/seo/__init__.py
@@ -0,0 +1,36 @@
+"""
+SEO metrics sync and query module.
+
+Syncs data from Google Search Console, Bing Webmaster Tools, and Umami
+into SQLite tables. Query functions support the admin SEO hub views.
+"""
+
+from ._bing import sync_bing
+from ._gsc import sync_gsc
+from ._queries import (
+ cleanup_old_metrics,
+ get_article_scorecard,
+ get_country_breakdown,
+ get_device_breakdown,
+ get_funnel_metrics,
+ get_search_performance,
+ get_sync_status,
+ get_top_pages,
+ get_top_queries,
+)
+from ._umami import sync_umami
+
+__all__ = [
+ "sync_gsc",
+ "sync_bing",
+ "sync_umami",
+ "get_search_performance",
+ "get_top_queries",
+ "get_top_pages",
+ "get_country_breakdown",
+ "get_device_breakdown",
+ "get_funnel_metrics",
+ "get_article_scorecard",
+ "get_sync_status",
+ "cleanup_old_metrics",
+]
diff --git a/web/src/padelnomics/seo/_bing.py b/web/src/padelnomics/seo/_bing.py
new file mode 100644
index 0000000..5a76446
--- /dev/null
+++ b/web/src/padelnomics/seo/_bing.py
@@ -0,0 +1,142 @@
+"""Bing Webmaster Tools sync via REST API.
+
+Uses an API key for auth. Fetches query stats and page stats.
+"""
+
+from datetime import datetime, timedelta
+from urllib.parse import urlparse
+
+import httpx
+
+from ..core import config, execute
+
+_TIMEOUT_SECONDS = 30
+
+
+def _normalize_url(full_url: str) -> str:
+ """Strip a full URL to just the path."""
+ parsed = urlparse(full_url)
+ return parsed.path or "/"
+
+
+async def sync_bing(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS) -> int:
+ """Sync Bing Webmaster query stats into seo_search_metrics. Returns rows synced."""
+ assert 1 <= days_back <= 90, "days_back must be 1-90"
+ assert 1 <= timeout_seconds <= 120, "timeout_seconds must be 1-120"
+
+ if not config.BING_WEBMASTER_API_KEY or not config.BING_SITE_URL:
+ return 0 # Bing not configured — skip silently
+
+ started_at = datetime.utcnow()
+
+ try:
+ rows_synced = 0
+ async with httpx.AsyncClient(timeout=timeout_seconds) as client:
+ # Fetch query stats for the date range
+ response = await client.get(
+ "https://ssl.bing.com/webmaster/api.svc/json/GetQueryStats",
+ params={
+ "apikey": config.BING_WEBMASTER_API_KEY,
+ "siteUrl": config.BING_SITE_URL,
+ },
+ )
+ response.raise_for_status()
+ data = response.json()
+
+ # Bing returns {"d": [{"Query": ..., "Date": ..., ...}, ...]}
+ entries = data.get("d", []) if isinstance(data, dict) else data
+ if not isinstance(entries, list):
+ entries = []
+
+ cutoff = datetime.utcnow() - timedelta(days=days_back)
+
+ for entry in entries:
+ # Bing date format: "/Date(1708905600000)/" (ms since epoch)
+ date_str = entry.get("Date", "")
+ if "/Date(" in date_str:
+ ms = int(date_str.split("(")[1].split(")")[0])
+ entry_date = datetime.utcfromtimestamp(ms / 1000)
+ else:
+ continue
+
+ if entry_date < cutoff:
+ continue
+
+ metric_date = entry_date.strftime("%Y-%m-%d")
+ query = entry.get("Query", "")
+
+ await execute(
+ """INSERT OR REPLACE INTO seo_search_metrics
+ (source, metric_date, page_url, query, country, device,
+ clicks, impressions, ctr, position_avg)
+ VALUES ('bing', ?, '/', ?, NULL, NULL, ?, ?, ?, ?)""",
+ (
+ metric_date, query,
+ entry.get("Clicks", 0),
+ entry.get("Impressions", 0),
+ entry.get("AvgCTR", 0.0),
+ entry.get("AvgClickPosition", 0.0),
+ ),
+ )
+ rows_synced += 1
+
+ # Also fetch page-level stats
+ page_response = await client.get(
+ "https://ssl.bing.com/webmaster/api.svc/json/GetPageStats",
+ params={
+ "apikey": config.BING_WEBMASTER_API_KEY,
+ "siteUrl": config.BING_SITE_URL,
+ },
+ )
+ page_response.raise_for_status()
+ page_data = page_response.json()
+
+ page_entries = page_data.get("d", []) if isinstance(page_data, dict) else page_data
+ if not isinstance(page_entries, list):
+ page_entries = []
+
+ for entry in page_entries:
+ date_str = entry.get("Date", "")
+ if "/Date(" in date_str:
+ ms = int(date_str.split("(")[1].split(")")[0])
+ entry_date = datetime.utcfromtimestamp(ms / 1000)
+ else:
+ continue
+
+ if entry_date < cutoff:
+ continue
+
+ metric_date = entry_date.strftime("%Y-%m-%d")
+ page_url = _normalize_url(entry.get("Url", "/"))
+
+ await execute(
+ """INSERT OR REPLACE INTO seo_search_metrics
+ (source, metric_date, page_url, query, country, device,
+ clicks, impressions, ctr, position_avg)
+ VALUES ('bing', ?, ?, '', NULL, NULL, ?, ?, NULL, NULL)""",
+ (
+ metric_date, page_url,
+ entry.get("Clicks", 0),
+ entry.get("Impressions", 0),
+ ),
+ )
+ rows_synced += 1
+
+ duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
+ await execute(
+ """INSERT INTO seo_sync_log
+ (source, status, rows_synced, started_at, completed_at, duration_ms)
+ VALUES ('bing', 'success', ?, ?, ?, ?)""",
+ (rows_synced, started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
+ )
+ return rows_synced
+
+ except Exception as exc:
+ duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
+ await execute(
+ """INSERT INTO seo_sync_log
+ (source, status, rows_synced, error, started_at, completed_at, duration_ms)
+ VALUES ('bing', 'failed', 0, ?, ?, ?, ?)""",
+ (str(exc), started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
+ )
+ raise
diff --git a/web/src/padelnomics/seo/_gsc.py b/web/src/padelnomics/seo/_gsc.py
new file mode 100644
index 0000000..83fa70e
--- /dev/null
+++ b/web/src/padelnomics/seo/_gsc.py
@@ -0,0 +1,142 @@
+"""Google Search Console sync via Search Analytics API.
+
+Uses a service account JSON key file for auth. The google-api-python-client
+is synchronous, so sync runs in asyncio.to_thread().
+"""
+
+import asyncio
+from datetime import datetime, timedelta
+from pathlib import Path
+from urllib.parse import urlparse
+
+from ..core import config, execute
+
+# GSC returns max 25K rows per request
+_ROWS_PER_PAGE = 25_000
+
+
+def _fetch_gsc_data(
+ start_date: str,
+ end_date: str,
+ max_pages: int,
+) -> list[dict]:
+ """Synchronous GSC fetch — called via asyncio.to_thread().
+
+ Returns list of dicts with keys: date, page, query, country, device,
+ clicks, impressions, ctr, position.
+ """
+ from google.oauth2.service_account import Credentials
+ from googleapiclient.discovery import build
+
+ key_path = Path(config.GSC_SERVICE_ACCOUNT_PATH)
+ assert key_path.exists(), f"GSC service account key not found: {key_path}"
+
+ credentials = Credentials.from_service_account_file(
+ str(key_path),
+ scopes=["https://www.googleapis.com/auth/webmasters.readonly"],
+ )
+ service = build("searchconsole", "v1", credentials=credentials)
+
+ all_rows = []
+ start_row = 0
+
+ for _page_num in range(max_pages):
+ body = {
+ "startDate": start_date,
+ "endDate": end_date,
+ "dimensions": ["date", "page", "query", "country", "device"],
+ "rowLimit": _ROWS_PER_PAGE,
+ "startRow": start_row,
+ }
+ response = service.searchanalytics().query(
+ siteUrl=config.GSC_SITE_URL,
+ body=body,
+ ).execute()
+
+ rows = response.get("rows", [])
+ if not rows:
+ break
+
+ for row in rows:
+ keys = row["keys"]
+ all_rows.append({
+ "date": keys[0],
+ "page": keys[1],
+ "query": keys[2],
+ "country": keys[3],
+ "device": keys[4],
+ "clicks": row.get("clicks", 0),
+ "impressions": row.get("impressions", 0),
+ "ctr": row.get("ctr", 0.0),
+ "position": row.get("position", 0.0),
+ })
+
+ if len(rows) < _ROWS_PER_PAGE:
+ break
+ start_row += _ROWS_PER_PAGE
+
+ return all_rows
+
+
+def _normalize_url(full_url: str) -> str:
+ """Strip a full URL to just the path (no domain).
+
+ Example: 'https://padelnomics.io/en/markets/germany/berlin' → '/en/markets/germany/berlin'
+ """
+ parsed = urlparse(full_url)
+ return parsed.path or "/"
+
+
+async def sync_gsc(days_back: int = 3, max_pages: int = 10) -> int:
+ """Sync GSC search analytics into seo_search_metrics. Returns rows synced."""
+ assert 1 <= days_back <= 90, "days_back must be 1-90"
+ assert 1 <= max_pages <= 20, "max_pages must be 1-20"
+
+ if not config.GSC_SERVICE_ACCOUNT_PATH or not config.GSC_SITE_URL:
+ return 0 # GSC not configured — skip silently
+
+ started_at = datetime.utcnow()
+
+ # GSC has ~2 day delay; fetch from days_back ago to 2 days ago
+ end_date = (datetime.utcnow() - timedelta(days=2)).strftime("%Y-%m-%d")
+ start_date = (datetime.utcnow() - timedelta(days=days_back + 2)).strftime("%Y-%m-%d")
+
+ try:
+ rows = await asyncio.to_thread(
+ _fetch_gsc_data, start_date, end_date, max_pages,
+ )
+
+ rows_synced = 0
+ for row in rows:
+ page_url = _normalize_url(row["page"])
+ await execute(
+ """INSERT OR REPLACE INTO seo_search_metrics
+ (source, metric_date, page_url, query, country, device,
+ clicks, impressions, ctr, position_avg)
+ VALUES ('gsc', ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (
+ row["date"], page_url, row["query"], row["country"],
+ row["device"], row["clicks"], row["impressions"],
+ row["ctr"], row["position"],
+ ),
+ )
+ rows_synced += 1
+
+ duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
+ await execute(
+ """INSERT INTO seo_sync_log
+ (source, status, rows_synced, started_at, completed_at, duration_ms)
+ VALUES ('gsc', 'success', ?, ?, ?, ?)""",
+ (rows_synced, started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
+ )
+ return rows_synced
+
+ except Exception as exc:
+ duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
+ await execute(
+ """INSERT INTO seo_sync_log
+ (source, status, rows_synced, error, started_at, completed_at, duration_ms)
+ VALUES ('gsc', 'failed', 0, ?, ?, ?, ?)""",
+ (str(exc), started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
+ )
+ raise
diff --git a/web/src/padelnomics/seo/_queries.py b/web/src/padelnomics/seo/_queries.py
new file mode 100644
index 0000000..94434c0
--- /dev/null
+++ b/web/src/padelnomics/seo/_queries.py
@@ -0,0 +1,379 @@
+"""SQL query functions for the admin SEO hub views.
+
+All heavy lifting happens in SQL. Functions accept filter parameters
+and return plain dicts/lists.
+"""
+
+from datetime import datetime, timedelta
+
+from ..core import execute, fetch_all, fetch_one
+
+
+def _date_cutoff(date_range_days: int) -> str:
+ """Return ISO date string for N days ago."""
+ return (datetime.utcnow() - timedelta(days=date_range_days)).strftime("%Y-%m-%d")
+
+
+async def get_search_performance(
+ date_range_days: int = 28,
+ source: str | None = None,
+) -> dict:
+ """Aggregate search performance: total clicks, impressions, avg CTR, avg position."""
+ assert 1 <= date_range_days <= 730
+
+ cutoff = _date_cutoff(date_range_days)
+ source_filter = "AND source = ?" if source else ""
+ params = [cutoff]
+ if source:
+ params.append(source)
+
+ row = await fetch_one(
+ f"""SELECT
+ COALESCE(SUM(clicks), 0) AS total_clicks,
+ COALESCE(SUM(impressions), 0) AS total_impressions,
+ CASE WHEN SUM(impressions) > 0
+ THEN CAST(SUM(clicks) AS REAL) / SUM(impressions)
+ ELSE 0 END AS avg_ctr,
+ CASE WHEN SUM(impressions) > 0
+ THEN SUM(position_avg * impressions) / SUM(impressions)
+ ELSE 0 END AS avg_position
+ FROM seo_search_metrics
+ WHERE metric_date >= ? {source_filter}""",
+ tuple(params),
+ )
+ return dict(row) if row else {
+ "total_clicks": 0, "total_impressions": 0,
+ "avg_ctr": 0, "avg_position": 0,
+ }
+
+
+async def get_top_queries(
+ date_range_days: int = 28,
+ source: str | None = None,
+ limit: int = 50,
+) -> list[dict]:
+ """Top queries by impressions with clicks, CTR, avg position."""
+ assert 1 <= date_range_days <= 730
+ assert 1 <= limit <= 500
+
+ cutoff = _date_cutoff(date_range_days)
+ source_filter = "AND source = ?" if source else ""
+ params: list = [cutoff]
+ if source:
+ params.append(source)
+ params.append(limit)
+
+ rows = await fetch_all(
+ f"""SELECT
+ query,
+ SUM(clicks) AS clicks,
+ SUM(impressions) AS impressions,
+ CASE WHEN SUM(impressions) > 0
+ THEN CAST(SUM(clicks) AS REAL) / SUM(impressions)
+ ELSE 0 END AS ctr,
+ CASE WHEN SUM(impressions) > 0
+ THEN SUM(position_avg * impressions) / SUM(impressions)
+ ELSE 0 END AS position_avg
+ FROM seo_search_metrics
+ WHERE metric_date >= ?
+ AND query IS NOT NULL AND query != ''
+ {source_filter}
+ GROUP BY query
+ ORDER BY impressions DESC
+ LIMIT ?""",
+ tuple(params),
+ )
+ return [dict(r) for r in rows]
+
+
+async def get_top_pages(
+ date_range_days: int = 28,
+ source: str | None = None,
+ limit: int = 50,
+) -> list[dict]:
+ """Top pages by impressions with clicks, CTR, avg position."""
+ assert 1 <= date_range_days <= 730
+ assert 1 <= limit <= 500
+
+ cutoff = _date_cutoff(date_range_days)
+ source_filter = "AND source = ?" if source else ""
+ params: list = [cutoff]
+ if source:
+ params.append(source)
+ params.append(limit)
+
+ rows = await fetch_all(
+ f"""SELECT
+ page_url,
+ SUM(clicks) AS clicks,
+ SUM(impressions) AS impressions,
+ CASE WHEN SUM(impressions) > 0
+ THEN CAST(SUM(clicks) AS REAL) / SUM(impressions)
+ ELSE 0 END AS ctr,
+ CASE WHEN SUM(impressions) > 0
+ THEN SUM(position_avg * impressions) / SUM(impressions)
+ ELSE 0 END AS position_avg
+ FROM seo_search_metrics
+ WHERE metric_date >= ?
+ {source_filter}
+ GROUP BY page_url
+ ORDER BY impressions DESC
+ LIMIT ?""",
+ tuple(params),
+ )
+ return [dict(r) for r in rows]
+
+
+async def get_country_breakdown(
+ date_range_days: int = 28,
+) -> list[dict]:
+ """Clicks and impressions by country."""
+ assert 1 <= date_range_days <= 730
+
+ cutoff = _date_cutoff(date_range_days)
+ rows = await fetch_all(
+ """SELECT
+ country,
+ SUM(clicks) AS clicks,
+ SUM(impressions) AS impressions
+ FROM seo_search_metrics
+ WHERE metric_date >= ?
+ AND country IS NOT NULL AND country != ''
+ GROUP BY country
+ ORDER BY impressions DESC
+ LIMIT 50""",
+ (cutoff,),
+ )
+ return [dict(r) for r in rows]
+
+
+async def get_device_breakdown(
+ date_range_days: int = 28,
+) -> list[dict]:
+ """Clicks and impressions by device type (GSC only)."""
+ assert 1 <= date_range_days <= 730
+
+ cutoff = _date_cutoff(date_range_days)
+ rows = await fetch_all(
+ """SELECT
+ device,
+ SUM(clicks) AS clicks,
+ SUM(impressions) AS impressions
+ FROM seo_search_metrics
+ WHERE metric_date >= ?
+ AND source = 'gsc'
+ AND device IS NOT NULL AND device != ''
+ GROUP BY device
+ ORDER BY impressions DESC""",
+ (cutoff,),
+ )
+ return [dict(r) for r in rows]
+
+
+async def get_funnel_metrics(
+ date_range_days: int = 28,
+) -> dict:
+ """Full funnel: search → analytics → conversions.
+
+ Combines search metrics (GSC/Bing), analytics (Umami), and
+ business metrics (planner users, leads) from SQLite.
+ """
+ assert 1 <= date_range_days <= 730
+
+ cutoff = _date_cutoff(date_range_days)
+
+ # Search layer
+ search = await fetch_one(
+ """SELECT
+ COALESCE(SUM(impressions), 0) AS impressions,
+ COALESCE(SUM(clicks), 0) AS clicks
+ FROM seo_search_metrics
+ WHERE metric_date >= ?""",
+ (cutoff,),
+ )
+
+ # Analytics layer
+ analytics = await fetch_one(
+ """SELECT
+ COALESCE(SUM(pageviews), 0) AS pageviews,
+ COALESCE(SUM(visitors), 0) AS visitors
+ FROM seo_analytics_metrics
+ WHERE metric_date >= ?
+ AND page_url != '/'""",
+ (cutoff,),
+ )
+
+ # Business layer (from existing SQLite tables)
+ planner_users = await fetch_one(
+ """SELECT COUNT(DISTINCT user_id) AS cnt
+ FROM scenarios
+ WHERE deleted_at IS NULL
+ AND created_at >= ?""",
+ (cutoff,),
+ )
+
+ leads = await fetch_one(
+ """SELECT COUNT(*) AS cnt
+ FROM lead_requests
+ WHERE lead_type = 'quote'
+ AND created_at >= ?""",
+ (cutoff,),
+ )
+
+ imp = search["impressions"] if search else 0
+ clicks = search["clicks"] if search else 0
+ pvs = analytics["pageviews"] if analytics else 0
+ vis = analytics["visitors"] if analytics else 0
+ planners = planner_users["cnt"] if planner_users else 0
+ lead_count = leads["cnt"] if leads else 0
+
+ return {
+ "impressions": imp,
+ "clicks": clicks,
+ "pageviews": pvs,
+ "visitors": vis,
+ "planner_users": planners,
+ "leads": lead_count,
+ # Conversion rates between stages
+ "ctr": clicks / imp if imp > 0 else 0,
+ "click_to_view": pvs / clicks if clicks > 0 else 0,
+ "view_to_visitor": vis / pvs if pvs > 0 else 0,
+ "visitor_to_planner": planners / vis if vis > 0 else 0,
+ "planner_to_lead": lead_count / planners if planners > 0 else 0,
+ }
+
+
+async def get_article_scorecard(
+ date_range_days: int = 28,
+ template_slug: str | None = None,
+ country: str | None = None,
+ language: str | None = None,
+ sort_by: str = "impressions",
+ sort_dir: str = "desc",
+ limit: int = 100,
+) -> list[dict]:
+ """Per-article scorecard joining articles + search + analytics metrics.
+
+ Returns article metadata enriched with search and analytics data,
+ plus attention flags for articles needing action.
+ """
+ assert 1 <= date_range_days <= 730
+ assert 1 <= limit <= 500
+ assert sort_dir in ("asc", "desc")
+
+ # Allowlist sort columns to prevent SQL injection
+ sort_columns = {
+ "impressions", "clicks", "ctr", "position_avg",
+ "pageviews", "title", "published_at",
+ }
+ if sort_by not in sort_columns:
+ sort_by = "impressions"
+
+ cutoff = _date_cutoff(date_range_days)
+
+ wheres = ["a.status = 'published'"]
+ params: list = [cutoff, cutoff]
+
+ if template_slug:
+ wheres.append("a.template_slug = ?")
+ params.append(template_slug)
+ if country:
+ wheres.append("a.country = ?")
+ params.append(country)
+ if language:
+ wheres.append("a.language = ?")
+ params.append(language)
+
+ where_clause = " AND ".join(wheres)
+ params.append(limit)
+
+ rows = await fetch_all(
+ f"""SELECT
+ a.id,
+ a.title,
+ a.url_path,
+ a.template_slug,
+ a.country,
+ a.language,
+ a.published_at,
+ COALESCE(s.impressions, 0) AS impressions,
+ COALESCE(s.clicks, 0) AS clicks,
+ COALESCE(s.ctr, 0) AS ctr,
+ COALESCE(s.position_avg, 0) AS position_avg,
+ COALESCE(u.pageviews, 0) AS pageviews,
+ COALESCE(u.visitors, 0) AS visitors,
+ u.bounce_rate,
+ u.time_avg_seconds,
+ -- Attention flags
+ CASE WHEN COALESCE(s.impressions, 0) > 100
+ AND COALESCE(s.ctr, 0) < 0.02
+ THEN 1 ELSE 0 END AS flag_low_ctr,
+ CASE WHEN COALESCE(s.clicks, 0) = 0
+ AND a.published_at <= date('now', '-30 days')
+ THEN 1 ELSE 0 END AS flag_no_clicks
+ FROM articles a
+ LEFT JOIN (
+ SELECT page_url,
+ SUM(impressions) AS impressions,
+ SUM(clicks) AS clicks,
+ CASE WHEN SUM(impressions) > 0
+ THEN CAST(SUM(clicks) AS REAL) / SUM(impressions)
+ ELSE 0 END AS ctr,
+ CASE WHEN SUM(impressions) > 0
+ THEN SUM(position_avg * impressions) / SUM(impressions)
+ ELSE 0 END AS position_avg
+ FROM seo_search_metrics
+ WHERE metric_date >= ?
+ GROUP BY page_url
+ ) s ON s.page_url = a.url_path
+ LEFT JOIN (
+ SELECT page_url,
+ SUM(pageviews) AS pageviews,
+ SUM(visitors) AS visitors,
+ AVG(bounce_rate) AS bounce_rate,
+ AVG(time_avg_seconds) AS time_avg_seconds
+ FROM seo_analytics_metrics
+ WHERE metric_date >= ?
+ GROUP BY page_url
+ ) u ON u.page_url = a.url_path
+ WHERE {where_clause}
+ ORDER BY {sort_by} {sort_dir}
+ LIMIT ?""",
+ tuple(params),
+ )
+ return [dict(r) for r in rows]
+
+
+async def get_sync_status() -> list[dict]:
+ """Last sync status for each source (gsc, bing, umami)."""
+ rows = await fetch_all(
+ """SELECT source, status, rows_synced, error,
+ started_at, completed_at, duration_ms
+ FROM seo_sync_log
+ WHERE id IN (
+ SELECT MAX(id) FROM seo_sync_log GROUP BY source
+ )
+ ORDER BY source"""
+ )
+ return [dict(r) for r in rows]
+
+
+async def cleanup_old_metrics(retention_days: int = 365) -> int:
+ """Delete metrics older than retention_days. Returns rows deleted."""
+ assert 30 <= retention_days <= 1095
+
+ cutoff = _date_cutoff(retention_days)
+
+ deleted_search = await execute(
+ "DELETE FROM seo_search_metrics WHERE metric_date < ?", (cutoff,)
+ )
+ deleted_analytics = await execute(
+ "DELETE FROM seo_analytics_metrics WHERE metric_date < ?", (cutoff,)
+ )
+ # Sync log: keep 30 days
+ sync_cutoff = _date_cutoff(30)
+ deleted_sync = await execute(
+ "DELETE FROM seo_sync_log WHERE started_at < ?", (sync_cutoff,)
+ )
+
+ return (deleted_search or 0) + (deleted_analytics or 0) + (deleted_sync or 0)
diff --git a/web/src/padelnomics/seo/_umami.py b/web/src/padelnomics/seo/_umami.py
new file mode 100644
index 0000000..c35f357
--- /dev/null
+++ b/web/src/padelnomics/seo/_umami.py
@@ -0,0 +1,116 @@
+"""Umami analytics sync via REST API.
+
+Uses bearer token auth. Self-hosted instance, no rate limits.
+Config already exists: UMAMI_API_URL, UMAMI_API_TOKEN, UMAMI_WEBSITE_ID.
+"""
+
+from datetime import datetime, timedelta
+
+import httpx
+
+from ..core import config, execute
+
+_TIMEOUT_SECONDS = 15
+
+
+async def sync_umami(days_back: int = 3, timeout_seconds: int = _TIMEOUT_SECONDS) -> int:
+ """Sync Umami per-URL metrics into seo_analytics_metrics. Returns rows synced."""
+ assert 1 <= days_back <= 90, "days_back must be 1-90"
+ assert 1 <= timeout_seconds <= 120, "timeout_seconds must be 1-120"
+
+ if not config.UMAMI_API_TOKEN or not config.UMAMI_API_URL:
+ return 0 # Umami not configured — skip silently
+
+ started_at = datetime.utcnow()
+
+ try:
+ rows_synced = 0
+ headers = {"Authorization": f"Bearer {config.UMAMI_API_TOKEN}"}
+ base = config.UMAMI_API_URL.rstrip("/")
+ website_id = config.UMAMI_WEBSITE_ID
+
+ async with httpx.AsyncClient(timeout=timeout_seconds, headers=headers) as client:
+ # Fetch per-URL metrics for each day individually
+ # (Umami's metrics endpoint returns totals for the period,
+ # so we query one day at a time for daily granularity)
+ for day_offset in range(days_back):
+ day = datetime.utcnow() - timedelta(days=day_offset + 1)
+ metric_date = day.strftime("%Y-%m-%d")
+ start_ms = int(day.replace(hour=0, minute=0, second=0).timestamp() * 1000)
+ end_ms = int(day.replace(hour=23, minute=59, second=59).timestamp() * 1000)
+
+ # Get URL-level metrics
+ response = await client.get(
+ f"{base}/api/websites/{website_id}/metrics",
+ params={
+ "startAt": start_ms,
+ "endAt": end_ms,
+ "type": "url",
+ "limit": 500,
+ },
+ )
+ response.raise_for_status()
+ url_metrics = response.json()
+
+ if not isinstance(url_metrics, list):
+ continue
+
+ for entry in url_metrics:
+ page_url = entry.get("x", "")
+ pageviews = entry.get("y", 0)
+
+ if not page_url:
+ continue
+
+ await execute(
+ """INSERT OR REPLACE INTO seo_analytics_metrics
+ (metric_date, page_url, pageviews, visitors,
+ bounce_rate, time_avg_seconds)
+ VALUES (?, ?, ?, 0, NULL, NULL)""",
+ (metric_date, page_url, pageviews),
+ )
+ rows_synced += 1
+
+ # Try to get overall stats for bounce rate and visit duration
+ # (Umami doesn't provide per-URL bounce rate, only site-wide)
+ stats_response = await client.get(
+ f"{base}/api/websites/{website_id}/stats",
+ params={"startAt": start_ms, "endAt": end_ms},
+ )
+ if stats_response.status_code == 200:
+ stats = stats_response.json()
+ visitors = stats.get("visitors", {}).get("value", 0)
+ bounce_rate = stats.get("bounces", {}).get("value", 0)
+ total_time = stats.get("totaltime", {}).get("value", 0)
+ page_count = stats.get("pageviews", {}).get("value", 1) or 1
+
+ # Store site-wide stats on the root URL for the day
+ avg_time = int(total_time / max(visitors, 1))
+ br = bounce_rate / max(visitors, 1) if visitors else 0
+
+ await execute(
+ """INSERT OR REPLACE INTO seo_analytics_metrics
+ (metric_date, page_url, pageviews, visitors,
+ bounce_rate, time_avg_seconds)
+ VALUES (?, '/', ?, ?, ?, ?)""",
+ (metric_date, page_count, visitors, br, avg_time),
+ )
+
+ duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
+ await execute(
+ """INSERT INTO seo_sync_log
+ (source, status, rows_synced, started_at, completed_at, duration_ms)
+ VALUES ('umami', 'success', ?, ?, ?, ?)""",
+ (rows_synced, started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
+ )
+ return rows_synced
+
+ except Exception as exc:
+ duration_ms = int((datetime.utcnow() - started_at).total_seconds() * 1000)
+ await execute(
+ """INSERT INTO seo_sync_log
+ (source, status, rows_synced, error, started_at, completed_at, duration_ms)
+ VALUES ('umami', 'failed', 0, ?, ?, ?, ?)""",
+ (str(exc), started_at.isoformat(), datetime.utcnow().isoformat(), duration_ms),
+ )
+ raise
diff --git a/web/src/padelnomics/worker.py b/web/src/padelnomics/worker.py
index d639ee4..96b48d1 100644
--- a/web/src/padelnomics/worker.py
+++ b/web/src/padelnomics/worker.py
@@ -670,6 +670,45 @@ async def handle_cleanup_tasks(payload: dict) -> None:
)
+# =============================================================================
+# SEO Metrics Sync
+# =============================================================================
+
+@task("sync_gsc")
+async def handle_sync_gsc(payload: dict) -> None:
+ """Sync Google Search Console data."""
+ from .seo import sync_gsc
+ days_back = payload.get("days_back", 3)
+ rows = await sync_gsc(days_back=days_back)
+ print(f"[WORKER] GSC sync complete: {rows} rows")
+
+
+@task("sync_bing")
+async def handle_sync_bing(payload: dict) -> None:
+ """Sync Bing Webmaster data."""
+ from .seo import sync_bing
+ days_back = payload.get("days_back", 3)
+ rows = await sync_bing(days_back=days_back)
+ print(f"[WORKER] Bing sync complete: {rows} rows")
+
+
+@task("sync_umami")
+async def handle_sync_umami(payload: dict) -> None:
+ """Sync Umami analytics data."""
+ from .seo import sync_umami
+ days_back = payload.get("days_back", 3)
+ rows = await sync_umami(days_back=days_back)
+ print(f"[WORKER] Umami sync complete: {rows} rows")
+
+
+@task("cleanup_seo_metrics")
+async def handle_cleanup_seo_metrics(payload: dict) -> None:
+ """Delete SEO metrics older than 12 months."""
+ from .seo import cleanup_old_metrics
+ deleted = await cleanup_old_metrics(retention_days=365)
+ print(f"[WORKER] Cleaned up {deleted} old SEO metric rows")
+
+
# =============================================================================
# Worker Loop
# =============================================================================
@@ -723,6 +762,7 @@ async def run_scheduler() -> None:
await init_db()
last_credit_refill = None
+ last_seo_sync_date = None
while True:
try:
@@ -741,6 +781,17 @@ async def run_scheduler() -> None:
last_credit_refill = this_month
print(f"[SCHEDULER] Queued monthly credit refill for {this_month}")
+ # Daily SEO metrics sync — run once per day after 6am UTC
+ # (GSC data has ~2 day delay, syncing at 6am ensures data is ready)
+ today_date = today.strftime("%Y-%m-%d")
+ if last_seo_sync_date != today_date and today.hour >= 6:
+ await enqueue("sync_gsc")
+ await enqueue("sync_bing")
+ await enqueue("sync_umami")
+ await enqueue("cleanup_seo_metrics")
+ last_seo_sync_date = today_date
+ print(f"[SCHEDULER] Queued SEO metric syncs for {today_date}")
+
await asyncio.sleep(3600) # 1 hour
except Exception as e:
diff --git a/web/tests/test_seo.py b/web/tests/test_seo.py
new file mode 100644
index 0000000..708ee51
--- /dev/null
+++ b/web/tests/test_seo.py
@@ -0,0 +1,523 @@
+"""Tests for the SEO metrics module: queries, sync functions, admin routes."""
+
+from datetime import datetime, timedelta
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from padelnomics.seo._queries import (
+ cleanup_old_metrics,
+ get_article_scorecard,
+ get_country_breakdown,
+ get_device_breakdown,
+ get_funnel_metrics,
+ get_search_performance,
+ get_sync_status,
+ get_top_pages,
+ get_top_queries,
+)
+
+from padelnomics import core
+
+# ── Fixtures ──────────────────────────────────────────────────
+
+def _today():
+ return datetime.utcnow().strftime("%Y-%m-%d")
+
+
+def _days_ago(n: int) -> str:
+ return (datetime.utcnow() - timedelta(days=n)).strftime("%Y-%m-%d")
+
+
+@pytest.fixture
+async def seo_data(db):
+ """Populate seo_search_metrics and seo_analytics_metrics with sample data."""
+ today = _today()
+ yesterday = _days_ago(1)
+
+ # GSC search data
+ rows = [
+ ("gsc", today, "/en/markets/germany/berlin", "padel berlin", "de", "mobile", 50, 500, 0.10, 5.2),
+ ("gsc", today, "/en/markets/germany/munich", "padel munich", "de", "desktop", 30, 300, 0.10, 8.1),
+ ("gsc", today, "/en/markets/germany/berlin", "padel court cost", "de", "desktop", 10, 200, 0.05, 12.0),
+ ("gsc", yesterday, "/en/markets/germany/berlin", "padel berlin", "de", "mobile", 45, 480, 0.09, 5.5),
+ # Bing data
+ ("bing", today, "/", "padel business plan", None, None, 5, 100, 0.05, 15.0),
+ ]
+ for source, d, page, query, country, device, clicks, imp, ctr, pos in rows:
+ await db.execute(
+ """INSERT INTO seo_search_metrics
+ (source, metric_date, page_url, query, country, device,
+ clicks, impressions, ctr, position_avg)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (source, d, page, query, country, device, clicks, imp, ctr, pos),
+ )
+
+ # Umami analytics data
+ analytics_rows = [
+ (today, "/en/markets/germany/berlin", 120, 80, 0.35, 45),
+ (today, "/en/markets/germany/munich", 60, 40, 0.40, 30),
+ (today, "/", 200, 150, 0.50, 20),
+ ]
+ for d, page, pv, vis, br, t in analytics_rows:
+ await db.execute(
+ """INSERT INTO seo_analytics_metrics
+ (metric_date, page_url, pageviews, visitors, bounce_rate, time_avg_seconds)
+ VALUES (?, ?, ?, ?, ?, ?)""",
+ (d, page, pv, vis, br, t),
+ )
+
+ await db.commit()
+
+
+@pytest.fixture
+async def articles_data(db, seo_data):
+ """Create articles that match the SEO data URLs."""
+ now = datetime.utcnow().isoformat()
+ pub = _days_ago(10)
+
+ for title, url, tpl, lang in [
+ ("Padel in Berlin", "/en/markets/germany/berlin", "city-cost-de", "en"),
+ ("Padel in Munich", "/en/markets/germany/munich", "city-cost-de", "en"),
+ ]:
+ await db.execute(
+ """INSERT INTO articles
+ (url_path, slug, title, template_slug, language, status, published_at, created_at)
+ VALUES (?, ?, ?, ?, ?, 'published', ?, ?)""",
+ (url, url.split("/")[-1], title, tpl, lang, pub, now),
+ )
+ await db.commit()
+
+
+@pytest.fixture
+async def admin_client(app, db):
+ """Authenticated admin client."""
+ now = datetime.utcnow().isoformat()
+ async with db.execute(
+ "INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
+ ("admin@test.com", "Admin", now),
+ ) as cursor:
+ admin_id = cursor.lastrowid
+ await db.execute(
+ "INSERT INTO user_roles (user_id, role) VALUES (?, 'admin')", (admin_id,)
+ )
+ await db.commit()
+
+ async with app.test_client() as c:
+ async with c.session_transaction() as sess:
+ sess["user_id"] = admin_id
+ yield c
+
+
+# ── Query function tests ─────────────────────────────────────
+
+class TestSearchPerformance:
+ """Tests for get_search_performance()."""
+
+ async def test_returns_aggregate_metrics(self, db, seo_data):
+ result = await get_search_performance(date_range_days=28)
+ assert result["total_clicks"] > 0
+ assert result["total_impressions"] > 0
+ assert 0 < result["avg_ctr"] < 1
+ assert result["avg_position"] > 0
+
+ async def test_filter_by_source(self, db, seo_data):
+ gsc = await get_search_performance(date_range_days=28, source="gsc")
+ bing = await get_search_performance(date_range_days=28, source="bing")
+ combined = await get_search_performance(date_range_days=28)
+ assert gsc["total_clicks"] + bing["total_clicks"] == combined["total_clicks"]
+
+ async def test_empty_data(self, db):
+ result = await get_search_performance(date_range_days=28)
+ assert result["total_clicks"] == 0
+ assert result["total_impressions"] == 0
+
+ async def test_date_range_filter(self, db, seo_data):
+ # Only today's data should match 1-day range
+ result = await get_search_performance(date_range_days=1)
+ # Yesterday's data excluded — fewer total clicks
+ full = await get_search_performance(date_range_days=28)
+ assert result["total_clicks"] <= full["total_clicks"]
+
+
+class TestTopQueries:
+ """Tests for get_top_queries()."""
+
+ async def test_returns_queries_sorted_by_impressions(self, db, seo_data):
+ queries = await get_top_queries(date_range_days=28)
+ assert len(queries) > 0
+ # Should be sorted desc by impressions
+ for i in range(len(queries) - 1):
+ assert queries[i]["impressions"] >= queries[i + 1]["impressions"]
+
+ async def test_limit(self, db, seo_data):
+ queries = await get_top_queries(date_range_days=28, limit=2)
+ assert len(queries) <= 2
+
+ async def test_filter_by_source(self, db, seo_data):
+ gsc_queries = await get_top_queries(source="gsc")
+ for q in gsc_queries:
+ assert q["query"] != "padel business plan" # that's bing data
+
+
+class TestTopPages:
+ """Tests for get_top_pages()."""
+
+ async def test_returns_pages(self, db, seo_data):
+ pages = await get_top_pages(date_range_days=28)
+ assert len(pages) > 0
+ # Berlin page should be first (most impressions)
+ assert pages[0]["page_url"] == "/en/markets/germany/berlin"
+
+
+class TestCountryBreakdown:
+ """Tests for get_country_breakdown()."""
+
+ async def test_returns_countries(self, db, seo_data):
+ countries = await get_country_breakdown(date_range_days=28)
+ assert len(countries) > 0
+ assert any(c["country"] == "de" for c in countries)
+
+
+class TestDeviceBreakdown:
+ """Tests for get_device_breakdown()."""
+
+ async def test_returns_devices_gsc_only(self, db, seo_data):
+ devices = await get_device_breakdown(date_range_days=28)
+ assert len(devices) > 0
+ device_names = [d["device"] for d in devices]
+ assert "mobile" in device_names
+ assert "desktop" in device_names
+
+
+class TestFunnelMetrics:
+ """Tests for get_funnel_metrics()."""
+
+ async def test_returns_all_stages(self, db, seo_data):
+ funnel = await get_funnel_metrics(date_range_days=28)
+ assert "impressions" in funnel
+ assert "clicks" in funnel
+ assert "pageviews" in funnel
+ assert "visitors" in funnel
+ assert "planner_users" in funnel
+ assert "leads" in funnel
+
+ async def test_conversion_rates(self, db, seo_data):
+ funnel = await get_funnel_metrics(date_range_days=28)
+ assert funnel["ctr"] > 0 # We have clicks and impressions
+ assert 0 <= funnel["ctr"] <= 1
+
+ async def test_empty_data(self, db):
+ funnel = await get_funnel_metrics(date_range_days=28)
+ assert funnel["impressions"] == 0
+ assert funnel["planner_users"] == 0
+
+
+class TestArticleScorecard:
+ """Tests for get_article_scorecard()."""
+
+ async def test_joins_articles_with_metrics(self, db, articles_data):
+ scorecard = await get_article_scorecard(date_range_days=28)
+ assert len(scorecard) == 2
+ berlin = next(a for a in scorecard if "berlin" in a["url_path"])
+ assert berlin["impressions"] > 0
+ assert berlin["pageviews"] > 0
+
+ async def test_filter_by_template(self, db, articles_data):
+ scorecard = await get_article_scorecard(
+ date_range_days=28, template_slug="city-cost-de",
+ )
+ assert len(scorecard) == 2
+ for a in scorecard:
+ assert a["template_slug"] == "city-cost-de"
+
+ async def test_sort_by_clicks(self, db, articles_data):
+ scorecard = await get_article_scorecard(
+ date_range_days=28, sort_by="clicks", sort_dir="desc",
+ )
+ if len(scorecard) >= 2:
+ assert scorecard[0]["clicks"] >= scorecard[1]["clicks"]
+
+ async def test_attention_flags(self, db, articles_data):
+ """Berlin has >100 impressions and low CTR — should flag."""
+ scorecard = await get_article_scorecard(date_range_days=28)
+ berlin = next(a for a in scorecard if "berlin" in a["url_path"])
+ # Berlin: 1180 impressions total, 105 clicks → CTR ~8.9% → no flag
+ # Flags depend on actual data; just check fields exist
+ assert "flag_low_ctr" in berlin
+ assert "flag_no_clicks" in berlin
+
+ async def test_invalid_sort_defaults_to_impressions(self, db, articles_data):
+ scorecard = await get_article_scorecard(
+ date_range_days=28, sort_by="invalid_column",
+ )
+ # Should not crash — falls back to impressions
+ assert len(scorecard) >= 0
+
+
+class TestSyncStatus:
+ """Tests for get_sync_status()."""
+
+ async def test_returns_last_sync_per_source(self, db):
+ now = datetime.utcnow().isoformat()
+ await db.execute(
+ """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms)
+ VALUES ('gsc', 'success', 100, ?, ?, 500)""",
+ (now, now),
+ )
+ await db.execute(
+ """INSERT INTO seo_sync_log (source, status, rows_synced, started_at, completed_at, duration_ms)
+ VALUES ('umami', 'failed', 0, ?, ?, 200)""",
+ (now, now),
+ )
+ await db.commit()
+
+ status = await get_sync_status()
+ assert len(status) == 2
+ sources = {s["source"] for s in status}
+ assert "gsc" in sources
+ assert "umami" in sources
+
+ async def test_empty_when_no_syncs(self, db):
+ status = await get_sync_status()
+ assert status == []
+
+
+class TestCleanupOldMetrics:
+ """Tests for cleanup_old_metrics()."""
+
+ async def test_deletes_old_data(self, db):
+ old_date = (datetime.utcnow() - timedelta(days=400)).strftime("%Y-%m-%d")
+ recent_date = _today()
+
+ await db.execute(
+ """INSERT INTO seo_search_metrics
+ (source, metric_date, page_url, clicks, impressions)
+ VALUES ('gsc', ?, '/old', 1, 10)""",
+ (old_date,),
+ )
+ await db.execute(
+ """INSERT INTO seo_search_metrics
+ (source, metric_date, page_url, clicks, impressions)
+ VALUES ('gsc', ?, '/recent', 1, 10)""",
+ (recent_date,),
+ )
+ await db.commit()
+
+ deleted = await cleanup_old_metrics(retention_days=365)
+ assert deleted >= 1
+
+ rows = await core.fetch_all("SELECT * FROM seo_search_metrics")
+ assert len(rows) == 1
+ assert rows[0]["page_url"] == "/recent"
+
+
+# ── Sync function tests (mocked HTTP) ────────────────────────
+
+class TestSyncUmami:
+ """Tests for sync_umami() with mocked HTTP."""
+
+ async def test_skips_when_not_configured(self, db):
+ original = core.config.UMAMI_API_TOKEN
+ core.config.UMAMI_API_TOKEN = ""
+ try:
+ from padelnomics.seo._umami import sync_umami
+ result = await sync_umami(days_back=1)
+ assert result == 0
+ finally:
+ core.config.UMAMI_API_TOKEN = original
+
+ async def test_syncs_url_metrics(self, db):
+ from padelnomics.seo._umami import sync_umami
+
+ core.config.UMAMI_API_TOKEN = "test-token"
+ core.config.UMAMI_API_URL = "https://umami.test.io"
+
+ mock_metrics = [
+ {"x": "/en/markets/germany/berlin", "y": 50},
+ {"x": "/en/markets/germany/munich", "y": 30},
+ ]
+ mock_stats = {
+ "visitors": {"value": 100},
+ "bounces": {"value": 30},
+ "totaltime": {"value": 5000},
+ "pageviews": {"value": 200},
+ }
+
+ mock_response_metrics = MagicMock()
+ mock_response_metrics.status_code = 200
+ mock_response_metrics.json.return_value = mock_metrics
+ mock_response_metrics.raise_for_status = MagicMock()
+
+ mock_response_stats = MagicMock()
+ mock_response_stats.status_code = 200
+ mock_response_stats.json.return_value = mock_stats
+ mock_response_stats.raise_for_status = MagicMock()
+
+ async def mock_get(url, **kwargs):
+ if "/metrics" in url:
+ return mock_response_metrics
+ return mock_response_stats
+
+ mock_client = AsyncMock()
+ mock_client.get = mock_get
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+
+ with patch("padelnomics.seo._umami.httpx.AsyncClient", return_value=mock_client):
+ result = await sync_umami(days_back=1)
+
+ assert result == 2 # 2 URL metrics
+
+ # Verify data stored
+ rows = await core.fetch_all(
+ "SELECT * FROM seo_analytics_metrics WHERE page_url != '/'"
+ )
+ assert len(rows) == 2
+
+ # Verify sync log
+ log = await core.fetch_all("SELECT * FROM seo_sync_log WHERE source = 'umami'")
+ assert len(log) == 1
+ assert log[0]["status"] == "success"
+
+
+class TestSyncBing:
+ """Tests for sync_bing() with mocked HTTP."""
+
+ async def test_skips_when_not_configured(self, db):
+ original_key = core.config.BING_WEBMASTER_API_KEY
+ core.config.BING_WEBMASTER_API_KEY = ""
+ try:
+ from padelnomics.seo._bing import sync_bing
+ result = await sync_bing(days_back=1)
+ assert result == 0
+ finally:
+ core.config.BING_WEBMASTER_API_KEY = original_key
+
+
+class TestSyncGsc:
+ """Tests for sync_gsc() with mocked Google API."""
+
+ async def test_skips_when_not_configured(self, db):
+ original = core.config.GSC_SERVICE_ACCOUNT_PATH
+ core.config.GSC_SERVICE_ACCOUNT_PATH = ""
+ try:
+ from padelnomics.seo._gsc import sync_gsc
+ result = await sync_gsc(days_back=1)
+ assert result == 0
+ finally:
+ core.config.GSC_SERVICE_ACCOUNT_PATH = original
+
+
+# ── Admin route tests ─────────────────────────────────────────
+
+class TestSeoAdminRoutes:
+ """Tests for the SEO hub admin routes."""
+
+ async def test_seo_hub_loads(self, admin_client, db):
+ resp = await admin_client.get("/admin/seo")
+ assert resp.status_code == 200
+ text = await resp.get_data(as_text=True)
+ assert "SEO" in text
+
+ async def test_seo_hub_with_data(self, admin_client, db, seo_data):
+ resp = await admin_client.get("/admin/seo?days=28")
+ assert resp.status_code == 200
+
+ async def test_seo_search_partial(self, admin_client, db, seo_data):
+ resp = await admin_client.get("/admin/seo/search?days=28")
+ assert resp.status_code == 200
+ text = await resp.get_data(as_text=True)
+ assert "Top Queries" in text
+
+ async def test_seo_search_filter_by_source(self, admin_client, db, seo_data):
+ resp = await admin_client.get("/admin/seo/search?days=28&source=gsc")
+ assert resp.status_code == 200
+
+ async def test_seo_funnel_partial(self, admin_client, db, seo_data):
+ resp = await admin_client.get("/admin/seo/funnel?days=28")
+ assert resp.status_code == 200
+ text = await resp.get_data(as_text=True)
+ assert "Impressions" in text
+
+ async def test_seo_scorecard_partial(self, admin_client, db, articles_data):
+ resp = await admin_client.get("/admin/seo/scorecard?days=28")
+ assert resp.status_code == 200
+ text = await resp.get_data(as_text=True)
+ assert "Berlin" in text or "scorecard" in text.lower() or "articles" in text.lower()
+
+ async def test_seo_scorecard_filter(self, admin_client, db, articles_data):
+ resp = await admin_client.get(
+ "/admin/seo/scorecard?days=28&template_slug=city-cost-de&sort=clicks&dir=desc"
+ )
+ assert resp.status_code == 200
+
+ async def test_seo_sync_requires_auth(self, client, db):
+ resp = await client.post("/admin/seo/sync")
+ # Should redirect to login (302) or return 403
+ assert resp.status_code in (302, 403)
+
+ async def test_seo_sync_now(self, admin_client, db):
+ """Sync Now enqueues tasks."""
+ async with admin_client.session_transaction() as sess:
+ sess["csrf_token"] = "test"
+
+ resp = await admin_client.post(
+ "/admin/seo/sync",
+ form={"source": "all", "csrf_token": "test"},
+ )
+ # Should redirect back to SEO hub
+ assert resp.status_code == 302
+
+ # Verify tasks enqueued
+ tasks = await core.fetch_all(
+ "SELECT task_name FROM tasks WHERE task_name LIKE 'sync_%'"
+ )
+ task_names = {t["task_name"] for t in tasks}
+ assert "sync_gsc" in task_names
+ assert "sync_bing" in task_names
+ assert "sync_umami" in task_names
+
+ async def test_seo_sync_single_source(self, admin_client, db):
+ async with admin_client.session_transaction() as sess:
+ sess["csrf_token"] = "test"
+
+ resp = await admin_client.post(
+ "/admin/seo/sync",
+ form={"source": "gsc", "csrf_token": "test"},
+ )
+ assert resp.status_code == 302
+
+ tasks = await core.fetch_all("SELECT task_name FROM tasks WHERE task_name = 'sync_gsc'")
+ assert len(tasks) == 1
+
+ async def test_seo_hub_date_range(self, admin_client, db, seo_data):
+ for days in [7, 28, 90, 365]:
+ resp = await admin_client.get(f"/admin/seo?days={days}")
+ assert resp.status_code == 200
+
+ async def test_seo_sidebar_link(self, admin_client, db):
+ resp = await admin_client.get("/admin/")
+ text = await resp.get_data(as_text=True)
+ assert "SEO Hub" in text
+
+
+# ── Assertion boundary tests ─────────────────────────────────
+
+class TestQueryBounds:
+ """Test that query functions validate their bounds."""
+
+ async def test_search_performance_rejects_zero_days(self, db):
+ with pytest.raises(AssertionError):
+ await get_search_performance(date_range_days=0)
+
+ async def test_top_queries_rejects_zero_limit(self, db):
+ with pytest.raises(AssertionError):
+ await get_top_queries(limit=0)
+
+ async def test_cleanup_rejects_short_retention(self, db):
+ with pytest.raises(AssertionError):
+ await cleanup_old_metrics(retention_days=7)
+
+ async def test_scorecard_rejects_invalid_sort_dir(self, db):
+ with pytest.raises(AssertionError):
+ await get_article_scorecard(sort_dir="invalid")