From 5eae09e5bbc31019b8a158450953ccf0629cfa40 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sun, 5 Jul 2026 16:08:32 +0200 Subject: [PATCH] perf: replace regex swear filter with Aho-Corasick O(n) trie + add FilterResult unit tests --- .../admin/system/ReloadConfigCommand.class | Bin 1066 -> 1066 bytes .../moderation/ban/UnbanCommand.class | Bin 2513 -> 2513 bytes .../lochat/core/filter/CooldownFilter.class | Bin 1566 -> 1566 bytes .../core/filter/filters/FloodFilter.class | Bin 3282 -> 3282 bytes .../core/filter/filters/SpamFilter.class | Bin 4708 -> 4708 bytes .../core/filter/filters/SwearFilter.class | Bin 7660 -> 4997 bytes .../core/filter/filters/UrlFilter.class | Bin 4978 -> 4978 bytes .../gradient/config/GradientMessages.class | Bin 4055 -> 4055 bytes .../core/filter/filters/SwearFilter.java | 85 ++------- .../loki/lochat/utils/text/AhoCorasick.java | 172 ++++++++++++++++++ .../lochat/core/filter/FilterResultTest.java | 65 +++++++ .../lochat/utils/text/AhoCorasickTest.java | 145 +++++++++++++++ 12 files changed, 394 insertions(+), 73 deletions(-) create mode 100644 src/main/java/com/loki/lochat/utils/text/AhoCorasick.java create mode 100644 src/test/java/com/loki/lochat/core/filter/FilterResultTest.java create mode 100644 src/test/java/com/loki/lochat/utils/text/AhoCorasickTest.java diff --git a/bin/main/com/loki/lochat/commands/admin/system/ReloadConfigCommand.class b/bin/main/com/loki/lochat/commands/admin/system/ReloadConfigCommand.class index 26f4813068bd14ee5ed46a8da923db340cd9796f..056e12f586c8333fbbe04421b6674ade58e289e5 100644 GIT binary patch delta 27 icmZ3*v5I3u2{R`@0}F!y11E#v%*n2#Npz delta 23 dcmca8d{KDAV-7~u$@QG#lYetGGr9s%*T2#5dx diff --git a/bin/main/com/loki/lochat/core/filter/CooldownFilter.class b/bin/main/com/loki/lochat/core/filter/CooldownFilter.class index f71c9c2be284485227d08b17e04590d5d6ca3a5f..18bb2e2c4e672cfe50f0eda67d0d11cb6c93b779 100644 GIT binary patch delta 13 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 BBEJ9t literal 7660 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 B6bt|W diff --git a/bin/main/com/loki/lochat/core/filter/filters/UrlFilter.class b/bin/main/com/loki/lochat/core/filter/filters/UrlFilter.class index 67547a21de5e81ead0943dc29849707863a202b8..92add1d6cdee290c7cb5d8f37d2e3d7db998506a 100644 GIT binary patch delta 267 zcmWNM%}PQ65Jr#GMxvYd3Z+G*f)~=vic-r6!G3Ok28G3qGH{{pqD{MO(?X)%?0SNh zJyEpk0qzUbw>rbjaORuHkNmF(Cq2a&4snu9;Ndc(YML}D8#$r7z&MMVW|$>Qk(@=& zPng%PPl6><6qr*#Pl+{F*rv>Z7_iEjI+v`|7H_FApvogPp2Zioc-8hBb>2mj=Yxh7 z6W^sh+4p4+WIxjNW0pCQ=adbxPKzDRITTx5SjGL!UNOnF(ud4*LykM8=&;GXE_K+Y bYk%%jyr)0<@(yh1^OI2!9m$9nl!T}4MLiIv!0+~ zPZW)MfcpZrIdB%|eCMpq4hv!7`_V}|%rHv~k2tQ@97$57nYVW*aI-{N(sY?cRw$9P z(D@DWdb>t~br#qlLs6$qwkdN!g^uV`rOOsqy6uU#?D9aJCmM{!SN3>gpNW2ah$hb` zEh{B{%5B+?Wj~SKSMsUcXY6oJO+1wMhzm`-+Apo{e&u>VRT9v*M2TJSH cWN1_OId-QW_wtVH#T!K`_UE-D4J#)7e_J0a+W-In diff --git a/bin/main/com/loki/lochat/gradient/config/GradientMessages.class b/bin/main/com/loki/lochat/gradient/config/GradientMessages.class index 08f14eced0ac13979a936358282c0c884aee3910..5d3594d46f65b8ceaa5031c0c1190219f8ec751a 100644 GIT binary patch delta 145 zcmcaEe_ejVUT$6~1{MZs22KVU23`i)$)~tysVXptFeoy}F(@%;FsLvXGpI4xGN>~+ zGH5dR07U{Av=|~7bS8K5#Ix!H^%_im%M-wA2GnCd*@<_es2z}H&mhI%z@QA&V-93H rGPq2B!CT4bJvoWbn=xqeI=*n$NCs|(sL5RX5v&OeJPe7G)A{* zGN?27FsLyE07WAhv?h1*#Ix!G_3BN2%M-wA0@PzV*@<_es11;13pCA+L7Bmx!5qkT sWN@1Ng13^ swearPatterns = new HashSet<>(); - - // Карта замены латиницы на кириллицу для обхода фильтра - private static final Map LATIN_TO_CYRILLIC = Map.ofEntries( - Map.entry('a', 'а'), Map.entry('A', 'А'), - Map.entry('e', 'е'), Map.entry('E', 'Е'), - Map.entry('o', 'о'), Map.entry('O', 'О'), - Map.entry('p', 'р'), Map.entry('P', 'Р'), - Map.entry('c', 'с'), Map.entry('C', 'С'), - Map.entry('y', 'у'), Map.entry('Y', 'У'), - Map.entry('x', 'х'), Map.entry('X', 'Х'), - Map.entry('k', 'к'), Map.entry('K', 'К'), - Map.entry('m', 'м'), Map.entry('M', 'М'), - Map.entry('h', 'н'), Map.entry('H', 'Н'), - Map.entry('t', 'т'), Map.entry('T', 'Т'), - Map.entry('b', 'в'), Map.entry('B', 'В') - ); + private AhoCorasick ahoCorasick; public SwearFilter(FileConfiguration config, JavaPlugin plugin) { this.enabled = config.getBoolean("filters.swear.enabled", true); this.replaceMode = config.getBoolean("filters.swear.replace", false); this.replacement = config.getString("filters.swear.replacement", "***"); - + if (enabled) { loadWordList(plugin); } } private void loadWordList(JavaPlugin plugin) { + List words = new ArrayList<>(); try (InputStream is = plugin.getResource("badwords.txt"); BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { - reader.lines() .map(String::trim) .filter(line -> !line.isEmpty() && !line.startsWith("#")) - .forEach(word -> { - // Создаем паттерн с учетом возможной замены букв - String regex = buildFlexiblePattern(word); - swearPatterns.add(Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE)); - }); + .forEach(words::add); } catch (Exception e) { plugin.getLogger().warning("Failed to load badwords.txt: " + e.getMessage()); } - } - - /** - * Создает гибкий паттерн, который ловит замены букв (х->x, о->o и т.д.) - */ - private String buildFlexiblePattern(String word) { - StringBuilder pattern = new StringBuilder(); - - for (char c : word.toLowerCase().toCharArray()) { - // Для каждой буквы создаем группу с возможными заменами - switch (c) { - case 'а' -> pattern.append("[аaА@]"); - case 'е' -> pattern.append("[еeЕ3]"); - case 'о' -> pattern.append("[оoО0]"); - case 'р' -> pattern.append("[рpР]"); - case 'с' -> pattern.append("[сcС]"); - case 'у' -> pattern.append("[уyУ]"); - case 'х' -> pattern.append("[хxХ]"); - case 'к' -> pattern.append("[кkК]"); - case 'м' -> pattern.append("[мmМ]"); - case 'н' -> pattern.append("[нhНN]"); - case 'т' -> pattern.append("[тtТ]"); - case 'в' -> pattern.append("[вbВ]"); - case 'и' -> pattern.append("[иiИ1]"); - case 'з' -> pattern.append("[з3З]"); - case 'б' -> pattern.append("[б6Б]"); - default -> pattern.append(Pattern.quote(String.valueOf(c))); - } - } - - return pattern.toString(); + this.ahoCorasick = new AhoCorasick(words); } public FilterResult filter(Player player, String message) { @@ -100,23 +49,13 @@ public class SwearFilter { return FilterResult.ok(message); } - for (Pattern pattern : swearPatterns) { - if (pattern.matcher(message).find()) { - if (replaceMode) { - return FilterResult.ok(replaceSwear(message)); - } - return FilterResult.blocked("Сообщение содержит запрещенные слова"); + if (ahoCorasick.matches(message)) { + if (replaceMode) { + return FilterResult.ok(ahoCorasick.replaceAll(message, replacement)); } + return FilterResult.blocked("Сообщение содержит запрещенные слова"); } return FilterResult.ok(message); } - - private String replaceSwear(String message) { - String result = message; - for (Pattern pattern : swearPatterns) { - result = pattern.matcher(result).replaceAll(replacement); - } - return result; - } } diff --git a/src/main/java/com/loki/lochat/utils/text/AhoCorasick.java b/src/main/java/com/loki/lochat/utils/text/AhoCorasick.java new file mode 100644 index 0000000..eaa4086 --- /dev/null +++ b/src/main/java/com/loki/lochat/utils/text/AhoCorasick.java @@ -0,0 +1,172 @@ +package com.loki.lochat.utils.text; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class AhoCorasick { + + private static final Map NORMALIZE = new HashMap<>(); + + static { + NORMALIZE.put('a', 'а'); + NORMALIZE.put('A', 'А'); + NORMALIZE.put('e', 'е'); + NORMALIZE.put('E', 'Е'); + NORMALIZE.put('o', 'о'); + NORMALIZE.put('O', 'О'); + NORMALIZE.put('p', 'р'); + NORMALIZE.put('P', 'Р'); + NORMALIZE.put('c', 'с'); + NORMALIZE.put('C', 'С'); + NORMALIZE.put('y', 'у'); + NORMALIZE.put('Y', 'У'); + NORMALIZE.put('x', 'х'); + NORMALIZE.put('X', 'Х'); + NORMALIZE.put('k', 'к'); + NORMALIZE.put('K', 'К'); + NORMALIZE.put('m', 'м'); + NORMALIZE.put('M', 'М'); + NORMALIZE.put('h', 'н'); + NORMALIZE.put('H', 'Н'); + NORMALIZE.put('n', 'н'); + NORMALIZE.put('N', 'Н'); + NORMALIZE.put('t', 'т'); + NORMALIZE.put('T', 'Т'); + NORMALIZE.put('b', 'в'); + NORMALIZE.put('B', 'В'); + + NORMALIZE.put('@', 'а'); + NORMALIZE.put('0', 'о'); + NORMALIZE.put('3', 'е'); + NORMALIZE.put('1', 'и'); + NORMALIZE.put('6', 'б'); + } + + private final Node root; + + public AhoCorasick(List words) { + this.root = new Node((char) 0); + buildTrie(words); + buildFailLinks(); + } + + private void buildTrie(List words) { + for (String word : words) { + if (word.isEmpty()) { + continue; + } + Node node = root; + for (char c : word.toCharArray()) { + char normalized = normalize(c); + Node current = node; + node = node.children.computeIfAbsent(normalized, ch -> new Node(ch, current)); + } + node.output = true; + node.word = word; + } + } + + private void buildFailLinks() { + Deque queue = new ArrayDeque<>(); + for (Node node : root.children.values()) { + node.fail = root; + queue.addLast(node); + } + while (!queue.isEmpty()) { + Node current = queue.pollFirst(); + for (Node child : current.children.values()) { + Node fail = current.fail; + while (fail != root && !fail.children.containsKey(child.c)) { + fail = fail.fail; + } + if (fail != root || fail.children.containsKey(child.c)) { + Node f = fail.children.get(child.c); + child.fail = f != null ? f : root; + } else { + child.fail = root; + } + child.output = child.output || child.fail.output; + if (child.fail.output && child.word == null) { + child.word = child.fail.word; + } + queue.addLast(child); + } + } + } + + public boolean matches(String text) { + Node node = root; + for (int i = 0; i < text.length(); i++) { + char c = normalize(text.charAt(i)); + while (node != root && !node.children.containsKey(c)) { + node = node.fail; + } + node = node.children.getOrDefault(c, root); + if (node.output) { + return true; + } + } + return false; + } + + public String replaceAll(String text, String replacement) { + Node node = root; + int lastEnd = 0; + StringBuilder result = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char c = normalize(text.charAt(i)); + while (node != root && !node.children.containsKey(c)) { + node = node.fail; + } + node = node.children.getOrDefault(c, root); + if (node.output && node.word != null) { + result.append(text, lastEnd, i - node.word.length() + 1); + result.append(replacement); + lastEnd = i + 1; + } + } + result.append(text, lastEnd, text.length()); + return result.toString(); + } + + public List findMatches(String text) { + List found = new ArrayList<>(); + Node node = root; + for (int i = 0; i < text.length(); i++) { + char c = normalize(text.charAt(i)); + while (node != root && !node.children.containsKey(c)) { + node = node.fail; + } + node = node.children.getOrDefault(c, root); + if (node.output && node.word != null) { + found.add(node.word); + } + } + return found; + } + + private static char normalize(char c) { + return NORMALIZE.getOrDefault(c, Character.toLowerCase(c)); + } + + private static final class Node { + final char c; + final Map children = new HashMap<>(); + Node fail; + boolean output; + String word; + + Node(char c) { + this.c = c; + this.fail = this; + } + + Node(char c, Node parent) { + this.c = c; + } + } +} diff --git a/src/test/java/com/loki/lochat/core/filter/FilterResultTest.java b/src/test/java/com/loki/lochat/core/filter/FilterResultTest.java new file mode 100644 index 0000000..5ea2b5e --- /dev/null +++ b/src/test/java/com/loki/lochat/core/filter/FilterResultTest.java @@ -0,0 +1,65 @@ +package com.loki.lochat.core.filter; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class FilterResultTest { + + @Test + void ok_allowed() { + FilterResult r = FilterResult.ok("hello"); + assertTrue(r.allowed()); + assertNull(r.blockReason()); + assertEquals("hello", r.filteredMessage()); + } + + @Test + void ok_getMessage() { + FilterResult r = FilterResult.ok("hello"); + assertEquals("hello", r.getMessage()); + } + + @Test + void blocked_notAllowed() { + FilterResult r = FilterResult.blocked("bad word"); + assertFalse(r.allowed()); + assertEquals("bad word", r.blockReason()); + assertNull(r.filteredMessage()); + } + + @Test + void ok_nullMessage() { + FilterResult r = FilterResult.ok(null); + assertTrue(r.allowed()); + assertNull(r.filteredMessage()); + } + + @Test + void blocked_nullReason() { + FilterResult r = FilterResult.blocked(null); + assertFalse(r.allowed()); + assertNull(r.blockReason()); + } + + @Test + void record_equals() { + FilterResult a = FilterResult.ok("hello"); + FilterResult b = FilterResult.ok("hello"); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void record_notEquals() { + FilterResult a = FilterResult.ok("hello"); + FilterResult b = FilterResult.blocked("bad"); + assertNotEquals(a, b); + } + + @Test + void record_toString() { + FilterResult r = FilterResult.ok("test"); + assertTrue(r.toString().contains("test")); + } +} diff --git a/src/test/java/com/loki/lochat/utils/text/AhoCorasickTest.java b/src/test/java/com/loki/lochat/utils/text/AhoCorasickTest.java new file mode 100644 index 0000000..b7727f2 --- /dev/null +++ b/src/test/java/com/loki/lochat/utils/text/AhoCorasickTest.java @@ -0,0 +1,145 @@ +package com.loki.lochat.utils.text; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class AhoCorasickTest { + + @Test + void matches_simpleWord() { + AhoCorasick ac = new AhoCorasick(List.of("хуй")); + assertTrue(ac.matches("это хуй")); + assertTrue(ac.matches("хуй")); + assertFalse(ac.matches("привет")); + } + + @Test + void matches_multipleWords() { + AhoCorasick ac = new AhoCorasick(List.of("хуй", "пизда", "ебать")); + assertTrue(ac.matches("старый хуй")); + assertTrue(ac.matches("полная пизда")); + assertTrue(ac.matches("ебать копать")); + assertFalse(ac.matches("нормальный текст")); + } + + @Test + void matches_overlappingWords() { + AhoCorasick ac = new AhoCorasick(List.of("хуй", "хуе")); + assertTrue(ac.matches("хуе")); + assertTrue(ac.matches("хуй")); + } + + @Test + void matches_subword() { + AhoCorasick ac = new AhoCorasick(List.of("хуй")); + assertFalse(ac.matches("ху")); + assertFalse(ac.matches("")); + } + + @Test + void matches_latinToCyrillicSubstitution() { + AhoCorasick ac = new AhoCorasick(List.of("хуй", "пизда")); + assertTrue(ac.matches("xуй")); + assertTrue(ac.matches("xyй")); + assertTrue(ac.matches("хyй")); + } + + @Test + void matches_caseInsensitive() { + AhoCorasick ac = new AhoCorasick(List.of("хуй")); + assertTrue(ac.matches("ХУЙ")); + assertTrue(ac.matches("Хуй")); + assertTrue(ac.matches("хуй")); + } + + @Test + void matches_mixedSubstitutions() { + AhoCorasick ac = new AhoCorasick(List.of("ебать")); + assertTrue(ac.matches("3бать")); + assertTrue(ac.matches("ебaть")); + } + + @Test + void replaceAll_replacesMatches() { + AhoCorasick ac = new AhoCorasick(List.of("хуй", "пизда")); + assertEquals("*** текст", ac.replaceAll("хуй текст", "***")); + assertEquals("*** и ***", ac.replaceAll("хуй и пизда", "***")); + } + + @Test + void replaceAll_noMatch() { + AhoCorasick ac = new AhoCorasick(List.of("хуй")); + assertEquals("чистый текст", ac.replaceAll("чистый текст", "***")); + } + + @Test + void replaceAll_nonOverlapping() { + AhoCorasick ac = new AhoCorasick(List.of("хуй", "пизда")); + assertEquals("*** ***", ac.replaceAll("хуй пизда", "***")); + } + + @Test + void findMatches_returnsAll() { + AhoCorasick ac = new AhoCorasick(List.of("хуй", "пизда", "ебать")); + List found = ac.findMatches("хуй и пизда и ебать"); + assertTrue(found.contains("хуй")); + assertTrue(found.contains("пизда")); + assertTrue(found.contains("ебать")); + } + + @Test + void findMatches_empty() { + AhoCorasick ac = new AhoCorasick(List.of("хуй")); + assertTrue(ac.findMatches("чистый текст").isEmpty()); + } + + @Test + void emptyWordList() { + AhoCorasick ac = new AhoCorasick(List.of()); + assertFalse(ac.matches("любой текст")); + assertEquals("любой текст", ac.replaceAll("любой текст", "***")); + } + + @Test + void emptyStringInWordList() { + AhoCorasick ac = new AhoCorasick(List.of("")); + assertFalse(ac.matches("тест")); + } + + @Test + void matches_longText() { + AhoCorasick ac = new AhoCorasick(List.of("хуй", "пизда")); + assertTrue(ac.matches("a".repeat(1000) + "хуй" + "b".repeat(1000))); + } + + @Test + void matches_specialCharacters() { + AhoCorasick ac = new AhoCorasick(List.of("хуй")); + assertTrue(ac.matches(",хуй.")); + assertTrue(ac.matches("текст с xуй внутри")); + } + + @Test + void matches_insideWord() { + AhoCorasick ac = new AhoCorasick(List.of("хуй")); + assertTrue(ac.matches("хуйтекст")); + assertTrue(ac.matches("текстхуйтекст")); + } + + @Test + void matches_punctuationAround() { + AhoCorasick ac = new AhoCorasick(List.of("хуй")); + assertTrue(ac.matches("хуй,")); + assertTrue(ac.matches("(хуй)")); + assertTrue(ac.matches("\"хуй\"")); + } + + @Test + void replaceAll_partialWord() { + AhoCorasick ac = new AhoCorasick(List.of("хуй")); + assertEquals("***ня", ac.replaceAll("хуйня", "***")); + } +}