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