Notify IndexNow after editorial publication with durable retry

This commit is contained in:
Codex 2026-10-31 14:18:14 +08:00
parent d981a2aa4f
commit 5748124383
11 changed files with 261 additions and 7 deletions

View File

@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Services\Editorial\PublicationEffects;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class RetryEditorialIndexNow extends Command
{
protected $signature = 'editorial:indexnow-retry';
protected $description = 'Retry due editorial IndexNow notifications without republishing content';
public function handle(): int
{
if (! app()->environment(['production', 'testing']) || ! config('editorial.indexnow_enabled', app()->environment('production'))) {
$this->line('IndexNow retry disabled in this environment.');
return self::SUCCESS;
}
$ids = DB::table('editorial_publications')->where('effects->indexnow->status', 'pending')
->where(function ($q) {
$q->whereNull('effects->indexnow->next_attempt_at')
->orWhere('effects->indexnow->next_attempt_at', '<=', now()->timestamp);
})->orderBy('updated_at')->limit(10)->pluck('id');
$pending = 0;
foreach ($ids as $id) {
try {
$receipt = app(PublicationEffects::class)->synchronize($id);
$pending += ($receipt['effects']['indexnow']['status'] ?? '') === 'pending' ? 1 : 0;
} catch (\Throwable) {
$pending++;
}
}
$this->line(json_encode(['processed' => count($ids), 'pending' => $pending]));
return $pending ? self::FAILURE : self::SUCCESS;
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Services\Editorial;
use App\Models\Articles;
use Illuminate\Support\Facades\Http;
/** Durable notification state lives in the publication receipt, alongside cache/search effects. */
final class IndexNowNotification
{
public function initial(array $articles): array
{
if (! $this->enabled()) {
return ['status' => 'skipped', 'reason' => 'disabled-environment'];
}
if (! collect($articles)->contains(fn ($a) => $a['content_changed'] ?? false)) {
return ['status' => 'skipped', 'reason' => 'no-content-change'];
}
return ['status' => 'pending', 'attempts' => 0];
}
public function synchronize(array $state, array $published): array
{
if (($state['status'] ?? '') !== 'pending' || ! $this->enabled()
|| ($state['next_attempt_at'] ?? 0) > now()->timestamp) {
return $state;
}
$base = rtrim((string) config('editorial.base_url'), '/');
$host = parse_url($base, PHP_URL_HOST);
// No per-article target, redirect, or credentials in URLs. Never probe public pages from local.
if (! $host || ! preg_match('~\Ahttps://[a-z0-9.-]+\z~i', $base)
|| $base !== rtrim((string) config('app.url'), '/')
|| $host !== config('app.site_code')) {
return $this->pending($state, 'site-binding-mismatch');
}
$key = config('key.indexnow');
if (! is_string($key) || ! preg_match('/\A[a-zA-Z0-9-]{8,128}\z/', $key)) {
return $this->pending($state, 'key-missing-or-invalid');
}
$urls = [];
foreach ($published as $item) {
if (! ($item['content_changed'] ?? false)) {
continue;
}
$article = Articles::with('oCategory')->find($item['article_id']);
if (! $article || (int) $article->iStatus !== 1 || ! $article->oCategory
|| (int) $article->oCategory->iStatus !== 1 || $article->created_at->isFuture()
|| $article->bIsSeoExpired()) {
continue;
}
$url = $base.'/a/'.$article->sSlug.'.html';
if ($url === $item['url'] && preg_match('/\A[a-z0-9_-]+\z/', $article->sSlug)) {
$urls[] = $url;
}
}
$urls = array_values(array_unique($urls));
if (! $urls) {
return array_replace($state, ['status' => 'skipped', 'reason' => 'no-current-indexable-url']);
}
try {
$response = Http::connectTimeout(5)->timeout(15)->withoutRedirecting()->acceptJson()
->post('https://api.indexnow.org/indexnow', [
'host' => $host, 'key' => $key, 'keyLocation' => $base.'/'.$key.'.txt', 'urlList' => $urls,
]);
$code = $response->status();
if (in_array($code, [200, 202], true)) {
return ['status' => 'done', 'attempts' => ($state['attempts'] ?? 0) + 1,
'http_status' => $code, 'urls' => $urls, 'submitted_at' => now()->toIso8601String(),
'result' => $code === 202 ? 'received-key-validation-pending' : 'received'];
}
return $this->pending($state, 'http-error', $code);
} catch (\Throwable) {
// Response bodies and exception messages may contain the key. Persist only safe diagnostics.
return $this->pending($state, 'transport-error');
}
}
private function enabled(): bool
{
return app()->environment(['production', 'testing'])
&& (bool) config('editorial.indexnow_enabled', app()->environment('production'));
}
private function pending(array $state, string $reason, ?int $code = null): array
{
$attempts = ($state['attempts'] ?? 0) + 1;
return ['status' => 'pending', 'attempts' => $attempts, 'reason' => $reason, 'http_status' => $code,
'last_attempt_at' => now()->toIso8601String(),
'next_attempt_at' => now()->timestamp + min(86400, 300 * 2 ** min(9, $attempts - 1))];
}
}

View File

@ -36,6 +36,12 @@ public function synchronize(string $id): array
}
// Sitemap is generated from current DB rows, no external submission is performed.
$effects['sitemap'] = ['status' => ($effects['cache']['status'] ?? '') === 'done' ? 'done' : 'pending', 'method' => 'database-driven'];
// Legacy receipts were not queued for notification; never backfill them implicitly.
$notification = $effects['indexnow'] ?? ['status' => 'skipped', 'reason' => 'legacy-publication'];
if (($effects['cache']['status'] ?? '') === 'done') {
$notification = app(IndexNowNotification::class)->synchronize($notification, $receipt['articles']);
}
$effects['indexnow'] = $notification;
DB::table('editorial_publications')->where('id', $id)->update(['effects' => json_encode($effects), 'updated_at' => now()]);
});

View File

@ -206,10 +206,13 @@ public function publish(string $id, array $input): array
DB::table('editorial_documents')->where('content_key', $item['content_key'])->update(['article_id' => $articleId, 'published_revision' => $item['revision'], 'updated_at' => now()]);
DB::table('editorial_versions')->insert(['content_key' => $item['content_key'], 'revision' => $item['revision'], 'snapshot' => json_encode($item, JSON_UNESCAPED_UNICODE), 'created_at' => now()]);
$published[] = ['content_key' => $item['content_key'], 'article_id' => $articleId, 'revision' => $item['revision'],
'content_changed' => $item['base_article_hash'] !== $this->articleHash(DB::table('articles')->where('id', $articleId)->first()),
'url' => rtrim(config('editorial.base_url'), '/').'/a/'.$item['slug'].'.html'];
}
DB::table('editorial_publications')->insert(['id' => $data['request_id'], 'draft_id' => $id, 'payload_hash' => $opHash,
'receipt' => json_encode(['publication_id' => $data['request_id'], 'status' => 'published', 'articles' => $published], JSON_UNESCAPED_UNICODE), 'created_at' => now(), 'updated_at' => now()]);
'receipt' => json_encode(['publication_id' => $data['request_id'], 'status' => 'published', 'articles' => $published], JSON_UNESCAPED_UNICODE),
'effects' => json_encode(['indexnow' => app(IndexNowNotification::class)->initial($published)]),
'created_at' => now(), 'updated_at' => now()]);
DB::table('editorial_drafts')->where('id', $id)->update(['status' => 'published', 'publication_id' => $data['request_id'], 'updated_at' => now()]);
return $data['request_id'];

View File

@ -141,7 +141,8 @@ private function verify(string $file, array $state): array
'body_sha256' => hash('sha256', $source['body_html']), 'like_growth' => $source['like_growth']];
}
$state['readback'] = $readback;
$done = collect($state['receipt']['effects'])->every(fn ($e) => ($e['status'] ?? '') === 'done');
$done = collect($state['receipt']['effects'])->every(fn ($e, $name) => ($e['status'] ?? '') === 'done'
|| ($name === 'indexnow' && ($e['status'] ?? '') === 'skipped'));
$state['phase'] = $done && collect($readback)->every(fn ($r) => $r['revision_matches'] && $r['content_matches']) ? 'complete' : 'published_pending';
$this->save($file, $state);

View File

@ -7,4 +7,5 @@
'preview_minutes' => 30,
'max_batch' => 10,
'search_index' => env('EDITORIAL_SEARCH_INDEX', env('APP_ENV') === 'local' ? 'fna_v3_articles' : null),
'indexnow_enabled' => env('EDITORIAL_INDEXNOW_ENABLED', env('APP_ENV') === 'production'),
];

View File

@ -10,7 +10,7 @@ ## 职责与数据
项目正文源保留可编辑稿;`editorial_drafts`保存收到并渲染后的冻结版本,发布读取该快照,不临时重新读取文件。`articles`为公开显示数据,草稿不写入它。`editorial_documents`按稳定`content_key`绑定本站文章;`editorial_versions`保留已发布版本和首次接管旧文的基线版本0;`editorial_publications`保存独立发布回执及后续处理状态。图片元信息在`editorial_assets`。
新API不调用旧接收器,不发送旧Telegram通知、IndexNow提交或ATOM请求。旧资源接收接口继续沿用原流程,不在本次改造范围;普通编辑API拒绝免费节点与小火箭ID两个自动资源栏目。旧接口/人工直接编辑同一文章会造成草稿基线变化,新API发布时返回409,不能静默覆盖并行修改。
新API不调用旧接收器,不发送旧Telegram通知或ATOM请求。2026-10-31起编辑文章新增/实质修改后具有独立IndexNow回执与失败重试,见[教程SEO通知](EDITORIAL_INDEXNOW.md);本地和测试不真实外发。旧资源接收接口继续沿用原流程,不在本次改造范围;普通编辑API拒绝免费节点与小火箭ID两个自动资源栏目。旧接口/人工直接编辑同一文章会造成草稿基线变化,新API发布时返回409,不能静默覆盖并行修改。
## 凭据与环境
@ -116,9 +116,9 @@ ### 3 发布确认过的版本
- `status: published`:本次内容已写入本站公开文章表;这是所调用环境的状态,本地成功不代表生产成功。
- `articles`:各文章的`content_key`、`article_id`、`revision`和URL。
- `effects.cache/search/sitemap`:各后续处理的独立结果。搜索暂时失败不撤销已提交正文,返回`pending`,不能重发正文来弥补搜索失败。
- `effects.cache/search/sitemap/indexnow`:各后续处理的独立结果。搜索暂时失败不撤销已提交正文,返回`pending`,不能重发正文来弥补搜索失败。
后续处理为刷新本站响应缓存与热门数据缓存、同步本站搜索,以及使用数据库驱动的sitemap。缓存未刷新时sitemap也标记pending。不调用搜索引擎主动提交。
后续处理为刷新本站响应缓存与热门数据缓存、同步本站搜索,以及使用数据库驱动的sitemap。缓存未刷新时sitemap也标记pending。IndexNow以独立`effects.indexnow`记录,不与sitemap结果混用,见[教程SEO通知](EDITORIAL_INDEXNOW.md)。
### 4 查询、重试与回退

View File

@ -0,0 +1,12 @@
# 编辑文章 IndexNow
用户2026-10-31要求接入教程发布后的自动推送。新增或实际内容改变的编辑文章在发布事务写入IndexNow待处理状态,缓存刷新完成后提交当前有效URL。目标为IndexNow官方API,复用本站既有key及根目录验证文件,不生成/轮换密钥,不改旧资源接收器。
- `config/editorial.php` 的 `indexnow_enabled`:默认仅production开启,可用`EDITORIAL_INDEXNOW_ENABLED=false`关闭。local不会发出通知,测试使用Http fake。
- `PublicationEffects`保存`effects.indexnow`,包括状态、次数、HTTP状态和下一次重试时间;不保存key、响应正文或异常消息。只有200/202记done;202表示收到并待验证key。提交被接收不等于搜索引擎已经抓取或收录,见[官方协议](https://www.indexnow.org/documentation)。
- `editorial:indexnow-retry`挂接原Laravel调度,每5分钟检查一次,单次最多10个到期回执,防重叠;退避从5分钟递增,最大24小时。定时任务运行在FNA业务服务器,以原nginx身份执行,白色机仅SSH跳板。
- 已done的通知不重复调用;同一发布UUID重放不重发文章。网络结果未知或提交后进程崩溃可能重复通知同一URL,按至少一次投递处理,不能承诺第三方只收到一次。每次重试都重新确认文章仍公开、栏目启用且可索引。
- 内容未变、已撤下、不再允许索引时跳过;旧版本中没有IndexNow状态的历史发布回执不自动补推。暂时禁用不会丢弃已pending状态。
- 仅推送同一站点的规范文章地址;app.url、editorial.base_url、app.site_code必须一致,不跟随重定向,不从文章输入指定外部推送目标。
状态:本地专项测试及回归已通过,正在准备生产部署;最终提交和服务器实测证据将在本次任务收尾更新。普通文章发布继续通过[白色机发布封装](EDITORIAL_RELEASE.md),公网编辑API保持关闭。

View File

@ -28,7 +28,7 @@ ## 调用
- id.json仅`{"release_id":"原UUID"}`。服务器先保存稳定草稿ID/发布UUID,再调用API;相同bundle重复prepare不新建版本或重新抽取参数,相同release_id改正文拒绝409。改稿应显式创建新release并使用当前版本。
- review.json的`data.previews`含原模板渲染的HTML,`data.review`含hash和digest。先阅读及检查冻结版本;程序渲染通过不等于人类/浏览器视觉验收。正式HTML内含线上资源链接,禁止直接在本机浏览器打开加载;视觉检查在本地对应稿件完成。必须线上页面验收时仍按项目访问批准规则到Windows,不以取预览为由越界。
- publish.json为`{"release_id":"原UUID","expected_hash":"review中hash","review_digest":"review中digest"}`,不得盲填或以另一份稿件替换。程序先查回执;已发表则只重试后处理,草稿版本冲突返回失败。
- status会从API重新查询并核对公开正文,不只读本地台账。`phase=complete`要求内容/版本/上传图哈希和缓存/搜索/sitemap状态通过;`published_pending`代表正文已提交但后续仍待处理,退出码2,不能据此另发正文。返回`published_at`来自服务器真实发布事务时间。
- status会从API重新查询并核对公开正文,不只读本地台账。`phase=complete`要求内容/版本/上传图哈希和缓存/搜索/sitemap/IndexNow状态通过(无需通知的IndexNow skipped视为完成),且IndexNow通知为done或明确skipped;`published_pending`代表正文已提交但后续仍待处理,退出码2,不能据此另发正文。返回`published_at`来自服务器真实发布事务时间。
本机工具只输出结果文件路径及状态,不回显SSH认证、正文或预览。SSH断开、超时或非JSON输出都保存未知/失败状态,不自动重放写入;用原release_id查status。不存在草稿时沿原bundle恢复prepare。
@ -41,3 +41,7 @@ ## 状态与恢复
上线前仅执行`2026_10_31_150000_add_article_display_timestamp.php`迁移(新增可空`sDisplayPublishedAt`);旧文显示回退原时间。新文草稿冻结0~21600秒偏移,首次发布计算展示时间;保留created_at、实际回执、点赞起点,旧文改稿不改展示日期。回滚程序保留新增可空列和历史发布记录,不删业务数据。
验证与当前部署状态见[2026-10-31发布记录](EDITORIAL_RELEASE_2026-10-31.md)。周期调度和额度唯一见[WEEKLY_TUTORIALS.md](WEEKLY_TUTORIALS.md)。
IndexNow的结果、环境开关及服务器延时重试见[教程SEO通知](EDITORIAL_INDEXNOW.md)。通知pending只重试后处理,不再次发表正文。
2026-10-31编辑发布已接入[IndexNow通知与重试](EDITORIAL_INDEXNOW.md),复用发布回执effects;通知pending不表示正文未发布,禁止为通知重发正文。

View File

@ -19,6 +19,8 @@
Schedule::command('article:sync-likes')->everyFiveMinutes();
Schedule::command('editorial:indexnow-retry')->everyFiveMinutes()->environments(['production'])->withoutOverlapping(10);
//Artisan::command('db:reCreate {sTableName?}', function ($sTableName = null) {
// // 1. 没传参数?直接亮红牌!

View File

@ -389,4 +389,92 @@ public function test_raw_script_markup_is_escaped_and_publish_request_id_cannot_
$this->publish($next, $request)->assertConflict();
$this->assertSame(1, DB::table('articles')->count());
}
private function enableEditorialIndexNow(): 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']);
// API authentication in these tests is bound to the same origin.
file_put_contents($this->credential, json_encode(['project' => 'fna-v3', 'environment' => 'testing',
'base_url' => 'https://example.org', 'schema' => 'gemdocs.credential.v1', 'fields' => ['api_key' => $this->token]]));
}
public function test_editorial_indexnow_notifies_new_and_changed_content_once_but_not_noop(): void
{
$this->enableEditorialIndexNow();
Http::fake(['api.indexnow.org/*' => Http::response('', 200)]);
$item = $this->item();
$item['category_slug'] = 'advanced';
$draft = $this->draft([$item]);
$id = (string) Str::uuid();
$first = $this->publish($draft, $id)->assertOk()->assertJsonPath('effects.indexnow.status', 'done');
$this->assertTrue($first->json('articles.0.content_changed'));
$this->publish($draft, $id)->assertOk();
$this->postJson('/api/editorial/v1/publications/'.$id.'/synchronize')->assertOk();
Http::assertSentCount(1);
Http::assertSent(fn ($r) => $r->url() === 'https://api.indexnow.org/indexnow'
&& $r['host'] === 'example.org' && $r['urlList'] === ['https://example.org/a/example.html']);
$noop = $this->draft([$this->item('example', 1)]);
// The category change in this revision is itself meaningful.
$this->publish($noop)->assertOk()->assertJsonPath('effects.indexnow.status', 'done');
$same = $this->draft([$this->item('example', 2)]);
$this->publish($same)->assertOk()->assertJsonPath('effects.indexnow.reason', 'no-content-change');
Http::assertSentCount(2);
$changed = $this->item('example', 3);
$changed['body'] .= '<p>新的说明。</p>';
$this->publish($this->draft([$changed]))->assertOk()->assertJsonPath('effects.indexnow.status', 'done');
Http::assertSentCount(3);
}
public function test_editorial_indexnow_failure_retries_with_backoff_without_republishing(): void
{
$this->enableEditorialIndexNow();
Http::fake(['api.indexnow.org/*' => Http::sequence()->push('do not persist response secrets', 429)->push('', 202)]);
$draft = $this->draft([$this->item()]);
$result = $this->publish($draft)->assertOk()->assertJsonPath('status', 'published')
->assertJsonPath('effects.indexnow.status', 'pending')->assertJsonPath('effects.indexnow.http_status', 429);
$this->assertStringNotContainsString('do not persist', json_encode($result->json()));
$this->artisan('editorial:indexnow-retry')->assertExitCode(0);
Http::assertSentCount(1);
$this->travel(6)->minutes();
$this->artisan('editorial:indexnow-retry')->assertExitCode(0);
$this->getJson('/api/editorial/v1/publications/'.$result->json('publication_id'))->assertOk()
->assertJsonPath('effects.indexnow.status', 'done')->assertJsonPath('effects.indexnow.http_status', 202)
->assertJsonPath('effects.indexnow.attempts', 2);
$this->assertSame(1, DB::table('articles')->count());
$this->assertSame(1, DB::table('editorial_versions')->count());
$this->assertSame(1, DB::table('editorial_publications')->count());
$this->artisan('editorial:indexnow-retry')->assertExitCode(0);
Http::assertSentCount(2);
}
public function test_editorial_indexnow_does_not_send_disabled_or_mismatched_hosts_or_legacy_receipts(): void
{
Http::fake();
$draft = $this->draft([$this->item()]);
$result = $this->publish($draft)->assertOk()->assertJsonPath('effects.indexnow.status', 'skipped');
$this->enableEditorialIndexNow();
$legacy = $result->json('publication_id');
DB::table('editorial_publications')->where('id', $legacy)->update(['effects' => null]);
$this->postJson('/api/editorial/v1/publications/'.$legacy.'/synchronize')->assertOk()
->assertJsonPath('effects.indexnow.reason', 'legacy-publication');
config(['app.site_code' => 'wrong.example']);
$new = $this->draft([$this->item('another')]);
$this->publish($new)->assertOk()->assertJsonPath('effects.indexnow.reason', 'site-binding-mismatch');
Http::assertNothingSent();
}
public function test_editorial_indexnow_retries_skip_withdrawn_or_expired_articles(): void
{
$this->enableEditorialIndexNow();
Http::fake(['api.indexnow.org/*' => Http::response('', 503)]);
$result = $this->publish($this->draft([$this->item()]))->assertOk();
DB::table('articles')->update(['iStatus' => 0]);
$this->travel(6)->minutes();
$this->artisan('editorial:indexnow-retry')->assertExitCode(0);
$this->getJson('/api/editorial/v1/publications/'.$result->json('publication_id'))->assertOk()
->assertJsonPath('effects.indexnow.reason', 'no-current-indexable-url');
Http::assertSentCount(1);
}
}