fn_b/app/Services/Editorial/IndexNowNotification.php

98 lines
4.0 KiB
PHP

<?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))];
}
}