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:
parent
87ca9082a9
commit
99ffd012d4
5 changed files with 310 additions and 3 deletions
101
app/Console/Commands/StripLinksFromCompanyNames.php
Normal file
101
app/Console/Commands/StripLinksFromCompanyNames.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
app/Services/Company/CompanyNameNormalizer.php
Normal file
80
app/Services/Company/CompanyNameNormalizer.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
84
tests/Feature/CompanyNameNormalizerTest.php
Normal file
84
tests/Feature/CompanyNameNormalizerTest.php
Normal 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/');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue