*/ public function portals(): array { return array_values(array_filter( [Portal::Presseecho, Portal::Businessportal24], fn (Portal $portal): bool => $this->client->propertyFor($portal->value) !== null, )); } /** * Fällige Meldungen eines Portals, jüngste zuerst, nie geprüfte vor * bereits geprüften — höchstens `$limit` Stück. * * @return Collection */ public function candidates(Portal $portal, int $limit): Collection { $selected = collect(); $bandStart = now(); foreach ($this->recheckBands() as $maxAgeDays => $intervalDays) { $remaining = $limit - $selected->count(); if ($remaining <= 0) { break; } $bandEnd = $maxAgeDays === PHP_INT_MAX ? null : now()->subDays($maxAgeDays); $selected = $selected->merge( $this->baseQuery($portal) ->where('published_at', '<=', $bandStart) ->when($bandEnd !== null, fn (Builder $query) => $query->where('published_at', '>', $bandEnd)) ->where(fn (Builder $query) => $query ->whereNull('ic.last_checked_at') ->orWhere('ic.last_checked_at', '<', now()->subDays($intervalDays))) ->orderByRaw('ic.last_checked_at is null desc') ->orderByDesc('published_at') ->limit($remaining) ->get(['press_releases.id', 'press_releases.slug', 'press_releases.portal', 'press_releases.language', 'press_releases.published_at']) ); if ($bandEnd === null) { break; } $bandStart = $bandEnd; } return $selected->unique('id')->values(); } /** * Fragt eine Meldung auf einem Portal ab und speichert den Befund. */ public function check(PressRelease $pressRelease, Portal $portal): PressReleaseIndexCheck { $url = $this->publicUrl($pressRelease, $portal); $property = $this->client->propertyFor($portal->value); if ($url === null || $property === null) { throw new \RuntimeException(sprintf('Meldung %d hat auf %s keine öffentliche URL oder Property.', $pressRelease->id, $portal->value)); } // Immer en-US: `coverageState` ist ein übersetzter Anzeigetext, und // IndexState::fromIndexStatus() erkennt die englischen Fassungen. $result = $this->client->inspect($url, $property, 'en-US'); $indexStatus = is_array($result['indexStatusResult'] ?? null) ? $result['indexStatusResult'] : []; return PressReleaseIndexCheck::create([ 'press_release_id' => $pressRelease->id, 'portal' => $portal, 'url' => $url, 'state' => IndexState::fromIndexStatus($indexStatus), 'verdict' => Str::limit((string) ($indexStatus['verdict'] ?? ''), 30, '') ?: null, 'coverage_state' => Str::limit((string) ($indexStatus['coverageState'] ?? ''), 160, '') ?: null, 'indexing_state' => Str::limit((string) ($indexStatus['indexingState'] ?? ''), 60, '') ?: null, 'robots_txt_state' => Str::limit((string) ($indexStatus['robotsTxtState'] ?? ''), 40, '') ?: null, 'page_fetch_state' => Str::limit((string) ($indexStatus['pageFetchState'] ?? ''), 60, '') ?: null, 'last_crawl_time' => ! empty($indexStatus['lastCrawlTime']) ? Carbon::parse((string) $indexStatus['lastCrawlTime']) : null, 'google_canonical' => Str::limit((string) ($indexStatus['googleCanonical'] ?? ''), 500, '') ?: null, 'user_canonical' => Str::limit((string) ($indexStatus['userCanonical'] ?? ''), 500, '') ?: null, 'raw' => $indexStatus, 'checked_at' => now(), ]); } /** * Jüngste Momentaufnahme je Portal für die Absenderansicht (E8: nur * ausgeliefert, wenn config search_console.customer_visible an ist). * * @return list */ public function latestFor(PressRelease $pressRelease): array { return PressReleaseIndexCheck::query() ->where('press_release_id', $pressRelease->id) ->orderByDesc('checked_at') ->get() ->unique(fn (PressReleaseIndexCheck $check): string => $check->portal->value) ->values() ->all(); } /** * Quellenauskunft (Anhang 2, 1c.9): welche Adresse Google als maßgeblich * führt, verglichen mit der eigenen Fassung. Sagt nichts darüber, warum. * * @return array{kind: 'self'|'own_other'|'foreign'|'unknown', url: ?string, host: ?string} */ public function sourceVerdict(PressReleaseIndexCheck $check): array { $google = $check->google_canonical; if ($google === null || $google === '') { return ['kind' => 'unknown', 'url' => null, 'host' => null]; } $normalize = fn (string $url): string => rtrim(strtolower((string) preg_replace('#^https?://(www\.)?#i', '', $url)), '/'); $host = strtolower((string) parse_url($google, PHP_URL_HOST)); if ($normalize($google) === $normalize($check->url)) { return ['kind' => 'self', 'url' => $google, 'host' => $host]; } $ownHosts = array_filter(array_map( fn (?string $url): string => strtolower((string) parse_url((string) $url, PHP_URL_HOST)), [config('domains.domain_presseecho_url'), config('domains.domain_businessportal_url')], ), fn (string $own): bool => $own !== ''); $isOwn = collect($ownHosts)->contains(fn (string $own): bool => str_ends_with($host, $own) || str_ends_with($own, $host)); return ['kind' => $isOwn ? 'own_other' : 'foreign', 'url' => $google, 'host' => $host]; } /** * @return Builder */ private function baseQuery(Portal $portal): Builder { $latest = PressReleaseIndexCheck::query() ->select('press_release_id', DB::raw('max(checked_at) as last_checked_at')) ->where('portal', $portal->value) ->groupBy('press_release_id'); return PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->visibleInArchive() ->forPortalValues([$portal->value, Portal::Both->value]) ->leftJoinSub($latest, 'ic', 'ic.press_release_id', '=', 'press_releases.id'); } /** * @return array */ private function recheckBands(): array { /** @var array $bands */ $bands = config('search_console.recheck_days', [PHP_INT_MAX => 180]); ksort($bands); return $bands; } private function publicUrl(PressRelease $pressRelease, Portal $portal): ?string { foreach ($pressRelease->publicPortalLinks() as $link) { if ($link['portal'] === $portal) { return $link['url']; } } return null; } }