connektar legt je Meldung eine Firma an. ApiNewsroomAssignment prüft vor dem Anlegen, ob unter derselben Firmen-E-Mail schon dieselbe Firma steht (Namensregel der Klassifizierung), und gibt dann deren ID zurück — der Alt-Vertrag (201 + Location) bleibt. Nie-veröffentlicht-Adressen werden nie zugeordnet, abweichende Namen bekommen wie bisher eine neue Firma. Liegt die Firma auf dem anderen Portal, wird sie both; bei fremd belegtem Slug bleibt es beim alten Verhalten. LegacyPressReleaseController: both ist kein Ziel-Portal für eine Meldung — dann entscheidet das Portal des API-Nutzers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
272 lines
10 KiB
PHP
272 lines
10 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Company;
|
||
|
||
use App\Enums\CompanyAddressClass;
|
||
use App\Models\Company;
|
||
use App\Models\PressRelease;
|
||
use App\Scopes\PortalScope;
|
||
|
||
/**
|
||
* Klassifiziert Firmen-E-Mail-Adressen für die Newsroom-Konsolidierung.
|
||
*
|
||
* Eine Logik, zwei Abnehmer: der Trockenlauf-Bericht (1a.1) und die
|
||
* Persistierung in `company_email_groups` (1a.2). Beide müssen dieselbe
|
||
* Antwort geben — sonst zeigt der Bericht etwas anderes, als die
|
||
* Zusammenführung später tut.
|
||
*
|
||
* Schlüssel ist die FIRMEN-E-Mail, nicht die Pressekontaktadresse. Der
|
||
* Pressekontakt ist der Firma untergeordnet und wechselt; die Firmenidentität
|
||
* darf nicht an einer Person hängen.
|
||
*
|
||
* Fünf Klassen statt der drei Signale aus dem Regelwerk. Zwei Ergänzungen
|
||
* kommen aus den Daten: die Namensvariante (Rechtsform, Tippfehler, Zusatz —
|
||
* dieselbe Firma) und die Adresse ohne jede Veröffentlichung. Letztere ist der
|
||
* Grund, warum der Trockenlauf vor jeder Zusammenführung stehen muss: Hinter
|
||
* `yahoo@yahoo.com` stehen 4376 Firmen und keine einzige Meldung. Würde über
|
||
* sie zusammengeführt, verschmölzen Tausende Unbeteiligte zu einem Newsroom.
|
||
*/
|
||
class CompanyAddressClassifier
|
||
{
|
||
/**
|
||
* Ab so vielen unterschiedlichen Firmennamen auf einer Adresse kommt sie
|
||
* für die Nie-veröffentlicht-Prüfung überhaupt in Frage. Bewusst
|
||
* großzügig: Eine echte Agentur mit 50 Kunden soll noch als Agentur gelten.
|
||
*/
|
||
private const JunkThreshold = 25;
|
||
|
||
/**
|
||
* Die Namensanzahl allein trennt Müll NICHT von einer großen Agentur —
|
||
* `pr@deutsche-stadtauskunft.ag` trägt 133 Namen und hat trotzdem 134
|
||
* Firmen mit veröffentlichten Meldungen. Das trennscharfe Signal ist die
|
||
* Veröffentlichung: Hinter einer Platzhalter- oder Wegwerfadresse ist nie
|
||
* etwas erschienen, hinter einer Agentur laufend — auch wenn das Jahre her
|
||
* ist und die Meldungen heute offline sind (siehe `classifyAll()`).
|
||
*/
|
||
private const JunkPublishedRatio = 0.05;
|
||
|
||
/**
|
||
* Rechtsformen und Zusätze, die für die Namensgleichheit nichts bedeuten.
|
||
* „Hoppe Unternehmensberatung" und „Hoppe Unternehmensberatung GmbH" sind
|
||
* dieselbe Firma.
|
||
*
|
||
* Als einzelne Wörter geführt und vom Ende her wiederholt entfernt, bis
|
||
* keines mehr passt. So fallen „GmbH & Co. KG", „Ltd. & Co. KG" und
|
||
* „AG & Co. KGaA" gleichermaßen weg, ohne dass jede Kombination einzeln
|
||
* aufgeführt sein muss — die Satzzeichen sind zu dem Zeitpunkt ohnehin
|
||
* schon fort.
|
||
*
|
||
* @var list<string>
|
||
*/
|
||
private const LegalFormTokens = [
|
||
'gmbh', 'mbh', 'ug', 'haftungsbeschraenkt', 'ohg', 'kgaa', 'kg', 'ag', 'se', 'co', 'cie', 'und',
|
||
'ek', 'ev', 'gbr', 'partg', 'ltd', 'llc', 'inc', 'bv', 'nv', 'sa', 'srl', 'plc', 'mvz',
|
||
];
|
||
|
||
/**
|
||
* Zwei normalisierte Namen gelten als derselbe, wenn sie sich nur um
|
||
* wenige Zeichen unterscheiden (Tippfehler: „Berursverband" /
|
||
* „Berufsverband", „S Research" / „JS Research") oder denselben Anfang
|
||
* teilen (Zusätze: „Praxis Prof Dr Dhom Kollegen" / „Praxis Prof Dr Dhom
|
||
* Partner"). Unter EINER Firmen-E-Mail ist ein gemeinsamer Anfang aus
|
||
* drei Wörtern ein sehr starkes Zeichen für dieselbe Einheit.
|
||
*/
|
||
private const SimilarMaxDistance = 2;
|
||
|
||
private const SimilarDistanceRatio = 0.15;
|
||
|
||
private const SharedPrefixWords = 3;
|
||
|
||
/**
|
||
* Alle Adressen mit mehr als einer Firma, klassifiziert.
|
||
*
|
||
* @param int $collectionThreshold ab wie vielen abweichenden Namen eine Adresse als Sammeladresse gilt (E1)
|
||
* @return list<array{email: string, klasse: CompanyAddressClass, firmen: int, namen: int, namen_normalisiert: int, beispiele: list<string>, firmen_mit_pm: int}>
|
||
*/
|
||
public function classifyAll(int $collectionThreshold): array
|
||
{
|
||
// Einmal alle Firmen, die JEMALS eine Meldung veröffentlicht haben,
|
||
// statt je Firma eine korrelierte Unterabfrage: Das läuft über den
|
||
// gesamten Bestand, und das ist eine Abfrage gegen hunderttausend.
|
||
//
|
||
// „Jemals" heißt: heute `published` ODER Aufrufe gezählt. Der Status
|
||
// allein trügt beim Altbestand — das Altsystem hat 2009–2018 rund
|
||
// 126 000 Meldungen auf `rejected` gesetzt, 83 000 davon mit Aufrufen.
|
||
// Eine abgelehnte Meldung wird nie aufgerufen; die waren online und
|
||
// sind später pauschal offline genommen worden. Ohne die Aufrufe
|
||
// stünden pr4you, Blödorn oder Crossover Point mit Hunderten Meldungen
|
||
// aus 2006–2011 als „nie veröffentlicht" neben `yahoo@yahoo.com`.
|
||
$withPublication = array_flip(
|
||
PressRelease::query()
|
||
->withoutGlobalScope(PortalScope::class)
|
||
->whereNotNull('company_id')
|
||
->where(function ($query): void {
|
||
$query->where('status', 'published')->orWhere('hits', '>', 0);
|
||
})
|
||
->distinct()
|
||
->pluck('company_id')
|
||
->all()
|
||
);
|
||
|
||
$rows = Company::query()
|
||
->withoutGlobalScope(PortalScope::class)
|
||
->whereNotNull('email')
|
||
->where('email', '!=', '')
|
||
->orderBy('email')
|
||
->orderBy('id')
|
||
->get(['id', 'email', 'name']);
|
||
|
||
$groups = [];
|
||
|
||
foreach ($rows as $row) {
|
||
$key = Company::emailKey($row->email);
|
||
|
||
if ($key === null) {
|
||
continue;
|
||
}
|
||
|
||
$groups[$key] ??= ['email' => $key, 'firmen' => 0, 'beispiele' => [], 'firmen_mit_pm' => 0, 'normalisiert' => []];
|
||
$groups[$key]['firmen']++;
|
||
$groups[$key]['firmen_mit_pm'] += isset($withPublication[$row->id]) ? 1 : 0;
|
||
|
||
$name = trim((string) $row->name);
|
||
|
||
if (! in_array($name, $groups[$key]['beispiele'], true)) {
|
||
$groups[$key]['beispiele'][] = $name;
|
||
}
|
||
|
||
$groups[$key]['normalisiert'][$this->normalise($name)] = true;
|
||
}
|
||
|
||
$result = [];
|
||
|
||
foreach ($groups as $group) {
|
||
if ($group['firmen'] < 2) {
|
||
continue;
|
||
}
|
||
|
||
$group['namen'] = count($group['beispiele']);
|
||
$group['namen_normalisiert'] = $this->countDistinct(array_keys($group['normalisiert']));
|
||
unset($group['normalisiert']);
|
||
$group['klasse'] = $this->classify($group, $collectionThreshold);
|
||
|
||
$result[] = $group;
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* @param array{firmen: int, namen: int, namen_normalisiert: int, firmen_mit_pm: int} $group
|
||
*/
|
||
private function classify(array $group, int $collectionThreshold): CompanyAddressClass
|
||
{
|
||
if ($group['namen'] >= self::JunkThreshold
|
||
&& $group['firmen_mit_pm'] / max(1, $group['firmen']) < self::JunkPublishedRatio) {
|
||
return CompanyAddressClass::NeverPublished;
|
||
}
|
||
|
||
if ($group['namen'] === 1) {
|
||
return CompanyAddressClass::Duplicate;
|
||
}
|
||
|
||
// Normalisiert derselbe Name: Rechtsform, Schreibweise oder Zusatz
|
||
// weicht ab, die Firma ist dieselbe. Genau der Fall „gleicher ODER
|
||
// ÄHNLICHER Firmenname" aus dem Regelwerk.
|
||
if ($group['namen_normalisiert'] === 1) {
|
||
return CompanyAddressClass::NameVariant;
|
||
}
|
||
|
||
return $group['namen_normalisiert'] >= $collectionThreshold
|
||
? CompanyAddressClass::CollectionAddress
|
||
: CompanyAddressClass::Proposal;
|
||
}
|
||
|
||
/**
|
||
* Firmenname auf seinen Kern reduziert: Kleinschreibung, Umlaute
|
||
* aufgelöst, Rechtsform und Satzzeichen entfernt.
|
||
*/
|
||
public function normalise(string $name): string
|
||
{
|
||
$value = mb_strtolower($name);
|
||
$value = strtr($value, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']);
|
||
$value = (string) preg_replace('/[^a-z0-9]+/', ' ', $value);
|
||
$value = trim((string) preg_replace('/\s+/', ' ', $value));
|
||
|
||
$words = explode(' ', $value);
|
||
|
||
// Vom Ende her, solange das letzte Wort eine Rechtsform ist — aber nie
|
||
// bis auf nichts: „AG" allein ist ein Firmenname, kein Zusatz.
|
||
while (count($words) > 1 && in_array(end($words), self::LegalFormTokens, true)) {
|
||
array_pop($words);
|
||
}
|
||
|
||
return implode(' ', $words);
|
||
}
|
||
|
||
/**
|
||
* Zahl der wirklich verschiedenen Namen in einer Liste normalisierter
|
||
* Namen. Ähnliche werden gierig demselben Cluster zugeschlagen.
|
||
*
|
||
* Gedeckelt bei JunkThreshold: Ab so vielen verschiedenen Namen steht die
|
||
* Klasse fest (Sammeladresse oder nie veröffentlicht), und der Vergleich
|
||
* ist quadratisch — bei `yahoo@yahoo.com` mit über 4000 Namen wären das
|
||
* neun Millionen Levenshtein-Aufrufe für ein Ergebnis, das ohnehin
|
||
* „viele" heißt. Der zurückgegebene Wert ist dann eine Untergrenze.
|
||
*
|
||
* @param list<string> $names
|
||
*/
|
||
private function countDistinct(array $names): int
|
||
{
|
||
$representatives = [];
|
||
|
||
foreach ($names as $name) {
|
||
if (count($representatives) >= self::JunkThreshold) {
|
||
break;
|
||
}
|
||
|
||
foreach ($representatives as $representative) {
|
||
if ($this->similar($name, $representative)) {
|
||
continue 2;
|
||
}
|
||
}
|
||
|
||
$representatives[] = $name;
|
||
}
|
||
|
||
return count($representatives);
|
||
}
|
||
|
||
/**
|
||
* Sind zwei Firmennamen dieselbe Firma? Dieselbe Frage, die die
|
||
* Klassifizierung je Adresse stellt — hier für die laufende Zuordnung
|
||
* (1a.4) einer einzelnen neuen Firma zu einer bestehenden.
|
||
*/
|
||
public function namesMatch(string $a, string $b): bool
|
||
{
|
||
return $this->similar($this->normalise($a), $this->normalise($b));
|
||
}
|
||
|
||
private function similar(string $a, string $b): bool
|
||
{
|
||
if ($a === $b) {
|
||
return true;
|
||
}
|
||
|
||
$length = max(mb_strlen($a), mb_strlen($b));
|
||
$allowed = max(self::SimilarMaxDistance, (int) ceil($length * self::SimilarDistanceRatio));
|
||
|
||
// levenshtein() arbeitet auf Bytes und ist auf 255 begrenzt — die
|
||
// Namen sind zu diesem Zeitpunkt ASCII und kurz.
|
||
if (levenshtein(substr($a, 0, 255), substr($b, 0, 255)) <= $allowed) {
|
||
return true;
|
||
}
|
||
|
||
$wordsA = explode(' ', $a);
|
||
$wordsB = explode(' ', $b);
|
||
|
||
return count($wordsA) >= self::SharedPrefixWords
|
||
&& count($wordsB) >= self::SharedPrefixWords
|
||
&& array_slice($wordsA, 0, self::SharedPrefixWords) === array_slice($wordsB, 0, self::SharedPrefixWords);
|
||
}
|
||
}
|