- feed_fetches: jeder Feed-Abruf mit Art, Schlüssel, Portal, Sprache, erkanntem Reader (config/feeds.php, FeedReaderIdentifier), vom Dienst gemeldeter Abonnentenzahl und gekürztem User-Agent — keine IP. Schreiben per defer() nach der Antwort, auch bei Cache-Treffer. - Aufbewahrung 395 Tage über press-releases:purge-events; FEED_FETCHES_RECORDED_SINCE. - Admin ▸ Reports ▸ Indexierung & Abrufe: Block „Abrufe der RSS-Feeds" (Summen je Feed-Art, je Reader-Dienst, meistabgerufene Feeds; FeedFetchReport, Verdichtung in PHP für MySQL und SQLite). - Panel „Auslieferung": Abrufe je Feed in 30 Tagen; RSS-Seite nennt die Zählung ohne IP. - Tests: FeedFetchLogTest (5), IndexStatusCheckTest erweitert. - dev/2026-09-01: Nachtrag Protokoll Teil 11, Roadmap 3.1; 0.7 gilt auch für Crawler- und Feed-Log. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2285 lines
118 KiB
Markdown
2285 lines
118 KiB
Markdown
# Umsetzungsprotokoll — Abarbeitung von `bestandsaufnahme-code.md`, Abschnitt 7
|
||
|
||
**Stand:** 01.09.2026
|
||
**Bezug:** `bestandsaufnahme-code.md` (Befund-Nummern H1–H4, M1–M9, P1, L1–L9)
|
||
|
||
Jeder Abschnitt hält fest: was war der Befund, was wurde geändert, wie ist es
|
||
abgesichert. Abweichungen von der ursprünglich vorgeschlagenen Reihenfolge oder
|
||
Lösung sind ausdrücklich vermerkt.
|
||
|
||
---
|
||
|
||
## Ausgangsstand
|
||
|
||
| Prüfung | vorher |
|
||
|---|---|
|
||
| `php artisan test` | 1479 grün, 1 übersprungen |
|
||
| `vendor/bin/pint --test` | passed |
|
||
| `vendor/bin/phpstan analyse` | **390 Fehler außerhalb der Baseline** |
|
||
|
||
---
|
||
|
||
## Block A — vor dem nächsten Deploy
|
||
|
||
### Schritt 1 — H1: Suchausfall im Deploy-Fenster
|
||
|
||
**Befund:** `PressReleaseSearchIndex::isAvailable()` cachte seine Antwort eine
|
||
Minute. Stand dort `false`, während die Migration die Volltext-Indizes von
|
||
`press_releases` entfernte, setzte der Rückfallpfad sein `MATCH()` weiter auf
|
||
die Tabelle ab, an der es keinen Index mehr gab → MySQL-Fehler 1191, 500er auf
|
||
jeder Suche der Web-Seite und der Admin-PM-Liste.
|
||
|
||
**Geändert**
|
||
|
||
- `app/Models/PressReleaseSearchIndex.php`
|
||
- Cache-Key als Konstante `AvailabilityCacheKey` herausgezogen.
|
||
- `isAvailable()` cacht die beiden Antworten unterschiedlich lange: `true`
|
||
(Dauerzustand nach dem Umstieg) 60 s, `false` (gilt nur während des
|
||
Umstiegs) 5 s. Die Prüflogik steckt jetzt in `resolveAvailability()`.
|
||
- Neu: `forgetAvailability()`.
|
||
- `database/migrations/…_create_press_release_search_table.php` und
|
||
`…_drop_fulltext_indexes_from_press_releases.php` rufen
|
||
`forgetAvailability()` in `up()` **und** `down()` auf. Das ist der eigentliche
|
||
Fix; die kurze TTL ist das Netz für einen Eingriff von Hand.
|
||
|
||
**Abweichung vom Vorschlag:** Der Bericht schlug zusätzlich vor, den
|
||
Rückfallpfad gegen `QueryException` abzusichern. Nicht umgesetzt — `constrain()`
|
||
baut die Abfrage nur auf, ausgeführt wird sie später vom Aufrufer. Ein
|
||
try/catch an dieser Stelle könnte den Fehler gar nicht sehen. Die
|
||
Cache-Invalidierung in der Migration deckt den Fall vollständig ab.
|
||
|
||
**Abgesichert:** drei neue Tests in `tests/Feature/PressReleaseSearchIndexTest.php`
|
||
(kurze TTL bei `false`, lange bei `true`, Wirkung von `forgetAvailability()`).
|
||
|
||
---
|
||
|
||
### Schritt 2 — H2: Cache-Flush in der Schleife
|
||
|
||
**Befund:** `CompanyRemovalService::markGone($company, includePressReleases: true)`
|
||
lud alle Mitteilungen der Firma per `get()` und rief je Zeile
|
||
`PressReleaseService::markGone()`, das jedes Mal **sämtliche** Seiten- und
|
||
Sitemap-Caches verwarf. Bei 300 Meldungen: 301 Vollinvalidierungen auf einen
|
||
Admin-Klick, dazu die unbegrenzte Ergebnismenge im Speicher.
|
||
|
||
**Geändert**
|
||
|
||
- `PressReleaseService::markGone()` / `restoreFromGone()` bekommen
|
||
`bool $flushPublicPages = true`. Das Standardverhalten bleibt unverändert.
|
||
- `CompanyRemovalService::markGone()` iteriert mit `chunkById(200)` statt
|
||
`get()` und übergibt `flushPublicPages: false`. Der eine Flush am Ende der
|
||
Methode bestand bereits und genügt.
|
||
|
||
**Abgesichert:** `GoneTombstoneTest` → „cascading a newsroom removal flushes the
|
||
page cache once, not per release" (drei Mitteilungen, Generations-Zähler steigt
|
||
um genau 1).
|
||
|
||
---
|
||
|
||
### Schritt 3 — H3 + H4: Umkehrbarkeit von `gone_at`
|
||
|
||
**Befund:** `markGone()` überschrieb `companies.is_active` (→ false) und
|
||
`press_releases.no_export` (→ true), ohne den Vorzustand zu sichern. Das
|
||
Aufheben setzte beide pauschal auf den Normalzustand zurück. Eine Firma, die
|
||
schon vorher deaktiviert war, stand danach öffentlich in Newsroom-Übersicht und
|
||
Sitemap; eine Mitteilung blieb dauerhaft als „kein Export" markiert. Die
|
||
Oberfläche verspricht an beiden Stellen ausdrücklich Umkehrbarkeit.
|
||
|
||
**Geändert**
|
||
|
||
- Neue Migration `2026_09_01_090000_add_gone_restore_state_to_companies_and_press_releases_tables.php`:
|
||
`companies.is_active_before_gone` und `press_releases.no_export_before_gone`,
|
||
beide `boolean nullable`. `null` = nicht entfernt bzw. bereits
|
||
zurückgenommen. Idempotent über `Schema::hasColumn()`.
|
||
- `Company` und `PressRelease`: `$fillable` + `casts()` ergänzt.
|
||
- `CompanyRemovalService::markGone()` sichert `is_active`,
|
||
`restoreFromGone()` stellt es wieder her (`?? true` als einzige mögliche
|
||
Annahme für Altfälle ohne Merker).
|
||
- `PressReleaseService::markGone()`/`restoreFromGone()` analog für `no_export`.
|
||
Fehlt der Merker, bleibt der aktuelle Wert stehen — raten wäre schlechter als
|
||
nichts zu tun.
|
||
|
||
**Zur Kostenfrage der Migration:** Auf `press_releases` ist das `ADD COLUMN`
|
||
seit dem Entfernen der Volltext-Indizes ein `ALGORITHM=INSTANT`. `companies`
|
||
trägt seinen FULLTEXT-Index weiterhin und wird kopiert — bei 70 MB wenige
|
||
Sekunden, wie in `CLAUDE.md` als vertretbar festgehalten. Die Migration ist
|
||
bewusst **nach** `drop_fulltext_indexes_from_press_releases` datiert.
|
||
|
||
**Verworfene Alternative:** `is_active` in `markGone()` gar nicht anzufassen und
|
||
stattdessen überall auf `gone_at` zu filtern. Dafür hätten 12 Abfragen in
|
||
`app/Services/Web/`, `SitemapController` und `AuthorizesPressReleaseMediaAccess`
|
||
angefasst werden müssen — eine übersehene Stelle hätte einen entfernten
|
||
Newsroom öffentlich gelassen. Der Merker ist die kleinere Angriffsfläche.
|
||
|
||
**Abgesichert:** drei neue Tests in `GoneTombstoneTest` (inaktive Firma bleibt
|
||
nach dem Aufheben inaktiv; `no_export` wird zurückgesetzt; ein vorher gesetztes
|
||
`no_export` bleibt bestehen).
|
||
|
||
---
|
||
|
||
### Schritt 4 — M5: Watermark-Migration umging den AppSetting-Cache
|
||
|
||
**Befund:** Die Migration schrieb die Wasserstandsmarke per Query Builder,
|
||
`AppSetting::getValue()` cacht aber dauerhaft (`rememberForever`). Lief der
|
||
Scheduler-Task im Deploy-Fenster schon einmal, hielt der Cache die alte,
|
||
niedrigere Marke — der nächste Lauf hätte die Ereignisse dazwischen ein zweites
|
||
Mal auf `press_releases.hits` addiert.
|
||
|
||
**Geändert**
|
||
|
||
- `AppSetting::forgetCached(string $key)` als öffentliche Methode; `setValue()`
|
||
benutzt sie jetzt selbst.
|
||
- `…_seed_press_release_view_watermark.php` ruft sie in `up()` und `down()` auf.
|
||
|
||
---
|
||
|
||
## Block B — Werkzeug
|
||
|
||
### Schritt 5 — P1: Statische Analyse wieder in Betrieb
|
||
|
||
**Befund:** 390 Fehler außerhalb der Baseline, davon der größte Teil
|
||
Falschmeldungen: Larastan löste **keinen einzigen Cast** auf, weil alle 49
|
||
Models ihre Casts über die `casts()`-Methode deklarieren (Laravel-11-Stil) und
|
||
Larastans Schalter `parseModelCastsMethod` per Default `false` ist.
|
||
`$pressRelease->status` galt als `string`, jeder Enum-Vergleich als „immer
|
||
falsch". Zusätzlich: Baseline seit dem 12.05.2026 unverändert, `composer
|
||
analyse` in keiner Pipeline.
|
||
|
||
**Geändert**
|
||
|
||
1. `phpstan.neon`: `parseModelCastsMethod: true`, mit Begründung im Kommentar.
|
||
Wirkung: 390 → 266 Meldungen.
|
||
2. **102 Relations-Annotationen** in `app/Models/*.php` ergänzt — jede
|
||
`BelongsTo`/`HasMany`/`HasOne`/`BelongsToMany`/`MorphTo`-Methode hat jetzt
|
||
ihr `@return …<Related, $this>`. Das war die zweite Sammelursache: Ohne
|
||
Generics landete PHPStan auf `Illuminate\…\Model` und meldete jede
|
||
Modell-Eigenschaft und jeden Scope als unbekannt. Wirkung: 266 → 169,
|
||
`property.notFound` von 84 auf 12. Nebeneffekt: dieselbe Verbesserung in der
|
||
IDE-Vervollständigung.
|
||
3. Aufräum-Funde vorgezogen (siehe Schritt 5b).
|
||
4. Baseline neu erzeugt: **166 Einträge / 188 Meldungen** (vorher 127 Einträge
|
||
plus 390 ungefasste Meldungen). `vendor/bin/phpstan analyse` meldet jetzt
|
||
**No errors**.
|
||
5. `.github/workflows/tests.yml`: neuer Schritt „Run Static Analysis" vor den
|
||
Tests. Ohne Gate driftet die Baseline erneut.
|
||
|
||
**Was bewusst in der Baseline bleibt und warum**
|
||
|
||
| Anzahl | Art | Grund |
|
||
|---|---|---|
|
||
| 66 | `property.notFound` | `withCount`-Aliasse (`recent_releases_count`) und Closure-Parameter, die als blankes `Builder` typisiert sind. Behebbar nur mit `@param Builder<PressRelease>` an jeder Closure — viel Rauschen für keinen Erkenntnisgewinn. |
|
||
| 34 | `nullsafe.neverNull` | `?->` auf Werten, die seit der Cast-Auflösung nachweislich nicht null sind. Stilfrage, kein Fehler. |
|
||
| 18 | `method.notFound` | Eigene Scopes (`forPortalValues()`, `visibleInArchive()`) auf denselben blanken Closure-Buildern. |
|
||
| 10 | `trait.unused` | **Falschmeldung:** `app/Livewire/Concerns/*` wird ausschließlich aus Volt-Single-File-Komponenten in `resources/views/livewire/**.blade.php` eingebunden, die PHPStan nicht analysiert. Nachgeprüft, die Traits sind in Benutzung. |
|
||
| 9 | `class.notFound` | Legacy-Migrationsmodelle (`App\Models\Legacy\…`) und `Laravel\Cashier\Concerns\Stripe\TaxId`. |
|
||
| 5+2+2 | `instanceof.alwaysTrue` / `property.onlyWritten` / `method.unused` in `ProcessStripeWebhook` | **Falschmeldungs-Kaskade:** Cashier typisiert `findBillable()` gegen den **Trait** `Billable`. PHPStan kann den nicht mit `User` vereinbaren, hält deshalb `if (! $user instanceof User) return;` für immer wahr und alles danach für unerreichbar — inklusive der Stellen, an denen `$this->activityLog` und `$this->numbers` gelesen werden. Die Laufzeitprüfung ist richtig und bleibt. |
|
||
|
||
**Korrektur zur Bestandsaufnahme:** Befund **L6** („`ProcessStripeWebhook`:
|
||
`$numbers` und `$activityLog` injiziert, nie gelesen") war falsch. Beide werden
|
||
in den Zeilen 123, 149, 257 und 293 verwendet; die Meldung ist ein Folgefehler
|
||
der oben beschriebenen Cashier-Kaskade. Nichts entfernt.
|
||
|
||
---
|
||
|
||
### Schritt 5b — Aufräum-Funde L1–L5 und M7 (vorgezogen)
|
||
|
||
**Warum vorgezogen:** Diese Funde stammen aus der statischen Analyse. Hätte ich
|
||
die Baseline vor ihrer Behebung erzeugt, wären sie zugedeckt gewesen — und ihre
|
||
spätere Behebung hätte die Baseline sofort wieder ungültig gemacht
|
||
(`ignore.unmatched`). Sie gehören deshalb vor den Baseline-Neuaufbau, nicht in
|
||
Schritt 9.
|
||
|
||
| Fund | Datei | Änderung |
|
||
|---|---|---|
|
||
| L1 | `ProofPdfRenderer::renderOverview()` | `if ($embed === null \|\| $image === null)` → `if ($embed === null)`. `$embed` ist bereits null, wenn `$image` null ist. |
|
||
| L3 | `ManualInvoiceService::invoiceFor()` | Prüfung `current_period_end === null` entfernt — die Spalte ist `date()` **NOT NULL**. |
|
||
| L4 | `Company::logoUrl()` | `if ($legacyPortal && filled($logoFilename))` → `if (filled($logoFilename))`. `$legacyPortal` fällt auf `$this->portal->value` zurück und ist damit immer gesetzt. |
|
||
| L5 | `RecordMailActivity::recordSent()` | `getTo() ?? []` → `getTo()`. Die Symfony-Methode liefert immer ein Array. |
|
||
| — | `PressReleasePreviewController` | `$magicLink->expires_at && …->isPast()` → `…->isPast()`. `magic_links.expires_at` ist **NOT NULL**. |
|
||
| — | `RetireAgencyCompanyType` | `if (! $isNewsroom && $isProtected)` → `if (! $isNewsroom)`. Der Zweig darüber endet für den Gegenfall immer mit `continue`. |
|
||
| — | `MigrateLegacyMedia::selectedTypes()` | `@return list<'company-logos'\|'press-release-images'>` ergänzt. Damit ist das `match` ohne `default` nachweislich vollständig statt nur faktisch. |
|
||
|
||
**M7 — Legacy-API konnte `portal = null` schreiben** (ebenfalls vorgezogen, weil
|
||
derselbe Sachverhalt):
|
||
|
||
- `ResolvesRequestedPortal::resolveRequestedPortal()` gibt jetzt ein
|
||
nicht-nullbares `Portal` zurück; letzte Stufe ist `Portal::Both` — exakt der
|
||
Default, den beide `portal`-Spalten ohnehin tragen. Vorher konnte die Methode
|
||
`null` liefern, was als SQL-Fehler und damit als 500er beim Alt-Client
|
||
gelandet wäre; `PressReleaseController:53` hätte sogar einen Fatal Error
|
||
ausgelöst, weil es das Ergebnis direkt mit `->value` weiterreicht.
|
||
- `LegacyPressReleaseController` und `LegacyCompanyController`: `?->value` → das
|
||
Enum bzw. `->value` direkt.
|
||
- `PressRelease::publicPortalLinks()`: der unerreichbare `null =>`-Arm im
|
||
`match` entfällt.
|
||
- `PressRelease::booted()` und `LegacyPressReleaseController` weisen jetzt das
|
||
Enum statt des Rohwerts zu — die Spalten sind darauf gecastet.
|
||
|
||
**Bewusst NICHT geändert:** `ProofPdfRenderer:946` (`$width < 1 || $height < 1`
|
||
nach `imagesx()`/`imagesy()`). Laut PHPStan-Stub tot, zur Laufzeit eine
|
||
Schutzprüfung in der Bildverarbeitung des Rechtsnachweis-PDFs. Für null
|
||
funktionalen Gewinn wird dort keine Absicherung entfernt — Meldung baseliniert.
|
||
|
||
---
|
||
|
||
## Block C — Voraussetzungen für die Roadmap
|
||
|
||
### Schritt 6 — M9: Bounce-Suppression vervollständigt
|
||
|
||
**Befund:** `newsletter:import-bounces` erfasste ausschließlich `users` und
|
||
protokollierte alles Übrige als „ohne User-Konto". Die zweite Empfängerliste
|
||
`newsletter_subscriptions` — die öffentliche Anmeldung auf den Portalen, mit
|
||
eigenem `unsubscribed_at` und eigener Synchronisierung nach außen — blieb
|
||
unberührt. Die meisten Einträge dort haben gar kein Konto, wurden also gar
|
||
nicht erreicht. Die Roadmap macht „Bounce-Suppression produktiv" zum Blocker
|
||
für 1b.6, 1d.3 und 3.2.
|
||
|
||
**Geändert** (`app/Console/Commands/ImportNewsletterBounces.php`)
|
||
|
||
- Aufgeteilt in `suppressUsers()` und `suppressSubscriptions()`; beide melden
|
||
zurück, ob die Adresse in ihrer Liste stand.
|
||
- „Ohne User-Konto" gilt jetzt erst, wenn die Adresse in **keiner** der beiden
|
||
Listen steht.
|
||
- Anmeldungen werden scope-frei über alle Portale gesucht (der CLI-Lauf hat
|
||
keinen Portal-Kontext, eine Adresse kann auf beiden Portalen stehen),
|
||
bekommen `unsubscribed_at`, laufen durch `NewsletterSyncService` (damit die
|
||
Abmeldung auch beim externen Versender ankommt) und landen mit
|
||
`method: bounce` im Aktivitäts-Verlauf.
|
||
- Eigene Ergebniszeile für die Anmeldungen; `--dry-run` deckt beide Listen ab.
|
||
- **L9 gleich mit erledigt:** Der Vergleich läuft über
|
||
`whereRaw('LOWER(email) = ?')` wie in `User::findByEmail()`. Bounce-Listen
|
||
kommen aus dem Mailserver-Log und geben die Adresse so wieder, wie der
|
||
Absender sie geschrieben hat.
|
||
|
||
**Abgesichert:** vier neue Tests (Anmeldung ohne Konto wird abgemeldet;
|
||
Idempotenz beim zweiten Lauf; alle Portale einer Adresse; Dry-Run;
|
||
Groß-/Kleinschreibung).
|
||
|
||
---
|
||
|
||
### Schritt 7 — M4: Aufbewahrung `press_release_events`
|
||
|
||
**Befund:** Die Tabelle nimmt seit dem Umbau des Aufruf-Zählers jeden
|
||
Seitenaufruf auf (Bestand knapp eine Million) und war als einzige Log-Tabelle
|
||
des Systems ohne Bereinigung.
|
||
|
||
**Geändert**
|
||
|
||
- Neu `config/press_releases.php`: `events.retention_days`, Default **395 Tage
|
||
(13 Monate)**, überschreibbar per `PRESS_RELEASE_EVENTS_RETENTION_DAYS`.
|
||
- Neu `app/Console/Commands/PurgePressReleaseEvents.php` mit `--days`,
|
||
`--chunk`, `--dry-run`.
|
||
- Scheduler: täglich 03:20, `withoutOverlapping()`, mit Betriebs-Alert.
|
||
- `.env.example` ergänzt.
|
||
|
||
**Zwei Sicherungen, beide zwingend und getestet**
|
||
|
||
1. Gelöscht wird ausschließlich **unterhalb der Wasserstandsmarke** von
|
||
`press-releases:aggregate-views`. Alles darüber ist noch nicht in `hits`
|
||
verbucht und wäre sonst dauerhaft verloren. Fehlt die Marke, bricht der Lauf
|
||
mit Exit-Code 1 ab, statt zu raten.
|
||
2. Gelöscht wird in Blöcken — ein DELETE über Millionen Zeilen hielte die
|
||
Sperre über die gesamte Laufzeit.
|
||
|
||
**⚠ Offene Entscheidung:** Die **13 Monate sind meine Festlegung, nicht deine.**
|
||
Gewählt, weil damit der Vorjahresmonats-Vergleich möglich bleibt. Der Wert
|
||
bestimmt zugleich, wie weit die Verlaufsanzeige der geplanten Absenderansicht
|
||
(Roadmap 1c.1) zurückreicht — wer dort mehr Historie zeigen will, muss ihn
|
||
**vor dem ersten Lauf** erhöhen. Einmal gelöschte Ereignisse kommen nicht
|
||
zurück. Der Zusammenhang steht als Kommentar in `config/press_releases.php`.
|
||
|
||
**Abgesichert:** fünf Tests in `tests/Feature/PurgePressReleaseEventsTest.php`.
|
||
|
||
---
|
||
|
||
### Schritt 8 — Index auf `companies.email`
|
||
|
||
**Befund:** `companies` hatte nur `(portal, slug)`, `(portal, is_active)`,
|
||
`(created_at, id)` und einen FULLTEXT über `(name, email, slug)`. Ein
|
||
FULLTEXT-Index beantwortet kein `WHERE email = ?` — jede Suche über die
|
||
Firmen-E-Mail war ein voller Tabellendurchlauf. Roadmap 1a schlüsselt die
|
||
Newsroom-Konsolidierung ausdrücklich darüber, 1a.4 bei **jeder** eingehenden
|
||
API-Meldung.
|
||
|
||
**Geändert:** Migration `2026_09_01_091000_add_email_index_to_companies_table.php`.
|
||
|
||
**Warum nur `email` und nicht `(portal, email)`:** Die Adresse ist hochselektiv,
|
||
die Portal-Einschränkung kostet danach nichts mehr — und derselbe Index trägt
|
||
zusätzlich die portalübergreifende Gruppierung des Trockenlauf-Berichts (1a.1),
|
||
was ein zusammengesetzter Index nicht könnte.
|
||
|
||
**Kosten:** `ADD INDEX` läuft bei InnoDB auch mit vorhandenem FULLTEXT-Index
|
||
`INPLACE` — anders als `ADD COLUMN`. Bei 70 MB Sekunden.
|
||
|
||
---
|
||
|
||
## Block D — Schritt 9: laufende Aufräumarbeiten
|
||
|
||
### M1 — Generations-Zähler prozessweit eingefroren
|
||
|
||
`WebPageCache::$generation` wurde mit `??=` einmal je Prozess gefüllt. Unter
|
||
PHP-FPM stimmt das; ein Queue-Worker lebt aber über beliebig viele Jobs hinweg
|
||
und hätte dauerhaft mit dem Stand seines ersten Zugriffs gearbeitet — konkret
|
||
hätte `BoostService::forgetPlacementCaches()` die Keys einer alten Generation
|
||
gelöscht, während der aktuelle Eintrag bis zum TTL-Ablauf stehen bleibt.
|
||
|
||
- Neu: `WebPageCache::resetMemo()`.
|
||
- `AppServiceProvider::boot()` registriert `Queue::looping(…)`, verwirft die
|
||
Merkung also zwischen zwei Jobs. Die Merkung selbst bleibt — sie war als
|
||
Ersparnis gedacht und ist es weiterhin, nur eben mit definierter Lebensdauer.
|
||
Ein späterer Octane-Betrieb bräuchte denselben Reset auf `RequestReceived`;
|
||
das steht als Hinweis am Feld.
|
||
|
||
### M2 — Zähler lag im Namensraum, den er invalidiert
|
||
|
||
`web.generation` → **`page-cache.generation`**. Der Zähler ist der eine Eintrag,
|
||
der eine Verwerfung des `web.`-Namensraums überleben muss.
|
||
|
||
> **Deploy-Hinweis:** Die Umbenennung setzt den Zähler einmalig auf 0 zurück.
|
||
> Unkritisch, solange sie zusammen mit dem übrigen noch nicht committeten Block
|
||
> ausgeliefert wird — die Generations-Nummer existiert auf dem Live-System noch
|
||
> gar nicht. Wird sie später einzeln nachgezogen, vorher einmal
|
||
> `php artisan cache:clear` einplanen.
|
||
|
||
### M3 — Wasserstandsmarke nur chunkweise idempotent
|
||
|
||
`AggregatePressReleaseViews`: `increment()`-Schleife und `AppSetting::setValue()`
|
||
laufen jetzt in **einer** `DB::transaction()` je Durchgang. Vorher war nur der
|
||
Durchgang wiederanlaufsicher, nicht der Abbruch darin — ein Absturz mitten im
|
||
Chunk hätte einen Teil der Zähler erhöht, die Marke aber nicht bewegt, und der
|
||
nächste Lauf hätte genau diese Aufrufe doppelt verbucht. Der Klassenkommentar,
|
||
der Idempotenz behauptete, ist präzisiert.
|
||
|
||
### M6 — `#[Locked]` auf `$companyId`
|
||
|
||
`resources/views/livewire/admin/companies/edit.blade.php`: `public int $companyId`
|
||
hat jetzt `#[Locked]`, wie das Pendant `$id` der PM-Bearbeitung. `save()`,
|
||
`deleteCompany()`, `markGone()` und `restoreGone()` arbeiten alle auf diesem
|
||
Wert; ohne das Attribut konnte der Client ihn im Livewire-Payload austauschen,
|
||
sodass die Aktion eine andere Firma träfe als die im Formular angezeigte.
|
||
|
||
### M8 — pt-osc-Passwort nicht maskiert
|
||
|
||
`OnlineSchemaChange::writeDefaultsFile()` schreibt Benutzer und Passwort jetzt
|
||
über `quoteOptionValue()` in doppelten Anführungszeichen mit escaptem `\` und
|
||
`"`. In einer my.cnf beginnt ein `#` sonst einen Kommentar und umschließende
|
||
Leerzeichen fallen weg — ein Passwort mit Raute wäre abgeschnitten bei pt-osc
|
||
angekommen und der Lauf an einem Authentifizierungsfehler gescheitert, dessen
|
||
Ursache nirgends sichtbar ist.
|
||
|
||
### L7 — `max_allowed_packet` beim Erstlauf des Reindex
|
||
|
||
`ReindexPressReleaseSearch`: Klassenkommentar um den Hinweis ergänzt, dass ein
|
||
Durchgang `--chunk` vollständige Textkörper in **einem** `upsert` schreibt und
|
||
beim erstmaligen Indizieren des Bestands im Zweifel `--chunk=100` zu wählen ist.
|
||
|
||
### L8 — Blade-Codestil
|
||
|
||
`admin/press-releases/index.blade.php`: `fn($inner)` → `fn ($inner)`. Pint
|
||
erfasst Blade-Dateien nicht, deshalb war die Abweichung durchgerutscht.
|
||
|
||
### L9
|
||
|
||
Zusammen mit Schritt 6 erledigt, siehe dort.
|
||
|
||
---
|
||
|
||
## Endstand
|
||
|
||
| Prüfung | vorher | nachher |
|
||
|---|---|---|
|
||
| `php artisan test` | 1479 grün, 1 übersprungen | **1495 grün**, 1 übersprungen (6517 Assertions) |
|
||
| `vendor/bin/pint --test` | passed | **passed** |
|
||
| `vendor/bin/phpstan analyse` | 390 Fehler außerhalb der Baseline | **No errors** (Baseline 166 Einträge / 188 Meldungen) |
|
||
|
||
**16 neue Tests**, verteilt auf `PressReleaseSearchIndexTest` (3),
|
||
`GoneTombstoneTest` (4), `ImportNewsletterBouncesTest` (4) und das neue
|
||
`PurgePressReleaseEventsTest` (5).
|
||
|
||
### Neue Dateien
|
||
|
||
| Datei | Zweck |
|
||
|---|---|
|
||
| `app/Console/Commands/PurgePressReleaseEvents.php` | Aufbewahrung des Ereignis-Logs (M4) |
|
||
| `config/press_releases.php` | Aufbewahrungszeitraum, konfigurierbar |
|
||
| `database/migrations/2026_09_01_090000_add_gone_restore_state_…` | Vorzustand der 410-Entfernung (H3/H4) |
|
||
| `database/migrations/2026_09_01_091000_add_email_index_to_companies_table.php` | Voraussetzung Roadmap 1a |
|
||
| `tests/Feature/PurgePressReleaseEventsTest.php` | Absicherung M4 |
|
||
|
||
### Reihenfolge beim Deploy
|
||
|
||
1. `php artisan migrate --force` — die neuen Migrationen laufen **nach**
|
||
`drop_fulltext_indexes_from_press_releases` (auf `press_releases` damit
|
||
`INSTANT`, auf `companies` ein Kopiervorgang von wenigen Sekunden).
|
||
2. Danach einmal `php artisan press-releases:aggregate-views`, damit die
|
||
Wasserstandsmarke gesetzt ist, **bevor** der neue Aufräum-Task um 03:20 das
|
||
erste Mal läuft. Ohne Marke bricht er von selbst ab — es geht also nichts
|
||
verloren, der erste Lauf wäre nur wirkungslos.
|
||
3. `php artisan config:cache` (neue Konfigurationsdatei).
|
||
|
||
### Was offen bleibt
|
||
|
||
| Thema | Status |
|
||
|---|---|
|
||
| Aufbewahrung des Ereignis-Logs: 395 Tage | **Meine Festlegung, deine Entscheidung.** Bestimmt die Tiefe von Roadmap 1c.1 und ist nur *vor* dem ersten Lauf frei wählbar. |
|
||
| Die 166 Baseline-Einträge | Kein Fehler, aber auch keine Sichtprüfung wert — Zusammensetzung und Begründung siehe Schritt 5. Wer sie weiter senken will, fängt bei den Closure-Generics in `app/Services/Web/` an. |
|
||
| Roadmap-Entscheidungen E1–E6 | Unberührt — das sind fachliche Fragen, keine Code-Befunde. |
|
||
|
||
---
|
||
|
||
# Teil 2 — Roadmap Block 0 (Messbarkeit)
|
||
|
||
## 0.1 und 0.2 — Basiswerte
|
||
|
||
Zwei Auswertungen als Artisan-Befehle, Ergebnisse und Ableitungen in
|
||
**[`basiswerte-block0.md`](basiswerte-block0.md)**.
|
||
|
||
```
|
||
php artisan press-releases:view-distribution [--months=3] [--from=] [--to=] [--json]
|
||
php artisan press-releases:rejection-rate [--months=3] [--from=] [--to=] [--json]
|
||
```
|
||
|
||
**Zwei Befunde, die den Plan verändert haben:**
|
||
|
||
1. **Der Gesamtmedian taugt nicht als Basiswert.** `hits` ist ein mitlaufender
|
||
Zähler, und die Aufrufe wachsen nach der Veröffentlichung wochenlang weiter
|
||
(⌀ 75 → 128 → 179 → 231 in den ersten vier Wochen, ohne Plateau). Über ein
|
||
Drei-Monats-Fenster misst man damit die Altersmischung, nicht die Reichweite.
|
||
Der Report weist deshalb zusätzlich **Alterskohorten** aus; der Basiswert ist
|
||
die Kohorte „28–55 Tage", nicht die Gesamtzahl.
|
||
2. **openPR ist keine zweite Quelle.** Es ist der Presse-API-Verteiler von
|
||
connektar, also derselbe Zulauf. In den Daten hat genau ein API-Token je
|
||
einen Zugriff gemacht. Roadmap-Text korrigiert.
|
||
|
||
**E2 ist damit entschieden: Rohzahlen.** Begründung in `basiswerte-block0.md`.
|
||
Kurzfassung: kein Bodensatz (unter 25 Aufrufen liegen 2 % bzw. 0 %),
|
||
Verteilungen nahezu symmetrisch, und eine Einordnung gegen den
|
||
Portaldurchschnitt wäre wegen der Wachstumskurve sogar falsch.
|
||
|
||
---
|
||
|
||
## 0.3 bis 0.7 — Konversionsleiter
|
||
|
||
### Warum eine eigene Tabelle statt Umami
|
||
|
||
Die zu messende Strecke führt über zwei Domains und einen Magic Link. Ein
|
||
cookiefreies Analytics-Tool verliert die Sitzung genau an der Stelle, an der
|
||
die Konversion passiert. Claim, Registrierung und Buchung sind außerdem keine
|
||
Seitenaufrufe, sondern Zustandsänderungen in der Datenbank, bei denen die
|
||
Identität ohnehin bekannt ist.
|
||
|
||
**Rollenteilung bleibt:** Ereignistabelle für den Trichter (Block 1), Umami für
|
||
Seitenaufrufe und Suchbegriffe (Block 2). Keine Vermischung.
|
||
|
||
### 0.3 — `conversion_events`
|
||
|
||
Spalten: `type`, `source`, `company_id`, `press_release_id`, `created_at`.
|
||
Keine Personen, keine Klartextadressen, keine IP, kein User-Agent. Der rote
|
||
Faden durch den Trichter ist die **Firma**, nicht der Mensch — für „an welcher
|
||
Stufe bricht es ab" reicht das vollständig.
|
||
|
||
Die Leiter als Enum `ConversionEventType`, mit Stufe **und** Diagnose am Fall:
|
||
|
||
| # | Stufe | Ein Abbruch hier bedeutet |
|
||
|---|---|---|
|
||
| 1 | Mail-Link geklickt | Betreff oder Anlass trägt nicht |
|
||
| 2 | Magic Link eingelöst | Der Verwaltungszugang wird nicht verstanden |
|
||
| 3 | Newsroom bearbeitet | Kein Anreiz, das Profil zu pflegen |
|
||
| 4 | Konto angelegt | Der Nutzen des Kontos ist nicht erkennbar |
|
||
| 5 | Erste Buchung | Preis oder Angebot passt nicht |
|
||
|
||
### 0.4 — Herkunft auf dem Token
|
||
|
||
Neue Spalte `magic_links.source` (bewusst eine Spalte statt eines Eintrags in
|
||
`payload`: Die Herkunft wird gruppiert und gefiltert, nicht nur gelesen).
|
||
`MagicLinkGenerator::createLoginLink()` nimmt sie entgegen.
|
||
|
||
Die Mail-Links tragen `?src=<Herkunft>&pr=<Meldung>`. `ConversionSource::parse()`
|
||
fällt bei unbekannten Werten auf `organic` zurück statt zu scheitern — der
|
||
Parameter steht in fremder Hand.
|
||
|
||
**Zur Meldungs-ID im Klartext:** Sie ist fälschbar. Vertretbar, weil sie nichts
|
||
freischaltet, sondern ein Ereignis in der internen Statistik einer Firma
|
||
zuordnet; wer sie fälscht, verfälscht nur die eigene Auswertung. Sobald der
|
||
Verwaltungszugang über Magic Link steht (1b.2), tragen die Mail-Links ein
|
||
Token, das Herkunft und Meldung fälschungssicher mitführt — der `pr`-Parameter
|
||
entfällt dann.
|
||
|
||
### 0.5 — Schreibstellen
|
||
|
||
| Stufe | Wo |
|
||
|---|---|
|
||
| 1 | `RecordMailLinkClick` — globale Middleware. Bewusst global statt je Route: Die Mail-Links zeigen auf Hub, Spokes und öffentliche Detailseiten, eine vergessene Route wäre eine unsichtbare Lücke. Nur `mail_*`-Herkünfte lösen aus, nur GET. |
|
||
| 2 | `MagicLinkConsumeController`, Herkunft vom Token |
|
||
| 3 | **Enum und Auswertung stehen, Schreibstelle folgt mit 1b.3** (abgesprochen) |
|
||
| 4 | `RecordAuthActivity::recordRegistered()` |
|
||
| 5 | `ProcessStripeWebhook` an beiden Zahlungspfaden (`mirrorPaidInvoice`, `fulfillSinglePurchase`), dedupliziert über `recordOnce()` je Firma |
|
||
|
||
Zwei Regeln für alle Schreibstellen: **Fehler werden geschluckt** (die Messung
|
||
begleitet einen Vorgang, sie ist nicht der Vorgang) und **kein Personenbezug**.
|
||
|
||
### 0.6 — Auswertung
|
||
|
||
```
|
||
php artisan conversion:funnel [--months=3] [--from=] [--to=] [--json]
|
||
```
|
||
|
||
Zählt je Stufe die **eindeutigen Firmen**, nicht die Ereignisse: Ein doppelter
|
||
Klick ist kein doppelter Interessent, und nur mit derselben Einheit auf allen
|
||
Stufen ist ein Übergang überhaupt eine Quote. Ereignisse ohne Firmenbezug
|
||
stehen in einer eigenen Spalte daneben statt im Nenner.
|
||
|
||
### 0.7 — Aufbewahrung
|
||
|
||
**395 Tage**, derselbe Horizont wie beim Aufruf-Ereignis-Log (Entscheidung
|
||
01.09.2026). `config/conversion.php`, Befehl `conversion:purge-events`, täglich
|
||
03:25 im Scheduler.
|
||
|
||
> **Offen und bewusst nicht von mir geschrieben:** Der Absatz in der
|
||
> Datenschutzerklärung. Rechtstext gehört nicht aus einer Codeänderung heraus
|
||
> formuliert. Inhaltlich zu führen ist: Zweck (interne Reichweiten- und
|
||
> Konversionsstatistik), Umfang (Ereignistyp, Herkunft, Firmen- und
|
||
> Meldungsbezug — kein Personenbezug, keine IP), Aufbewahrung (395 Tage),
|
||
> Rechtsgrundlage.
|
||
|
||
### Angepasster Bestandstest
|
||
|
||
`EmailBrandingTest` verglich die Mail-URL roh gegen das gerenderte HTML. Die
|
||
Links tragen jetzt zwei Parameter, Blade escapt das trennende `&` korrekt zu
|
||
`&` — der Vergleich läuft nun über `e()`. Verhalten unverändert richtig,
|
||
nur die Erwartung war zu eng.
|
||
|
||
---
|
||
|
||
# Teil 3 — Roadmap Block 1a (Newsroom-Konsolidierung)
|
||
|
||
## 1a.1 — Trockenlauf-Bericht
|
||
|
||
```
|
||
php artisan companies:merge-report [--threshold=] [--limit=15] [--json]
|
||
```
|
||
|
||
**Live-Ergebnis vom 11.09.2026, Schwellwert 3, nach der Tippfehler-Korrektur:**
|
||
|
||
| Klasse | Adressen | Firmen | fällt weg | davon mit PM |
|
||
|---|---|---|---|---|
|
||
| Dublette | 12.812 | 27.832 | **15.020** | 14.633 |
|
||
| Namensvariante | 1.615 | 4.540 | **2.925** | 1.087 |
|
||
| Vorschlag | 3.632 | 9.907 | – | 2.211 |
|
||
| Sammeladresse | 1.085 | 6.706 | – | 1.072 |
|
||
| nie veröffentlicht | 23 | 5.965 | – | 0 |
|
||
|
||
**17.945 Firmen** würden automatisch zu bestehenden Newsrooms verschmelzen —
|
||
16 % des Bestands von 111.646.
|
||
|
||
**Drei Befunde aus den Live-Läufen mit Schwellwert 2, 3 und 4:**
|
||
|
||
1. **Die Klasse „nie veröffentlicht" fehlte im Regelwerk.** 23 Adressen mit
|
||
5.965 Firmen und null Veröffentlichungen, angeführt von `yahoo@yahoo.com`
|
||
(4.376 Firmen). Dahinter steht keine Firma und keine Agentur. Hätte 1a.3
|
||
ohne diesen Trockenlauf gebaut, wären 4.376 Unbeteiligte zu einem Newsroom
|
||
verschmolzen — der teuerste Fehler dieses Blocks, nicht sauber
|
||
rückabwickelbar. Die Namensanzahl allein trennt diese Adressen NICHT von
|
||
großen Agenturen (`pr@deutsche-stadtauskunft.ag`: 133 Namen, 134 Firmen mit
|
||
Meldungen); trennscharf ist erst die Veröffentlichung.
|
||
2. **Tippfehler galten als verschiedene Firmen.** `info@js-research.de` stand
|
||
mit 204 Firmen an zweiter Stelle der Agenturliste — eine Firma, siebzehnmal
|
||
leicht anders geschrieben. Normalisierte Namen werden jetzt zu
|
||
Ähnlichkeitsclustern zusammengefasst (Levenshtein ≤ 2 bzw. 15 %, oder drei
|
||
gemeinsame Anfangswörter). Namensvarianten stiegen dadurch von 730 auf 1.615
|
||
Adressen, Sammeladressen sanken von 1.430 auf 1.085.
|
||
3. **„& Co. KG" passte nie.** Rechtsformen wurden nach dem Entfernen der
|
||
Satzzeichen abgeglichen — „gmbh & co kg" traf nie auf „gmbh co kg". Jetzt
|
||
wortweise vom Ende her gestrippt.
|
||
|
||
**E1 entschieden: Schwellwert 3.** Bei 2 wird jede zweite Schreibweise zur
|
||
„Agentur" (ProCoReX, Welcon — je eine Firma mit Rechtsformwechsel). Bei 4
|
||
wandern 768 echte Mehrkunden-Adressen wie `presse@agentur-zb.de` in die
|
||
Handarbeit. Konfiguriert in `config/newsroom.php`.
|
||
|
||
**Zu 3.3:** 1.085 Sammeladressen mit 6.706 Firmen, davon 1.072 mit Meldungen —
|
||
namentlich benannte PR-Agenturen. Das Agenturmodell ist kein Zukunftsfeature,
|
||
sondern ein vorhandenes Datenproblem.
|
||
|
||
## 1a.2 — Persistierte Klassifizierung
|
||
|
||
```
|
||
php artisan companies:classify-addresses [--threshold=] [--dry-run]
|
||
```
|
||
|
||
Tabelle `company_email_groups`, eine Zeile je Adresse — die Klassifizierung
|
||
ist eine Eigenschaft der Adresse, nicht der Firma. Bewusst eigene Tabelle statt
|
||
Spalten auf `companies` (FULLTEXT → jedes ADD COLUMN ein Rebuild).
|
||
|
||
Die Klassifizierungslogik liegt in `CompanyAddressClassifier` und wird von
|
||
Bericht **und** Persistierung benutzt — beide müssen dieselbe Antwort geben,
|
||
sonst zeigt der Bericht etwas anderes, als die Zusammenführung später tut.
|
||
|
||
**`manual_classification`** übersteuert die berechnete Klasse und überlebt
|
||
jede Neuberechnung. Nötig für Grenzfälle, die nur ein Mensch entscheiden kann:
|
||
`presse@pr4you.de` ist eine echte Agentur, sieht in den Daten aber aus wie eine
|
||
Wegwerfadresse. `CompanyEmailGroup::effectiveClassification()` ist die einzige
|
||
Stelle, die 1a.3/1a.4 fragen dürfen. Wer über eine Adresse entscheidet, liest
|
||
nie die berechnete Spalte direkt.
|
||
|
||
**Wiederholbar:** Adressen, unter denen nur noch eine Firma steht, fallen beim
|
||
nächsten Lauf heraus — außer sie tragen eine Handentscheidung.
|
||
|
||
**Laufzeit:** 3,2 s über den ganzen Bestand. Die Ähnlichkeitsprüfung ist
|
||
quadratisch und bei 25 Repräsentanten gedeckelt — ab da steht die Klasse fest,
|
||
und `yahoo@yahoo.com` hätte sonst neun Millionen Levenshtein-Aufrufe gekostet.
|
||
|
||
**Deploy:** `migrate --force`, `config:cache`, dann einmal
|
||
`companies:classify-addresses`. Der Befehl gehört vorerst nicht in den
|
||
Scheduler — die Klassifizierung soll sich nicht über Nacht ändern, während
|
||
jemand im Admin darüber entscheidet. Nach 1a.4 neu bewerten.
|
||
|
||
**Live-Trockenlauf vom 11.09.2026** (nach Migration, Schwellwert 3):
|
||
|
||
| Klasse | Adressen | zusammenführbar |
|
||
|--------------------|---------:|-----------------|
|
||
| Dublette | 12 812 | ja |
|
||
| Namensvariante | 1 615 | ja |
|
||
| Vorschlag | 3 632 | nein |
|
||
| Sammeladresse | 1 085 | nein |
|
||
| nie veröffentlicht | 23 | nein |
|
||
| **gesamt** | **19 167** | |
|
||
|
||
Deckungsgleich mit dem Bericht aus 1a.1 — Bericht und Persistierung rechnen
|
||
dieselbe Logik. 14 427 Adressen (75 %) sind zusammenführbar; hinter ihnen
|
||
stehen die 17 945 Firmen aus 1a.1. Der scharfe Lauf am selben Tag schrieb
|
||
dieselben 19 167 Zeilen nach `company_email_groups` — die Tabelle ist auf Live
|
||
gefüllt, 1a.3 kann darauf filtern.
|
||
|
||
### Korrektur: „veröffentlicht" heißt „jemals veröffentlicht"
|
||
|
||
Die 23 Nie-veröffentlicht-Adressen von Live hatten **alle** exakt null Firmen
|
||
mit Meldung — darunter pr4you (65 Firmen), Blödorn PR (35), Crossover Point
|
||
(31), GCE (48). Sieben Agenturen, die nie etwas veröffentlicht haben sollen,
|
||
sind kein Befund, sondern ein Messfehler.
|
||
|
||
Nachgeprüft in der Dev-DB (Kopie des Bestands):
|
||
|
||
| Adresse | Meldungen | Status | davon mit Aufrufen | Zeitraum |
|
||
|---|---:|---|---:|---|
|
||
| presse@pr4you.de | 199 | rejected | 199 | 2008–2011 |
|
||
| h.ballwanz@pr4you.de | 473 | rejected | 473 | 2006–2011 |
|
||
| info@bloedorn-pr.de | 194 | rejected | 193 | 2006–2011 |
|
||
| info@gce-agency.com | 328 | rejected | 328 | 2009–2011 |
|
||
| press@crossoverpoint.de | 149 | rejected | 148 | 2009–2011 |
|
||
| yahoo@yahoo.com | 5 176 | rejected | **0** | 2015–2019 |
|
||
| hcgdietinsight4@gmail.com | 73 | rejected | **0** | 2011–2012 |
|
||
|
||
**Ursache:** Das Altsystem hat 2009–2018 rund 126 000 Meldungen auf `rejected`
|
||
gesetzt (gegenüber 31 000 `published`); 83 000 davon tragen Aufrufe. Eine
|
||
abgelehnte Meldung wird nie aufgerufen — die waren online und sind später
|
||
pauschal offline genommen worden. `rejected` im Altbestand bedeutet
|
||
„offline", nicht „redaktionell abgelehnt". Der Importer hat den Status
|
||
korrekt übernommen; nur die Deutung war falsch.
|
||
|
||
**Fix** in `CompanyAddressClassifier::classifyAll()`: eine Firma gilt als
|
||
veröffentlicht, wenn sie eine Meldung mit `status = published` **oder**
|
||
`hits > 0` hat. Zwei Tests halten beide Seiten fest (Agentur mit alten
|
||
Offline-Meldungen → Sammeladresse; abgelehnte Meldungen ohne einen Aufruf →
|
||
nie veröffentlicht). Laufzeit unverändert (2,6 s).
|
||
|
||
**Wirkung** (Dev-DB, Stand etwas älter als Live): 23 → **7** nie
|
||
veröffentlicht, 16 wandern zur Sammeladresse. Die übrigen Klassen sind
|
||
unberührt — das Veröffentlichungssignal entscheidet nur zwischen „nie
|
||
veröffentlicht" und Sammeladresse. Die Sieben, die bleiben:
|
||
`yahoo@yahoo.com`, `pr@getarticlesdone.com`, `hcgdietinsight4@gmail.com`,
|
||
`zhangmh@itecheasy.com`, `andrewkarvert112@gmail.com`,
|
||
`kontakt@pr-vertretung.de` (159 Firmen, 1 mit Aufruf), `bukmaier@prdienst.de`
|
||
(28 Firmen, keine einzige Meldung). Letztere ist die einzige mit
|
||
Agentur-Namen — sie kann bei Bedarf per `manual_classification` umgestellt
|
||
werden.
|
||
|
||
**Nebenbefund für spätere Auswertungen:** Jede Statistik, die
|
||
`status = rejected` über den Altbestand als Ablehnung liest, misst falsch.
|
||
Die Ablehnungsquote aus 0.2 ist nicht betroffen (sie läuft über die
|
||
Statuslogs des neuen Systems).
|
||
|
||
**Deploy:** Nach dem Deploy `companies:classify-addresses` erneut laufen
|
||
lassen — die 16 Adressen werden dabei umklassifiziert, Handentscheidungen
|
||
bleiben.
|
||
|
||
## 1a.3 — Zusammenführung im Bestand
|
||
|
||
```
|
||
php artisan companies:merge [--dry-run] [--limit=] [--email=] [--json]
|
||
php artisan companies:unmerge --merge=ID | --batch=UUID | --email= [--dry-run]
|
||
```
|
||
|
||
### Der Fakt, der den Entwurf bestimmt hat
|
||
|
||
Die meisten „Dubletten" sind **dieselbe Firma auf zwei Portalen**: Das
|
||
Altsystem hat jede Firma je Portal einmal angelegt.
|
||
|
||
| | Gruppen |
|
||
|---|---:|
|
||
| Adressgruppen gesamt (Dev-DB) | 18 860 |
|
||
| davon mit Firmen auf beiden Portalen | **14 519** (77 %) |
|
||
| Zweiergruppen | 15 754 |
|
||
| davon exakt PE + B24 | 12 605 |
|
||
| davon zusätzlich mit identischem Slug | 12 037 |
|
||
|
||
`Portal::Both` existiert im Code — Newsroom-Controller, PortalScope und
|
||
Slug-Auflösung behandeln es —, aber **keine Firma** trug es. Die
|
||
Zusammenführung ist also weniger „Dubletten löschen" als „zwei Portalhälften
|
||
zu einer Firma machen": Der Überlebende bekommt `both`, die Meldungen
|
||
behalten ihr Portal, der Newsroom zeigt auf jedem Portal die Meldungen dieses
|
||
Portals.
|
||
|
||
### Entscheidungen (15.09.2026, A/A/A + both)
|
||
|
||
1. **Es bleibt** die Firma mit den meisten jemals veröffentlichten Meldungen
|
||
(`published` oder `hits > 0`), bei Gleichstand die ältere. Leere
|
||
Stammdatenfelder (Website, Telefon, Fax, Adresse, Land, Logo, Boilerplate,
|
||
Inhaber) füllt sie aus den wegfallenden Firmen; belegte bleiben. Ist der
|
||
Überlebende inaktiv und eine wegfallende Firma aktiv, wird er aktiv.
|
||
2. **Meldungen, Kontakte, KI-Erklärungen, Konversionsereignisse** wandern per
|
||
`company_id`. `company_user` und Zahlungsoptionen werden zur Vereinigung;
|
||
ein Nutzer, der schon am Überlebenden stand, wird nicht doppelt — sein
|
||
alter Eintrag fällt weg und ist als `dropped` mit Rolle protokolliert.
|
||
Jede Bewegung steht in `company_merge_moves`; der Vorgang in
|
||
`company_merges` (Batch-ID, Überlebender, sein Portal vorher).
|
||
3. **Die wegfallende Firma** wird soft-deleted. Ihr alter Slug führt per
|
||
**301** zum Überlebenden (`NewsroomDetailPageData::mergedInto()`), sofern
|
||
der Slug abweicht — bei gleichem Slug findet die Auflösung den `both`-
|
||
Überlebenden direkt. Kein 410: Die Firma gibt es noch, nur woanders.
|
||
|
||
**Slug-Kollision:** Wird der Überlebende `both`, muss sein Slug auf dem
|
||
anderen Portal frei sein (die öffentliche Auflösung fragt
|
||
`portal IN (eigenes, both)`). Ist der Erstkandidat blockiert, kommt der
|
||
nächste dran; ist keiner frei, wird die Adresse **übersprungen** und
|
||
aufgelistet. Nicht umbenannt — die URL ist öffentlich. Vorab geschätzt 664
|
||
Gruppen, tatsächlich **5** — die Kollisionen sind fast immer einseitig
|
||
(Alt-Slugs sind je Portal durchnummeriert), und die andere Hälfte ist frei.
|
||
|
||
**410-entfernte Firmen** bleiben außen vor: Ihre Meldungen in den
|
||
Überlebenden zu heben, würde bewusst entfernte Inhalte wieder sichtbar
|
||
machen.
|
||
|
||
**Keine Schema-Änderung an bestehenden Tabellen.** Kein
|
||
`merged_from_company_id` auf `press_releases` (1 GB, einmaliger Vorgang),
|
||
kein `merged_into_company_id` auf `companies` (FULLTEXT) — beides kommt aus
|
||
den zwei neuen Tabellen. `press_release_search` enthält keine Firmendaten;
|
||
das Umhängen per Query-Builder braucht keinen Reindex.
|
||
|
||
### Nebenbefund und Fix: Slug-Eindeutigkeit kannte `both` nicht
|
||
|
||
`HasUniqueSlug` prüft bei Firmen nur `portal = X`. Eine neue PE-Firma hätte
|
||
denselben Slug wie eine `both`-Firma bekommen können — und die Newsroom-URL
|
||
hätte zwei Firmen gefunden. `Company::slugExists()` prüft jetzt
|
||
`portal IN (X, both)`, und eine `both`-Firma braucht den Slug überall frei.
|
||
Test in `HasUniqueSlugTest`.
|
||
|
||
### Lauf auf der Dev-DB (Kopie des Bestands)
|
||
|
||
| | |
|
||
|---|---:|
|
||
| Adressen zusammengeführt | 14 142 |
|
||
| Firmen im Überlebenden aufgegangen | 16 194 |
|
||
| Meldungen gewandert | 13 297 |
|
||
| Firmen jetzt `both` | 12 926 |
|
||
| Kontakte gewandert | 8 038 |
|
||
| Nutzer-Verknüpfungen weggefallen (schon am Überlebenden) | 15 467 |
|
||
| Stammdatenfelder aufgefüllt | 1 819 |
|
||
| übersprungen (Slug-Kollision) | 5 |
|
||
| Laufzeit merge / unmerge | 2:00 min / 1:15 min |
|
||
|
||
Nach `companies:unmerge --batch=…` stimmten Portalverteilung (42 287 / 66 892),
|
||
`company_user` (107 663) und Soft-Deletes (2 155) wieder exakt mit dem
|
||
Stand davor überein. Die Rückabwicklung ist verifiziert.
|
||
|
||
Zwei Performance-Punkte beim Bau: Die Kollisionsprüfung mit `portal != ?`
|
||
kostete 24 ms je Adresse (kein Index) — mit `portal IN (…)` greift der
|
||
Unique-Index (portal, slug), 0 ms. Und wegfallende Firmen werden per
|
||
Query-Builder soft-deleted, nicht per `$company->delete()`: Der Observer
|
||
würde je Firma den Admin-Cache leeren; der Lauf leert einmal am Ende.
|
||
|
||
**Prüfen:** `companies:merges --batch=<ID>` listet je Vorgang den
|
||
Überlebenden mit seinen Newsroom-URLs auf beiden Portalen, die aufgegangenen
|
||
Firmen und deren alte URLs, die per 301 weiterführen. Nachgerüstet nach dem
|
||
ersten Live-Lauf mit `--limit=50` — der scharfe Lauf zeigte nur die Batch-ID,
|
||
und die Frage „welche 50 sind das?" war berechtigt. Der scharfe Lauf zeigt
|
||
seither auch die ersten 30 Vorgänge als Tabelle.
|
||
|
||
**Deploy:** `migrate --force`, dann `companies:merge --dry-run`, dann
|
||
`companies:merge --limit=50` und die 50 im Admin ansehen, dann ohne Limit.
|
||
Erledigte Adressen zählen beim zweiten Lauf nicht gegen das Limit. Danach
|
||
`companies:classify-addresses` — die zusammengeführten Adressen fallen aus
|
||
`company_email_groups`, weil nur noch eine Firma darunter steht.
|
||
|
||
**Live-Trockenlauf vom 15.09.2026** (nach Migration): 14 422 Adressen,
|
||
18 096 Firmen gehen auf, 14 545 Meldungen wandern, 13 296 Firmen werden
|
||
`both`, 7 Slug-Kollisionen übersprungen (pizza.de, x84.de, 2× eposso-energy,
|
||
anzeiger24, togev, afan.academy). Gegenüber der Dev-DB rund 280 Adressen
|
||
mehr — der laufende API-Zulauf: Unter `301@seo-manager.info` stehen vier
|
||
Firmen mit IDs ab 111 999, alle aus September, je Meldung eine. Genau das,
|
||
was 1a.4 abstellt.
|
||
|
||
Auffällig: Bei `1@schlotterimmobilienplus.de` bleibt #112132, eine dieser
|
||
neuen API-Kopien, weil die drei älteren Firmen keine veröffentlichte Meldung
|
||
tragen — Regel 1 greift wie entschieden, die alte URL führt per 301 auf die
|
||
neue. Wer den alten Slug behalten will, entscheidet das vor dem Lauf von
|
||
Hand (`--email=` einzeln, oder die Gruppe per `manual_classification`
|
||
aussparen).
|
||
|
||
**Live-Läufe vom 15.09.2026:** Batch `4e1a7235…` (`--limit=50`, 60 Firmen,
|
||
33 Meldungen, 48× `both`), danach Batch `af3fb304…` über den Rest — die 50
|
||
erledigten Adressen wurden dabei als „nichts zusammenzuführen" übersprungen,
|
||
wie vorgesehen. Zwei Beobachtungen aus der Tabelle:
|
||
|
||
- `a.hezel@serviceplan.com`: Es bleibt #52565 „serviveplan public relations"
|
||
(Tippfehler im Namen), weil sie mehr Meldungen trägt als #18934
|
||
„serviceplan public relations". Regel 1 entscheidet nach Inhalt, nicht nach
|
||
Schreibweise — der Name wird im Admin korrigiert, die URL bleibt.
|
||
- `a.helf@ubc-collection.com`: acht Firmen, sieben davon mit IDs ab 112 698
|
||
aus den letzten Tagen, je eine Meldung — der API-Zulauf, den 1a.4 abstellt.
|
||
|
||
**Kompletter Live-Lauf, 15.09.2026** (Batch `a0e38bbd…`): 14 356 Adressen,
|
||
18 013 Firmen aufgegangen, 14 496 Meldungen gewandert, 13 236 Firmen jetzt
|
||
`both`, 6 Slug-Kollisionen übersprungen. Zusammen mit den beiden
|
||
50er-Batches davor: **~14 450 Adressen, ~18 100 Firmen, ~14 550 Meldungen.**
|
||
|
||
`companies:classify-addresses` danach: 4 749 Adressen — Dublette **4**,
|
||
Namensvariante **3** (die Slug-Kollisionen plus API-Zulauf seit dem Lauf),
|
||
Vorschlag 3 633, Sammeladresse 1 102, nie veröffentlicht 7. Der Bestand ist
|
||
konsolidiert; was jetzt noch zusammenführbar ist, entsteht laufend neu — das
|
||
ist 1a.4.
|
||
|
||
Offen: die 6 Slug-Kollisionen einzeln ansehen (pizza.de, x84.de,
|
||
2× eposso-energy, anzeiger24, togev). Möglich ist, die fremde Firma mit dem
|
||
blockierenden Slug zu prüfen — oft ist das dieselbe Firma unter anderer
|
||
Adresse (Vorschlag-Klasse) — und danach mit `--email=` nachzuziehen.
|
||
|
||
## 1a.4 — Laufende Zuordnung neuer API-Meldungen
|
||
|
||
Der Alt-Vertrag der API legt vor jeder Meldung eine Firma an: connektar
|
||
schickt je Meldung `POST /service/users/me/companies` und dann die Meldung
|
||
mit der zurückgegebenen ID. Deshalb entstand je Meldung ein Newsroom —
|
||
`a.helf@ubc-collection.com` hatte sieben aus einer Woche.
|
||
|
||
**`ApiNewsroomAssignment`** prüft jetzt VOR dem Anlegen, ob unter derselben
|
||
Firmen-E-Mail schon eine Firma mit demselben Namen steht — „derselbe Name"
|
||
ist `CompanyAddressClassifier::namesMatch()`, also exakt die Regel der
|
||
Klassifizierung (Rechtsform, Tippfehler, gemeinsamer Anfang). Bei Treffer
|
||
bekommt der Aufrufer deren ID zurück. Der Vertrag bleibt: 201 + Location.
|
||
connektar merkt nichts davon, außer dass die ID eine alte ist.
|
||
|
||
Regeln:
|
||
|
||
- **Kein Treffer bei abweichendem Namen** → neue Firma wie bisher. Das ist
|
||
der Agenturfall: neuer Kunde unter der Sammeladresse.
|
||
- **Nie-veröffentlicht-Adresse** (`company_email_groups`, Handentscheidung
|
||
übersteuert) → nie zuordnen.
|
||
- **410-entfernte und aufgegangene (soft-deleted) Firmen** werden nicht
|
||
getroffen — der Überlebende schon.
|
||
- **Anderes Portal:** Die Firma wird `both`, die neue Meldung bekommt das
|
||
angefragte Portal, die alten behalten ihres. Ist der Slug auf dem anderen
|
||
Portal fremd belegt, bleibt es beim alten Verhalten (neue Firma) — dieselbe
|
||
Kollisionsregel wie in 1a.3, jetzt als `Company::slugTakenOnOtherPortals()`
|
||
an einer Stelle.
|
||
- Der API-Nutzer wird als `member` an die Firma gehängt, sonst dürfte er dort
|
||
keine Meldung anlegen. Jede Zuordnung steht im Aktivitätsverlauf
|
||
(„API-Firma … dem bestehenden Newsroom #… zugeordnet").
|
||
|
||
**Folgeänderung in `LegacyPressReleaseController`:** `both` ist kein
|
||
Ziel-Portal für eine Meldung. Fällt die Portal-Auflösung auf das Portal der
|
||
Firma zurück und das ist `both`, entscheidet das Portal des API-Nutzers.
|
||
Vorher hätte eine Meldung ohne Parameter und Domain-Kontext `both` bekommen.
|
||
|
||
Tests: `tests/Feature/Api/ApiNewsroomAssignmentTest.php` (9).
|
||
|
||
**Messung:** `companies:classify-addresses` nach ein, zwei Wochen — Dublette
|
||
und Namensvariante müssen bei den 7 aus den Slug-Kollisionen bleiben. Steigt
|
||
die Zahl, trifft die Zuordnung nicht; `companies:merges` und der
|
||
Aktivitätsverlauf zeigen, was zugeordnet wurde.
|
||
|
||
---
|
||
|
||
# Teil 4 — Block 1b: Newsroom als Produkt (15.09.2026)
|
||
|
||
## 1b.1 / 1b.5 — Sichtbarkeitsregel und Rückbau
|
||
|
||
Die Regel bestand bereits: Detailseite 404 ohne veröffentlichte Meldung,
|
||
Verzeichnis, Sitemap und Hub-Zähler nur mit einer. Der Rückbau war aber
|
||
unvollständig: `depublish()` und `markGone()` leerten den Seiten-Cache,
|
||
`archive()`, `changeStatusFromAdmin()` (weg von published) und
|
||
`deleteFromAdmin()` einer veröffentlichten Meldung nicht — der Newsroom stand
|
||
nach der letzten Meldung bis zu eine Stunde weiter im Cache. Jetzt leeren alle
|
||
fünf Wege. Drei Tests in `NewsroomPageTest` halten den Rückbau fest.
|
||
|
||
## 1b.2 — Verwaltungszugang über Magic Link
|
||
|
||
```
|
||
GET/POST /{edition}/newsroom/{slug}/verwalten
|
||
```
|
||
|
||
Der allgemeine Anmeldelink (`/anmeldelink` auf pressekonto, WS-2) bestand:
|
||
Wer eine E-Mail eingibt, die an einer Firma oder einem Pressekontakt steht,
|
||
bekommt lazy ein Konto mit Firmen-Scope. Was fehlte, war der Weg **vom
|
||
Newsroom aus**, ohne dass der Besucher eine Adresse kennt oder eingibt —
|
||
und unabhängig davon, ob der Newsroom überhaupt schon öffentlich ist.
|
||
|
||
**`NewsroomClaimService` + `NewsroomClaimController`:**
|
||
|
||
- Die Seite zeigt Firmenname und die **maskierte** Empfängeradresse
|
||
(`i***@muster.de`). Ein Klick schickt den Anmeldelink dorthin. Kein
|
||
Eingabefeld → kein Enumerationsproblem, keine fremde Adresse.
|
||
- Empfänger: die Firmen-E-Mail; fehlt sie, die Pressekontakte. Bewusst nicht
|
||
beides — ein Klick eines Fremden soll nicht fünf Postfächer treffen.
|
||
- Erreichbar für jede Firma mit Slug, **auch ohne veröffentlichte Meldung
|
||
und auch deaktiviert** (das ist der Punkt von 1b.2). 410-entfernte bleiben
|
||
Tombstone, aufgegangene (1a.3) führen per 301 zum Überlebenden — dieselbe
|
||
Auflösung wie die Newsroom-Seite, jetzt in `MergedNewsroomLookup`.
|
||
- Konto, Zuordnung (`responsible`) und Versand laufen durch
|
||
`ContactAccessService::requestMagicAccess()` — kein zweites Zugangssystem.
|
||
Neu dort: Herkunft und Ziel-Firma werden durchgereicht.
|
||
- **Herkunft `profile_claim` auf dem Token** — Stufe 2 der Konversionsleiter
|
||
misst damit, wie viele Newsrooms über diesen Weg beansprucht werden.
|
||
- **Landung:** Der Login führt direkt auf die Firmenseite im Kundenbereich
|
||
(`me.press-kits.show`), nicht aufs Dashboard — `company_id` liegt im
|
||
`payload` des Tokens, kein Schema-Eingriff.
|
||
- Drosselung: 3 Links je Newsroom und Tag (`config/newsroom.php`,
|
||
`NEWSROOM_CLAIM_LINKS_PER_DAY`), 15 je IP und Stunde, Honeypot.
|
||
|
||
Tests: `tests/Feature/Web/NewsroomClaimTest.php` (7).
|
||
|
||
**Noch nicht verlinkt.** Die Seite existiert, aber weder die Newsroom-Seite
|
||
noch die Veröffentlichungsmail zeigen darauf — das ist 1b.4 und hängt an E4
|
||
(ein Element pro Mail: Magic-Link-Hinweis oder Infoseite).
|
||
|
||
**Zu 1b.3 vorab:** Die Firmenseite im Kundenbereich kann heute Name,
|
||
Adresse, E-Mail, Telefon, Website, Land, Logo und Pressekontakte pflegen.
|
||
Was 1b.3 ergänzt: Beschreibungstext (durch die Rot-Ebene), Bilder (mit
|
||
KI-Deklaration und Audit-Log), und die Leitplanke, dass Links im Profiltext
|
||
systemseitig gesetzt werden.
|
||
|
||
## 1b.4 — Hinweise (E4 entschieden 15.09.2026)
|
||
|
||
**E4:** Die Veröffentlichungsmail trägt den Magic-Link-Hinweis — am Ende, als
|
||
Hinweis, nicht als Angebot. Damit bekommt der Link auf die Infoseite (1d.2)
|
||
keinen Platz in dieser Mail (ein Element pro Mail).
|
||
|
||
- **Newsroom-Seite:** eine Zeile unter dem Firmenprofil („Sie vertreten
|
||
…? Profil, Logo und Pressekontakte lassen sich kostenlos pflegen. Diesen
|
||
Newsroom verwalten"), `rel="nofollow"`, 12 px, ink-3.
|
||
- **Veröffentlichungsmail:** ein Absatz vor dem Hilfe-Hinweis, in derselben
|
||
grauen Kleinschrift wie der bestehende Login-Hinweis. Link auf
|
||
`/newsroom/{slug}/verwalten` des Portals, auf dem die Meldung erschien
|
||
(bei beiden: presseecho), getaggt mit `src=mail_publish&pr=`. Kein Hinweis
|
||
ohne Firma oder bei 410-entfernter Firma.
|
||
- **Herkunft läuft durch:** Die Claim-Seite reicht `src` aus dem Mail-Link
|
||
per verstecktem Feld weiter; das Token trägt dann `mail_publish` statt
|
||
`profile_claim`. Stufe 1 (Klick) zeichnet die globale Middleware beim
|
||
Aufruf der Claim-Seite ohnehin auf. So unterscheidet die Leiter „aus der
|
||
Mail beansprucht" von „auf der Seite entdeckt".
|
||
|
||
Tests: drei weitere in `NewsroomClaimTest` (Seite, Mail, Herkunft).
|
||
|
||
## 1b.3 — Kostenlose Profilpflege (Entscheidung 15.09.2026: Bilder zurückgestellt)
|
||
|
||
Die Firmenseite im Kundenbereich konnte Name, Adresse, Kontakt, Website,
|
||
Land, Logo und Pressekontakte. Neu:
|
||
|
||
- **Beschreibung** (`companies.boilerplate`, max. 2 000 Zeichen) als
|
||
Textarea. Erscheint im Newsroom und unter jeder Meldung als „Über das
|
||
Unternehmen". Beim Speichern wird der Seiten-Cache geleert.
|
||
- **Rot-Ebene:** Der Text läuft durch dieselbe Klassifikation wie eine
|
||
Meldung (`CompanyProfileCheck`: KI-Treiber, bei Ausfall deterministische
|
||
Blacklist — das Muster der Spur-D-Vorprüfung). Rot → Validierungsfehler,
|
||
nichts wird gespeichert. Gelb ist für ein Firmenprofil kein Grund.
|
||
- **Leitplanke Links:** Reiner Text. `strip_tags` beim Speichern, escaped
|
||
beim Rendern — es gibt im Profiltext keine Links, die man auszeichnen
|
||
müsste. Die Website ist das eine Link-Feld und trägt bereits `nofollow`.
|
||
Damit ist die Leitplanke durch Weglassen erfüllt statt durch eine Policy.
|
||
- **Bilder:** zurückgestellt, bis die Bild-Kennzeichnung angepasst ist.
|
||
Im Formular als „Bilder im Newsroom — BALD — Funktion folgt" sichtbar,
|
||
nicht weggelassen (Roadmap-Leitplanke).
|
||
- **Stufe 3 der Konversionsleiter** (`newsroom_edited`) wird hier
|
||
geschrieben — die letzte offene Schreibstelle aus Block 0. Einmal je
|
||
Firma; Herkunft ist die des zuletzt eingelösten Magic Links des Kontos
|
||
(so zählt „aus der Mail beansprucht, dann Beschreibung geschrieben" für
|
||
die Mail), sonst `organic`.
|
||
|
||
Tests: `tests/Feature/NewsroomProfileTest.php` (4).
|
||
|
||
## 1b.6 — Bounce-Suppression (Schritte 1 und 2, 15.09.2026)
|
||
|
||
Grundlage ist das Konzept `bounce-mails-2026-09-15.md` (eigenes Postfach als
|
||
Return-Path, `X-Pk-Ref`-Header, IMAP-Abholung mit eigenem DSN-Parser,
|
||
SMTP-Ablehnung als zweiter Pfad, Circuit Breaker). Zwei Korrekturen daran:
|
||
Der Return-Path muss auf der **From-Domain** liegen (`bounce@pressekonto.com`,
|
||
nicht presseecho.de — SPF-Ausrichtung für DMARC), und nicht jede 5xx ist ein
|
||
Empfängerproblem: 554-Spam-Pattern des Relays sperrt sonst gesunde Adressen.
|
||
|
||
**Schritt 1 — Liste und Gate**
|
||
|
||
- `email_bounces` (Ereignis-Log, idempotent über `message_id`) und
|
||
`email_suppressions` (Zustand je Adresse: Grund, Zähler, `suppressed_at`,
|
||
`released_at`). Hart → sofort gesperrt; weich → ab dem dritten in sieben
|
||
Tagen (`config/bounces.php`). Freigabe setzt `released_at`, der nächste
|
||
Bounce löscht sie wieder.
|
||
- `EmailSuppressionService` — die eine Stelle, die „nicht erreichbar" heißt.
|
||
- `GateOutgoingMail` am `MessageSending`-Event: streicht gesperrte Empfänger
|
||
aus jeder ausgehenden Mail, verwirft sie ganz, wenn keiner bleibt (mit
|
||
Eintrag im Aktivitätsverlauf, Typ „Mail-Sperre"), setzt den Return-Path
|
||
(`MAIL_RETURN_PATH`). Eine Stelle für Magic Links, Benachrichtigungen,
|
||
Newsletter und alles Künftige.
|
||
- `X-Pk-Ref` auf Veröffentlichungs-, Ablehnungs- und Magic-Link-Mail
|
||
(`pm:ID:company:ID`, `user:ID`).
|
||
- API-Meldungen: Ist der Pressekontakt gesperrt, geht die Mail an die
|
||
Firmen-E-Mail; ist die auch gesperrt, an niemanden — nie an den
|
||
API-Einlieferer.
|
||
- `newsletter:import-bounces` schreibt jetzt zuerst in die zentrale Liste —
|
||
damit erreicht die Handliste auch Pressekontakte und Firmen ohne Konto.
|
||
|
||
**Schritt 2 — SMTP-Pfad und Circuit Breaker**
|
||
|
||
- `RecordSmtpFailure` am `JobFailed`-Event: `SmtpFailureClassifier` deutet
|
||
die `TransportException`. `5.1.x` mit Adresse → harter Bounce, `4.x.x` →
|
||
weicher, alles andere (5.7.x, 554, Verbindung) → Relay-Ablehnung.
|
||
- `MailCircuitBreaker`: ab 5 Relay-Ablehnungen in 15 Minuten wird der
|
||
Versand 30 Minuten angehalten, **ein** Betriebs-Alert. Im Worker schlagen
|
||
Mails dann fehl (nachholbar mit `queue:retry all`), im Web-Kontext werden
|
||
sie verworfen und protokolliert; Betriebs-Alerts gehen immer durch.
|
||
`php artisan mail:circuit [--reset]` zeigt bzw. hebt die Pause auf.
|
||
|
||
Tests: `tests/Feature/EmailSuppressionTest.php` (11).
|
||
|
||
**Deploy:** `migrate --force`, `config:cache`, dann in der `.env`
|
||
`MAIL_RETURN_PATH=bounce@pressekonto.com` — erst, wenn das Postfach existiert.
|
||
Danach einmal `newsletter:import-bounces` laufen lassen, damit die bisherige
|
||
Handliste in die zentrale Sperre kommt.
|
||
|
||
**Offen:** Schritt 3 (IMAP-Abholung, `webklex/laravel-imap`, freigegeben
|
||
15.09.2026) und Schritt 4 (Markierung im Admin, Claim-Seite, Freigabe).
|
||
|
||
**Schritt 3 — Abholung aus dem Postfach** (15.09.2026)
|
||
|
||
- Abhängigkeit `webklex/laravel-imap` ^6.2 (freigegeben 15.09.2026). Bringt
|
||
ein eigenes IMAP-Protokoll mit — keine PHP-Extension nötig.
|
||
- `DsnParser`: eigener Leser für RFC-3464-Zustellberichte auf der rohen
|
||
Nachricht. Zerlegt MIME (auch verschachtelt), nimmt aus dem
|
||
`message/delivery-status`-Teil je Empfänger `Final-Recipient`, `Action`,
|
||
`Status`, `Diagnostic-Code`, aus dem Original-Teil unseren `X-Pk-Ref`.
|
||
Maßgeblich ist `Action`: `failed` → hart (bei 4.x.x weich), `delayed` →
|
||
weich, `delivered`/`relayed` → ignoriert. Kein `message/delivery-status`
|
||
→ null, kein Raten.
|
||
- `BounceMailProcessor`: entscheidet je Mail den Zielordner — `Processed`,
|
||
`Ignored` (Zustellbestätigung, Abwesenheitsnotiz per `Auto-Submitted`/
|
||
Betreff) oder `Unparsed` (Freitext-Rückläufer; Eintrag im Aktivitätslog,
|
||
damit ein Mensch ihn ansieht). Testbar mit .eml-Fixtures in
|
||
`tests/Fixtures/bounces/`.
|
||
- `mail:fetch-bounces [--account=bounces] [--limit=200] [--dry-run]`: alle
|
||
zehn Minuten im Scheduler, liest UNSEEN aus INBOX, verschiebt jede Mail in
|
||
ihren Ordner (legt ihn an, falls er fehlt). Verschieben statt Flag: Bricht
|
||
der Lauf ab, liest der nächste dieselbe Mail erneut — dank
|
||
Message-ID-Unique ohne Doppelzählung. Ohne konfiguriertes Konto beendet
|
||
sich der Befehl sofort; Verbindungsfehler lösen einen Betriebs-Alert aus.
|
||
- `config/imap.php` (Paketvorlage, Konto `bounces` aus `BOUNCE_IMAP_*`).
|
||
|
||
Tests: `tests/Feature/BounceMailProcessingTest.php` (6) mit fünf Fixtures
|
||
(Postfix-Hardbounce mit Fortsetzungszeilen und Kleinschreibung, delayed,
|
||
Zustellbestätigung, Freitext, Abwesenheit).
|
||
|
||
**Deploy Schritt 3:** `composer install`, `config:cache`, dann in der `.env`
|
||
`BOUNCE_IMAP_HOST=w01eb4f6.kasserver.com`, `BOUNCE_IMAP_USERNAME`/`PASSWORD`
|
||
des Postfachs bounce@pressekonto.com, `MAIL_RETURN_PATH=bounce@pressekonto.com`.
|
||
Erster Lauf von Hand: `php artisan mail:fetch-bounces --dry-run`.
|
||
|
||
**Schritt 4 — 1b.6 selbst** (15.09.2026)
|
||
|
||
- **Admin ▸ Mail-Sperrliste** (`/admin/mail/sperrliste`, Recht
|
||
`users:manage`): gesperrte/freigegebene Adressen mit Grund, Quelle,
|
||
Zählern; Freigabe mit Rückfrage; manuelle Sperre; die letzten nicht
|
||
lesbaren Rückläufer (liegen im Postfach unter „Unparsed"); Zustand des
|
||
Circuit Breakers und ob Return-Path/Postfach konfiguriert sind.
|
||
- **Firmenseite im Admin:** Badge „E-Mail nicht erreichbar" im Kopf
|
||
(verlinkt in die Sperrliste), Markierung an der Firmen-E-Mail und an
|
||
jedem gesperrten Pressekontakt.
|
||
- **Claim-Seite:** Gesperrte Adressen sind keine Empfänger mehr. Gibt es
|
||
Adressen, aber alle sind gesperrt, sagt die Seite, dass die hinterlegte
|
||
Adresse nicht erreichbar ist, und verschickt nichts. Die Meldungsseite
|
||
zeigt dieselben Empfänger (gleiche Quelle).
|
||
- Aus allen Mailstrecken ausgenommen sind gesperrte Adressen bereits seit
|
||
Schritt 1 über das Versand-Gate.
|
||
|
||
Tests: `tests/Feature/MailSuppressionAdminTest.php` (3).
|
||
|
||
**Damit ist 1b.6 komplett — und Block 1b.** Offen bleibt nur das
|
||
Anlegen des Postfachs und die Live-Konfiguration (siehe Deploy-Hinweise
|
||
zu Schritt 1 und 3).
|
||
|
||
## Zwischenschritt — Paket-Update (15.09.2026)
|
||
|
||
Anlass: Auf Live war `composer require webklex/laravel-imap` samt
|
||
`composer update` von Hand gelaufen. Das hob dort *alle* Pakete an, die
|
||
`composer.lock` wich vom Repo ab, und die im Mai veröffentlichten
|
||
Livewire-Assets (`public/vendor/livewire`, gitignored) passten nicht mehr
|
||
zum neuen Livewire — Konsolen-Warnung „published assets are out of
|
||
date" und verschobene Formatierung, weil der Portal-Build noch gegen die
|
||
alte Flux-CSS gebaut war.
|
||
|
||
Entscheidung: Das Update wird hier nachgezogen und versioniert, statt
|
||
Live zurückzusetzen.
|
||
|
||
- `composer update` innerhalb der Constraints: 71 Pakete. Wesentlich:
|
||
Laravel 12.63 → 12.69, Livewire 4.2.4 → 4.4.5, Flux/Flux Pro 2.14 →
|
||
2.20, Volt 1.10 → 1.11, Fortify 1.36 → 1.39, Cashier 16.5 → 16.8 (zieht
|
||
`stripe/stripe-php` 17 → 21 nach, API-Version bleibt über Cashier
|
||
gepinnt), Larastan 3.9 → 3.12, Pint 1.29 → 1.32. Majors (Laravel 13,
|
||
Pest 5, spatie/permission 8) bewusst nicht.
|
||
- `npm update` + `npm audit fix`: Vite 6.4.0 → 6.4.3, Tailwind 4.1 → 4.3,
|
||
Alpine 3.15 → 3.17; vorher 13 gemeldete Schwachstellen (Build-Tooling),
|
||
danach 0.
|
||
- Larastan 3.12 versteht Query-Scopes jetzt selbst → drei Baseline-Einträge
|
||
(`visibleInArchive`, `forPortalValues`) waren tot und sind raus. Drei
|
||
neue Befunde behoben: `PressReleaseService::notifyAuthor()` wählt die
|
||
Mailable jetzt über den Benachrichtigungstyp statt über ein zweites
|
||
`match`; `PressReleaseMediaPrivatizer` prüft vor `fclose()` nicht mehr
|
||
auf `is_resource`; `FixApiPortalDuplicates` iteriert über ein Array
|
||
statt über Collection-Offsets. phpstan: No errors, Pint sauber.
|
||
- Flux-Changelog 2.15–2.20 gesichtet: keine Breaking Changes, aber
|
||
Feinkorrekturen an Icon-Buttons (Inset/Padding), Navbar-Abständen,
|
||
Sidebar-Tooltips und Tabellen-Padding. Die auf Live gesehenen Sprünge
|
||
kamen aus dem alten Build; nach frischem `npm run build` bleiben
|
||
allenfalls diese kleinen Anpassungen.
|
||
- Tests: 1612 passed.
|
||
|
||
Deploy: `composer install`, `npm run build`, **`php artisan
|
||
livewire:publish --assets`** (gehört dauerhaft ins Ploi-Skript, sonst
|
||
läuft `public/vendor/livewire` bei jedem Livewire-Update wieder aus dem
|
||
Takt), `optimize:clear`, `config:cache`, `view:cache`, `queue:restart`.
|
||
Vorher auf dem Server `git checkout composer.lock`, damit der Pull nicht
|
||
an der handgeänderten Datei hängen bleibt.
|
||
|
||
## 1b.6 — Erster Blick ins echte Postfach (15.09.2026)
|
||
|
||
Zugang zu `bounce@pressekonto.com` steht; 151 Rückläufer aus `service@`
|
||
hinübergeschoben. Der Trockenlauf meldete zunächst **0**, weil alle das
|
||
Gelesen-Flag mitgebracht hatten und der Abruf nur UNSEEN holte.
|
||
|
||
Befund über alle 151 (Dry-Run mit dem Parser):
|
||
|
||
- 147 klassische DSNs (Postfix/All-Inkl, Exchange, Gmail) — sauber gelesen.
|
||
- 2 × `message/global-delivery-status` (RFC 6533, SmarterMail) — bisher
|
||
„nicht lesbar".
|
||
- 2 × Freitext des eigenen All-Inkl-Relays („anti spam system … list of
|
||
non existent recipients", Header `X-KASMail-Error`) — mit
|
||
`Auto-Submitted: auto-replied` und deshalb bisher als **Abwesenheit
|
||
ignoriert**. Genau die Adressen wären nie gesperrt worden.
|
||
- Statuscodes: 74 × 5.4.4 (Domain ohne MX, davon 66 × dieselbe Adresse),
|
||
24 × 5.0.0, 13 × 5.1.1, 8 × 5.1.10, 8 × 5.7.1, Rest 5.2.x/4.x.
|
||
- 37 eindeutige Adressen.
|
||
|
||
Vier Änderungen (Commit im Anschluss):
|
||
|
||
1. Abruf holt **alles in der INBOX**, nicht nur Ungelesenes — das
|
||
Verschieben in `Processed/Ignored/Unparsed` ist ohnehin die Zustandsmarke.
|
||
2. Parser versteht RFC 6533 (`message/global-delivery-status`,
|
||
`message/global-headers`).
|
||
3. Parser liest das All-Inkl-Freitextformat — nur bei `X-KASMail-Error`
|
||
und dem wörtlichen Einleitungssatz. MAILER-DAEMON/„Undelivered" wird
|
||
nie mehr als Abwesenheitsnotiz gewertet.
|
||
4. Einstufung: `failed` mit 5.7.x (Policy, „Relay access denied"), 5.2.2/
|
||
5.2.5 (Postfach voll) oder SMTP 552 im Diagnosetext zählt **weich**
|
||
statt hart — die Adresse existiert, ihr Server nimmt nur nichts an.
|
||
16 von 147 im Bestand.
|
||
|
||
Danach: 151/151 gelesen, 0 ignoriert, 0 nicht lesbar. Nicht scharf von
|
||
hier gelaufen — das würde die Mails im Live-Postfach verschieben und die
|
||
Sperren nur in die Dev-DB schreiben. Der scharfe Lauf gehört auf den
|
||
Server nach dem Deploy (`php artisan mail:fetch-bounces`), der Scheduler
|
||
holt danach alle zehn Minuten nach.
|
||
|
||
Fixtures: `global-delivery-status.eml`, `kas-antispam.eml`,
|
||
`policy-5.7.1.eml`; Tests in `BounceMailProcessingTest` (+3, 9).
|
||
|
||
### Nebenbefund: Dev-DB von der Testsuite geleert
|
||
|
||
Um 15:16 lag ein `bootstrap/cache/config.php`; der überlagert die
|
||
phpunit.xml-Umgebung (SQLite :memory:), und die Suite um 15:20 lief mit
|
||
`RefreshDatabase` gegen die MySQL-Dev-DB `pressekonto` — die Bestandskopie
|
||
ist weg (alle Tabellen neu angelegt 15:21:58–15:22:11). `composer run
|
||
test` räumt den Cache vorher weg, `php artisan test` direkt nicht.
|
||
`tests/TestCase.php` bricht jetzt hart ab, wenn die Verbindung nicht
|
||
SQLite :memory: ist. Die Kopie muss neu eingespielt werden.
|
||
|
||
## Kontakterreichbarkeit — Umsetzung der Punkte 1–4 (16.09.2026)
|
||
|
||
Grundlage: `dev/2026-09-11-audit/kontakterreichbarkeit-mx-bounces-2026-09-15.md`.
|
||
Vor der Entscheidung gemessen (Dev-DB, echtes DNS):
|
||
|
||
- 877 Firmen-Domains aus sechs Monaten API-Einlieferung: 870 mit MX, 1
|
||
nur A (zulässiger Fallback), **6 ohne Zustellweg**, 0 Null MX, 0
|
||
Resolver-Fehler. Die sechs stehen für 75 Meldungen; `sdmediapr.com`
|
||
allein für 68 Meldungen einer Firma und **66 der 151 Rückläufer**.
|
||
- Pressekontakte an Meldungen: 401 im Gesamtbestand, 21 in sechs
|
||
Monaten — Benachrichtigungen gehen praktisch immer an die Firmenadresse.
|
||
Grenze 2 des Dokuments (erster Kontakt verdeckt zweiten) ist damit ohne
|
||
praktische Bedeutung und wurde nicht angefasst.
|
||
- Mit Kontakten dazu (voller Lauf, 1 200 Domains): 10 Domains ohne
|
||
Zustellweg, darunter `kaeser-stroebelde` (Punkt verloren) und
|
||
`bester-immobilienmakler-neustadt-am-ruebenber` (bei 45 Zeichen
|
||
abgeschnitten) — Datenqualität beim Einlieferer, nicht bei uns.
|
||
|
||
Entscheidung mit dem Nutzer: Punkte 1–4 jetzt, **keine**
|
||
Veröffentlichungssperre (Abschnitt 6 / Stufe 3 des Dokuments) — ein bis
|
||
zwei Fälle im Monat rechtfertigen die Zustandsmaschine nicht, die
|
||
Sperrliste verhindert Wiederholungsmails bereits. Nach Beobachtung neu
|
||
entscheiden.
|
||
|
||
**1. Formprüfung beim Einreichen** — `App\Rules\PlausibleEmailDomain`,
|
||
deterministisch, ohne Netz: Domain braucht eine Endung, gültige Labels,
|
||
alphabetische TLD (oder `xn--`), kurze Liste beweisbarer Tippfehler-TLDs
|
||
(`.ded`, `.cmo` …). IDN wird per Punycode normalisiert. Greift an
|
||
`company.email` und `contact.email` der Legacy-API; 422 mit klarem Grund.
|
||
`info@hoerwelt-basel` bestand Laravels `email`-Regel bisher.
|
||
|
||
**2. Domainprüfung als Beobachtung** — `email_domain_checks` je Domain
|
||
mit Zustand (`EmailDomainState`: Deliverable, ImplicitMx, NullMx,
|
||
NoMailPath, DomainMissing, BrokenMx, Unresolved), Grund, Prüfzeit,
|
||
Ablauf. `MailDomainChecker` über die Schnittstelle `DnsResolver`
|
||
(`SystemDnsResolver` produktiv, `FakeDnsResolver` im Test — kein Test
|
||
fragt echtes DNS). Regeln aus dem Dokument umgesetzt: A/AAAA-Fallback nur
|
||
ohne MX, Null MX schlägt A, ein nutzbares MX-Ziel genügt, Timeout/SERVFAIL
|
||
ist kein Befund und wird mit wachsendem Abstand wiederholt, positive
|
||
Befunde gelten 30 Tage, negative 3. Anstoß über `EmailDomainObserver` an
|
||
`Company` und `Contact` (Job `CheckEmailDomain` nach Commit, alle
|
||
Strecken gleich), Auffrischung täglich 04:20 via `mail:check-domains
|
||
--stale`, Bestandsaufnahme mit `mail:check-domains --months=`. **Aus
|
||
einem DNS-Befund entsteht nie eine Adresssperre** — im Test verankert.
|
||
Firmenseite im Admin zeigt den Befund als Badge.
|
||
|
||
**3. Einlieferer-Bericht** — Admin ▸ Reports ▸ Einlieferer-Bericht
|
||
(`/admin/reports/einlieferer`), Auswahl Einlieferer und Zeitraum. Drei
|
||
Quellen, in der Anzeige vereint: nicht erreichbare Adressen (Sperrliste
|
||
und Domainbefund, letzterer als schärfere Aussage bevorzugt) mit Firma und
|
||
Zahl betroffener Meldungen; automatisch abgelehnte Meldungen mit der
|
||
Begründung aus dem Status-Log (`source = ki`); Richtlinien-Ablehnungen
|
||
als Hinweis. Bericht als Text zum Kopieren und zwei CSV-Downloads.
|
||
Versendet wird nichts — der Nutzer entscheidet, was er an connektar
|
||
weitergibt. Service `SubmitterContactReport`, Wertobjekte
|
||
`SubmitterSummary`, `BrokenContact`, `RejectedRelease`.
|
||
|
||
**4. Richtlinien-Ablehnungen zählen nicht** — neue Art
|
||
`EmailBounceKind::Policy` (5.7.x, „Relay access denied", Transportregel,
|
||
Spamfilter der Gegenseite): protokolliert und im Bericht sichtbar, aber
|
||
ohne Wirkung auf den Schwellwert. Reihenfolge im Prozessor: Statuscode
|
||
vor Diagnosetext — 5.1.x bleibt hart, auch wenn im Text „spam" steht
|
||
(Freitext des eigenen Relays). Volles Postfach bleibt weich.
|
||
|
||
Tests: `MailDomainCheckTest` (11), `PlausibleEmailDomainTest` (5),
|
||
`SubmitterContactReportTest` (8), `BounceMailProcessingTest` (+1, 10);
|
||
zwei bestehende Tests von `assertNothingPushed` auf
|
||
`assertNotPushed(ClassifyPressRelease)` verengt, weil der Observer jetzt
|
||
einen Job anstößt.
|
||
|
||
Deploy: `migrate --force`, dann einmal `php artisan mail:check-domains
|
||
--months=6 --limit=1500` (≈ 2 Minuten, nur DNS-Lesen). Danach ist der
|
||
Bericht gefüllt.
|
||
|
||
---
|
||
|
||
# Teil 5 — Block 1c: Sichtbarkeit belegen (16.09.2026)
|
||
|
||
## Befunde vor dem Bau
|
||
|
||
- `press_release_events` trägt seit 17.07.2026 je Aufruf einen Zeitstempel,
|
||
Index `(press_release_id, type, created_at)`, Tagesreihe je Meldung 4 ms,
|
||
Aufbewahrung 13 Monate (Teil 2). Davor nur die Summe `hits`. Beginn steht
|
||
in `config/press_releases.php` (`events.recorded_since`).
|
||
- E2 bleibt: Rohzahlen, keine Einordnung.
|
||
- **Leerlauf nach dem Claim:** Die Firmenseite im Kundenbereich filterte
|
||
Meldungen nach `user_id`. Ein Beansprucher sah „Keine Pressemitteilungen
|
||
für diese Firma", obwohl connektar 68 eingereicht hatte — die Policy
|
||
erlaubte das Ansehen längst. Das Statistik-Panel war ein Platzhalter.
|
||
- Keine Suchmaschinen-Anbindung im Code. Entscheidung mit dem Nutzer:
|
||
**Indexierung Variante B** (ohne Fremddienst), Search Console (A) später
|
||
als Abgleich, „ganz nach hinten".
|
||
|
||
## 1c.3 — Newsroom-Übersicht
|
||
|
||
`VisibilityStats::companySummary()` (Meldungen, veröffentlicht, Aufrufe
|
||
gesamt aus `hits`, Aufrufe 30 Tage aus Ereignissen, zuletzt veröffentlicht)
|
||
und `recentViewsFor()` für die Liste. Firmenseite: alle Meldungen der
|
||
Firma nach `coalesce(published_at, created_at)`, Spalten Aufrufe und 30
|
||
Tage, Fremdeinreichungen als „eingereicht über Partner" markiert;
|
||
Statistik-Panel „Sichtbarkeit" echt.
|
||
|
||
## 1c.1 — Absenderansicht je Meldung
|
||
|
||
Panel „Sichtbarkeit" auf `me.press-releases.show`, nur für Meldungen, die
|
||
je online waren: Aufrufe gesamt (seit Veröffentlichung), Aufrufe im
|
||
Zeitraum (30/90 Tage umschaltbar, `?verlauf=`), Teilungen je Kanal,
|
||
Tagesbalken als reines SVG ohne Bibliothek. Tage vor Beginn der Zählung
|
||
oder vor der Veröffentlichung sind **`null`, nicht 0** — blass gezeichnet
|
||
mit Fußnote, damit die Summe nicht der Grafik widerspricht.
|
||
Auffindbarkeit: „In der Sitemap für Suchmaschinen seit <published_at>"
|
||
(Aussage über uns) plus Schaltfläche „Bei Google prüfen" (`site:`-Suche).
|
||
Die alten Kacheln Aufrufe/Geteilt im Status-Panel sind darin aufgegangen.
|
||
|
||
## 1c.2 — Zugang über Magic Link
|
||
|
||
War durch 1b.2 fast fertig (Konto, Firmenzuordnung, Policy). Ergänzt: Der
|
||
Mail-Link trug schon `pr=<id>`; die Claim-Seite reicht ihn als verstecktes
|
||
Feld durch, der Controller prüft die Zugehörigkeit zur Firma, das Token
|
||
bekommt `press_release_id`, der Login landet auf der Meldung
|
||
(`MagicLinkConsumeController`), sonst wie bisher auf der Firma. Wortlaut
|
||
in der Veröffentlichungsmail: „Diesen Newsroom verwalten und Aufrufe
|
||
einsehen" — weiterhin ein Element (E4).
|
||
|
||
Tests: `VisibilityStatsTest` (6), `NewsroomClaimTest` (+1, 11). Gesamt
|
||
1680 grün, phpstan für die neuen Dateien sauber.
|
||
|
||
**Nebenbefund:** phpstan meldet acht Punkte in `SettleUnpaidLegacyInvoices`
|
||
und `ManualInvoicePdfRenderer` aus dem parallel entwickelten Projekt
|
||
`dev/lagacy-rechnungen/` (Commit 36691a5) — nullsafe-Zugriffe auf nicht
|
||
nullbare Werte, eine immer-falsche Negation. Nicht angefasst.
|
||
|
||
Deploy: kein `migrate`, nur Code; `view:cache` neu. Kein Backfill nötig.
|
||
|
||
---
|
||
|
||
# Teil 6 — Block 1d: Infoseite und Prüfkreislauf (16.09.2026)
|
||
|
||
## Entscheidungen mit dem Nutzer
|
||
|
||
- **E3:** keine Credits für Prüfläufe. Die Prüfung ist Teil des Freigebens;
|
||
ein eigenständiges Werkzeug zum Textreinkopieren gibt es nicht (wäre der
|
||
Weg, das Gate auszutesten). Automatische Prüfung je Meldung
|
||
`scoring.classification.max_automatic_attempts` (Vorgabe 3); danach
|
||
geblockt in die Admin-Queue; Freischalten oder Kommentar; ab dann ist die
|
||
Automatik für diese Meldung aus.
|
||
- **Gelb** geht weiter automatisch online. Der Schalter
|
||
`yellow_to_manual_queue` war im Code nie ausgewertet (1 288 gelbe
|
||
Meldungen in sechs Monaten ohne Blick); jetzt ausgewertet, Vorgabe
|
||
`false`, im Admin sichtbar (Filter „KI: Gelb" bestand schon).
|
||
|
||
## Befund vorab
|
||
|
||
84 KI-Ablehnungen in sechs Monaten, jede Meldung genau einmal, eine
|
||
davon später veröffentlicht — heute loopt niemand. Der Zähler schützt vor
|
||
einem Verhalten, das erst mit 1d.1/1d.2 entsteht.
|
||
|
||
## 1d.1 / 1d.2 / 1d.6 (Commit c08c9e5)
|
||
|
||
Hilfeseite `/{edition}/gute-pressemitteilung` (DE/EN): Anlass, Aufbau,
|
||
Sprache, Prüfablauf mit Versuchszahl aus der Konfiguration, die sieben
|
||
Dimensionen des Content-Scores offen (Pressestil, Struktur, Lesbarkeit,
|
||
Vollständigkeit, Quellen, Überschrift, Eigenständigkeit) — Gewichtung und
|
||
Schwellen nicht (Offenlegungsgrenze), Pressebilder, Partner als BALD.
|
||
Kein Preis-, Tarif- oder Credit-Link im Seiteninhalt (Test prüft den
|
||
Prosa-Block). Sitemap, Hilfe-Center. Ablehnungsmail verlinkt die Seite auf
|
||
dem Portal der Meldung mit `src=mail_reject&pr=<id>`.
|
||
|
||
## E3 — begrenzter Prüfkreislauf
|
||
|
||
- Spalten `auto_check_attempts`, `manual_review_required_at` an
|
||
`press_releases` (INSTANT, keine Volltext-Indizes mehr).
|
||
- `submitForReview()`: zählt den Lauf; ist die Automatik erschöpft, geht
|
||
die Freigabe ohne `ClassifyPressRelease` direkt in `review` mit
|
||
Status-Log „wartet auf manuelle Entscheidung". Content-Score läuft weiter.
|
||
- `routeByClassification()`: Rot beim N-ten Lauf → `holdForManualReview()`
|
||
statt Ablehnung (bleibt `review`, Log „Automatische Prüfung erschöpft
|
||
(N von N): <Grund>", ActivityLog). Gelb → Schalter. Der Autor bekommt
|
||
beim Block die Mail „Ihre Pressemitteilung wird persönlich geprüft"
|
||
(Nachtrag unten).
|
||
- Admin ▸ Pressemitteilungen: Ansicht „Prüfung nötig" (Zähler rot, wenn >
|
||
0), Filteroption, Badges „Prüfung nötig" / „nur manuell" in der Liste;
|
||
Detailseite zeigt alle KI-Begründungen chronologisch und beschriftet die
|
||
Aktionen „Freischalten" / „Mit Kommentar zurück" (bestehende
|
||
publish/reject-Aktionen, Kommentar geht per Ablehnungsmail an den Autor).
|
||
- Kundenseite: Button „Zur Prüfung freigeben" (statt „einreichen", überall
|
||
vereinheitlicht), Modal erklärt in drei Schritten, was nach der Freigabe
|
||
passiert; abgelehnte Meldung zeigt „Automatische Prüfung X von N genutzt"
|
||
mit Link auf 1d.1; geblockte Meldung: „wird von einem Menschen geprüft".
|
||
- Nebenbefund Blade: ein inline `@php(...)` wird bis zum nächsten
|
||
`@php … @endphp` als Rohblock gelesen — vier Stellen auf Blockform
|
||
umgestellt, eine davon Bestand (Modal). Seit dem Nachtrag unten durch
|
||
einen Test abgesichert.
|
||
|
||
Tests: `ManualReviewGateTest` (5), `StaticPagesTest` (+2),
|
||
`ConversionFunnelTest` (erweitert). 1d.5 ist mit 1c.2 erledigt (der
|
||
Beansprucher sieht Ablehnungsgrund, KI-Stufe, Content-Score).
|
||
|
||
Deploy: `migrate --force` (Millisekunden), `view:cache`, `queue:restart`.
|
||
Kein Backfill — bestehende Meldungen starten mit 0 Versuchen.
|
||
|
||
## 1d.3 — Abmeldung für Empfänger ohne Konto
|
||
|
||
**Befund:** Die Benachrichtigungen zu API-Meldungen gehen an Pressekontakt
|
||
oder Firmen-E-Mail — der Abmeldelink darin war aber für das *Konto des
|
||
Einlieferers* signiert (`configureUnsubscribe($user, …)`). Ein Klick des
|
||
Pressekontakts hätte connektar abgemeldet, nicht sich selbst.
|
||
|
||
**Umsetzung:** `EmailSuppressionReason::Unsubscribed` als dritter Zustand
|
||
in der Sperrliste. Mailables bekommen den tatsächlichen Empfänger
|
||
(`$recipientEmail`); weicht er vom Konto ab, trägt der Link die Adresse
|
||
(`/e-mail-benachrichtigungen/abmelden-adresse?email=…&type=…`, signiert,
|
||
One-Click-POST ohne CSRF wie bisher). Die Abmeldung gilt für alle
|
||
Benachrichtigungen an die Adresse; Rücknahme von der Bestätigungsseite.
|
||
Eine bestehende Zustellsperre wird nicht zur Abmeldung herabgestuft, und
|
||
eine Abmeldung löst nur der Empfänger selbst.
|
||
|
||
**Ausnahme im Gate:** `GateOutgoingMail` lässt `MagicLoginLink` an
|
||
abgemeldete Adressen durch (`suppressedAmong(…, includeUnsubscribed:
|
||
false)`), die Claim-Seite behandelt sie als erreichbar — den Anmeldelink
|
||
fordert die Person selbst an. Harte Bounces sperren weiterhin alles.
|
||
|
||
Tests: `AddressUnsubscribeTest` (5). Admin ▸ Mail-Sperrliste zeigt den
|
||
Grund „abgemeldet" ohne weitere Änderung.
|
||
|
||
## Nachtrag 1d (16.09.2026) — Mail beim Block, Blade-Guard
|
||
|
||
Beide offenen Punkte aus dem Block auf Wunsch des Nutzers umgesetzt.
|
||
|
||
**Mail „ein Mensch prüft jetzt".** Neue Mailable
|
||
`PressReleaseHeldForReview`, ausgelöst aus `holdForManualReview()` über
|
||
denselben `notifyAuthor()`-Pfad wie Veröffentlichung und Ablehnung — also
|
||
mit der API-Regel (Pressekontakt → Firmen-E-Mail → sonst gar nicht, nie an
|
||
connektar), Sperrliste, adressbezogener Abmeldung (1d.3) und
|
||
Bounce-Referenz. Zwei Textvarianten: Automatik erschöpft („auch im N.
|
||
Durchlauf nicht freigeben … statt einer weiteren Ablehnung sieht sich jetzt
|
||
ein Mensch die Mitteilung an") und Gelb-Queue („zur Sicherheit
|
||
zurückgehalten"). Kern der Mail: *Sie müssen nichts tun*, die Meldung ist
|
||
nicht öffentlich, das Ergebnis kommt als Veröffentlichungs- oder
|
||
Ablehnungsmail. Ein Button „Stand der Prüfung ansehen" führt ins Konto
|
||
(`src=mail_review`, neue `ConversionSource::MailReview`, damit der Klick im
|
||
Funnel unterscheidbar bleibt). Kein Ablehnungsgrund in der Mail — die
|
||
KI-Begründungen stehen im Konto, und die Mail soll beruhigen, nicht
|
||
nachtreten.
|
||
|
||
Opt-out: kein neuer Präferenz-Typ. Der Zwischenstand hängt am Typ
|
||
„abgelehnt" — wer keine Prüfergebnisse will, will auch den nicht. Die drei
|
||
Texte des Typs (Einstellung, Footer-Begründung, Abmelde-Label) sprechen
|
||
jetzt von „Prüfergebnis" statt nur „Ablehnung".
|
||
|
||
**Blade-Guard.** `BladePhpBlockGuardTest` läuft über alle Views und wendet
|
||
exakt das Compiler-Muster an (`/(?<!@)@php(.*?)@endphp/s`,
|
||
`BladeCompiler::storePhpBlocks`): Jeder Treffer, dessen Inhalt mit `(`
|
||
beginnt, ist ein inline `@php(`, das einen späteren Block verschluckt. Der
|
||
Test fand sofort eine Stelle: den eigenen Erklär-Kommentar im Modal, der
|
||
`@php(` und `@endphp` wörtlich enthielt. Blade liest die Rohblöcke *vor*
|
||
den Kommentaren — die Stelle funktionierte nur, weil der Kommentar den
|
||
Platzhalter anschließend mit entfernte. Kommentar umformuliert; Regel in
|
||
CLAUDE.md festgehalten.
|
||
|
||
Tests: `ManualReviewGateTest` (+1 → 6, Mail beim Block, Textvarianten,
|
||
Opt-out), `BladePhpBlockGuardTest` (1).
|
||
|
||
**Damit ist Block 1d komplett, ohne offene Punkte.**
|
||
|
||
## Nachtrag 17.09.2026 — Profil-Kurzfelder sind keine Werbefläche
|
||
|
||
**Anlass:** Frage des Nutzers nach der Live-Sichtung von 1d, ob beim
|
||
Speichern des Profils auf Spam geprüft wird.
|
||
|
||
**Stand vorher:** Die Beschreibung („Über das Unternehmen") lief seit 1b.3
|
||
durch die Rot-Ebene (`CompanyProfileCheck`, KI mit Blacklist-Fallback).
|
||
Nicht geprüft waren die Kurzfelder, die ebenfalls öffentlich bzw. in Mails
|
||
erscheinen: Firmenname, Adresse, Pressekontakt (Name, Zuständigkeit).
|
||
|
||
**Umsetzung:** Regel `App\Rules\NoPromotionalText` — deterministisch,
|
||
ohne KI: keine Links (Schema, `www.`, nackte Domain mit Pfad), keine
|
||
E-Mail-Adressen, keine Zeichenketten (`!!!`, Emoji-Reihen), kein
|
||
Blacklist-Treffer; jeweils mit eigener Fehlermeldung am Feld. Nackte
|
||
Domains ohne Pfad („meinshop.de GmbH") bleiben bewusst erlaubt — Marke,
|
||
nicht Link. Angewendet in Firmen ▸ Stammdaten (Name, Adresse), Firma
|
||
anlegen (Name, Adresse) und Pressekontakt (Vorname, Nachname,
|
||
Zuständigkeit). Die Adresse läuft zusätzlich wie die Beschreibung durch die
|
||
Rot-Ebene, nur bei Änderung.
|
||
|
||
**Befund im Bestand (Dev-DB = Live-Kopie):**
|
||
- 4.390 aktive Firmen tragen bbcode-/HTML-Linkspam in der Adresse
|
||
(Moncler, Chanel-Outlet …), 4.381 davon mit `website = "180"` — ein
|
||
Legacy-Importfehler; **keine davon hat je eine Meldung veröffentlicht**,
|
||
und ohne veröffentlichte Meldung gibt es keine Newsroom-Seite
|
||
(`NewsroomDetailPageData`). Nicht öffentlich, aber Datenmüll.
|
||
- 38 öffentliche Firmen (mit Meldungen, jüngste 09.09.2026) haben eine URL
|
||
im **Namen** („Steinreinigungsuntermehmen - https://www.bgn.at/…"),
|
||
überwiegend über die API eingeliefert. Diese Namen stehen auf der
|
||
Newsroom-Seite und in der Übersicht.
|
||
- Kontakte: 0 Treffer. Beschreibungen: 0 Treffer.
|
||
|
||
Tests: `NoPromotionalTextTest` (20 inkl. Datensätze).
|
||
|
||
**Entscheidung (17.09.2026): Links im Firmennamen normalisieren, nicht
|
||
ablehnen** — am API-Eingang und einmalig im Bestand. Die 4.390 bbcode-
|
||
Leichen bleiben vorerst unangetastet (nicht öffentlich).
|
||
|
||
`CompanyNameNormalizer::normalize(name, website)`: URLs (Schema, `www.`,
|
||
Domain mit Pfad) aus dem Namen ziehen, verwaiste Trenner aufräumen, erste
|
||
entfernte URL ins leere Website-Feld; besteht der Name nur aus der URL,
|
||
wird der Host zum Namen („ak-autos.de"). Bestehende Website bleibt.
|
||
- `LegacyCompanyController::store`: normalisiert vor der Zuordnung (1a.4),
|
||
damit bereinigter Name und bereinigter Bestand zusammenfinden; Slug aus
|
||
dem sauberen Namen; ActivityLog „Korrektur" mit Vorher/Nachher.
|
||
- `companies:strip-links-from-names {--dry-run} {--all}`: ohne `--all` nur
|
||
aktive Firmen mit veröffentlichter Meldung (die 38 öffentlichen); Slugs
|
||
bleiben (Newsroom-URLs sind draußen verlinkt); ActivityLog je Firma;
|
||
Seiten-Cache geleert. Dry-Run auf der Dev-DB: 38 Treffer, Vorher/Nachher
|
||
geprüft (z. B. „Steinreinigungsuntermehmen - https://www.bgn.at/…" →
|
||
„Steinreinigungsuntermehmen", „www.hobelmesser.shop - Turmfalke Sägen …"
|
||
→ „Turmfalke Sägen & Messer Inh. Ugur Özmen e.K.").
|
||
|
||
Tests: `CompanyNameNormalizerTest` (11). Live: `companies:strip-links-from-names
|
||
--dry-run`, Liste ansehen, dann ohne Flag.
|
||
|
||
**bbcode-Leichen (Nachtrag, auf Wunsch):** `companies:retire-link-spam
|
||
{--dry-run}` deaktiviert aktive Firmen mit `[url=` oder `<a href` in
|
||
Adresse, Beschreibung oder Name, die nie eine Meldung veröffentlicht haben
|
||
— kein Löschen, `is_active = false`, IDs in `storage/app/companies-link-
|
||
spam-<zeitstempel>.txt`, ein ActivityLog-Eintrag mit Zahl. Vorher geprüft:
|
||
Die 1.302 davon mit Meldungen haben nur `rejected` (bis 2019) und
|
||
`archived` (bis 2022), nichts Öffentliches. Die 44 Besitzerkonten bleiben
|
||
unangetastet — 24 führen auch saubere Firmen. Dry-Run Dev-DB: 4.391.
|
||
Test: `RetireLinkSpamCompaniesTest`.
|
||
|
||
---
|
||
|
||
# Teil 7 — Block 2: Organische Sichtbarkeit (ab 17.09.2026)
|
||
|
||
Block 1 ist komplett und live (17.09.2026). Block 2 beginnt mit dem
|
||
technischen Teil (2.1, 2.2, 2.7); die Inhalts- und Marketing-Pakete (2.3,
|
||
2.4, 2.6) folgen, 2.5 hängt an E5.
|
||
|
||
### 2.1 — NewsArticle und Organization (17.09.2026)
|
||
|
||
**Stand vorher:** Das Grundgerüst gab es seit Juli (Commit 379688f):
|
||
NewsArticle + BreadcrumbList auf jeder öffentlichen Meldung, Organization
|
||
auf dem Newsroom, WebSite + Organization auf den Portal-Startseiten,
|
||
BreadcrumbList auf Kategorien; Ausgabe über `x-web.structured-data`
|
||
(JSON_HEX_TAG gegen `</script>`-Ausbruch). Die Vorschau bleibt ohne Markup.
|
||
|
||
**Was fehlte:** Die Entitäten hingen nicht zusammen. `NewsArticle.author`
|
||
und die Organization der Newsroom-Seite waren zwei namensgleiche Objekte
|
||
ohne gemeinsame `@id`; der Publisher hatte kein Logo (Google empfiehlt es
|
||
für Article-Rich-Results) und ebenfalls keine `@id`.
|
||
|
||
**Umsetzung:** `App\Support\StructuredData` bündelt die Entitäten:
|
||
- `publisher($portalBase)` — Organization des Portals mit
|
||
`@id = {portal}/#organization`, Name aus `config/domains.php`, Logo als
|
||
ImageObject (`/images/presseecho-logo.svg` bzw.
|
||
`businessportal-logo.svg`, 600×200). Verwendet auf Meldung (publisher)
|
||
und Startseite.
|
||
- `newsroomUrl()` / `newsroomId()` — `@id = {newsroom-url}#organization`.
|
||
Die Newsroom-Seite trägt sie, `NewsArticle.author` verweist darauf.
|
||
- Bewusst nicht ins Organization-Markup: Adresse, E-Mail, Telefon. Die
|
||
Newsroom-Seite zeigt sie nicht, und Markup ohne sichtbares Gegenstück
|
||
ist gegen Googles Richtlinien.
|
||
|
||
Tests: `StructuredDataTest` erweitert (Publisher-`@id`, Logo,
|
||
Author-`@id`, Newsroom-`@id`).
|
||
|
||
### 2.2 — Google News, technischer Teil (17.09.2026)
|
||
|
||
**Umsetzung:** News-Sitemap je Spoke: `/sitemap-news.xml`
|
||
(`NewsSitemapController`) — nur im Archiv sichtbare Meldungen der letzten
|
||
48 Stunden, höchstens 1.000, `news:publication` mit Portalname und
|
||
Sprache (`language` der Meldung, Fallback `de`), `news:publication_date`,
|
||
`news:title`. 15 Minuten gecacht je Host, wird mit den anderen Sitemaps
|
||
über `WebCaches::forgetSitemaps()` geleert. `robots.txt` der Spokes nennt
|
||
sie; der Hub liefert eine leere Liste und nennt sie nicht.
|
||
|
||
**Was jetzt beim Betreiber liegt (nicht im Code):**
|
||
1. Search Console: beide Spokes als Property bestätigt haben.
|
||
2. Publisher Center (publishercenter.google.com): je Portal eine
|
||
Publikation anlegen — Name exakt wie in `news:name` („presseecho",
|
||
„businessportal24"), Sprache Deutsch, Standort DE, Website-URL des
|
||
Portals; Logo (quadratisch und rechteckig) hochladen.
|
||
3. In der Publikation den Bereich „Google News" einrichten: Abschnitte
|
||
(z. B. die Kategorien) als Feed oder Web-Bereich anlegen; die
|
||
News-Sitemap wird nicht dort, sondern in der Search Console
|
||
eingereicht (Sitemaps → `/sitemap-news.xml`).
|
||
4. Erwartung ehrlich halten: Google News nimmt Presseportale nicht
|
||
verlässlich auf („Pressemitteilungen" sind laut Inhaltsrichtlinien
|
||
zulässig, aber kein bevorzugter Inhalt). Die Sitemap ist die
|
||
Voraussetzung, keine Zusage. Ergebnis nach 4–6 Wochen in der Search
|
||
Console unter „Leistung → Google News" ablesen.
|
||
|
||
Tests: `NewsSitemapTest` (3).
|
||
|
||
### 2.7 — Auffindbarkeit für KI-Systeme (17.09.2026)
|
||
|
||
**Umsetzung:** `/llms.txt` je Host (`LlmsTxtController`, 1 h gecacht):
|
||
Titel, Kurzbeschreibung (Tagline aus `config/domains.php`), sachlicher
|
||
Absatz zu Urheberschaft (Unternehmen = Urheber, Portal = Herausgeber) und
|
||
zu den strukturierten Daten, URL-Aufbau (Meldung, Newsroom, Kategorie,
|
||
Sitemaps), aktive Kategorien mit Links, Seiten für Unternehmen,
|
||
Rechtliches. Der Hub verweist auf beide Portale und die OpenAPI-Datei.
|
||
Formulierungsgrenze eingehalten: keine Aussage darüber, ob oder wie
|
||
KI-Systeme die Inhalte verwenden. Die Entitätszuordnung aus 2.1 (`@id`
|
||
zwischen Meldung und Newsroom) ist der zweite Teil von 2.7.
|
||
|
||
Tests: `LlmsTxtTest` (2).
|
||
|
||
### 2.3 — Branchen-Landingpages aus dem Archivbestand (17.09.2026)
|
||
|
||
**Befund (Dev-DB = Live-Kopie):** 34.597 sichtbare Meldungen seit 2008
|
||
(presseecho 12.587, businessportal24 22.010), 14 Hauptkategorien ohne
|
||
Unterkategorien. Die Branchenseiten (`/de/category/{slug}`) existieren
|
||
seit Juli mit Lead/Top/Feed, Statistik, Newsrooms — zeigen aber nur die
|
||
jüngsten ~15 Meldungen. Der Bestand hing nur an der Sitemap, nicht an
|
||
internen Links. Dazu: **0 von 14 Kategorien haben eine Beschreibung**,
|
||
alle Branchenseiten zeigten denselben Standardsatz (14 gleich aussehende
|
||
Seiten); und der Seitentitel hieß auf beiden Spokes „– pressekonto"
|
||
(`config('app.name')` statt Portalname) — behoben.
|
||
„Sonstiges" trägt 9.607 Meldungen (28 %), ist also keine Branche, sondern
|
||
der Sammelposten; das ist ein Einlieferungs-Thema, kein Seitenthema.
|
||
|
||
**Umsetzung:**
|
||
1. **Jahresarchiv je Branche** — `/de/category/{slug}/archiv/{jahr}`
|
||
(`CategoryArchivePageData`, `CategoryArchiveController`, View
|
||
`kategorie-archiv`): 30 Meldungen je Seite, neueste zuerst, Datum mit
|
||
Jahr, Firma, Rubrik; Jahresleiste mit Zahlen; ohne Jahr das jüngste
|
||
Jahr mit Meldungen. Jahr statt fortlaufender Seitennummer, weil eine
|
||
Jahresseite ihren Inhalt behält, während `?page=2` bei jeder neuen
|
||
Meldung verrutscht. Canonical mit `?page=N` auf Folgeseiten (der
|
||
Layout-Default hätte auf Seite 1 kanonisiert), hreflang nur auf Seite 1,
|
||
CollectionPage + BreadcrumbList als JSON-LD. Gecacht bis Seite 3 je
|
||
Jahr (Schutz gegen Cache-Aufblähung wie bei den Newsrooms). Leere
|
||
Jahre, unbekannte Kategorien und leere Folgeseiten sind 404.
|
||
2. **Jahresleiste auf der Branchenseite** unter dem Feed
|
||
(`archiveYears`, eine gruppierte Query), und alle Jahresarchive in der
|
||
Sitemap (14 × ~18 Jahre ≈ 250 URLs).
|
||
3. **Beschreibungen:** `categories:seed-descriptions {--dry-run}
|
||
{--force}` füllt leere DE/EN-Beschreibungen aus
|
||
`database/seeders/data/category-descriptions.php` (je 2–3 Sätze:
|
||
Themenumfang und wer dort veröffentlicht, ohne Werbeaussagen). Nur
|
||
leere Felder, danach in Admin ▸ Kategorien änderbar. Dry-Run auf der
|
||
Dev-DB: alle 14 Kategorien gefunden.
|
||
|
||
Tests: `CategoryArchiveTest` (5), `SeedCategoryDescriptionsTest` (2);
|
||
`tests/Feature/Web` komplett grün (305).
|
||
|
||
**Live:** `php artisan categories:seed-descriptions --dry-run`, dann ohne
|
||
Flag. Bewusst nicht gebaut: Landingpages unterhalb der Kategorien
|
||
(„Branchen" im engeren Sinn) — dafür gibt es keine Unterkategorien und
|
||
keine Daten; die Meldungen tragen nur die 14 Hauptkategorien.
|
||
|
||
### 2.4 — Transparenzseiten (17.09.2026, nach Briefing)
|
||
|
||
**Zuschnitt aus dem Briefing:** kein Preisvergleich Anbieter gegen
|
||
Anbieter, sondern (a) Modellvergleich ohne Namen und fremde Preise und
|
||
(b) ein internes Rechenbeispiel mit den eigenen Optionen. Aus dem einen
|
||
Roadmap-Punkt wurden drei Seiten mit unterschiedlicher Suchintention,
|
||
alle auf Hub und Spokes, DE und EN:
|
||
|
||
| URL | Sucht danach |
|
||
|---|---|
|
||
| `/{edition}/was-kostet-eine-pressemitteilung` | Preis, Vergleich, Budget |
|
||
| `/{edition}/pressemitteilung-veroeffentlichen-ohne-abo` | Ablehnung eines Modells |
|
||
| `/{edition}/pressemitteilung-loeschen-oder-aendern-lassen` | akutes Problem, oft bei einem anderen Portal |
|
||
|
||
**Umsetzung:** `TransparencyPageController` + `PricingTransparencyData`
|
||
— alle Zahlen aus `config/billing`, `config/credits`, `config/legal` und
|
||
dem Starter-Tarif (19 € Einzel, 29 €/3 Meldungen Starter, 15 € Extra-PM,
|
||
10 € Änderung, 35 € Depublikation, USt.), damit die Seiten nie einen
|
||
anderen Preis nennen als die Preisübersicht. Texte in
|
||
`lang/{de,en}/static.php` (`cost`, `no_subscription`, `change_delete`).
|
||
Tabellen-Typografie für `.pm-body` ergänzt (Asset-Build nötig).
|
||
|
||
Seite 1, Aufbau wie im Briefing: kurze Antwort im ersten Bildschirm →
|
||
vier Abrechnungsmodelle und was sie bei seltener Nutzung kosten → sechs
|
||
Prüffragen für Folgekosten → „Was bei uns gilt" (dieselben sechs Fragen
|
||
beantwortet plus eine siebte: abgelehnte Meldungen kosten nichts) →
|
||
Rechenbeispiel Einzelabrechnung gegen bei Bedarf gebuchte Starter-Monate:
|
||
1 Meldung 19 vs. 29 (Einzel), 3 im selben Monat 57 vs. 29 (Starter),
|
||
3 verteilt 57 vs. 87 (Einzel), 12 monatlich 228 vs. 348 (Einzel), 12 in
|
||
4 Monaten 228 vs. 116 (Starter). Der Test `TransparencyPagesTest`
|
||
erzwingt, dass beide Modelle Zeilen gewinnen.
|
||
|
||
Seite 2: Ablauf in vier Schritten, was enthalten ist, **was nicht** (kein
|
||
Versand an Redaktionen, keine Platzierungsgarantie, keine Zusage über
|
||
Rankings oder KI-Nennungen — Formulierungsgrenze 2.7), nichts verfällt,
|
||
wann sich ein Tarif trotzdem lohnt.
|
||
|
||
Seite 3: fünf Wege als Tabelle (Anliegen / Wer / Kosten / Ergebnis),
|
||
Antragsweg über `/melden/pm` mit Bestätigungslink an die hinterlegte
|
||
Pressekontaktadresse, „Korrektur statt Löschung", und der Absatz für
|
||
fremde Portale: Impressum des Betreibers, DSA-Meldeweg und DSGVO-Anspruch
|
||
gelten überall und sind kostenlos, Dienstleister ist Ansprechpartner für
|
||
weitere Kopien — ohne Namen, ohne Zusagen über Dritte
|
||
(`docs/legal/Entscheidung.md`).
|
||
|
||
**Verlinkung:** Preisübersicht (Zeile unter dem USt-Hinweis), Hilfe-Center
|
||
(„Korrekturen nach Veröffentlichung" zeigt jetzt auf Seite 3 statt FAQ),
|
||
Sitemaps Hub + Spokes, `llms.txt`, untereinander.
|
||
|
||
**Bitte gegenlesen — drei Tatsachenbehauptungen, die ich aus Code und
|
||
Konzept abgeleitet habe:** (1) „Kein Versand an Redaktionen" (Seite 2),
|
||
(2) „Konto anlegen ohne Zahlungsdaten" (Seite 2), (3) „Kunden mit Konto
|
||
erledigen kleine Korrekturen direkt im Publisher-Bereich" (Seite 3,
|
||
Selbstbedienungskorrektur `legal.corrections_per_week`). Die „sieben
|
||
Zusagen" haben keine kanonische Liste in den Konzepten; die Fassung auf
|
||
Seite 1 ist aus Preisseite, AGB-Hinweisen und Decision-Update
|
||
Preisstruktur zusammengesetzt.
|
||
|
||
Tests: `TransparencyPagesTest` (9). Nicht gebaut: fremde Zahlen, auch
|
||
nicht als datierte Erhebung (Empfehlung im Briefing: lassen).
|
||
|
||
### Befund zu 2.5 / E5
|
||
|
||
Die alte Ratgeber-Strecke `/de/presse/*.html` leitet seit dem Rückbau
|
||
(R3.2) per 301 auf `/de/faq`. Es gibt also keine Ratgeberseiten mehr, die
|
||
zu konsolidieren wären — die Frage E5 reduziert sich darauf, ob die
|
||
Alt-URLs zum Schreiben von Pressemitteilungen künftig auf die Hilfeseite
|
||
`/de/gute-pressemitteilung` (1d.1) statt auf die FAQ zeigen sollen.
|
||
Empfehlung: ja für Slugs, die erkennbar das Schreiben betreffen, sonst
|
||
FAQ. Braucht die Liste der Alt-Slugs aus dem Webserver-Log oder der
|
||
Search Console; ohne die bleibt es bei FAQ.
|
||
|
||
**Deploy (2.1–2.4/2.7):** kein `migrate`, Asset-Build (`npm run build`,
|
||
neue Tabellen-Styles), `view:cache`/`route:cache` wie üblich, dann
|
||
`categories:seed-descriptions`. Danach
|
||
`/sitemap-news.xml`, `/llms.txt`, `/de/category/handel/archiv/2019` und
|
||
eine Meldung im Rich-Results-Test (search.google.com/test/rich-results)
|
||
prüfen.
|
||
|
||
# Teil 8 — Anhang 1 zur Roadmap (17.09.2026)
|
||
|
||
Grundlage: `roadmap-anhang1-2026.md`. 2.6 (Zuschriften an Betreiber)
|
||
bewusst ausgelassen — übernimmt der Betreiber an anderer Stelle. 2.7 war
|
||
mit Teil 7 bereits gebaut; die Formulierungsgrenze (Abruf belegbar,
|
||
Nennung nicht) gilt in allen neuen Texten.
|
||
|
||
### 1c.0 Crawler-Abruf-Protokoll
|
||
|
||
Tabelle `press_release_crawler_hits` (Meldung, Dienst, User-Agent auf 255
|
||
Zeichen gekürzt, Zeitpunkt — **keine IP**). Erkennung über
|
||
`config/crawlers.php`: 15 Dienste mit User-Agent-Mustern und Art
|
||
(Suche/KI) — OpenAI, Anthropic, Perplexity, Google-Extended (Gemini),
|
||
Google Suche, Bing/Copilot, Apple, Meta, Amazon, Mistral, Cohere,
|
||
DuckDuckGo, ByteDance, Common Crawl, Yandex. Die Liste ist Konfiguration,
|
||
neue Dienste kommen ohne Code dazu. Aufzeichnung in
|
||
`ReleaseDetailPageData` neben dem Aufruf-Zähler, per `defer()` nach der
|
||
Antwort und fehlertolerant (`PressRelease::recordDeferredCrawlerHit`).
|
||
Bereinigung hängt an `press-releases:purge-events` (gleiche Aufbewahrung,
|
||
keine Wasserstandsmarke nötig). `crawlers.recorded_since` (Vorgabe
|
||
18.09.2026, per `CRAWLER_HITS_RECORDED_SINCE` auf den Deploy-Tag setzen)
|
||
sagt der Absenderansicht, ab wann es Daten geben kann.
|
||
|
||
Dev-Probe: Abruf mit GPTBot-User-Agent → eine Zeile `openai`; Browser →
|
||
nichts.
|
||
|
||
### 1c.1 Stufe 1 — Panel umgebaut
|
||
|
||
Panel heißt jetzt **„Auslieferung"**, nicht mehr „Sichtbarkeit". Der
|
||
Google-Button (Variante B, `site:`-Suche) ist raus — er lieferte ein
|
||
unvollständiges Ergebnis ohne Einordnung. Geblieben: Aufrufe gesamt,
|
||
Aufrufe im Zeitraum, Geteilt, Tagesbalken, „Sitemap für Suchmaschinen:
|
||
gelistet seit". Feeds gibt es auf den Portalen nicht (kein RSS), deshalb
|
||
kein Feed-Eintrag.
|
||
|
||
### 1c.6 Abrufe durch Such- und KI-Dienste
|
||
|
||
Block im Panel: je Dienst Bezeichnung, Art (Suche/KI), letzter Abruf,
|
||
Anzahl. Darunter der Satz „Ein Abruf belegt, dass der Crawler des
|
||
Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder
|
||
zitiert wird." Ohne Abrufe: „Noch kein Abruf protokolliert" plus Datum,
|
||
ab dem erfasst wird.
|
||
|
||
### 1c.4 Indexierungsstatus (Search Console), intern
|
||
|
||
- `config/search_console.php`: `GSC_ENABLED`, `GSC_SERVICE_ACCOUNT_FILE`
|
||
(JSON-Schlüssel eines Service-Kontos, außerhalb `public/`),
|
||
`GSC_PROPERTY_PRESSEECHO` / `GSC_PROPERTY_BUSINESSPORTAL24` (Vorgabe
|
||
`sc-domain:…`), `GSC_DAILY_BUDGET` (300 je Portal, Quote 2.000),
|
||
`GSC_CUSTOMER_VISIBLE` (false, E8).
|
||
- `SearchConsoleClient`: Service-Konto-JWT (RS256 per `openssl_sign`),
|
||
Token 50 Minuten gecacht, ein Endpunkt (`urlInspection/index:inspect`).
|
||
Bewusst ohne Google-SDK — keine neue Abhängigkeit.
|
||
- Tabelle `press_release_index_checks`: jede Antwort eine Zeile mit
|
||
Zeitstempel (Momentaufnahme, kein Verlauf nachträglich), Google-Felder
|
||
einzeln plus `raw`, dazu der reduzierte Befund `IndexState`:
|
||
indexiert / gecrawlt, nicht indexiert / noch nicht gecrawlt /
|
||
ausgeschlossen / unbekannt. „Noch nicht gecrawlt" = kein
|
||
`lastCrawlTime`.
|
||
- `press-releases:check-index-status` täglich 05:10, je Portal ein
|
||
Kontingent, nach Alter gestaffelt (`recheck_days`: ≤14 Tage alle 3,
|
||
≤90 alle 14, ≤365 alle 45, älter alle 180), nie geprüfte zuerst, dann
|
||
jüngste zuerst. Abbruch nach fünf Fehlern ohne Treffer (Quote/Auth).
|
||
`--dry-run` listet, `--portal=`, `--budget=`.
|
||
- Admin ▸ Reports ▸ **Indexierung** (`/admin/reports/indexierung`):
|
||
je Portal Verteilung der Befunde, getrennt nach Direkteinreichung und
|
||
Syndikation (Marker: Status-Log `source = api`), Ungeprüfte, jüngste
|
||
100 Prüfungen, CSV (2.000). **Einschränkung:** Der Altbestand hat
|
||
keine Status-Logs und zählt als „direkt" — die Trennung ist nur für
|
||
Meldungen seit dem neuen System belastbar (Dev: presseecho 10.971
|
||
direkt / 1.616 API, businessportal24 20.349 / …).
|
||
- Kundenansicht (E8, mitgebaut, **zu**): Block „Google-Index" im Panel
|
||
erscheint nur mit `GSC_CUSTOMER_VISIBLE=true`, zeigt je Portal den
|
||
jüngsten Befund mit Datum und „zuletzt gecrawlt". Test erzwingt, dass
|
||
der Block ohne Freigabe nicht ausgeliefert wird.
|
||
|
||
**Live seit 17.09.2026.** Service-Konto angelegt, Schlüsseldatei unter
|
||
`config/files/` (per `.gitignore` ausgeschlossen, liegt nur auf dem
|
||
Server), `GSC_ENABLED=true`, Kontingent 300. Zugang gegen die
|
||
Live-Property geprüft (`https://www.presseecho.de/` → „Page with
|
||
redirect", zuletzt gecrawlt 16.09.2026).
|
||
|
||
**Nachtrag (Commit b44fded):** Die API übersetzt `coverageState` in die
|
||
angefragte Sprache; mit `de-DE` kam „Seite mit Weiterleitung" zurück und
|
||
`IndexState::fromIndexStatus()` griff ins Leere. Abfrage jetzt immer
|
||
`en-US`; die Enum-Felder (`verdict`, `indexingState`, `robotsTxtState`,
|
||
`pageFetchState`) sind sprachunabhängig.
|
||
|
||
**Zum Kontingent:** `GSC_DAILY_BUDGET` = Abfragen je Lauf **und je
|
||
Portal** (300 → 600 Calls/Tag, Quote 2.000 je Property). Bei ~31.000
|
||
sichtbaren Meldungen ist der Bestand nach rund 100 Tagen einmal durch;
|
||
die jungen Meldungen, um die es bei 1c.5 geht, sind nach Tagen drin.
|
||
Schneller: vorübergehend 1.000, oder einmalig von Hand
|
||
`press-releases:check-index-status --budget=1500`.
|
||
|
||
### 1d Erstquelle gegen Kopie
|
||
|
||
Absatz in der Veröffentlichungsmail, **nur** bei Meldungen, die über die
|
||
API eingegangen sind (`PressReleasePublished::$viaSyndication`, Marker
|
||
wie beim Empfängerwechsel): Verteiler-Text liegt gleichlautend auf vielen
|
||
Portalen und hat keine erkennbare Erstquelle; eine direkt eingereichte
|
||
Meldung erscheint als Erstquelle mit strukturierten Daten, geprüften
|
||
Angaben, stabiler Adresse. Kein Preis, kein Angebot, kein Link — der Test
|
||
prüft, dass kein Euro-Zeichen im Absatz steht und direkte Meldungen den
|
||
Absatz nicht bekommen.
|
||
|
||
### 2.4 Vierte Seite: `/pressemitteilung-kostenlos-veroeffentlichen`
|
||
|
||
Aufbau nach Anhang: kurze Antwort (Preis, kein kostenloser Weg, E7 als
|
||
Grundsatz), was kostenlose Portale leisten und was nicht, warum Links aus
|
||
Presseportalen kaum tragen (**inklusive der Aussage, dass unsere
|
||
externen Links ebenfalls `sponsored nofollow` tragen** — Link-Policy
|
||
seit 11.06.2026, sonst wäre der Abschnitt unehrlich), warum kostenfreie
|
||
Annahme und Prüfung sich ausschließen, wer am Ende zahlt, CTA ohne
|
||
Verkaufsdruck auf „Ohne Abo veröffentlichen".
|
||
|
||
**Liste kostenloser Portale: vorbereitet, leer.** Jeder Eintrag ist eine
|
||
Tatsachenbehauptung über einen Dritten; ich habe keine geprüfte Liste.
|
||
`config/transparency.php` (`free_portals` als Name/URL/Notiz,
|
||
`TRANSPARENCY_FREE_PORTALS_CHECKED_AT` = Prüfdatum). Der Abschnitt
|
||
erscheint nur, wenn beides gesetzt ist, Links dort `rel="nofollow"`, mit
|
||
dem Hinweis, dass kein Eintrag eine Empfehlung ist.
|
||
|
||
Unter-Navigation hat jetzt fünf Punkte; Modelltabelle auf Seite 1
|
||
verlinkt bei „Kostenlos mit Auflagen" auf die neue Seite; Hilfe-Center,
|
||
Sitemaps, `llms.txt`, Legacy-Slug-Liste ergänzt.
|
||
|
||
### E7
|
||
|
||
Keine Stelle im Web-Auftritt verspricht kostenlose Veröffentlichung
|
||
(geprüft: `lang/*`, `resources/views/web`). „Kostenlos" steht nur bei
|
||
Konto, Newsroom-Zugang, Meldeverfahren und kleinen Korrekturen — alles
|
||
zutreffend.
|
||
|
||
### Tests / Qualität
|
||
|
||
`CrawlerHitLogTest` (4), `VisibilityStatsTest` (umgebaut, 7),
|
||
`IndexStatusCheckTest` (11, API per `Http::fake`), `AddressUnsubscribeTest`
|
||
(+1), `TransparencyPagesTest` (11). Pint, Larastan Level 4 ohne Befund.
|
||
|
||
**Deploy:** `migrate` (zwei neue Tabellen, keine Änderung an
|
||
`press_releases`), `npm run build`, `view:cache`/`route:cache`,
|
||
`CRAWLER_HITS_RECORDED_SINCE` auf den Deploy-Tag setzen. Search Console
|
||
erst nach Einrichtung des Service-Kontos einschalten.
|
||
|
||
### 2.5 / E5 geschlossen (17.09.2026)
|
||
|
||
Entscheidung: bleibt bei der 301 auf `/faq`, kein Slug-Mapping. Die
|
||
Inhalte trägt das Hilfe-Center. Dafür steht **Hilfe** jetzt in der
|
||
Topleiste der Portale neben FAQ (Desktop) und im Burger-Menü unter den
|
||
Service-Links (Mobile); der Hub hatte den Link bereits.
|
||
|
||
# Teil 9 — Anhang 2: Doppelveröffentlichung und Quellenauskunft (17.09.2026)
|
||
|
||
Grundlage: `roadmap-anhang2-2026.md`. Commits siehe unten. **Der
|
||
Mechanismus ist gebaut und getestet, aber ausgeschaltet**
|
||
(`CANONICAL_ASSIGNMENT_ENABLED=false`), weil E9 — welche Kategorie auf
|
||
welches Portal — eine Betreiberentscheidung ist. Der Vorschlag steht in
|
||
`config/canonical.php`.
|
||
|
||
### Befund (Dev-Kopie, Stand 17.09.2026)
|
||
|
||
- 34.597 sichtbare Meldungen, davon **9.092 Paare gleichen Textes derselben
|
||
Firma auf beiden Portalen = 18.184 URLs, 53 % des Archivs**. Seit dem
|
||
neuen System (07/2026): 1.338 Paare — die API reicht praktisch jede
|
||
Meldung zweimal ein, je Portal eine eigene Zeile mit gleichem Slug.
|
||
- `portal = both` wird bei Meldungen nicht genutzt (0), nur bei Firmen und
|
||
Kategorien (alle 14 Kategorien sind `both`, Paare teilen die
|
||
`category_id`).
|
||
- Je Hauptkategorie: sonstiges 3.087, it 1.190, politik 1.117, haus 739,
|
||
handel 572, reisen 419, kultur 404, sport 356, mode 323, motor-verkehr
|
||
319, finanzen 170, medizin 149, wissenschaft-forschung 142, recht 105.
|
||
- Bisher: jede Fassung self-canonical je Host, beide in beiden Sitemaps —
|
||
Google wählt.
|
||
|
||
### 1c.7 Prüfsumme
|
||
|
||
`press_releases.content_hash` (SHA-256 über Titel + Text ohne Markup,
|
||
Leerraum zusammengezogen, Kleinschreibung — `App\Support\ContentFingerprint`),
|
||
gesetzt im Model beim Speichern; Bestand per
|
||
`press-releases:hash-content` (Query-Builder, Blöcke, keine Model-Events;
|
||
Dev: 179.242 Zeilen in 8:47 min). Migration in zwei Statements: ADD COLUMN
|
||
INSTANT, ADD INDEX INPLACE — kein Rebuild der 1-GB-Tabelle (MySQL 8.0.45).
|
||
`press-releases:duplicates-report` liefert die Zahlen oben.
|
||
|
||
### 1c.8 Kanonische Zuordnung
|
||
|
||
`press_releases.canonical_press_release_id` (kein FK, siehe Migration).
|
||
`CanonicalAssigner`: Paar = gleicher Hash, gleiche Firma, verschiedene
|
||
Portale, beide veröffentlicht. Erstquelle = Fassung auf dem
|
||
Kategorie-Portal (`portal_by_category`, DE-Slug der Hauptkategorie), sonst
|
||
`fallback` (`first` = zuerst veröffentlicht; oder ein Portalname). Die
|
||
zweite Fassung bekommt den Verweis, die erste wird geleert. Läuft bei
|
||
jeder Veröffentlichung (`PressReleaseService::publish`, fehlertolerant)
|
||
und über den Bestand mit `press-releases:assign-canonicals` (Dev: 9.092
|
||
Paare in Sekunden; `--dry-run` zeigt die Wirkung auch bei ausgeschaltetem
|
||
Flag).
|
||
|
||
Wirkung bei eingeschaltetem Flag: Detailseite der Kopie trägt
|
||
`rel=canonical` und `mainEntityOfPage` auf die Erstquelle; Sitemap und
|
||
News-Sitemap nennen nur die Erstquelle; Leser, Feeds, Newsroom und interne
|
||
Suche sehen weiterhin beide. `portal = both`-Meldungen folgen derselben
|
||
Zuordnung beim Rendern (Canonical auf das Kategorie-Portal), obwohl es
|
||
derzeit keine gibt.
|
||
|
||
**E9-Vorschlag** (in `config/canonical.php`): businessportal24 für
|
||
finanzen, handel, it, recht, wissenschaft-forschung, motor-verkehr;
|
||
presseecho für haus, kultur, medizin, mode, politik, reisen, sport,
|
||
sonstiges. Damit gehen ~6.600 Erstquellen an presseecho und ~2.500 an
|
||
businessportal24 — „sonstiges" (3.087) ist der Hebel; wer es auf
|
||
businessportal24 legt, dreht das Verhältnis.
|
||
|
||
### 1c.9 Quellenauskunft
|
||
|
||
`press_release_index_checks.user_canonical` wird seit diesem Stand
|
||
mitgespeichert. `IndexStatusChecker::sourceVerdict()` unterscheidet: Google
|
||
führt diese Adresse / die eigene Fassung auf dem anderen Portal / ein
|
||
fremdes Portal / keine Angabe. Anzeige im Block „Google-Index" der
|
||
Absenderansicht — **hinter `GSC_CUSTOMER_VISIBLE`, also zu**, mit dem Satz,
|
||
dass das Warum nicht bekannt ist. Reihenfolge aus Abschnitt 4 ist damit
|
||
eingehalten: erst Zuordnung (Flag), dann Auskunft (Flag).
|
||
|
||
### Abschnitt 5 — Beschreibung des Angebots
|
||
|
||
Geprüft: Preisseite, Transparenzseiten, FAQ, Veröffentlichungsmail. Alle
|
||
Stellen sagen „erscheint auf beiden Portalen" — Fundstellen für Leser,
|
||
nirgends „zweimal in der Suche". Keine Korrektur nötig. Ergänzt: Abschnitt
|
||
„Warum eine Meldung mehrfach im Netz steht und nur einmal in den
|
||
Suchergebnissen" auf `/gute-pressemitteilung` (Verteiler → viele Kopien,
|
||
eine Quelle; eigene Website zuerst; keine Garantie). Der Satz „bei uns
|
||
verweist die zweite Fassung auf die erste" erscheint erst mit
|
||
eingeschaltetem Flag.
|
||
|
||
### Abschnitt 7 — pressekonto.com
|
||
|
||
`<meta name="robots" content="noindex, nofollow">` jetzt in allen
|
||
Portal-Layouts (Admin, Kundenbereich unter `/admin/me`, Login/
|
||
Registrierung). robots.txt hatte `/admin`, `/login`, `/register` schon
|
||
ausgeschlossen — Disallow allein verhindert keine Aufnahme über Links.
|
||
|
||
**E10 (offen), mit Befund:** Die Transparenzseiten und alle anderen
|
||
statischen Seiten (faq, hilfe, agb, …) existieren auf **drei Hosts** mit
|
||
je eigenem Self-Canonical — dieselbe Doppelung wie bei den Meldungen, nur
|
||
dreifach. Empfehlung: Canonical der Transparenzseiten auf die Portale
|
||
(ältere Domains), der Hub verweist; welches Portal, hängt an E9-Logik
|
||
nicht, hier reicht presseecho als Standard. Nicht umgesetzt, wartet auf
|
||
die Entscheidung.
|
||
|
||
### Tests / Deploy
|
||
|
||
`CanonicalAssignmentTest` (9), `UmamiSnippetTest` von der lokalen .env
|
||
entkoppelt. Pint, Larastan ohne Befund.
|
||
|
||
**Deploy:** `migrate` (INSTANT/INPLACE auf `press_releases`, trotzdem
|
||
`DB_LOCK_WAIT_TIMEOUT=300` mitgeben), danach
|
||
`php artisan press-releases:hash-content` (läuft ~10 min, kann im
|
||
Hintergrund), dann `press-releases:duplicates-report`. Erst nach E9:
|
||
`CANONICAL_ASSIGNMENT_ENABLED=true`, `press-releases:assign-canonicals
|
||
--dry-run`, dann ohne Flag, `view:cache`, Sitemaps prüfen.
|
||
|
||
# Teil 10 — E9 und E10 entschieden, Hub-Erreichbarkeit geprüft (18.09.2026)
|
||
|
||
### E9 — kanonische Zuordnung aktiv
|
||
|
||
Zuordnung wie vorgeschlagen übernommen (`config/canonical.php`):
|
||
businessportal24 führt finanzen, handel, it, recht, wissenschaft-forschung,
|
||
motor-verkehr; presseecho führt haus, kultur, medizin, mode, politik,
|
||
reisen, sport, sonstiges. `CANONICAL_ASSIGNMENT_ENABLED` steht jetzt
|
||
standardmäßig auf `true` — ohne `.env`-Eintrag ist die Zuordnung an.
|
||
|
||
Auf der Dev-Kopie verifiziert: 9.092 Paare zugeordnet, die Kopie trägt
|
||
`rel=canonical` auf die Erstquelle, steht nicht mehr in der Sitemap ihres
|
||
Portals, die Erstquelle schon.
|
||
|
||
### E10 — eine Adresse je Text, Rechtstexte ausgenommen
|
||
|
||
Befund: faq, hilfe, gute-pressemitteilung, die vier Transparenzseiten, api
|
||
und alle Rechtstexte liegen auf **allen drei Hosts** mit identischem Text
|
||
(nur Markenname getauscht, per Prüfsumme bestätigt) und trugen je einen
|
||
eigenen Self-Canonical.
|
||
|
||
Umgesetzt (`config/canonical.static_page_host`, `App\Support\StaticPageCanonical`):
|
||
|
||
| Seiten | Führender Host |
|
||
|---|---|
|
||
| faq, hilfe, gute-pressemitteilung, 4 Transparenzseiten | presseecho |
|
||
| api (Doku des Publisher-Bereichs) | pressekonto |
|
||
| impressum, datenschutz, agb, richtlinien, melden, kuendigung | **je Host selbst** |
|
||
|
||
**Rechtstexte bewusst ausgenommen** — das ist eine Abweichung von „alles auf
|
||
die Portale". Jede Domain ist ein eigenes Angebot mit eigener
|
||
Anbieterkennzeichnung und eigenem Meldeweg; wer auf businessportal24.com
|
||
das Impressum aufruft, muss das Impressum dieser Domain bekommen, und es
|
||
muss für sich auffindbar bleiben. Ein Canonical auf eine fremde Domain
|
||
würde dem widersprechen. Inhaltlich sind die Texte identisch — das ist bei
|
||
Pflichtangaben unkritisch.
|
||
|
||
Mitgezogen: Sitemap listet je Seite nur noch den führenden Host (Hub
|
||
behält api + Rechtstexte), `llms.txt` nennt die führende Adresse, `og:url`
|
||
folgt dem Canonical.
|
||
|
||
### Erreichbarkeit pressekonto.com — geprüft, drei Befunde
|
||
|
||
Alle 24 Hub-Adressen und jeder Link der Hub-Startseite geprüft: **alles
|
||
200**, unbekannte Pfade 404, `sitemap.xml`, `sitemap-news.xml`,
|
||
`llms.txt`, `robots.txt`, `/docs/api/v1` in Ordnung.
|
||
|
||
1. **Portalseiten unter pressekonto.com** (behoben): `/de/preise`,
|
||
`/de/veroeffentlichen`, `/de/newsrooms`, `/de/suche` wurden auf dem Hub
|
||
ausgeliefert — mit dem Branding und Titel von **businessportal24**,
|
||
weil `WebPortalContext::fromRequest()` auf dem Hub auf dieses Portal
|
||
zurückfällt. Jetzt 301 auf presseecho
|
||
(`RedirectPortalOnlyPagesFromHub`, web-Gruppe). Die
|
||
Transparenz-Navigation verlinkt „Tarife & Preise" auf dem Hub direkt
|
||
auf presseecho, ohne Umweg über die Weiterleitung.
|
||
2. **Weiterleitungskette** (behoben): `/de/kontakt` und `/de/ueber-uns`
|
||
zeigten auf `/de`, das auf dem Hub selbst auf `/` weiterleitet — zwei
|
||
Sprünge. Jetzt direkt auf die Startseite des jeweiligen Hosts.
|
||
3. **Portal-Inhalte unter pressekonto.com** (behoben am 18.09.2026 nach
|
||
Freigabe): `/de/category/{slug}`, `/de/category/{slug}/archiv/{jahr}`,
|
||
`/de/press-release/{slug}`, `/de/newsroom/{slug}` und
|
||
`/de/newsroom/{slug}/verwalten` waren auf dem Hub erreichbar und
|
||
renderten das Portal, weil die Ausgabe-Routen für alle Hosts gelten.
|
||
Jetzt 301 (`canonical.portal_only_content_routes`):
|
||
|
||
- **Meldung und Newsroom folgen ihrem eigenen Portal**, nicht einer
|
||
festen Vorgabe. Bei Doppelveröffentlichung tragen beide Fassungen
|
||
denselben Slug — dann gewinnt die Erstquelle
|
||
(`canonical_press_release_id is null`), sonst landete die
|
||
Weiterleitung auf der Kopie.
|
||
- Kategorie, Jahresarchiv und Suche gibt es auf beiden Portalen: dort
|
||
entscheidet `portal_only_host` (presseecho).
|
||
- **Unbekannter Slug wird nicht weitergeleitet** — sonst entstünde eine
|
||
301-Kette in ein 404 auf dem Portal. Der Hub antwortet wie bisher 404.
|
||
- **Nur GET.** Ein 301 auf POST würde die Anfrage verlieren; Newsroom
|
||
beanspruchen und Share-Zählung laufen weiter.
|
||
- Query-Parameter bleiben erhalten (`/de/suche?q=…`).
|
||
|
||
Technischer Nebeneffekt: Der Self-Canonical kommt jetzt aus dem
|
||
tatsächlich aufgerufenen Host (`StaticPageCanonical::selfBase()`) statt aus
|
||
der geteilten Domain-Config. Portal-Inhalte behalten ihr Portal —
|
||
`WebPortalContext::fromRequest()` setzt dafür den Merker
|
||
`webPortalContextApplied`.
|
||
|
||
### Tests / Deploy
|
||
|
||
`HubPortalPageRedirectTest` (19), angepasste Sitemap-Erwartungen in
|
||
`StaticPagesTest` und `TransparencyPagesTest`. Gesamtlauf 1.804 grün
|
||
(eine Ausnahme: `ManualInvoicePdfTest` schlägt unabhängig von diesen
|
||
Änderungen fehl — mit und ohne Stash reproduziert, Anrede „Frau Erika
|
||
Muster" fehlt; gehört zum Rechnungs-Paket).
|
||
|
||
**Deploy:** `view:cache`/`route:cache` neu, `php artisan cache:clear` für
|
||
die Sitemap-Caches (1 h TTL). E9 ist ohne `.env`-Eintrag aktiv — davor
|
||
müssen `press-releases:hash-content` und `press-releases:assign-canonicals`
|
||
gelaufen sein, sonst passiert schlicht nichts.
|
||
|
||
### Nachtrag 18.09.2026 — Admin-Bericht „Indexierung & Abrufe"
|
||
|
||
**Fehler auf Live:** Bericht meldete „nicht konfiguriert", obwohl der
|
||
Lauf Daten holte. Ursache: `GSC_SERVICE_ACCOUNT_FILE` relativ
|
||
(`config/files/…`), `is_readable()` hing am Arbeitsverzeichnis — Cron
|
||
in der Projektwurzel lesbar, Web-Request in `public/` nicht. Relative
|
||
Pfade werden jetzt ab `base_path()` aufgelöst.
|
||
|
||
**Bericht erweitert** (`/admin/reports/indexierung`):
|
||
|
||
- Verteilung je Portal jetzt nach Herkunft **und Rolle** (Erstquelle /
|
||
Kopie mit Verweis, 1c.8). Seit E9 aktiv ist, ist „ausgeschlossen" bei
|
||
Kopien der erwartete Befund und kein Problem; ohne die Trennung wäre
|
||
die Quote unlesbar. Erwarteter Befund je Zeile fett.
|
||
- **Quellenbefund** (1c.9) über Erstquellen: Google führt diese Adresse /
|
||
eigene Fassung auf dem anderen Portal / fremdes Portal (mit Top-Hosts)
|
||
/ keine Angabe. In den jüngsten Prüfungen als Spalte „Google führt".
|
||
- **Abrufe durch Such- und KI-Dienste** (1c.0): je Dienst Abrufe und
|
||
Meldungen im Zeitraum (7/30/90 Tage), gesamt, letzter Abruf; Tagesbalken
|
||
14 Tage getrennt Suche/KI; meistabgerufene Meldungen; CSV.
|
||
`App\Services\PressRelease\CrawlerHitReport`.
|
||
- Herkunftsmarker als Join statt korreliertem EXISTS (2,6 s → 0,3 s je
|
||
Portal), Verteilung und Quellenbefund 15 min gecacht. Seite warm 0,5 s.
|
||
|
||
Dev-Verteilung presseecho (Bestand): direkt 8.845 Erstquellen / 2.126
|
||
Kopien, API 1.308 / 308.
|
||
|
||
# Teil 11 — Abschluss Block 0–2, Block 3.1 RSS-Feeds (18.09.2026)
|
||
|
||
## Sichtung des Ordners (18.09.2026)
|
||
|
||
Alle Dokumente in `dev/2026-09-01/` gegen Code und Git-Stand abgeglichen.
|
||
Befunde und was daraus wurde:
|
||
|
||
| Befund | Erledigt |
|
||
|---|---|
|
||
| `GSC_PROPERTY_BUSINESSPORTAL24` hatte als Vorgabe `sc-domain:businessportal24.de`, die Property ist `.com` | Vorgabe in `config/search_console.php` und `.env.example` korrigiert (live ist der Wert explizit gesetzt) |
|
||
| Anhang 1: Tippfehler in der Übersicht, 1c.2/1c.3/2.7 nicht abgehakt | angeglichen |
|
||
| Protokoll: „Teil 5" und „Teil 6" doppelt vergeben | zweite Serie ist jetzt Teil 7–10, Verweise in Roadmap und Anhang 2 nachgezogen |
|
||
| Log-Aufteilung je Thema lag uncommittet auf dem Branch | Commit 2ec6169 (billing, ki, monitoring, market als Tagesdateien; Suite loggt nur nach `testing-*.log`) |
|
||
| `docs/weiteres`: Decision-Update Duplicate-Content gelöscht, Verlinkung & Backlinks wieder da | Duplicate-Content ist durch Anhang 2 überholt (entfernt, README-Index nachgezogen); Verlinkung bleibt als Referenz für die Link-Policy |
|
||
| E6 stand offen, ist aber in 1d.1/1d.2 umgesetzt | als entschieden geführt |
|
||
| 2.6 Zuschriften | extern, in Arbeit beim Betreiber — nicht Teil der Umsetzung |
|
||
| 1c.5/E8 | Indexierungsprüfung läuft täglich; Entscheidung, sobald die Verteilung belastbar ist |
|
||
|
||
**Bleibt offen, bewusst nicht aus dem Code heraus erledigt:**
|
||
|
||
- 0.7: Absatz zur Ereignistabelle (Zweck, Umfang, 395 Tage, Rechtsgrundlage)
|
||
in der Datenschutzerklärung — Rechtstext, Betreiber. Gilt seit dem
|
||
21.09.2026 ebenso für das Crawler- und das Feed-Abruf-Protokoll (beide
|
||
ohne IP, gleiche Aufbewahrung).
|
||
- 0.1: Kohortenwerte „28–55 Tage" vom Live-System (Fenster 01.06.–01.09.)
|
||
einmal erheben und ablegen: `php artisan press-releases:view-distribution --from=2026-06-01 --to=2026-09-01 --json > dev/2026-09-01/basiswert-0.1.json`.
|
||
- Abbruchbedingungen der Blöcke 1–3 tragen weiter Platzhalter für Zeitraum
|
||
und Schwellwert; Block 1 läuft seit 17.09. ohne festen Vergleichspunkt.
|
||
- 2.2 Publisher Center (Betreiber), Ergebnis nach 4–6 Wochen in der Search
|
||
Console.
|
||
|
||
## 3.1 — RSS-Feeds je Branche
|
||
|
||
**Stand vorher:** keine Feeds (siehe 1c.1: „keine Feeds vorhanden, daher
|
||
kein Feed-Eintrag").
|
||
|
||
**Umsetzung** (`FeedController`, `web.feed`-View):
|
||
|
||
- `/{edition}/feed` — alle Meldungen der Ausgabe (Portal × Sprache);
|
||
`/{edition}/category/{slug}/feed` — Branche inklusive aktiver
|
||
Unterkategorien, wie die Branchenseite. Jeweils die jüngsten 50, neueste
|
||
zuerst, nur `visibleInArchive` (kein Termin in der Zukunft, kein 410).
|
||
- RSS 2.0 mit `atom:link rel="self"`, `dc:creator` = Firmenname,
|
||
`category` = lokalisierter Branchenname, `guid` = Permalink, Teaser 300
|
||
Zeichen ohne Markup. Kanaltitel „Pressemitteilungen {Branche} – {Portal}",
|
||
Beschreibung aus der Kategoriebeschreibung (2.3), sonst Tagline.
|
||
- Bei Doppelveröffentlichung führt jedes Portal seine eigene Fassung — Leser
|
||
und Feeds sehen beide (Anhang 2, Abschnitt 1); nur die Suchmaschine
|
||
bekommt die Erstquelle. Kein Canonical-Filter im Feed.
|
||
- Cache wie die Seitendaten (`WebPageCache`, 5 min frisch, bis 1 h stale
|
||
mit Neuaufbau nach der Antwort); `WebCaches::forgetPublicPages()` leert
|
||
ihn mit. Eine Korrektur (nur `forgetSitemaps()`) erscheint im Feed nach
|
||
spätestens fünf Minuten — hingenommen.
|
||
- Hub: `feed` und `kategorie.feed` in `canonical.portal_only_content_routes`
|
||
→ 301 auf presseecho, wie Kategorie und Archiv.
|
||
- Autodiscovery: `@yield('feed_links')` im Web-Layout; Startseiten nennen
|
||
den Portal-Feed, Branchenseite Branchen- und Portal-Feed, Jahresarchiv
|
||
den Branchen-Feed — Adressen aus der Portal-Basis, nicht aus `url()`
|
||
(das liefert wegen `forceRootUrl` den Hub). Sichtbarer Link „RSS-Feed"
|
||
in der Archiv-Leiste der Branchenseite; `llms.txt` nennt beide Muster.
|
||
- Absenderansicht: Block „RSS-Feeds" im Panel „Auslieferung" mit Branchen-
|
||
und Portal-Feed je Portal der Meldung, plus dem Satz, dass Abonnenten uns
|
||
nicht bekannt sind (`VisibilityStats::feedLinks()`).
|
||
- Übersetzungen unter `web.rss` — `web.feed` war schon die Meldungsliste der
|
||
Startseite.
|
||
|
||
Nicht gebaut: Feeds je Newsroom (kein Arbeitspaket), Bild-Enclosures
|
||
(zusätzliche Queries je Item, kein Bedarf benannt), Feed-Einträge in der
|
||
Sitemap (Feeds gehören nicht hinein).
|
||
|
||
Tests: `FeedTest` (7 — Branche mit Kindern und Fremdportal, Portal-Feed,
|
||
404, englische Ausgabe, Autodiscovery + llms.txt, Hub-301, Cache),
|
||
`VisibilityStatsTest` (+Feed-Links im Summary und im Panel). Pint, Larastan
|
||
ohne Befund.
|
||
|
||
**Deploy:** `view:cache`/`route:cache` neu; kein `migrate`, kein
|
||
`cache:clear` (neue Cache-Keys).
|
||
|
||
### Nachtrag 21.09.2026 — Newsroom-Feeds, RSS-Seite, Topleiste
|
||
|
||
Auf Wunsch des Betreibers ergänzt:
|
||
|
||
- **Feed je Newsroom:** `/{edition}/newsroom/{slug}/feed`
|
||
(`FeedController::newsroom`). Gleiche Sichtbarkeitsregel wie die
|
||
Newsroom-Seite: aktive Firma, nicht entfernt, mindestens eine
|
||
veröffentlichte Meldung — sonst 404. Die Sichtbarkeitsprüfung läuft vor
|
||
dem Cache, damit Deaktivierung und Entfernung sofort greifen; eine in
|
||
einer Zusammenführung aufgegangene Firma führt per 301 zum Feed des
|
||
bleibenden Newsrooms (1a.3). Kanalbeschreibung aus dem Boilerplate.
|
||
Newsroom-Seite: Autodiscovery (Newsroom- und Portal-Feed) und sichtbarer
|
||
Link „RSS-Feed" in der Kopfzeile neben „Newsroom seit". Hub: 301 auf
|
||
das Portal der Firma (`RedirectPortalOnlyPagesFromHub`).
|
||
- **RSS-Übersichtsseite** `/{edition}/rss` (`RssPageController`,
|
||
`web.rss`, Texte `static.rss.*` DE/EN): Was RSS ist, wie ein Reader
|
||
funktioniert (drei Schritte, Beispiele Feedly/Inoreader/NetNewsWire/
|
||
Thunderbird/Outlook), dann Hauptfeed, alle aktiven Top-Level-Branchen
|
||
mit Feed-Adresse, Muster des Newsroom-Feeds mit Link auf die
|
||
Newsroom-Übersicht, Hinweise (Ausgaben DE/EN, Schwesterportal, Anriss
|
||
statt Volltext, keine Weiterverwendung). Portalseite wie Preise:
|
||
Portal-Kontext angewendet (eine `Route::view` hätte auf presseecho das
|
||
Branding von businessportal24 gezeigt), auf dem Hub 301 auf presseecho
|
||
(`portal_only_pages`), in der Portal-Sitemap, in `llms.txt`.
|
||
- **Topleiste:** „RSS" neben FAQ · Hilfe · API (Desktop), im Burger-Menü
|
||
unter den Service-Links und im Footer. Der Hub-Header hat die drei Links
|
||
nicht und bekommt deshalb auch kein RSS.
|
||
- Panel „Auslieferung": je Portal jetzt Branchen-, Newsroom- und
|
||
Portal-Feed.
|
||
|
||
Tests: `FeedTest` (+3: Newsroom-Feed mit Sichtbarkeit, Hub-301 aufs
|
||
Firmenportal, Autodiscovery auf der Newsroom-Seite), `RssPageTest` (4),
|
||
`StaticPagesTest`-Datensatz, `VisibilityStatsTest`. Pint, Larastan ohne
|
||
Befund. Deploy unverändert: `view:cache`/`route:cache`.
|
||
|
||
### Nachtrag 21.09.2026 — Abruf-Protokoll der Feeds
|
||
|
||
Frage des Betreibers: lassen sich Feed-Abrufe im System messen? Ja —
|
||
gebaut nach dem Muster des Crawler-Protokolls (1c.0).
|
||
|
||
- Tabelle `feed_fetches`: Feed-Art (portal/category/newsroom), Schlüssel
|
||
(Kategorie- bzw. Firmen-Slug), Portal, Sprache, erkannter Reader-Dienst,
|
||
vom Dienst gemeldete Abonnentenzahl, gekürzter User-Agent, Zeitpunkt.
|
||
**Keine IP.** Reader-Erkennung über `config/feeds.php`
|
||
(`FeedReaderIdentifier`, 20 Dienste inkl. Browser); Abonnenten per
|
||
Muster „12 subscribers" aus dem User-Agent (Feedly, Inoreader, NewsBlur,
|
||
Feedbin nennen sie), sonst leer. Unbekannte Programme zählen als
|
||
„Sonstige".
|
||
- Schreiben per `defer()` nach der Antwort in `FeedController::respond()`,
|
||
fehlertolerant, **auch bei warmem Cache** — der Abruf ist die Messgröße.
|
||
Stolperstein: die Swoole-Erweiterung im Container definiert ein globales
|
||
`defer()`, Laravels Helfer muss per `use function
|
||
Illuminate\Support\defer` importiert werden (wie in
|
||
`ReleaseDetailPageData`), sonst „API must be called in the coroutine".
|
||
- Aufbewahrung wie Ereignis- und Crawler-Log (395 Tage), angehängt an
|
||
`press-releases:purge-events`. `FEED_FETCHES_RECORDED_SINCE` beim Deploy
|
||
auf den Deploy-Tag setzen.
|
||
- Admin ▸ Reports ▸ Indexierung & Abrufe: neuer Block „Abrufe der
|
||
RSS-Feeds" — Summen je Feed-Art im Zeitraum, Tabelle je Reader-Dienst
|
||
(Abrufe, Feeds, gemeldete Abonnenten = je Feed das Maximum, summiert,
|
||
letzter Abruf), meistabgerufene Feeds mit aufgelöstem Namen
|
||
(`FeedFetchReport`). Verdichtung in PHP, damit MySQL und SQLite gleich
|
||
rechnen.
|
||
- Panel „Auslieferung": je Feed die Abrufe der letzten 30 Tage; Satz
|
||
angepasst („Gezählt werden Abrufe der Feeds … wer sie abonniert hat, ist
|
||
uns nicht bekannt"). RSS-Seite sagt jetzt ebenfalls, dass Abrufe gezählt
|
||
werden und keine IP gespeichert wird.
|
||
- Was das Protokoll **nicht** kann: Abonnenten zählen (nur die von Diensten
|
||
gemeldete Zahl; Desktop-Reader holen einzeln) und Klicks aus dem Reader
|
||
auf die Meldung zuordnen (Reader schicken keinen Referrer; ein
|
||
Herkunftsparameter an den Feed-Links wurde bewusst nicht gesetzt, weil
|
||
er Adressvarianten erzeugt).
|
||
|
||
Tests: `FeedFetchLogTest` (5: Erkennung, Protokoll ohne IP inkl.
|
||
Cache-Treffer, Report je Reader/Feed, Panel, Bereinigung),
|
||
`IndexStatusCheckTest` erweitert. Pint, Larastan ohne Befund.
|
||
|
||
**Deploy:** `migrate` (neue Tabelle, keine Änderung an `press_releases`),
|
||
`FEED_FETCHES_RECORDED_SINCE` setzen, `view:cache`/`route:cache`,
|
||
`config:cache`. **Offen (0.7, Betreiber):** Datenschutzerklärung — derselbe
|
||
Absatz wie für Ereignis- und Crawler-Log (Zweck, Umfang ohne IP, 395 Tage).
|
||
|