
Typo3 News Tags
- 8 installs
- 33 repo stars
- Updated July 27, 2026
- dirnbauer/webconsulting-skills
Bulk-generate and assign thematic tags to TYPO3 EXT:news records at scale via keyword matching and a Symfony console command.
About
This skill bulk-generates thematic tags for georgringer/news and assigns them to existing news records via keyword matching. A developer uses it to tag large news corpora, define a tag catalogue, or build a console command for tag assignment.
- Bulk-generates thematic tags and assigns them to EXT:news records by keyword matching
- Works with tx_news_domain_model_tag and a Symfony console command at scale
Typo3 News Tags by the numbers
- 8 all-time installs (skills.sh)
- Ranked #1,519 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dirnbauer/webconsulting-skills --skill typo3-news-tagsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 33 |
| Last updated | July 27, 2026 |
| Repository | dirnbauer/webconsulting-skills ↗ |
What it does
Bulk-generate and assign thematic tags to TYPO3 EXT:news records at scale via keyword matching and a Symfony console command.
Files
TYPO3 News Tags — Bulk Generation & Assignment
Source: https://github.com/dirnbauer/webconsulting-skills
Compatibility: TYPO3 v14.x with georgringer/news ^14.All code examples target TYPO3 v14 APIs only. Do not use this skill for v12 / v13 sites.
Scope. Designing, generating and assigning thematic news tags (tx_news_domain_model_tag)in georgringer/news at scale (hundreds to tens of thousands of news), and optionally installing
the orthogonal generic tag system fromb13/tag(sys_tag).
>
Not in scope. Frontend rendering of tags in Fluid templates, tag translations, or tag merging.
When to use this skill
Trigger this skill when the user asks to:
- "Tag all news / tag the latest N news"
- "Define a tag catalogue / generic tags / thematic tags"
- "Assign categories or tags in bulk to existing news"
- "Install a generic tagging extension" (b13/tag)
- "Build a Symfony command that tags news" / "automate tagging in TYPO3"
- "Why are large UIDs missing from my QueryBuilder result" (DBAL gotcha, §8)
Mental model
EXT:news ships two relations for news classification:
| Relation | Table | Purpose | Routing |
|---|---|---|---|
| Categories | sys_category ← sys_category_record_mm | Primary hierarchical taxonomy | Optional |
| Tags | tx_news_domain_model_tag ← tx_news_domain_model_news_tag_mm | Flat, slug-routable thematic crosscuts | NewsTag aspect in site config |
Categories answer "what bucket is this in?" — typically a few per news, hierarchical, often locale-aware. Tags answer "what themes does this touch?" — typically 5–10 per news, flat, URL-friendly via /{tag-slug}/.
If the site already has narrow business categories ("Betrügerische Shops", "Phishing", …), tags should be orthogonal and thematic (e.g. "Künstliche Intelligenz", "Banking", "Senioren") — not redundant copies of categories.
b13/tag is a different system: sys_tag + sys_tag_mm with a keywords int column on the target table. It is generic across all record types but not integrated with EXT:news. Use it for tagging other tables; keep EXT:news's native tags for news.
Default workflow
1. Verify the corpus (always)
ddev mysql -e "SELECT COUNT(*) FROM tx_news_domain_model_news WHERE pid=<PID> AND deleted=0 AND hidden=0;"
ddev mysql -e "SELECT COUNT(*) FROM tx_news_domain_model_tag WHERE deleted=0;"
ddev mysql -e "SELECT COUNT(*) FROM tx_news_domain_model_news_tag_mm;"Identify where the readable text actually lives. With EXT:news's built-in contentElementRelation extension configuration or mask based content, `tx_news_domain_model_news.bodytext` is often empty — the real content sits in tt_content rows linked via tx_news_related_news. Verify:
SELECT AVG(LENGTH(bodytext)) FROM tx_news_domain_model_news WHERE pid=<PID> AND deleted=0;
SELECT CType, COUNT(*) FROM tt_content
WHERE tx_news_related_news > 0 AND deleted=0 AND hidden=0
GROUP BY CType ORDER BY 2 DESC;If average bodytext is ~0, you must harvest tt_content to get meaningful scoring.
2. Derive tag candidates from corpus frequency
Don't guess the catalogue — let the corpus pick it. Read 50–100 titles + teasers first to get a feel:
SELECT uid, title, LEFT(teaser, 200) FROM tx_news_domain_model_news
WHERE pid=<PID> AND deleted=0 AND hidden=0
ORDER BY datetime DESC LIMIT 100;Then run a frequency analysis on the full target corpus (latest N news + their linked tt_content). Brainstorm ~100 candidate concepts with 1–3 keywords each, count how many news mention each, and pick the top ~65 (or whatever count you need). This is much more defensible than a guessed catalogue and surfaces non-obvious recurring themes (e.g. Unternehmen and Polizei ranked top-5 in the example corpus — neither was on the original guess list).
Single-concept rule. Each tag should be ONE concept — never a X & Y combination. Compose multiple tags per news instead. So:
| Avoid | Prefer |
|---|---|
Banking & Konto | Banking + Konto (two tags) |
Künstliche Intelligenz & Deepfake | Künstliche Intelligenz + Deepfake |
Paket & Lieferung | Paket + Lieferung (+ DHL if relevant) |
Reise & Urlaub | Reise + Hotel + Flug |
Hyphenated compounds (Fake-Shop, Online-Shopping, Login-Daten) and standard German two-word concepts (Künstliche Intelligenz) are fine — they are one concept.
Each tag needs a name, a slug (lowercase, ASCII-only, hyphenated), and a curated list of keywords. See references/NewsThematicTags.example.php for a worked German example covering 65 single-concept fraud-prevention themes derived from a real ~1500-news corpus.
Keyword design rules:
- Lowercase. Both singular and plural where common (
fake-shop,fake-shops). - Both hyphenated and spaced spellings (
fake-shop,fake shop,fakeshop). - Compound nouns over generic single words (
kostenpflichtiges abooverkosten). - Beware short prefixes:
automatchesautor,automatisch,autorinunder the
left-only word boundary. Use compound forms instead (autokauf, autoverkauf, kfz). Same trap with apple (applied), bank (bankrott), post (posten).
- 1–3 char ambiguous tokens are OK with strict both-sides boundaries (
tan,ki,sms)
— the example command auto-applies stricter boundaries for short keywords.
- 5–15 keywords per tag is a good target for single-concept tags; broaden if a tag is
legitimately under-firing in the dry-run.
- Multi-word keywords are matched verbatim after whitespace collapse — be specific.
3. Decide on tag storage
For EXT:news, tags live on a storage PID. Use the same PID as the news (most common) — the route enhancer in config/sites/<id>/config.yaml uses the tag's slug, not its PID. Verify your site has the tag route wired:
routeEnhancers:
News:
type: Extbase
extension: News
plugin: Pi1
routes:
- routePath: '/{tag-name}'
_controller: 'News::list'
_arguments:
tag-name: overwriteDemand/tags
aspects:
tag-name:
type: NewsTagIf not present, add it before publishing tag URLs. The NewsTag aspect type ships with EXT:news and is the documented best practice; a plain PersistedAliasMapper with tableName: tx_news_domain_model_tag and routeFieldName: slug is a legacy alternative.
4. Build a Symfony Console command
Create one command per extension/package in Classes/Command/AssignNewsTagsCommand.php and register it in Configuration/Services.yaml with the console.command tag. See references/AssignNewsTagsCommand.example.php for a complete, idempotent implementation that:
- loads the tag catalogue from a PHP config file
- upserts tags via DataHandler (slug auto-generates, refindex updated)
- harvests content from
tt_contentlinked viatx_news_related_news - normalizes HTML → lowercase → collapsed whitespace
- scores each tag by distinct keyword matches (not total occurrences — avoids spam from
one repeated word dominating)
- selects the top N tags per news (5–10 typical, threshold ≥ 1)
- bulk-inserts MM rows via raw SQL multi-row
INSERT(batch of 500) - updates
tx_news_domain_model_news.tagscounter for backend list display - supports
--dry-run,--reset,--limit,--storage-pid,--force,--debug-uid
5. Iterate with --dry-run
ddev exec vendor/bin/typo3 cache:flush
ddev exec vendor/bin/typo3 <vendor>:news:assign-tags --dry-run --limit=300The dry-run prints the tags-per-news distribution and per-tag popularity. Healthy targets for fraud/news corpora:
- median 5+, p25 ≥ 3, p75 ≤ 8
- unmatched (0 tags) < 1% of corpus
- per-tag count: most-popular tag covers ≤ 60% of news; least-popular ≥ 1%
If many news fall to 1–2 tags, broaden keywords on common tags (E-Mail-Betrug, Banking, Werbung — these typically anchor most scam stories). If a tag has 0 matches, either keywords are wrong or the tag is not actually represented in the corpus — adjust or replace.
6. Run for real
ddev exec vendor/bin/typo3 <vendor>:news:assign-tags --reset --force
ddev exec vendor/bin/typo3 cache:flush--reset truncates tx_news_domain_model_news_tag_mm and deletes tags on the storage PID before recreating — safest for re-runs while iterating on keywords. Drop --reset for additive runs once the catalogue is stable.
Expected throughput: ~150 news/sec on a typical DDEV setup (1500 news ≈ 10 seconds).
7. Verify
-- 1. All tags present
SELECT COUNT(*) FROM tx_news_domain_model_tag WHERE pid=<PID> AND deleted=0;
-- 2. MM rows = sum(tags-per-news)
SELECT COUNT(*) FROM tx_news_domain_model_news_tag_mm;
-- 3. Distribution
SELECT tags_per_news, COUNT(*) AS news FROM (
SELECT uid_local, COUNT(*) AS tags_per_news
FROM tx_news_domain_model_news_tag_mm GROUP BY uid_local
) t GROUP BY tags_per_news ORDER BY tags_per_news;
-- 4. Counter consistency (must return 0 rows)
SELECT n.uid, n.tags, COUNT(mm.uid_foreign) AS actual
FROM tx_news_domain_model_news n
LEFT JOIN tx_news_domain_model_news_tag_mm mm ON mm.uid_local = n.uid
WHERE n.pid=<PID> AND n.deleted=0
GROUP BY n.uid, n.tags HAVING n.tags <> actual LIMIT 20;
-- 5. Per-tag popularity
SELECT t.title, COUNT(mm.uid_local) AS n
FROM tx_news_domain_model_tag t
LEFT JOIN tx_news_domain_model_news_tag_mm mm ON mm.uid_foreign = t.uid
WHERE t.pid=<PID> AND t.deleted=0
GROUP BY t.uid, t.title ORDER BY n DESC;
-- 6. Backend spot-check
SELECT n.uid, LEFT(n.title, 60), GROUP_CONCAT(t.title ORDER BY mm.sorting)
FROM tx_news_domain_model_news n
JOIN tx_news_domain_model_news_tag_mm mm ON mm.uid_local = n.uid
JOIN tx_news_domain_model_tag t ON t.uid = mm.uid_foreign
WHERE n.pid=<PID> AND n.deleted=0
GROUP BY n.uid ORDER BY n.datetime DESC LIMIT 10;Backend visual check: open News module → pick a recent news → the "Relations" tab shows the assigned tags (categories sit in the separate "Categories" tab). Frontend route check: visit https://<site>/<tag-slug>/.
Adding b13/tag (optional generic capability)
b13/tag provides a generic sys_tag table that can tag any record via an int keywords column. It does not integrate with EXT:news tags and should not replace them.
ddev composer require b13/tag
ddev exec vendor/bin/typo3 extension:setup
ddev mysql -e "SHOW TABLES LIKE 'sys_tag%';" # sys_tag + sys_tag_mmTo use it on a custom table:
1. Add keywords int(11) unsigned DEFAULT '0' NOT NULL to the table's SQL. 2. Configure TCA via B13\Tag\TcaHelper. 3. Register the field via ExtensionManagementUtility::addToAllTCAtypes().
See the b13/tag README for current TCA wiring.
8. DBAL createNamedParameter(PARAM_INT) gotcha
In some DBAL stacks, $qb->createNamedParameter($uid, Connection::PARAM_INT) silently drops rows when the integer is large (observed for UIDs in the tens of millions). The query returns 0 rows even though the row exists. Reproduce in your environment before adopting the workaround; on a clean TYPO3 v14 install with current Doctrine DBAL this may no longer fire.
Symptom. --debug-uid=29386376 returns "not found", but SELECT … WHERE uid = 29386376 in MySQL returns the row. The full fetchNews() query returns rows but the loop processes different news than expected — the result set is silently truncated.
Workaround. Use raw SQL with ? placeholders for queries that touch large integer columns (news UIDs, tt_content UIDs, content element relations):
// AVOID for large ints:
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, Connection::PARAM_INT));
// PREFER:
$conn->executeQuery(
'SELECT … FROM tx_news_domain_model_news WHERE uid = ?',
[$uid]
)->fetchAssociative();
// For IN clauses with int arrays, inline the cast values:
$ids = implode(',', array_map(static fn($v) => (int)$v, $chunk));
$conn->executeQuery("SELECT … WHERE tx_news_related_news IN ($ids) AND …");Small ints (PID, hidden, deleted) work fine with createNamedParameter — only large UIDs exhibit the issue. The reference command applies this workaround throughout.
9. Performance notes
- Tag upsert via DataHandler for the ~65 tag rows (slug eval, refindex) — negligible cost.
- MM inserts via direct multi-row SQL (batch 500). DataHandler MM writes are O(n²) per
record and unnecessary for MM tables that have no TCA semantics.
- Sort by `datetime DESC` with
LIMIT Nto get the latest N news — index it if cold. - Chunk tt_content fetches in groups of 200 news per IN-query to keep
max_allowed_packet
and prepared-statement parameter caps safe.
- Disable any caches that observe
tx_news_domain_model_newsduring the run, thencache:flush
afterwards. The reference command does not call DataHandler per news (only for tag upsert), so caches are minimally affected during the bulk pass.
10. Keyword matching — Unicode-safe word boundaries
PHP's \b is ASCII-only and breaks on ä/ö/ü/ß. Use \p{L}\p{N} lookarounds and the /u modifier:
private function buildKeywordPattern(string $keyword): string
{
$quoted = preg_quote($keyword, '/');
// Short tokens (<=3 chars): require both word boundaries to avoid false positives.
// Longer tokens: require left boundary only — covers German plural/genitive and compound
// suffixes (e.g. "phishing" matches "phishing-welle", "phishings", "phishingsoftware").
if (mb_strlen($keyword, 'UTF-8') <= 3) {
return '/(?<![\p{L}\p{N}])' . $quoted . '(?![\p{L}\p{N}])/u';
}
return '/(?<![\p{L}\p{N}])' . $quoted . '/u';
}Score by count of distinct matched keywords — not total occurrences. A single keyword repeated 50 times should not outweigh four different keywords matching once each.
Files in this skill
references/AssignNewsTagsCommand.example.php— complete, idempotent Symfony console
command (~370 LOC) with all options, scoring logic, DataHandler upsert, bulk MM insert, DBAL workaround, --debug-uid per-news inspector, summary report.
references/NewsThematicTags.example.php— 65 single-concept German fraud-prevention
tags (no X & Y combinations), derived from a ~1500-news corpus frequency analysis, with ~5–15 curated keywords each.
references/Services.example.yaml— minimal command registration snippet.
Common pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
| Median tags-per-news is 1–2 | Keywords too narrow; or bodytext empty without harvesting tt_content | Broaden top tags' keywords; verify fetchContentByNews is wired |
| Newest news (large UID) have 0 tags despite obvious keywords | DBAL createNamedParameter(PARAM_INT) truncation | Switch the affected query to raw SQL, see §8 |
| Tags exist but slugs are NULL | Created via direct INSERT instead of DataHandler | Use DataHandler with process_datamap so TCA slug eval fires |
tx_news_domain_model_news.tags counter is wrong | Counter not updated after MM writes | Run an UPDATE news SET tags = (SELECT COUNT(*) FROM mm WHERE mm.uid_local = news.uid) once, then ensure command writes it |
Frontend /<tag-slug>/ 404s | Route enhancer missing in site config | Add the News route with the NewsTag aspect (§3) and flush caches |
| Identical tag created twice on re-run | Lookup-before-insert missing | SELECT uid FROM tag WHERE slug = ? before each DataHandler NEW_x; reuse UID if found |
Acceptance checklist
Before reporting "done":
- [ ] Storage PID confirmed and tags created there
- [ ]
--dry-rundistribution reviewed (median ≥ 4, unmatched < 2%) - [ ] Counter consistency query returns 0 rows
- [ ] Backend News module shows tags on a recent record
- [ ]
https://<site>/<tag-slug>/resolves to the filtered news list - [ ] Command is re-runnable (
--resetworks; second run is idempotent) - [ ] If
b13/tagwas installed,sys_tag+sys_tag_mmexist and the extension is active
<?php
declare(strict_types=1);
namespace Vendor\YourExt\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Reference: assigns thematic tags to EXT:news records via keyword matching.
*
* Loads the tag catalogue from
* EXT:<your_ext>/Resources/Private/Data/NewsThematicTags.php,
* upserts each tag into tx_news_domain_model_tag (DataHandler-driven
* so slugs auto-generate), then scans the latest N news on the given
* storage PID, scores each tag by distinct keyword matches in
* title + teaser + linked tt_content, and assigns the top 5..10 tags.
*
* IMPORTANT: This is a reference example. Before adopting it:
* - replace `Vendor\YourExt` with your namespace
* - update `loadConfig()` to point at your extension key
* - tune `self::CONTENT_TYPES` to match your tt_content harvest set
* (mask CTypes vary by project; verify with:
* SELECT CType, COUNT(*) FROM tt_content
* WHERE tx_news_related_news>0 AND deleted=0 AND hidden=0
* GROUP BY CType ORDER BY 2 DESC)
* - register in your Configuration/Services.yaml — see Services.example.yaml
*
* See SKILL.md §8 for the DBAL `createNamedParameter(PARAM_INT)` workaround
* used in fetchNews(), fetchContentByNews() and the news.tags counter UPDATE.
*/
final class AssignNewsTagsCommand extends Command
{
private const TAG_TABLE = 'tx_news_domain_model_tag';
private const NEWS_TABLE = 'tx_news_domain_model_news';
private const MM_TABLE = 'tx_news_domain_model_news_tag_mm';
private const TT_CONTENT = 'tt_content';
private const CONTENT_TYPES = [
'text',
'mask_text_icon',
'mask_aufzaehlungbox',
'mask_box_lamp',
'mask_box_achtung',
];
protected function configure(): void
{
$this
->setDescription('Assign generic German thematic tags to EXT:news records via keyword matching')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Compute assignments and print summary without writing anything')
->addOption('reset', null, InputOption::VALUE_NONE, 'Truncate MM and delete existing tags on the storage PID before processing')
->addOption('limit', null, InputOption::VALUE_OPTIONAL, 'Max news to process (default from config)')
->addOption('storage-pid', null, InputOption::VALUE_OPTIONAL, 'Storage PID (default from config)')
->addOption('force', null, InputOption::VALUE_NONE, 'Skip interactive confirmation for --reset')
->addOption('debug-uid', null, InputOption::VALUE_OPTIONAL, 'Print score breakdown and selected tags for one specific news UID, then exit');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('EXT:news thematic tag assignment');
$config = $this->loadConfig();
$dryRun = (bool)$input->getOption('dry-run');
$reset = (bool)$input->getOption('reset');
$force = (bool)$input->getOption('force');
$storagePid = (int)($input->getOption('storage-pid') ?? $config['config']['storage_pid']);
$limit = (int)($input->getOption('limit') ?? $config['config']['news_limit']);
$minPer = (int)$config['config']['min_per_news'];
$maxPer = (int)$config['config']['max_per_news'];
$threshold = (int)$config['config']['score_threshold'];
$io->writeln(sprintf(
'<info>Storage PID:</info> %d <info>Limit:</info> %d <info>Tags catalogue:</info> %d <info>Per-news:</info> %d..%d <info>Mode:</info> %s',
$storagePid,
$limit,
count($config['tags']),
$minPer,
$maxPer,
$dryRun ? 'DRY-RUN' : 'WRITE'
));
Bootstrap::initializeBackendAuthentication();
$debugUid = $input->getOption('debug-uid') !== null ? (int)$input->getOption('debug-uid') : null;
if ($debugUid !== null) {
$this->debugSingleNews($debugUid, $storagePid, $config, $minPer, $maxPer, $threshold, $io);
return Command::SUCCESS;
}
if ($reset) {
if (!$dryRun && !$force && !$io->confirm(sprintf(
'This deletes existing tags on PID %d and truncates %s. Continue?',
$storagePid,
self::MM_TABLE
), false)) {
$io->warning('Aborted.');
return Command::SUCCESS;
}
$this->resetTagsAndMm($storagePid, $dryRun, $io);
}
// Phase 1: upsert tags, build slug -> uid map
$tagUidBySlug = $this->upsertTags($config['tags'], $storagePid, $dryRun, $io);
// Phase 2: load news
$newsRows = $this->fetchNews($storagePid, $limit);
$io->writeln(sprintf('<info>Loaded %d news rows.</info>', count($newsRows)));
if ($newsRows === []) {
$io->warning('No news to process.');
return Command::SUCCESS;
}
// Phase 3: load tt_content for all news (in chunks)
$contentByNews = $this->fetchContentByNews(array_column($newsRows, 'uid'));
$io->writeln(sprintf(
'<info>Harvested tt_content for %d news (%d distinct content rows).</info>',
count($contentByNews),
array_sum(array_map('count', $contentByNews))
));
// Phase 4: score + select + write
$distribution = [];
$perTagCount = array_fill_keys(array_keys($tagUidBySlug), 0);
$unmatched = 0;
$mmInsertCount = 0;
$progress = $io->createProgressBar(count($newsRows));
$progress->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %message%');
$progress->setMessage('');
$progress->start();
$connection = $this->getMmConnection();
$newsConnection = $this->getNewsConnection();
$batch = [];
$batchLimit = 500;
foreach ($newsRows as $news) {
$text = $this->buildSearchText($news, $contentByNews[$news['uid']] ?? []);
$scores = $this->scoreTags($text, $config['tags']);
$selected = $this->selectTags($scores, $maxPer, $threshold);
$count = count($selected);
$distribution[] = $count;
if ($count === 0) {
$unmatched++;
}
foreach ($selected as $sortIndex => $tagSlug) {
$perTagCount[$tagSlug]++;
if (!$dryRun) {
$batch[] = [
'uid_local' => (int)$news['uid'],
'uid_foreign' => $tagUidBySlug[$tagSlug],
'sorting' => $sortIndex + 1,
'sorting_foreign' => $perTagCount[$tagSlug],
];
$mmInsertCount++;
if (count($batch) >= $batchLimit) {
$this->flushMmBatch($connection, $batch);
$batch = [];
}
}
}
if (!$dryRun) {
// Use raw SQL: see fetchNews() comment about DBAL PARAM_INT misbinding.
$newsConnection->executeStatement(
'UPDATE ' . self::NEWS_TABLE . ' SET tags = ?, tstamp = ? WHERE uid = ' . (int)$news['uid'],
[$count, time()]
);
}
$progress->setMessage(sprintf('news=%d tags=%d', (int)$news['uid'], $count));
$progress->advance();
}
if (!$dryRun && $batch !== []) {
$this->flushMmBatch($connection, $batch);
}
$progress->finish();
$io->newLine(2);
$this->renderSummary($io, $distribution, $perTagCount, $tagUidBySlug, $config['tags'], $unmatched, $mmInsertCount, $dryRun);
return Command::SUCCESS;
}
/**
* @return array{tags: list<array{name:string,slug:string,keywords:list<string>}>, config: array<string,int>}
*/
private function loadConfig(): array
{
// Replace 'your_ext' with your extension key.
$path = ExtensionManagementUtility::extPath('your_ext') . 'Resources/Private/Data/NewsThematicTags.php';
if (!is_file($path)) {
// Fallback: composer-mode packages live in packages/<key>/ at repo root.
$path = dirname(__DIR__, 2) . '/Resources/Private/Data/NewsThematicTags.php';
}
if (!is_file($path)) {
throw new \RuntimeException('NewsThematicTags.php not found at expected path: ' . $path);
}
/** @var array $config */
$config = require $path;
return $config;
}
private function resetTagsAndMm(int $storagePid, bool $dryRun, SymfonyStyle $io): void
{
if ($dryRun) {
$io->writeln('<comment>[dry-run] would TRUNCATE ' . self::MM_TABLE . ' and DELETE ' . self::TAG_TABLE . ' WHERE pid=' . $storagePid . '</comment>');
return;
}
$mm = $this->getMmConnection();
$mm->truncate(self::MM_TABLE);
$tagConn = $this->getTagConnection();
$tagConn->delete(self::TAG_TABLE, ['pid' => $storagePid]);
$newsConn = $this->getNewsConnection();
$newsConn->executeStatement(
'UPDATE ' . self::NEWS_TABLE . ' SET tags = 0 WHERE pid = :pid',
['pid' => $storagePid]
);
$io->writeln('<info>Reset done.</info>');
}
/**
* @param list<array{name:string,slug:string,keywords:list<string>}> $tags
* @return array<string,int> map slug -> uid
*/
private function upsertTags(array $tags, int $storagePid, bool $dryRun, SymfonyStyle $io): array
{
$conn = $this->getTagConnection();
$existing = [];
foreach ($conn->select(['uid', 'slug', 'title'], self::TAG_TABLE, ['pid' => $storagePid, 'deleted' => 0])->fetchAllAssociative() as $row) {
$existing[$row['slug']] = (int)$row['uid'];
}
$toCreate = [];
foreach ($tags as $tag) {
if (!isset($existing[$tag['slug']])) {
$toCreate[] = $tag;
}
}
if ($toCreate === []) {
$io->writeln(sprintf('<info>All %d tags already present.</info>', count($tags)));
return $existing;
}
if ($dryRun) {
$io->writeln('<comment>[dry-run] would create ' . count($toCreate) . ' tag(s).</comment>');
// Provide placeholder UIDs so scoring/selection runs and summary is meaningful.
$next = 1000000;
$map = $existing;
foreach ($toCreate as $tag) {
$map[$tag['slug']] = $next++;
}
return $map;
}
$data = [];
$placeholders = [];
foreach ($toCreate as $idx => $tag) {
$key = 'NEW_tag_' . $idx;
$placeholders[$key] = $tag;
$data[self::TAG_TABLE][$key] = [
'pid' => $storagePid,
'title' => $tag['name'],
'slug' => $tag['slug'],
'hidden' => 0,
'sys_language_uid' => 0,
];
}
$dh = GeneralUtility::makeInstance(DataHandler::class);
$dh->start($data, []);
$dh->process_datamap();
if ($dh->errorLog !== []) {
foreach ($dh->errorLog as $err) {
$io->warning('DataHandler: ' . $err);
}
}
// Resolve assigned UIDs
$map = $existing;
foreach ($placeholders as $key => $tag) {
$resolved = $dh->substNEWwithIDs[$key] ?? null;
if ($resolved) {
$map[$tag['slug']] = (int)$resolved;
}
}
// Final reconciliation in case substitution map missed anything
foreach ($conn->select(['uid', 'slug'], self::TAG_TABLE, ['pid' => $storagePid, 'deleted' => 0])->fetchAllAssociative() as $row) {
if (!isset($map[$row['slug']])) {
$map[$row['slug']] = (int)$row['uid'];
}
}
$io->writeln(sprintf('<info>Created %d new tag(s) via DataHandler.</info>', count($toCreate)));
return $map;
}
/**
* @return list<array{uid:int,title:string,teaser:string,bodytext:string}>
*/
private function fetchNews(int $storagePid, int $limit): array
{
$conn = $this->getNewsConnection();
// Use raw SQL: DBAL's createNamedParameter(PARAM_INT) silently misbinds
// large integers in this stack (loses rows for large uids during IN/EQ).
$rows = $conn->executeQuery(
'SELECT uid, title, teaser, bodytext FROM ' . self::NEWS_TABLE
. ' WHERE pid = ? AND deleted = 0 AND hidden = 0'
. ' ORDER BY datetime DESC LIMIT ' . (int)$limit,
[$storagePid]
)->fetchAllAssociative();
return array_map(static fn(array $r): array => [
'uid' => (int)$r['uid'],
'title' => (string)($r['title'] ?? ''),
'teaser' => (string)($r['teaser'] ?? ''),
'bodytext' => (string)($r['bodytext'] ?? ''),
], $rows);
}
/**
* @param list<int> $newsUids
* @return array<int, list<string>> newsUid -> list of text snippets
*/
private function fetchContentByNews(array $newsUids): array
{
$result = [];
$chunks = array_chunk($newsUids, 200);
$conn = $this->getNewsConnection();
$cTypeList = "'" . implode("','", array_map(static fn(string $s): string => addslashes($s), self::CONTENT_TYPES)) . "'";
foreach ($chunks as $chunk) {
// Build an inline IN list of integers (already cast). Raw SQL avoids
// the DBAL PARAM_INT_ARRAY large-int misbinding seen elsewhere.
$intIds = implode(',', array_map(static fn($v) => (int)$v, $chunk));
if ($intIds === '') {
continue;
}
$sql = 'SELECT tx_news_related_news AS news, bodytext, tx_mask_text, header'
. ' FROM ' . self::TT_CONTENT
. ' WHERE tx_news_related_news IN (' . $intIds . ')'
. ' AND deleted = 0 AND hidden = 0'
. ' AND CType IN (' . $cTypeList . ')';
foreach ($conn->executeQuery($sql)->fetchAllAssociative() as $row) {
$uid = (int)$row['news'];
$parts = array_filter([
(string)($row['header'] ?? ''),
(string)($row['bodytext'] ?? ''),
(string)($row['tx_mask_text'] ?? ''),
], static fn($s) => $s !== '');
if ($parts !== []) {
$result[$uid][] = implode(' ', $parts);
}
}
}
return $result;
}
/**
* @param array{uid:int,title:string,teaser:string,bodytext:string} $news
* @param list<string> $contentSnippets
*/
private function buildSearchText(array $news, array $contentSnippets): string
{
$raw = $news['title'] . ' ' . $news['teaser'] . ' ' . $news['bodytext'] . ' ' . implode(' ', $contentSnippets);
$stripped = strip_tags($raw);
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$lower = mb_strtolower($decoded, 'UTF-8');
// Collapse whitespace (incl. NBSP) to single spaces.
return trim((string)preg_replace('/[\s\xC2\xA0]+/u', ' ', $lower));
}
/**
* @param list<array{name:string,slug:string,keywords:list<string>}> $tags
* @return array<string,int> tagSlug -> score (distinct keyword matches)
*/
private function scoreTags(string $text, array $tags): array
{
$scores = [];
foreach ($tags as $tag) {
$score = 0;
foreach ($tag['keywords'] as $kw) {
$kwLower = mb_strtolower($kw, 'UTF-8');
$pattern = $this->buildKeywordPattern($kwLower);
if (@preg_match($pattern, $text) === 1) {
$score++;
}
}
$scores[$tag['slug']] = $score;
}
return $scores;
}
private function buildKeywordPattern(string $keyword): string
{
$quoted = preg_quote($keyword, '/');
// Short tokens (<=3 chars): require both word boundaries to avoid false positives.
// Longer tokens: require left boundary only — covers German plural/genitive
// and compound suffixes (e.g. "phishing" matches "phishing-welle", "phishings").
if (mb_strlen($keyword, 'UTF-8') <= 3) {
return '/(?<![\p{L}\p{N}])' . $quoted . '(?![\p{L}\p{N}])/u';
}
return '/(?<![\p{L}\p{N}])' . $quoted . '/u';
}
/**
* @param array<string,int> $scores
* @return list<string> ordered list of tag slugs (highest score first), capped at $max
*/
private function selectTags(array $scores, int $max, int $threshold): array
{
$filtered = array_filter($scores, static fn(int $s) => $s >= $threshold);
// Sort by score DESC, then slug ASC for deterministic ordering.
uksort($filtered, static function ($a, $b) use ($filtered) {
$cmp = $filtered[$b] <=> $filtered[$a];
return $cmp !== 0 ? $cmp : strcmp($a, $b);
});
return array_slice(array_keys($filtered), 0, $max);
}
/**
* @param list<array{uid_local:int,uid_foreign:int,sorting:int,sorting_foreign:int}> $batch
*/
private function flushMmBatch(Connection $connection, array $batch): void
{
if ($batch === []) {
return;
}
// Build a single multi-row INSERT for speed; parameter count well under MariaDB limits.
$columns = ['uid_local', 'uid_foreign', 'sorting', 'sorting_foreign'];
$placeholders = [];
$params = [];
$i = 0;
foreach ($batch as $row) {
$placeholders[] = sprintf('(:l%1$d, :f%1$d, :s%1$d, :sf%1$d)', $i);
$params['l' . $i] = $row['uid_local'];
$params['f' . $i] = $row['uid_foreign'];
$params['s' . $i] = $row['sorting'];
$params['sf' . $i] = $row['sorting_foreign'];
$i++;
}
$sql = 'INSERT INTO ' . self::MM_TABLE . ' (' . implode(', ', $columns) . ') VALUES ' . implode(', ', $placeholders);
$connection->executeStatement($sql, $params);
}
/**
* @param list<int> $distribution
* @param array<string,int> $perTagCount
* @param array<string,int> $tagUidBySlug
* @param list<array{name:string,slug:string,keywords:list<string>}> $tags
*/
private function renderSummary(
SymfonyStyle $io,
array $distribution,
array $perTagCount,
array $tagUidBySlug,
array $tags,
int $unmatched,
int $mmInsertCount,
bool $dryRun
): void {
$total = count($distribution);
sort($distribution);
$min = $distribution[0] ?? 0;
$max = $distribution[$total - 1] ?? 0;
$median = $total > 0 ? $distribution[(int)floor($total / 2)] : 0;
$p25 = $total > 0 ? $distribution[(int)floor($total * 0.25)] : 0;
$p75 = $total > 0 ? $distribution[(int)floor($total * 0.75)] : 0;
$avg = $total > 0 ? round(array_sum($distribution) / $total, 2) : 0.0;
$io->section('Distribution (tags per news)');
$io->definitionList(
['total news' => (string)$total],
['min' => (string)$min],
['p25' => (string)$p25],
['median' => (string)$median],
['p75' => (string)$p75],
['max' => (string)$max],
['avg' => (string)$avg],
['unmatched (0 tags)' => (string)$unmatched],
['MM rows ' . ($dryRun ? 'would insert' : 'inserted') => (string)$mmInsertCount],
);
$buckets = [];
foreach ($distribution as $d) {
$buckets[$d] = ($buckets[$d] ?? 0) + 1;
}
ksort($buckets);
$rows = [];
foreach ($buckets as $count => $newsCount) {
$rows[] = [$count, $newsCount];
}
$io->table(['tags per news', 'news'], $rows);
$io->section('Per-tag popularity');
$tagsByName = [];
foreach ($tags as $t) {
$tagsByName[$t['slug']] = $t['name'];
}
$rows = [];
arsort($perTagCount);
foreach ($perTagCount as $slug => $n) {
$rows[] = [
$tagsByName[$slug] ?? $slug,
$slug,
$tagUidBySlug[$slug] ?? 0,
$n,
];
}
$io->table(['Tag', 'Slug', 'UID', 'News'], $rows);
if ($dryRun) {
$io->note('Dry-run only — no rows written.');
} else {
$io->success(sprintf('Tagged %d news with %d MM rows.', $total, $mmInsertCount));
}
}
private function debugSingleNews(int $uid, int $storagePid, array $config, int $minPer, int $maxPer, int $threshold, SymfonyStyle $io): void
{
$conn = $this->getNewsConnection();
$row = $conn->executeQuery(
'SELECT uid, pid, hidden, deleted, title, teaser, bodytext FROM ' . self::NEWS_TABLE . ' WHERE uid = ?',
[$uid]
)->fetchAssociative();
if (!$row) {
$io->error('news uid not found via raw SQL');
return;
}
$io->writeln(sprintf('uid=%d pid=%d hidden=%d deleted=%d', (int)$row['uid'], (int)$row['pid'], (int)$row['hidden'], (int)$row['deleted']));
$io->writeln('title: ' . $row['title']);
$io->writeln('teaser: ' . mb_substr((string)$row['teaser'], 0, 200) . '…');
$content = $this->fetchContentByNews([(int)$row['uid']]);
$news = [
'uid' => (int)$row['uid'],
'title' => (string)($row['title'] ?? ''),
'teaser' => (string)($row['teaser'] ?? ''),
'bodytext' => (string)($row['bodytext'] ?? ''),
];
$text = $this->buildSearchText($news, $content[(int)$row['uid']] ?? []);
$io->writeln('normalized text (first 400 chars): ' . mb_substr($text, 0, 400));
$io->newLine();
$scores = $this->scoreTags($text, $config['tags']);
$rows = [];
foreach ($config['tags'] as $tag) {
$rows[] = [$tag['name'], $tag['slug'], $scores[$tag['slug']] ?? 0];
}
usort($rows, static fn($a, $b) => $b[2] <=> $a[2]);
$io->table(['Tag', 'Slug', 'Score'], $rows);
$selected = $this->selectTags($scores, $maxPer, $threshold);
$io->writeln('Selected tags (top ' . $maxPer . ', threshold ' . $threshold . '): ' . implode(', ', $selected));
}
private function getMmConnection(): Connection
{
return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::MM_TABLE);
}
private function getTagConnection(): Connection
{
return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TAG_TABLE);
}
private function getNewsConnection(): Connection
{
return GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::NEWS_TABLE);
}
}
<?php
declare(strict_types=1);
/**
* Reference: 65 single-concept German tags for EXT:news in an Austrian internet-fraud
* watchlist corpus (~1500 news on a single storage PID).
*
* Each tag is ONE concept (not "X & Y" combinations) — this is deliberate so that
* `/<tag-slug>/` URLs read cleanly and so that backend editors can compose multiple
* tags per news instead of memorising compound names.
*
* Catalogue was derived from a frequency analysis of the actual corpus (title + teaser
* + linked tt_content harvested via tx_news_related_news) — see SKILL.md
* §"Default workflow" steps 1–2. Tags are ordered roughly by descending corpus
* frequency. Adjust the catalogue to your domain before adopting.
*
* Keyword design notes (see SKILL.md §"Keyword design rules"):
* - Short bare prefixes like 'auto' over-match in German (autor, automatisch, …).
* Prefer compound forms: autokauf, autoverkauf, autohändler, kfz, gebrauchtwagen.
* - 'min_per_news' is aspirational; the command accepts fewer if the corpus does
* not legitimately support 5 distinct themes for a given news.
*
* Consumed by Vendor\YourExt\Command\AssignNewsTagsCommand.
*/
return [
'tags' => [
// ── Communication channels & message types
['name' => 'E-Mail', 'slug' => 'e-mail', 'keywords' => ['e-mail', 'e-mailadresse', 'mailadresse', 'mailbox', 'posteingang', 'mailanhang', 'newsletter-betrug', 'gefälschte e-mail', 'betrügerische e-mail', 'spam-mail', 'mail-betrug']],
['name' => 'SMS', 'slug' => 'sms', 'keywords' => ['sms', 'sms-nachricht', 'kurznachricht', 'fake-sms', 'gefälschtes sms', 'betrügerisches sms']],
['name' => 'Spam', 'slug' => 'spam', 'keywords' => ['spam', 'spam-mail', 'spamfilter', 'unerwünschte werbung', 'spamming']],
['name' => 'Messenger', 'slug' => 'messenger', 'keywords' => ['messenger', 'messenger-dienst', 'messenger-nachricht', 'chat-nachricht']],
['name' => 'WhatsApp', 'slug' => 'whatsapp', 'keywords' => ['whatsapp', 'whatsapp-nachricht', 'whatsapp-gruppe', 'whatsapp-betrug']],
['name' => 'Telegram', 'slug' => 'telegram', 'keywords' => ['telegram', 'telegram-nachricht', 'telegram-gruppe', 'telegram-channel']],
['name' => 'Facebook', 'slug' => 'facebook', 'keywords' => ['facebook', 'fb-post', 'facebook-konto', 'facebook-anzeige', 'meta-konzern']],
['name' => 'Instagram', 'slug' => 'instagram', 'keywords' => ['instagram', 'insta', 'instagram-account', 'instagram-werbung', 'insta-story']],
['name' => 'TikTok', 'slug' => 'tiktok', 'keywords' => ['tiktok', 'tiktok-video', 'tiktok-werbung', 'tiktok-account']],
['name' => 'YouTube', 'slug' => 'youtube', 'keywords' => ['youtube', 'youtube-video', 'youtube-werbung', 'youtube-kanal']],
// ── Attack types & scams
['name' => 'Phishing', 'slug' => 'phishing', 'keywords' => ['phishing', 'phishing-mail', 'phishingmail', 'phishing-falle', 'phishing-nachricht', 'phishing-welle', 'phishing-seite', 'phishing-versuch', 'phishing-link']],
['name' => 'Telefonbetrug', 'slug' => 'telefonbetrug', 'keywords' => ['telefonbetrug', 'telefontrick', 'telefonbetrüger', 'fake-anruf', 'betrügerischer anruf', 'vishing', 'cold-call', 'ping-anruf']],
['name' => 'Spoofing', 'slug' => 'spoofing', 'keywords' => ['spoofing', 'rufnummer-spoofing', 'caller-id-spoofing', 'gefälschte rufnummer', 'spoof']],
['name' => 'Erpressung', 'slug' => 'erpressung', 'keywords' => ['erpressung', 'erpresserisch', 'erpresserische e-mail', 'erpresserische nachricht', 'masturbationsvideo', 'sextortion', 'erpressungsversuch']],
['name' => 'Vorschussbetrug', 'slug' => 'vorschussbetrug', 'keywords' => ['vorschussbetrug', 'scam ', 'scamming', 'scammer', 'vorauszahlung', 'vorkasse', 'vorauskasse', 'vorab überweisen']],
['name' => 'Investmentbetrug', 'slug' => 'investmentbetrug', 'keywords' => ['investment', 'anlagebetrug', 'trading-plattform', 'rendite', 'investmentplattform', 'investmentfalle', 'investmentbetrug', 'aktien', 'anlage', 'broker', 'trading']],
['name' => 'Jobbetrug', 'slug' => 'jobbetrug', 'keywords' => ['jobbetrug', 'jobangebot', 'job-angebot', 'fake-job', 'fake-jobangebot', 'heimarbeit', 'nebenjob', 'minijob', 'task-scam', 'fake-jobinserat']],
['name' => 'Wohnungsbetrug', 'slug' => 'wohnungsbetrug', 'keywords' => ['wohnungsbetrug', 'fake-wohnung', 'wohnungsanzeige', 'mietbetrug', 'fake-vermieter']],
['name' => 'Ticketbetrug', 'slug' => 'ticketbetrug', 'keywords' => ['ticketbetrug', 'ticket-betrug', 'fake-ticket', 'fake-tickets', 'ticombo', 'hellotickets', 'ticketmasche']],
['name' => 'Markenmissbrauch', 'slug' => 'markenmissbrauch', 'keywords' => ['markenmissbrauch', 'gefälschte rechnung', 'fake-rechnung', 'impressumsdiebstahl', 'imitiert', 'imitieren', 'scheinfirma', 'fake-firma', 'unter dem namen', 'geben sich als']],
['name' => 'Identitätsdiebstahl', 'slug' => 'identitaetsdiebstahl', 'keywords' => ['identitätsdiebstahl', 'identitätsmissbrauch', 'identitätsbetrug', 'identitätsklau', 'identität gestohlen', 'ausweiskopie', 'ausweisdaten', 'personalausweis']],
['name' => 'Datendiebstahl', 'slug' => 'datendiebstahl', 'keywords' => ['datendiebstahl', 'datenklau', 'datenleak', 'datenleck', 'daten gestohlen', 'gestohlene daten']],
['name' => 'Login-Daten', 'slug' => 'login-daten', 'keywords' => ['logindaten', 'login-daten', 'zugangsdaten', 'passwort', 'benutzerkonto-zugang', 'login-seite', 'anmeldedaten', 'kontodaten preisgeben']],
// ── Threats & malicious software
['name' => 'Schadsoftware', 'slug' => 'schadsoftware', 'keywords' => ['schadsoftware', 'schadprogramm', 'schadcode', 'schädlicher anhang']],
['name' => 'Virus', 'slug' => 'virus', 'keywords' => ['virus', 'computervirus', 'viren', 'virusinfektion']],
['name' => 'Trojaner', 'slug' => 'trojaner', 'keywords' => ['trojaner', 'trojanische', 'banking-trojaner']],
['name' => 'Deepfake', 'slug' => 'deepfake', 'keywords' => ['deepfake', 'deep fake', 'ki-manipulierte', 'manipulierter inhalt', 'gefälschtes video']],
// ── Tech & AI
['name' => 'Künstliche Intelligenz', 'slug' => 'kuenstliche-intelligenz', 'keywords' => ['ki', 'künstliche intelligenz', 'chatgpt', 'gpt', 'ki-werbung', 'ki-anwendung', 'ki-systeme', 'gemini', 'ki-generiert', 'ki-tool', 'ki-modell']],
['name' => 'Smartphone', 'slug' => 'smartphone', 'keywords' => ['smartphone', 'handy', 'mobiltelefon', 'mobilgerät', 'tablet']],
// ── Commerce & shopping
['name' => 'Fake-Shop', 'slug' => 'fake-shop', 'keywords' => ['fake-shop', 'fakeshop', 'fake shop', 'fake-shops', 'betrügerischer shop', 'scheinshop', 'fake-website', 'fake-webseite', 'problematischer shop']],
['name' => 'Online-Shopping', 'slug' => 'online-shopping', 'keywords' => ['online-shop', 'online-shopping', 'online einkauf', 'webshop', 'online-händler', 'online bestellen', 'online-bestellung', 'online kaufen']],
['name' => 'Kleinanzeigen', 'slug' => 'kleinanzeigen', 'keywords' => ['kleinanzeige', 'kleinanzeigen', 'willhaben', 'shpock', 'vinted', 'kleinanzeigenplattform', 'kleinanzeigen-portal']],
['name' => 'Marketplace', 'slug' => 'marketplace', 'keywords' => ['marketplace', 'amazon marketplace', 'marketplace-händler', 'marketplace-angebot', 'facebook-marketplace']],
['name' => 'Abo-Falle', 'slug' => 'abo-falle', 'keywords' => ['abo', 'abofalle', 'abo-falle', 'abonnement', 'kostenpflichtiges abo', 'jahresabo', 'monatsabo', 'kostenfalle', 'abo-vertrag']],
['name' => 'Gewinnspiel', 'slug' => 'gewinnspiel', 'keywords' => ['gewinnspiel', 'gewinn', 'lotto', 'preisausschreiben', 'jackpot', 'glücksspiel', 'gewinnbenachrichtigung', 'verlosung']],
// ── Brands & global services
['name' => 'Amazon', 'slug' => 'amazon', 'keywords' => ['amazon', 'amazon-konto', 'amazon-bestellung', 'amazon-shop']],
['name' => 'eBay', 'slug' => 'ebay', 'keywords' => ['ebay', 'ebay-kleinanzeigen', 'ebay-shop', 'ebay-konto']],
['name' => 'PayPal', 'slug' => 'paypal', 'keywords' => ['paypal', 'paypal-zahlung', 'paypal-konto', 'paypal-rechnung']],
['name' => 'Microsoft', 'slug' => 'microsoft', 'keywords' => ['microsoft', 'windows-defender', 'microsoft-nachricht', 'outlook', 'microsoft-anruf']],
['name' => 'Apple', 'slug' => 'apple', 'keywords' => ['apple', 'apple-id', 'icloud', 'apple-konto', 'iphone']],
['name' => 'Google', 'slug' => 'google', 'keywords' => ['google', 'google-konto', 'google-suche', 'gmail', 'google-werbung']],
// ── Finance & payment
['name' => 'Banking', 'slug' => 'banking', 'keywords' => ['banking', 'online-banking', 'onlinebanking', 'ebanking', 'banking-app', 'bank-app', 'bank-zugang', 'mobile-banking']],
['name' => 'Konto', 'slug' => 'konto', 'keywords' => ['konto', 'bankkonto', 'kontostand', 'kontonummer', 'kontodaten', 'kontoinhaber']],
['name' => 'Kreditkarte', 'slug' => 'kreditkarte', 'keywords' => ['kreditkarte', 'kreditkartendaten', 'kreditkartennummer', 'mastercard', 'visa', 'master card', 'kreditkartenbetrug']],
['name' => 'Kryptowährung', 'slug' => 'kryptowaehrung', 'keywords' => ['krypto', 'kryptowährung', 'kryptowährungen', 'krypto-plattform', 'crypto']],
['name' => 'Bitcoin', 'slug' => 'bitcoin', 'keywords' => ['bitcoin', 'btc', 'bitcoin-wallet', 'bitcoin-zahlung']],
// ── Logistics & delivery
['name' => 'Paket', 'slug' => 'paket', 'keywords' => ['paket', 'paketbenachrichtigung', 'paketankündigung', 'paketzustellung', 'fake-paket', 'paket-mail', 'paket-sms']],
['name' => 'Lieferung', 'slug' => 'lieferung', 'keywords' => ['lieferung', 'zustellung', 'sortierzentrum', 'lieferdienst', 'lieferadresse', 'lieferverfolgung']],
['name' => 'DHL', 'slug' => 'dhl', 'keywords' => ['dhl', 'dhl-paket', 'dhl-zustellung']],
// ── Travel
['name' => 'Reise', 'slug' => 'reise', 'keywords' => ['reise', 'urlaub', 'urlaubsbuchung', 'reisebuchung', 'reiseanbieter', 'pauschalreise', 'reisebüro']],
['name' => 'Hotel', 'slug' => 'hotel', 'keywords' => ['hotel', 'hotelbuchung', 'hotel-zimmer', 'hotelreservierung', 'unterkunft']],
// ── Austrian authorities & entities
['name' => 'Polizei', 'slug' => 'polizei', 'keywords' => ['polizei', 'polizeidienststelle', 'polizei melden', 'polizeiliche anzeige', 'kriminalpolizei']],
['name' => 'Finanzamt', 'slug' => 'finanzamt', 'keywords' => ['finanzamt', 'finanzonline', 'finanzministerium', 'steuerausgleich', 'finanzpolizei']],
['name' => 'WKO', 'slug' => 'wko', 'keywords' => ['wko', 'wirtschaftskammer', 'wirtschaftskammern', 'wko-mail']],
// ── Domains & sectors
['name' => 'Auto', 'slug' => 'auto', 'keywords' => ['kfz', 'gebrauchtwagen', 'fahrzeug', 'autokauf', 'autoverkauf', 'pkw', 'pkw-kauf', 'autohändler', 'automobil']],
['name' => 'Immobilien', 'slug' => 'immobilien', 'keywords' => ['immobilie', 'immobilien', 'haus zu mieten', 'wohnungsanzeige', 'immobilienagentur', 'immobilienmakler']],
['name' => 'Unternehmen', 'slug' => 'unternehmen', 'keywords' => ['unternehmer', 'unternehmen', 'firma', 'firmenname', 'gewerbetreibende', 'selbständige', 'betrieb']],
['name' => 'Bewerbung', 'slug' => 'bewerbung', 'keywords' => ['bewerbung', 'bewerber', 'bewerbungsverfahren', 'bewerbungsgespräch']],
['name' => 'Dating', 'slug' => 'dating', 'keywords' => ['dating', 'online-dating', 'datingportal', 'datingplattform', 'partnerbörse', 'liebesbetrug']],
// ── Topics & themes
['name' => 'Werbung', 'slug' => 'werbung', 'keywords' => ['werbung', 'werbeanzeige', 'gesponserte werbung', 'banner-werbung', 'popup-werbung', 'werbebanner', 'irreführende werbung', 'fake-werbung']],
['name' => 'Datenschutz', 'slug' => 'datenschutz', 'keywords' => ['datenschutz', 'dsgvo', 'datenschutzrecht', 'datenschutzbestimmungen', 'datenschutzeinstellungen']],
['name' => 'Promi', 'slug' => 'promi', 'keywords' => ['promi', 'prominente', 'prominenter', 'promi-zitat', 'fake-zitat', 'elon musk', 'celebrity']],
['name' => 'Kinder', 'slug' => 'kinder', 'keywords' => ['kinder', 'jugendliche', 'minderjährige', 'eltern', 'schule', 'kinderbetreuung']],
// ── Format
['name' => 'Webinar', 'slug' => 'webinar', 'keywords' => ['webinar', 'kostenloses webinar', 'online-seminar', 'live-webinar']],
['name' => 'Tipps', 'slug' => 'tipps', 'keywords' => ['tipps', 'so erkennen sie', 'so schützen sie sich', 'wie erkenne ich', 'was tun wenn', 'ratgeber', 'checkliste', 'so vermeiden sie', 'tipps und tricks']],
],
'config' => [
'min_per_news' => 5,
'max_per_news' => 10,
'storage_pid' => 70,
'news_limit' => 1500,
'score_threshold' => 1,
],
];
# Minimal Services.yaml snippet to register AssignNewsTagsCommand.
#
# Place this in your extension's Configuration/Services.yaml.
# Keep your existing _defaults / autowiring block; only the command tag
# registration and the autoload resource matter for this skill.
services:
_defaults:
autowire: true
autoconfigure: true
public: false
Vendor\YourExt\:
resource: '../Classes/*'
Vendor\YourExt\Command\AssignNewsTagsCommand:
tags:
- name: console.command
command: 'yourext:news:assign-tags'
description: 'Assign thematic tags to EXT:news records via keyword matching'