diff --git a/AGENTS.md b/AGENTS.md index 540de572..36598cca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,3 +11,5 @@ ## AI 文章写作(必读) 插画完成后按[自动交付](docs/maintenance/ILLUSTRATION_DELIVERY.md)提交批准批次;不要再手工上传发布/编辑图池或把保存草稿当作上线完成。 周期文章按[固定发布封装](docs/maintenance/EDITORIAL_RELEASE.md)执行:本机SSH必须经白色机到正式业务服务器,使用服务器进程内API,不临时开放公网接口。每周三篇、每天15:00检查、错过窗口回补及文章随机展示时间见[周期写作](docs/maintenance/WEEKLY_TUTORIALS.md);这一流程替代周期写作中的手动开关API步骤,不扩大其他实例或全站改版权限。 + +教程SEO、编辑API的IndexNow结果与重试、栏目sitemap的唯一维护说明见[教程SEO通知](docs/maintenance/EDITORIAL_INDEXNOW.md)。`sitemap: done`不代表搜索引擎已收到通知或已收录。 diff --git a/app/Http/Controllers/SitemapController.php b/app/Http/Controllers/SitemapController.php index 9ddbe704..ae6a6088 100644 --- a/app/Http/Controllers/SitemapController.php +++ b/app/Http/Controllers/SitemapController.php @@ -3,29 +3,33 @@ namespace App\Http\Controllers; use App\Models\Articles; +use App\Models\Categories; use Illuminate\Http\Response; class SitemapController extends Controller { public function index(): Response { - // 连表查询分类:按 aOpt['noindexday'] 动态过滤过期文章 + // Use the same expiry rule as article metadata, including on SQLite in isolated tests. $cArticles = Articles::where('iStatus', 1) - ->whereHas('oCategory', function ($q) { - $q->where(function ($query) { - // 1. 没有配置 noindexday,或者 noindexday = 0 的文章,一直保留 - $query->whereNull('aOpt') - ->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(aOpt, '$.noindexday')) IS NULL") - ->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(aOpt, '$.noindexday')) = '0'") - // 2. 配置了天数的,只取 N 天内创建的新文章 - ->orWhereRaw("articles.created_at >= NOW() - INTERVAL CAST(JSON_UNQUOTE(JSON_EXTRACT(aOpt, '$.noindexday')) AS UNSIGNED) DAY"); - }); - }) + ->where('created_at', '<=', now()) + ->whereHas('oCategory', fn ($q) => $q->where('iStatus', 1)) + ->with('oCategory') ->orderBy('created_at', 'desc') - ->get(); + ->get()->reject(fn ($article) => $article->bIsSeoExpired()); + + $visible = fn ($q) => $q->where('iStatus', 1)->where('created_at', '<=', now()); + $cCategories = Categories::whereIn('sSlug', ['client', 'advanced']) + ->where('iParentId', 0)->where('iStatus', 1) + ->where(function ($q) use ($visible) { + $q->whereHas('aArticles', $visible)->orWhereHas('aChildren', function ($children) use ($visible) { + $children->where('iStatus', 1)->whereHas('aArticles', $visible); + }); + })->orderBy('sSlug')->get(); $sXmlContent = view('sitemap', [ - 'cArticles' => $cArticles + 'cArticles' => $cArticles, + 'cCategories' => $cCategories, ])->render(); return response($sXmlContent, 200)->header('Content-Type', 'text/xml'); diff --git a/docs/maintenance/EDITORIAL_INDEXNOW.md b/docs/maintenance/EDITORIAL_INDEXNOW.md index f30222e7..46b08fb4 100644 --- a/docs/maintenance/EDITORIAL_INDEXNOW.md +++ b/docs/maintenance/EDITORIAL_INDEXNOW.md @@ -20,3 +20,16 @@ ## 2026-10-31已部署与验证 2026-10-31 14:20:55北京时间,通过本机SSH→白色机→业务服务器,由新组件对现有`/a/system-proxy-vs-tun.html`执行一次真实通知验收,HTTP200,密钥文件HTTP200且内容匹配;未改正文、未重新发布、未改旧回执、未批量补推。只证明通知被接收,不代表已收录。已完成服务器侧验收,未做线上页面验收。精确证据见[回执](EDITORIAL_INDEXNOW_2026-10-31.json)。 私有恢复点:VPS-003 `/root/backups/cfn-indexnow-20261031/`,包含Git bundle、原HEAD/工作区状态、标签模板、文章核心字段与旧回执快照、真实提交事件和部署记录。回滚可检出基线,保留已有effects状态和内容;单独停推可设`EDITORIAL_INDEXNOW_ENABLED=false`,不要删除pending回执。普通发布继续通过[白色机发布封装](EDITORIAL_RELEASE.md)。 + + +## 2026-10-31已部署与验证 + +功能提交`574812438338755b160453c4a574557365903506`已Git推送并部署正式cfn,独立分支`codex/editorial-indexnow-publish-20261031`,基线`d981a2aa4fa21abe4512ddd287a2f51f87b11197`。同名旧候选分支另有包含sitemap改动的并行提交415b5f8,保留未覆盖;本任务只部署IndexNow范围,没有捆绑其他SEO工作。 + +专项4项测试/52断言通过:新增/更新通知、同UUID去重、无变化跳过、429退避后202成功、旧回执不补推、禁用环境/域名错配拒发、撤下后停止重试。本地相关业务回归合计36项/344断言通过;其中共享工作区的sitemap测试未作为本次部署范围。 + +正式站现有188篇文章的核心内容与日期、所有旧编辑发布回执保持;生成标签模板哈希和data.ms并行目录保留。新重试命令以nginx实际运行,空队列返回processed=0/pending=0,已登记每5分钟调度;未为演练制造失败队列,也未宣称已观察到一笔真实失败的自动恢复。公网编辑API仍关闭,首页HTTP200。 + +2026-10-31 14:20:55北京时间,通过本机SSH→白色机→业务服务器,由新组件对现有`/a/system-proxy-vs-tun.html`执行一次真实通知验收,HTTP200,密钥文件HTTP200且内容匹配;未改正文、未重新发布、未改旧回执、未批量补推。只证明通知被接收,不代表已收录。已完成服务器侧验收,未做线上页面验收。精确证据见[回执](EDITORIAL_INDEXNOW_2026-10-31.json)。 + +私有恢复点:VPS-003 `/root/backups/cfn-indexnow-20261031/`,包含Git bundle、原HEAD/工作区状态、标签模板、文章核心字段与旧回执快照、真实提交事件和部署记录。回滚可检出基线,保留已有effects状态和内容;单独停推可设`EDITORIAL_INDEXNOW_ENABLED=false`,不要删除pending回执。普通发布继续通过[白色机发布封装](EDITORIAL_RELEASE.md)。 diff --git a/docs/maintenance/SEO_PLAN.md b/docs/maintenance/SEO_PLAN.md index 353d8c40..bbb1b8f6 100644 --- a/docs/maintenance/SEO_PLAN.md +++ b/docs/maintenance/SEO_PLAN.md @@ -1,6 +1,6 @@ # FNA_V3 SEO 优化建议与执行清单 -评估日期:2026-10-22,Asia/Shanghai。状态:**分析与建议文档已完成;SEO-001 的部署定位已由用户确认,配置改造尚未实施;SEO-020 于2026-10-27完成本地教程正文更新,设备实测及生产验收未做,D02首页最新免费节点全文展示于2026-10-27本地完成,其余项目尚未实施**。本文件是后续 SEO 工作的唯一执行清单;按 `SEO-001` 至 `SEO-036` 跟踪,实施后在本文件更新结果,不另建重复的“最新版计划”。 +评估日期:2026-10-22,Asia/Shanghai。状态:**分析与建议文档已完成;SEO-001 的部署定位已由用户确认,配置改造尚未实施;SEO-020 于2026-10-27完成本地教程正文更新,设备实测及生产验收未做,D02首页最新免费节点全文展示于2026-10-27本地完成,其余项目按分项记录;2026-10-31正在实施编辑文章IndexNow通知与两个教程栏目sitemap(见SEO-013/033)**。本文件是后续 SEO 工作的唯一执行清单;按 `SEO-001` 至 `SEO-036` 跟踪,实施后在本文件更新结果,不另建重复的“最新版计划”。 本轮依据当前代码、当前本地数据库、重新取得的 HTTP 响应及受限浏览器检查形成结论。网站源码、样式和业务数据未作修改。当前三款 DPL 广告属于已存在的实现,纳入分析基准并保留。网站仍在独立本地副本中开发;用户随后确认未来替换现有 cfn,并支持同一套代码部署多个同类站点,ClashFreeNode 为主力站,尚未实际部署或迁移。 @@ -182,7 +182,7 @@ ## 5 执行总表 | [ ] | SEO-010 | 分类、标签、搜索的标题与语义层级 | P1 | 001、004 | 已确认 | | [ ] | SEO-011 | 生成真实、干净的页面简介 | P1 | 010 | 已确认 | | [ ] | SEO-012 | 分离真实内容日期并统一时间基准 | P1 | 003 | 已确认/条件性风险 | -| [ ] | SEO-013 | 统一 sitemap 的集合和 lastmod | P1 | 003、004、008、012 | 已确认 | +| [ ] | SEO-013 | 统一 sitemap 的集合和 lastmod | P1 | 003、004、008、012 | 部分实施:教程栏目,见正文 | | [ ] | SEO-014 | 建立长期内链和内容导航 | P1 | 005、009、018 | 已确认/策略建议 | | [ ] | SEO-015 | 整理标签与重复主题页 | P1 | 002、003 | 策略建议 | | [ ] | SEO-016 | 隔离并同步本站搜索索引 | P1 | 008、032 | 已确认 | @@ -202,7 +202,7 @@ ## 5 执行总表 | [ ] | SEO-030 | 文章接收、更新与删除的发布合同 | P0 发布前 | 003、008、017、029 | 已确认/条件性风险 | | [ ] | SEO-031 | 正式主机、HTTPS 与 HTTP 服务质量 | P0 发布前 | 001、004 | 待部署验证 | | [ ] | SEO-032 | 本地、预发布和生产资源隔离 | P0 发布前 | 001 | 已确认/条件性风险 | -| [ ] | SEO-033 | 规范 IndexNow 及搜索提交 | P2 | 003、004、030、032 | 已确认/待部署验证 | +| [ ] | SEO-033 | 规范 IndexNow 及搜索提交 | P2 | 003、004、030、032 | 部分实施:编辑发布通知,见正文 | | [ ] | SEO-034 | SEO 自动回归与数据样例 | P1 | 跟随各批次 | 当前测试缺口 | | [ ] | SEO-035 | 搜索平台与业务效果监测 | P1 上线后 | 001、002、036 | 待线上数据 | | [ ] | SEO-036 | 发布、迁移、回滚与复核 | P0 发布时 | 前述必需项 | 待发布授权 | @@ -352,6 +352,8 @@ ### SEO-013 统一 sitemap 的集合和 lastmod **验收:** XML 解析、URL 编码、正式 host、状态、canonical、robots、noindex 做交叉校验;新增/撤稿/到期后集合及时变化;集合差异有可解释原因。Google 忽略 priority 与 changefreq,不应将调大这些数字作为优化工作。[Google sitemap 指南](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) +**2026-10-31定点实施:** 见[教程SEO通知](EDITORIAL_INDEXNOW.md)。仅编辑API新增/实质更新通知、独立回执/退避重试,以及client/advanced非空栏目进入sitemap;未完成本项所有全站建议,发布/验收以该正文为准。 + ### SEO-014 建立长期内链和内容导航 **优先级:P1。证据:E03。** 当前资源发现主要依赖列表首屏和“点赞活跃”;动态热门榜并不保证重要教程或可索引资源长期获得入口。 @@ -539,6 +541,8 @@ ### SEO-033 规范 IndexNow 及搜索提交 **验收:** 本地使用替身验证,不实际发送;正式获准后单条核验目标主机、验证文件和返回结果;删除事件不因目标已 404 而被本地逻辑永远过滤掉。IndexNow 表示通知采用该协议的搜索引擎,不能当成 Google 通用收录接口。Google Indexing API 仅支持其指定的招聘及直播事件页面,不适用于普通节点文章。[IndexNow 协议](https://www.indexnow.org/documentation)、[Google Indexing API 适用范围](https://developers.google.com/search/apis/indexing-api/v3/using-api) +**2026-10-31定点实施:** 见[教程SEO通知](EDITORIAL_INDEXNOW.md)。仅编辑API新增/实质更新通知、独立回执/退避重试,以及client/advanced非空栏目进入sitemap;未完成本项所有全站建议,发布/验收以该正文为准。 + ### SEO-034 SEO 自动回归与数据样例 **优先级:P1,随每批实现。** 当前仓库测试只有首页 200 示例和恒真示例,不能防止 URL、索引和发布规则回退。本轮未执行这些与审计目标无关的示例测试,也没有新增业务测试或修改代码。 diff --git a/resources/views/sitemap.blade.php b/resources/views/sitemap.blade.php old mode 100755 new mode 100644 index 0ef0ab41..31022f76 --- a/resources/views/sitemap.blade.php +++ b/resources/views/sitemap.blade.php @@ -7,6 +7,13 @@ daily + {{-- Only established tutorial landing pages; omit lastmod until a reliable content clock exists. --}} + @foreach($cCategories as $oCategory) + + {{ rtrim(config('app.url'), '/') }}/{{ $oCategory->sSlug }} + + @endforeach + {{-- 未过期的文章 --}} @foreach($cArticles as $oArticles) diff --git a/tests/Feature/EditorialApiTest.php b/tests/Feature/EditorialApiTest.php index 55de7d3f..a90feae8 100644 --- a/tests/Feature/EditorialApiTest.php +++ b/tests/Feature/EditorialApiTest.php @@ -477,4 +477,28 @@ public function test_editorial_indexnow_retries_skip_withdrawn_or_expired_articl Http::assertSentCount(1); } -} \ No newline at end of file + public function test_sitemap_contains_only_active_nonempty_tutorial_roots_and_indexable_articles(): void + { + config(['app.url' => 'http://localhost']); + $this->publish($this->draft([$this->item()]))->assertOk(); + DB::table('categories')->where('id', 3)->update(['iParentId' => 2]); + DB::table('categories')->insert(['id' => 2, 'sSlug' => 'client', 'sName' => '入门教程', 'iStatus' => 1]); + $xml = $this->get('/sitemap.xml')->assertOk()->getContent(); + $this->assertStringContainsString('http://localhost/client', $xml); + $this->assertStringNotContainsString('http://localhost/advanced', $xml); + $advanced = $this->item('advanced-test'); + $advanced['category_slug'] = 'advanced'; + $this->publish($this->draft([$advanced]))->assertOk(); + $xml = $this->get('/sitemap.xml')->assertOk()->getContent(); + $this->assertSame(1, substr_count($xml, 'http://localhost/advanced')); + $this->assertStringContainsString('/a/advanced-test.html', $xml); + $this->assertStringNotContainsString('http://localhost/windows', $xml); + DB::table('categories')->where('id', 10)->update(['iStatus' => 0]); + DB::table('articles')->where('iCategoryId', 3)->update(['iStatus' => 0]); + $xml = $this->get('/sitemap.xml')->assertOk()->getContent(); + $this->assertStringNotContainsString('/advanced', $xml); + $this->assertStringNotContainsString('/client', $xml); + $this->assertStringNotContainsString('/a/advanced-test.html', $xml); + } + +} diff --git a/tests/Feature/EditorialReleaseTest.php b/tests/Feature/EditorialReleaseTest.php index 3d8a89d6..bfcafe4e 100644 --- a/tests/Feature/EditorialReleaseTest.php +++ b/tests/Feature/EditorialReleaseTest.php @@ -262,4 +262,29 @@ public function test_release_display_time_is_frozen_and_real_publish_time_is_pre $this->assertSame($article->sDisplayPublishedAt, DB::table('articles')->value('sDisplayPublishedAt')); $this->travelBack(); } + public function test_release_tracks_indexnow_pending_and_recovers_without_a_new_publication(): void + { + config(['editorial.indexnow_enabled' => true, 'editorial.base_url' => 'https://example.org', + 'app.url' => 'https://example.org', 'app.site_code' => 'example.org', 'key.indexnow' => 'test-key-12345678']); + Http::fake(['api.indexnow.org/*' => Http::sequence()->push('', 503)->push('', 200)]); + $runner = app(ReleaseRunner::class); + $bundle = $this->bundle(); + $runner->run($bundle); + $review = $runner->run(['operation' => 'review', 'release_id' => $bundle['release_id']]); + $publish = ['operation' => 'publish', 'release_id' => $bundle['release_id'], + 'expected_hash' => $review['review']['hash'], 'review_digest' => $review['review']['digest']]; + $pending = $runner->run($publish); + $this->assertSame('published_pending', $pending['phase']); + $this->assertSame('pending', $pending['receipt']['effects']['indexnow']['status']); + Http::assertSentCount(1); + $this->travel(6)->minutes(); + $this->artisan('editorial:indexnow-retry')->assertExitCode(0); + $done = $runner->run(['operation' => 'status', 'release_id' => $bundle['release_id']]); + $this->assertSame('complete', $done['phase']); + $this->assertSame('done', $done['receipt']['effects']['indexnow']['status']); + $this->assertSame(1, DB::table('articles')->count()); + $this->assertSame(1, DB::table('editorial_publications')->count()); + Http::assertSentCount(2); + } + }