companies: Links aus Firmennamen normalisieren — API-Eingang und Bestandsbefehl

38 oeffentliche Firmen tragen eine URL im Namen ("Firma - https://..."),
laufend ueber die API. Nicht ablehnen (traefe die Meldung), sondern
normalisieren: URL raus, Rest bleibt Name, URL ins leere Website-Feld;
besteht der Name nur aus der URL, wird der Host zum Namen. Am API-Eingang
vor der Zuordnung; im Bestand per companies:strip-links-from-names mit
Dry-Run, Slugs bleiben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 09:07:49 +00:00
parent 87ca9082a9
commit 99ffd012d4
5 changed files with 310 additions and 3 deletions

View file

@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Models\Company;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Company\CompanyNameNormalizer;
use App\Support\WebCaches;
use Illuminate\Console\Command;
/**
* Bestandsbereinigung zu CompanyNameNormalizer (17.09.2026): Firmennamen
* mit Link („Firma - https://…", „www.beispiel.de") werden einmalig
* bereinigt, die URL wandert ins leere Website-Feld. Slugs bleiben — die
* Newsroom-URLs sind draußen verlinkt.
*/
class StripLinksFromCompanyNames extends Command
{
protected $signature = 'companies:strip-links-from-names
{--dry-run : Nur Vorher/Nachher zeigen, nichts schreiben}
{--all : Auch inaktive Firmen ohne veröffentlichte Meldung}';
protected $description = 'Zieht Links aus Firmennamen und legt sie ins Website-Feld (Bestandsbereinigung).';
public function handle(ActivityLog $activityLog): int
{
$query = Company::query()
->withoutGlobalScope(PortalScope::class)
->whereNull('gone_at')
// Grobfilter in SQL (portabel, SQLite kennt kein REGEXP), die
// eigentliche Erkennung macht der Normalizer in PHP.
->where(fn ($q) => $q->where('name', 'like', '%http://%')
->orWhere('name', 'like', '%https://%')
->orWhere('name', 'like', '%www.%')
->orWhere('name', 'like', '%/%'))
->orderBy('id');
// Ohne --all nur, was jemand sehen kann: aktive Firmen mit
// veröffentlichter Meldung. Der Rest ist Datenmüll ohne Newsroom-Seite.
if (! $this->option('all')) {
$query->where('is_active', true)
->whereHas('pressReleases', fn ($q) => $q->withoutGlobalScope(PortalScope::class)->where('status', 'published'));
}
$rows = [];
$changes = [];
foreach ($query->cursor() as $company) {
if (! CompanyNameNormalizer::containsUrl((string) $company->name)) {
continue;
}
$normalized = CompanyNameNormalizer::normalize((string) $company->name, $company->website);
if ($normalized['removed'] === [] || $normalized['name'] === $company->name) {
continue;
}
$rows[] = [$company->id, $company->name, $normalized['name'], $normalized['website'] ?? '—'];
$changes[] = [$company, $normalized];
}
if ($rows === []) {
$this->info('Keine Firmennamen mit Link gefunden.');
return self::SUCCESS;
}
$this->table(['ID', 'Vorher', 'Nachher', 'Website'], $rows);
if ($this->option('dry-run')) {
$this->info(sprintf('[Dry-Run] %d Firmennamen würden bereinigt.', count($rows)));
return self::SUCCESS;
}
foreach ($changes as [$company, $normalized]) {
$before = $company->name;
$company->forceFill([
'name' => $normalized['name'],
'website' => $normalized['website'],
])->save();
$activityLog->record(
ActivityType::Correction,
sprintf('Firma #%d: Link aus dem Namen entfernt („%s“ → „%s“)', $company->getKey(), $before, $company->name),
null,
$company,
['removed' => $normalized['removed']],
'system',
);
}
WebCaches::forgetPublicPages();
$this->info(sprintf('%d Firmennamen bereinigt.', count($changes)));
return self::SUCCESS;
}
}

View file

@ -2,13 +2,16 @@
namespace App\Http\Controllers\Api\Legacy;
use App\Enums\ActivityType;
use App\Enums\CompanyType;
use App\Enums\Portal;
use App\Http\Controllers\Api\Concerns\ResolvesRequestedPortal;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Rules\PlausibleEmailDomain;
use App\Services\ActivityLog;
use App\Services\Company\ApiNewsroomAssignment;
use App\Services\Company\CompanyNameNormalizer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@ -23,7 +26,7 @@ class LegacyCompanyController extends Controller
{
use ResolvesRequestedPortal, RespondsLikeLegacyApi;
public function store(Request $request, ApiNewsroomAssignment $assignment): JsonResponse
public function store(Request $request, ApiNewsroomAssignment $assignment, ActivityLog $activityLog): JsonResponse
{
abort_unless($request->user()->tokenCan('press-releases:write'), 403);
@ -50,6 +53,14 @@ class LegacyCompanyController extends Controller
// Kunden mit Keys beider Altportale im selben Portal landen.
$portal = $this->resolveRequestedPortal($validated['portal'] ?? null, $user->portal);
// Links gehören nicht in den Namen (17.09.2026): Einlieferer schicken
// „Firma - https://…" — die URL wandert ins Website-Feld, der Name
// bleibt sauber. Vor der Zuordnung, damit der bereinigte Name mit dem
// ebenso bereinigten Bestand verglichen wird.
$normalized = CompanyNameNormalizer::normalize($validated['name'], $validated['website'] ?? null);
$validated['name'] = $normalized['name'];
$validated['website'] = $normalized['website'];
// Steht unter dieser Firmen-E-Mail schon dieselbe Firma, bekommt der
// Aufrufer deren ID — statt je Meldung eines neuen Newsrooms
// (Roadmap 1a.4). Der Vertrag bleibt: 201 + Location.
@ -77,6 +88,16 @@ class LegacyCompanyController extends Controller
$company->save();
$company->users()->attach($user->id, ['role' => 'owner']);
if ($normalized['removed'] !== []) {
$activityLog->record(
ActivityType::Correction,
sprintf('API-Firma #%d: Link aus dem Namen entfernt („%s“ → „%s“)', $company->getKey(), $request->input('company.name'), $company->name),
$user,
$company,
['removed' => $normalized['removed']],
);
}
return $this->legacyCreated(url("/service/users/me/companies/{$company->id}"));
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace App\Services\Company;
use Illuminate\Support\Str;
/**
* Zieht Links aus einem Firmennamen (17.09.2026).
*
* Über die API kommen Namen wie „Steinreinigung - https://www.bgn.at/…"
* oder nur „www.beispiel.de" — SEO-Gewohnheit der Einlieferer, auf der
* Newsroom-Seite aber ein Link im Namen. Ablehnen träfe die Meldung des
* Einlieferers, deshalb normalisieren: URL raus, Rest bleibt Name, die
* URL wandert ins Website-Feld, falls das leer ist. Bleibt vom Namen nichts
* übrig, wird der Host der URL zum Namen („ak-autos.de") — eine nackte
* Domain ist Marke, kein Link.
*/
class CompanyNameNormalizer
{
private const URL_PATTERN = '~(?:https?://|www\.)[^\s,;|()<>"\']+|\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}/[^\s,;|()<>"\']*~iu';
/**
* @return array{name: string, website: ?string, removed: list<string>}
*/
public static function normalize(string $name, ?string $website = null): array
{
$removed = [];
$stripped = (string) preg_replace_callback(self::URL_PATTERN, static function (array $match) use (&$removed): string {
$removed[] = rtrim($match[0], '.,:;');
return ' ';
}, $name);
$clean = self::tidy($stripped);
if ($clean === '' && $removed !== []) {
$clean = self::host($removed[0]) ?? Str::squish($name);
}
if (blank($website) && $removed !== []) {
$website = $removed[0];
}
return [
'name' => $clean !== '' ? $clean : Str::squish($name),
'website' => filled($website) ? trim((string) $website) : null,
'removed' => $removed,
];
}
public static function containsUrl(string $name): bool
{
return preg_match(self::URL_PATTERN, $name) === 1;
}
/**
* Verwaiste Trenner nach dem Herausziehen der URL aufräumen:
* „Firma - “, „ - Firma", „Firma ( )", „Firma /".
*/
private static function tidy(string $value): string
{
$value = (string) preg_replace('~\(\s*\)|\[\s*\]~u', ' ', $value);
$value = Str::squish($value);
$value = (string) preg_replace('~(?:\s*[-–—|/,:]+\s*){2,}~u', ' - ', $value);
return trim($value, " \t\n\r\0\x0B-–—|/,:");
}
private static function host(string $url): ?string
{
$candidate = Str::startsWith(mb_strtolower($url), ['http://', 'https://']) ? $url : 'https://'.$url;
$host = parse_url($candidate, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return null;
}
return (string) preg_replace('~^www\.~i', '', mb_strtolower($host));
}
}

View file

@ -1504,5 +1504,26 @@ Rot-Ebene, nur bei Änderung.
Newsroom-Seite und in der Übersicht.
- Kontakte: 0 Treffer. Beschreibungen: 0 Treffer.
Tests: `NoPromotionalTextTest` (20 inkl. Datensätze). Entscheidung zu
API-Eingang und Bestandsbereinigung offen (siehe Frage an den Nutzer).
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.

View file

@ -0,0 +1,84 @@
<?php
use App\Enums\Portal;
use App\Models\ActivityEvent;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Company\CompanyNameNormalizer;
use Database\Seeders\RolesAndPermissionsSeeder;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
* Links gehören nicht in Firmennamen (17.09.2026): Normalisierung am
* API-Eingang und einmalig im Bestand — nicht ablehnen, sondern die URL ins
* Website-Feld ziehen.
*/
test('urls are pulled out of the name and moved to the website field', function (string $name, string $expectedName, ?string $expectedWebsite) {
$result = CompanyNameNormalizer::normalize($name);
expect($result['name'])->toBe($expectedName)
->and($result['website'])->toBe($expectedWebsite);
})->with([
['Steinreinigung - https://www.bgn.at/steinreinigung', 'Steinreinigung', 'https://www.bgn.at/steinreinigung'],
['Nasenkorrektur Wien - https://www.nasen-chirurgie.at/ - Nasen-OP Wien', 'Nasenkorrektur Wien - Nasen-OP Wien', 'https://www.nasen-chirurgie.at/'],
['Teledeal Media / www.standortanzeige.de', 'Teledeal Media', 'www.standortanzeige.de'],
['ECCO (www.eccowalkathon.de)', 'ECCO', 'www.eccowalkathon.de'],
['https://www.ak-autos.de/', 'ak-autos.de', 'https://www.ak-autos.de/'],
['www.wunderbare-seifenblasen.de', 'wunderbare-seifenblasen.de', 'www.wunderbare-seifenblasen.de'],
['Muster GmbH & Co. KG', 'Muster GmbH & Co. KG', null],
['meinshop.de GmbH', 'meinshop.de GmbH', null],
]);
test('an existing website is kept, the removed url only fills an empty field', function () {
$result = CompanyNameNormalizer::normalize('Muster - https://spam.example', 'https://muster.example');
expect($result['name'])->toBe('Muster')
->and($result['website'])->toBe('https://muster.example')
->and($result['removed'])->toBe(['https://spam.example']);
});
test('the legacy api stores a clean name and records the correction', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
config()->set('billing.enforce_booking', false);
$user = User::factory()->create(['is_active' => true, 'portal' => Portal::Businessportal24]);
$user->forceFill(['registration_type' => 'apiuser'])->save();
Sanctum::actingAs($user, ['press-releases:write']);
$response = $this->postJson('/service/users/me/companies', [
'company' => ['name' => 'Steinreinigung - https://www.bgn.at/steinreinigung', 'email' => 'info@bgn.at'],
])->assertCreated();
$company = Company::withoutGlobalScopes()->findOrFail((int) basename((string) $response->headers->get('Location')));
expect($company->name)->toBe('Steinreinigung')
->and($company->website)->toBe('https://www.bgn.at/steinreinigung')
->and($company->slug)->toBe('steinreinigung');
expect(ActivityEvent::query()->where('subject_id', $company->id)->where('description', 'like', '%Link aus dem Namen entfernt%')->exists())->toBeTrue();
});
test('the cleanup command shows a dry run and then rewrites only public companies', function () {
/** @var TestCase $this */
$public = Company::factory()->businessportal24()->create(['name' => 'Medienprofi - www.medienprofi.org', 'slug' => 'medienprofi-www-medienprofi-org', 'website' => null]);
PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $public->id]);
$silent = Company::factory()->businessportal24()->create(['name' => 'https://spam.example/', 'website' => null]);
$this->artisan('companies:strip-links-from-names', ['--dry-run' => true])
->expectsOutputToContain('[Dry-Run] 1 Firmennamen würden bereinigt.')
->assertSuccessful();
expect($public->fresh()->name)->toBe('Medienprofi - www.medienprofi.org');
$this->artisan('companies:strip-links-from-names')
->expectsOutputToContain('1 Firmennamen bereinigt.')
->assertSuccessful();
$public->refresh();
expect($public->name)->toBe('Medienprofi')
->and($public->website)->toBe('www.medienprofi.org')
->and($public->slug)->toBe('medienprofi-www-medienprofi-org')
->and($silent->fresh()->name)->toBe('https://spam.example/');
});