*/ 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, 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 $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); } }