109 lines
4.7 KiB
PHP
109 lines
4.7 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'), '/');
|
|
$urls = [];
|
|
foreach ($published as $item) {
|
|
if (! ($item['content_changed'] ?? false)) {
|
|
continue;
|
|
}
|
|
$article = Articles::with('oCategory')->find($item['article_id']);
|
|
if (! $article || !\App\Services\Seo::indexable($article)) {
|
|
continue;
|
|
}
|
|
$url = $base.'/a/'.$article->sSlug.'.html';
|
|
if ($url === $item['url'] && preg_match('/\A[a-z0-9_-]+\z/', $article->sSlug)) {
|
|
$urls[] = $url;
|
|
$urls[] = \App\Services\Seo::url(\App\Services\Seo::categoryPath($article->oCategory));
|
|
if ($article->oCategory->oParent) $urls[] = \App\Services\Seo::url(\App\Services\Seo::categoryPath($article->oCategory->oParent));
|
|
}
|
|
}
|
|
$urls = array_values(array_unique($urls));
|
|
if (! $urls) {
|
|
return array_replace($state, ['status' => 'skipped', 'reason' => 'no-current-indexable-url']);
|
|
}
|
|
|
|
return $this->submit($state, $urls);
|
|
}
|
|
|
|
public function submit(array $state, array $urls): array
|
|
{
|
|
if (!$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');
|
|
}
|
|
|
|
foreach ($urls as $url) {
|
|
if (!str_starts_with($url, $base.'/') || parse_url($url, PHP_URL_HOST) !== $host || parse_url($url, PHP_URL_QUERY) || parse_url($url, PHP_URL_FRAGMENT)) return $this->pending($state,'invalid-target');
|
|
}
|
|
if (!$urls) return ['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))];
|
|
}
|
|
}
|