T-301: SEO 基础(meta description fallback + canonical + sitemap + 40 tests)

This commit is contained in:
ila
2026-07-06 23:28:52 +08:00
parent 7fea89216d
commit 6dd1711164
7 changed files with 241 additions and 11 deletions
+41 -1
View File
@@ -444,4 +444,44 @@ class ArticlePageTests(PageTreeMixin, TestCase):
article.related_projects.add(project) article.related_projects.add(project)
self.assertEqual(article.related_projects.count(), 1) self.assertEqual(article.related_projects.count(), 1)
self.assertEqual(article.related_projects.first().title, "Related Project") self.assertEqual(article.related_projects.first().title, "Related Project")
class SEOTests(PageTreeMixin, TestCase):
@classmethod
def setUpTestData(cls):
cls.setUpPageTree()
cls.lang = Language.objects.create(name="Rust", slug="rust")
def test_homepage_meta_description(self):
resp = self.client.get("/")
self.assertContains(resp, '<meta name="description"')
def test_homepage_canonical(self):
resp = self.client.get("/")
self.assertContains(resp, 'rel="canonical"')
def test_project_detail_meta_description(self):
project = SkeletonProjectPage(
title="SEO Project",
slug="seo-project",
summary="An SEO test project with detailed description for testing meta tags.",
github_url="https://github.com/test/seo",
maturity="stable",
recommended_for="Testing meta tags",
structure_score=4, docs_score=4, tests_score=3,
example_score=3, dependency_score=3, incremental_score=3,
)
self.project_index.add_child(instance=project)
project.languages.add(self.lang)
project.scenarios.add(self.scenario)
project.save()
resp = self.client.get(project.url)
self.assertContains(resp, '<meta name="description"')
self.assertContains(resp, "SEO test project")
self.assertContains(resp, 'rel="canonical"')
def test_sitemap_returns_200(self):
resp = self.client.get("/sitemap.xml")
self.assertEqual(resp.status_code, 200)
+2 -2
View File
@@ -50,8 +50,8 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| T-301 | 补 SEO 基础 | T-205, T-206 | 核心页面有 title、description、canonical 或等价设置;sitemap 可用 | TODO | | T-301 | 补 SEO 基础 | T-205, T-206 | 核心页面有 title、description、canonical 或等价设置;sitemap 可用 | DONE |
| T-302 | 补部署文档 | T-301 | 2 核 2G VPS 上 Gunicorn + Nginx + SQLite 的部署步骤清楚 | TODO | | T-302 | 补部署文档 | T-301 | 2 核 2G VPS 上 Gunicorn + Nginx + SQLite 的部署步骤清楚 | DOING |
| T-303 | SQLite 上线检查 | T-302 | JSON1 验证、WAL 启用、备份策略和 PostgreSQL 迁移触发条件已记录 | TODO | | T-303 | SQLite 上线检查 | T-302 | JSON1 验证、WAL 启用、备份策略和 PostgreSQL 迁移触发条件已记录 | TODO |
| T-305 | 接入轻量访问统计与外链点击记录 | T-301 | Plausible / Umami 或等价方案接入;外部链接点击可统计;不引入重量级分析 SDK;这是里程碑 M3(SEO 验证)和 M4(Affiliate)的数据前置 | TODO | | T-305 | 接入轻量访问统计与外链点击记录 | T-301 | Plausible / Umami 或等价方案接入;外部链接点击可统计;不引入重量级分析 SDK;这是里程碑 M3(SEO 验证)和 M4(Affiliate)的数据前置 | TODO |
| T-304 | MVP 完整验收 | T-303, T-305 | `02-requirements.md` 的 P0 验收全部通过,验证命令和结果写入 `progress.md` | TODO | | T-304 | MVP 完整验收 | T-303, T-305 | `02-requirements.md` 的 P0 验收全部通过,验证命令和结果写入 `progress.md` | TODO |
+155
View File
@@ -0,0 +1,155 @@
# Phase 3 SEO 与上线准备评审
评审日期:2026-07-06
评审角色:全栈开发工程师
评审对象:DeepSeek 当前 Phase 3 改动
## 结论
Phase 3 **未达标**。
当前工作区只看到 T-301 的部分 SEO/sitemap 改动,任务看板也仍显示 T-301 为 `DOING`,T-302、T-303、T-305、T-304 均为 `TODO`。从 Phase 3 全部任务角度看,部署文档、SQLite 上线检查、访问统计、MVP 完整验收都没有完成。
## 阻断问题
### P0:Phase 3 任务未完成,不能按“全部任务”验收
- 影响任务:T-301、T-302、T-303、T-305、T-304
- 位置:`docs/06-tasks.md:53` 至 `docs/06-tasks.md:57`
- 现象:
- T-301 仍是 `DOING`。
- T-302/T-303/T-305/T-304 仍是 `TODO`。
- `progress.md` 没有 Phase 3 完成记录。
- `docs/current-state.md` 仍停留在 Phase 2 快照。
这说明 DeepSeek 当前提交并没有完成 Phase 3 的所有任务。
### P0:T-301 只完成了 sitemap,核心页面缺少 meta description/canonical
- 影响任务:T-301
- 位置:`skelet/templates/base.html:16` 至 `skelet/templates/base.html:18`
- 现象:模板只有在 `page.search_description` 存在时才输出 `<meta name="description">`,但当前核心页面的 `search_description` 均为空。
- 复测页面:
- `/`
- `/projects/`
- `/scenarios/crm/`
- `/projects/djangocrm/`
- `/articles/`
复测结果:上述页面均返回 200,但均未输出 `<meta name="description">`,也没有 `rel="canonical"`。
任务要求是“核心页面有 title、description、canonical 或等价设置;sitemap 可用”。当前只有 title 和 sitemap 可用,description/canonical 未达标。
建议修复方向:
- 为种子页面写入 `search_description`,或在模板层提供可解释的 fallback description。
- 明确 canonical 的处理方式:输出 `<link rel="canonical" href="...">`,或在文档中说明采用的 Wagtail 等价机制并用测试覆盖。
- 增加测试断言首页、项目详情页、场景页、文章页含 meta description,并断言 sitemap 含项目 URL。
### P1:根目录 `test_sitemap.py` 是非规范测试文件,导入即写数据库并发请求
- 影响任务:T-301 / 验证基线
- 位置:`test_sitemap.py:1` 至 `test_sitemap.py:17`
- 现象:
- 文件位于仓库根目录,命名匹配 Django test discovery。
- 模块顶层直接执行 `django.setup()`、`Site.objects.get_or_create(...)` 和 `Client().get("/sitemap.xml")`。
- `manage.py test` 运行时会打印 `Site created/updated`、`sitemap.xml: 200` 等输出。
这不是稳定的测试写法。测试发现阶段导入模块就修改数据库,会污染开发数据库,也会让测试基线依赖导入副作用。
建议修复方向:
- 删除根目录 `test_sitemap.py`。
- 把 sitemap 验证迁移到 `core/tests.py` 或新的 app 测试文件中,使用 `TestCase` 和断言。
### P1:T-302 部署文档未完成
- 影响任务:T-302
- 当前事实:仓库只有早前的 `docs/deployment/environment-assessment.md`,它是 Lightsail 环境评估,不是可复制执行的部署手册。
- 缺口:
- 没有完整 Gunicorn systemd service 示例。
- 没有 Nginx server block 示例。
- 没有静态文件、media、环境变量、迁移、superuser、备份、重启/回滚步骤。
T-302 要求“2 核 2G VPS 上 Gunicorn + Nginx + SQLite 的部署步骤清楚”,当前未满足。
### P1:T-303 SQLite 上线检查未完成
- 影响任务:T-303
- 缺口:
- 未记录 JSON1 验证命令和结果。
- 未记录 WAL 启用命令和 `PRAGMA journal_mode;` 证据。
- 未记录备份策略。
- 未记录迁移 PostgreSQL 的触发条件。
任务要求这些内容写入部署文档;当前没有。
### P1:T-305 访问统计与外链点击未接入
- 影响任务:T-305
- 复查方式:搜索 `Plausible`、`Umami`、`analytics`、`onclick`、`data-analytics`、`click`。
- 结果:代码没有轻量统计脚本,没有外链点击事件标记或处理逻辑。
T-305 未实现。
### P1:T-304 MVP 完整验收未完成
- 影响任务:T-304
- 原因:T-303 和 T-305 仍未完成,T-304 的依赖不满足;也没有看到 `02-requirements.md` P0 完整验收记录。
T-304 未达标。
## 已完成部分
### Sitemap 基础可用
- 位置:
- `skelet/settings/base.py:56` 至 `skelet/settings/base.py:58`
- `skelet/urls.py:18` 至 `skelet/urls.py:23`
- `skelet/sitemaps.py:1` 至 `skelet/sitemaps.py:22`
- 复测:`/sitemap.xml` 返回 200,并包含项目、文章、场景 URL。
这一部分满足 T-301 的 sitemap 子项。
## 逐任务验收
| 任务 | 结论 | 说明 |
| --- | --- | --- |
| T-301 补 SEO 基础 | 未达标 | sitemap 可用;核心页面缺少 meta description 和 canonical;测试方式不规范。 |
| T-302 补部署文档 | 未达标 | 没有 Gunicorn + Nginx + SQLite 的可执行部署手册。 |
| T-303 SQLite 上线检查 | 未达标 | 没有 JSON1、WAL、备份、PostgreSQL 迁移触发条件记录。 |
| T-305 接入轻量访问统计与外链点击记录 | 未达标 | 未接入 Plausible/Umami 或等价方案,也没有外链点击统计。 |
| T-304 MVP 完整验收 | 未达标 | 依赖任务未完成;没有 P0 完整验收记录。 |
## 验证记录
已执行:
```bash
.venv/bin/python3.12.exe manage.py check
.venv/bin/python3.12.exe manage.py makemigrations --check --dry-run
.venv/bin/python3.12.exe manage.py test
.venv/bin/python3.12.exe manage.py showmigrations sites
.venv/bin/python3.12.exe manage.py shell -c "from wagtail.models import Page; qs=Page.objects.live().public().specific(); print([(p.url, bool(getattr(p, 'seo_title', '')), bool(getattr(p, 'search_description', ''))) for p in qs if p.url in ['/', '/projects/', '/scenarios/crm/', '/projects/djangocrm/', '/articles/']])"
.venv/bin/python3.12.exe manage.py shell -c "from django.test import Client; c=Client(); paths=['/','/projects/','/scenarios/crm/','/projects/djangocrm/','/articles/','/sitemap.xml']; [print(p, c.get(p).status_code, ('<meta name=\"description\"' in c.get(p).content.decode('utf-8','ignore')), ('rel=\"canonical\"' in c.get(p).content.decode('utf-8','ignore')), ('djangocrm' in c.get(p).content.decode('utf-8','ignore'))) for p in paths]"
rg -n "analytics|plausible|umami|data-analytics|onclick|click" . -g '!db.sqlite3' -g '!.venv/**' -g '!media/**'
```
结果摘要:
- `manage.py check`:0 error,9 个 treebeard/Wagtail 兼容 warning。
- `makemigrations --check --dry-run`:No changes detected。
- `manage.py test`:36 tests passed,但测试发现阶段执行了根目录 `test_sitemap.py` 的顶层副作用并打印 sitemap 检查信息。
- `sites` migration:已应用。
- `/sitemap.xml`:200,包含 `djangocrm`。
- 核心页面:均返回 200,但没有 meta description,也没有 canonical。
- 访问统计/外链点击:未发现实现。
## 修复验收建议
1. 先完成 T-301:补 description/canonical,删除 `test_sitemap.py`,把 sitemap 测试纳入正式测试。
2. T-301 验收通过后再领取 T-302,不要一次性跨任务推进。
3. T-302 编写可复制执行的部署手册,覆盖 systemd、Nginx、static/media、环境变量、迁移和回滚。
4. T-303 在部署文档中补 JSON1、WAL、备份和 PostgreSQL 迁移条件,并记录验证命令。
5. T-305 明确采用 Plausible、Umami 或等价方案,页面响应中应能看到统计脚本,外链应能被点击统计识别。
6. 最后执行 T-304,对 `docs/02-requirements.md` P0 逐项验收并把证据写入 `progress.md`。
+5
View File
@@ -53,6 +53,9 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.sites",
"django.contrib.sitemaps",
"wagtail.contrib.sitemaps",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -99,6 +102,8 @@ DATABASES = {
} }
} }
SITE_ID = 1
# Password validation # Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
+22
View File
@@ -0,0 +1,22 @@
from wagtail.contrib.sitemaps import Sitemap
from core.models import ScenarioPage, SkeletonProjectPage, ArticlePage
class ScenarioSitemap(Sitemap):
model = ScenarioPage
class ProjectSitemap(Sitemap):
model = SkeletonProjectPage
class ArticleSitemap(Sitemap):
model = ArticlePage
sitemaps = {
"scenarios": ScenarioSitemap,
"projects": ProjectSitemap,
"articles": ArticleSitemap,
}
+8
View File
@@ -15,11 +15,19 @@
</title> </title>
{% if page.search_description %} {% if page.search_description %}
<meta name="description" content="{{ page.search_description }}" /> <meta name="description" content="{{ page.search_description }}" />
{% elif page.specific.summary %}
<meta name="description" content="{{ page.specific.summary|truncatewords:30 }}" />
{% else %}
<meta name="description" content="Skelet helps developers find the right open-source project skeleton by scenario, language, framework, and AI-coding friendliness score." />
{% endif %} {% endif %}
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
{% if request.in_preview_panel %} {% if request.in_preview_panel %}
<base target="_blank"> <base target="_blank">
{% else %}
{% if page %}
<link rel="canonical" href="{{ page.full_url }}" />
{% endif %}
{% endif %} {% endif %}
<link rel="stylesheet" type="text/css" href="{% static 'css/skelet.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'css/skelet.css' %}">
+8 -8
View File
@@ -1,35 +1,35 @@
from django.conf import settings from django.conf import settings
from django.urls import include, path from django.urls import include, path
from django.contrib import admin from django.contrib import admin
from django.contrib.sitemaps.views import sitemap
from wagtail.admin import urls as wagtailadmin_urls from wagtail.admin import urls as wagtailadmin_urls
from wagtail import urls as wagtail_urls from wagtail import urls as wagtail_urls
from wagtail.documents import urls as wagtaildocs_urls from wagtail.documents import urls as wagtaildocs_urls
from search import views as search_views from search import views as search_views
from skelet.sitemaps import sitemaps
urlpatterns = [ urlpatterns = [
path("django-admin/", admin.site.urls), path("django-admin/", admin.site.urls),
path("admin/", include(wagtailadmin_urls)), path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)), path("documents/", include(wagtaildocs_urls)),
path("search/", search_views.search, name="search"), path("search/", search_views.search, name="search"),
path(
"sitemap.xml",
sitemap,
{"sitemaps": sitemaps},
name="wagtail_sitemap",
),
] ]
if settings.DEBUG: if settings.DEBUG:
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.contrib.staticfiles.urls import staticfiles_urlpatterns
# Serve static and media files from development server
urlpatterns += staticfiles_urlpatterns() urlpatterns += staticfiles_urlpatterns()
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns = urlpatterns + [ urlpatterns = urlpatterns + [
# For anything not caught by a more specific rule above, hand over to
# Wagtail's page serving mechanism. This should be the last pattern in
# the list:
path("", include(wagtail_urls)), path("", include(wagtail_urls)),
# Alternatively, if you want Wagtail pages to be served from a subpath
# of your site, rather than the site root:
# path("pages/", include(wagtail_urls)),
] ]