From 90940dd9287f08185e622877bb25ef969739518e Mon Sep 17 00:00:00 2001 From: Vladimir Bryzgalov Date: Fri, 17 Apr 2026 22:21:10 +0500 Subject: [PATCH] feat(classifier): weighted heuristic for client/unclear/spam scoring --- includes/class-classifier.php | 216 ++++++++++++++++++++++++++++++++++ tests/ClassifierTest.php | 157 ++++++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 includes/class-classifier.php create mode 100644 tests/ClassifierTest.php diff --git a/includes/class-classifier.php b/includes/class-classifier.php new file mode 100644 index 0000000..31f588f --- /dev/null +++ b/includes/class-classifier.php @@ -0,0 +1,216 @@ + 3, + 'cyrillic_name' => 1, + 'ru_email_domain' => 1, + 'meaningful_text' => 1, + 'url_present' => -3, + 'keyword_hit' => -2, + 'keyword_cap' => -6, + 'latin_only' => -1, + 'honeypot' => -5, + ]; + + public const DEFAULT_THRESHOLDS = [ + 'client' => 3, + 'spam' => -2, + ]; + + public const DEFAULT_KEYWORDS = [ + // SEO / продвижение + 'seo', 'сео', 'продвижение сайта', 'backlinks', 'обратные ссылки', 'позиции в поиске', + // Finance / earning + 'bitcoin', 'биткоин', 'crypto', 'криптовалют', 'forex', 'форекс', 'trading', 'трейдинг', + 'loan', 'займ', 'микрозайм', 'инвестиции', 'заработок онлайн', 'удалённая работа без', + 'mlm', 'сетевой маркетинг', + // Casino + 'casino', 'казино', 'ставки', 'betting', 'букмекер', + // Diet / supplements + 'бад', 'биодобав', 'супер фуд', 'super food', 'детокс', 'похудение', 'похудет', + 'диетическ', 'жиросжиг', + // Pharma + 'viagra', 'виагра', 'cialis', 'сиалис', 'pharmacy', 'фарма', 'дженерик', + // Adult + 'porn', 'xxx', 'adult', 'porno', 'порно', 'эскорт', 'проститу', + 'секс-знакомств', 'интим-услуг', + ]; + + public const RU_EMAIL_DOMAINS = [ + 'yandex.ru', 'ya.ru', 'mail.ru', 'list.ru', 'inbox.ru', 'bk.ru', + 'rambler.ru', 'gmail.com', + ]; + + /** + * @param array $fields CF7 posted_data (field => value or array of values) + * @param array{reason?:string} $meta Context: reason = Akismet / Honeypot / CF7 rules / Неизвестно + * @return array{score:int,label:string,reasons:array} + */ + public static function classify(array $fields, array $meta = []): array + { + $weights = apply_filters('cf7stg_classifier_weights', self::DEFAULT_WEIGHTS); + $keywords = apply_filters('cf7stg_classifier_keywords', self::DEFAULT_KEYWORDS); + $thresholds = apply_filters('cf7stg_classifier_thresholds', self::DEFAULT_THRESHOLDS); + + $text_all = self::concat_fields($fields); + $name = self::pick_field($fields, ['your-name', 'name', 'имя', 'fio']); + $email = self::pick_field($fields, ['your-email', 'email', 'e-mail', 'mail']); + $message = self::pick_field($fields, ['your-message', 'message', 'comment', 'сообщение', 'вопрос']); + + $score = 0; + $reasons = []; + + // +3 RU phone + if (PhoneParser::parse($text_all) !== null) { + $parsed = PhoneParser::parse($text_all); + if ($parsed !== null && $parsed['is_russian']) { + $score += (int)$weights['phone_ru']; + $reasons[] = ['sign' => '+', 'weight' => (int)$weights['phone_ru'], 'text' => 'телефон RU']; + } + } + + // +1 cyrillic name (no digits, no URL, some cyrillic) + if ($name !== '' && self::is_cyrillic_name($name)) { + $score += (int)$weights['cyrillic_name']; + $reasons[] = ['sign' => '+', 'weight' => (int)$weights['cyrillic_name'], 'text' => 'кириллическое имя']; + } + + // +1 RU email domain + if ($email !== '' && self::is_ru_email_domain($email)) { + $score += (int)$weights['ru_email_domain']; + $reasons[] = ['sign' => '+', 'weight' => (int)$weights['ru_email_domain'], 'text' => 'ру-домен e-mail']; + } + + // +1 meaningful text (cyrillic, >10 chars, not just a link) + if ($message !== '' && self::is_meaningful_text($message)) { + $score += (int)$weights['meaningful_text']; + $reasons[] = ['sign' => '+', 'weight' => (int)$weights['meaningful_text'], 'text' => 'осмысленный текст']; + } + + // -3 URL anywhere + if (self::contains_url($text_all)) { + $score += (int)$weights['url_present']; + $reasons[] = ['sign' => '-', 'weight' => abs((int)$weights['url_present']), 'text' => 'URL в сообщении']; + } + + // -2 per keyword, capped at -6 + $hits = self::keyword_hits($text_all, $keywords); + if ($hits) { + $per = (int)$weights['keyword_hit']; // negative + $cap = (int)$weights['keyword_cap']; // negative + $applied = max(count($hits) * $per, $cap); + $score += $applied; + foreach ($hits as $kw) { + $reasons[] = ['sign' => '-', 'weight' => abs($per), 'text' => 'ключевое слово: ' . $kw]; + if (count(array_filter($reasons, static fn($r) => strpos($r['text'], 'ключевое слово') === 0)) >= 3) { + break; // keep reasons list readable + } + } + } + + // -1 latin-only name or message + if (($name !== '' && self::is_latin_only($name)) || ($message !== '' && self::is_latin_only($message))) { + $score += (int)$weights['latin_only']; + $reasons[] = ['sign' => '-', 'weight' => abs((int)$weights['latin_only']), 'text' => 'только латиница']; + } + + // -5 honeypot (weighted sign AND hard override) + $honeypot_tripped = (($meta['reason'] ?? '') === 'Honeypot'); + if ($honeypot_tripped) { + $score += (int)$weights['honeypot']; + $reasons[] = ['sign' => '-', 'weight' => abs((int)$weights['honeypot']), 'text' => 'сработал honeypot']; + } + + $label = 'unclear'; + if ($score >= (int)$thresholds['client']) { + $label = 'client'; + } elseif ($score <= (int)$thresholds['spam']) { + $label = 'spam'; + } + // Honeypot is a hard override: a filled honeypot field means a bot, + // regardless of how "human" the other fields look. + if ($honeypot_tripped) { + $label = 'spam'; + } + + return ['score' => $score, 'label' => $label, 'reasons' => $reasons]; + } + + private static function concat_fields(array $fields): string + { + $out = []; + foreach ($fields as $v) { + if (is_array($v)) { + $out[] = implode(' ', array_map('strval', $v)); + } else { + $out[] = (string)$v; + } + } + return implode("\n", $out); + } + + private static function pick_field(array $fields, array $keys): string + { + foreach ($keys as $k) { + if (isset($fields[$k])) { + $v = $fields[$k]; + if (is_array($v)) $v = implode(' ', $v); + return trim((string)$v); + } + } + return ''; + } + + private static function is_cyrillic_name(string $s): bool + { + if (preg_match('/[0-9]/u', $s)) return false; + if (preg_match('#https?://|www\.#i', $s)) return false; + return (bool)preg_match('/[а-яёА-ЯЁ]/u', $s); + } + + private static function is_ru_email_domain(string $email): bool + { + if (!preg_match('/@([^@\s]+)$/', trim($email), $m)) return false; + $domain = strtolower($m[1]); + return in_array($domain, self::RU_EMAIL_DOMAINS, true); + } + + private static function is_meaningful_text(string $s): bool + { + if (mb_strlen($s) < 10) return false; + if (preg_match('/^\s*https?:\/\/\S+\s*$/i', $s)) return false; + return (bool)preg_match('/[а-яёА-ЯЁ]{3,}/u', $s); + } + + private static function contains_url(string $s): bool + { + return (bool)preg_match('#(https?://|www\.)\S+#i', $s); + } + + private static function is_latin_only(string $s): bool + { + $s = trim($s); + if ($s === '') return false; + if (!preg_match('/[A-Za-z]/', $s)) return false; + return !preg_match('/[а-яёА-ЯЁ]/u', $s); + } + + private static function keyword_hits(string $text, array $keywords): array + { + $hits = []; + $text_lower = mb_strtolower($text); + foreach ($keywords as $kw) { + $needle = mb_strtolower($kw); + if ($needle === '') continue; + if (mb_strpos($text_lower, $needle) !== false) { + $hits[] = $kw; + } + } + return $hits; + } +} diff --git a/tests/ClassifierTest.php b/tests/ClassifierTest.php new file mode 100644 index 0000000..c5d383a --- /dev/null +++ b/tests/ClassifierTest.php @@ -0,0 +1,157 @@ + 'Анна Петровна', + 'your-tel' => '+7 903 123-45-67', + 'your-email' => 'anna@mail.ru', + 'your-message' => 'Нужна сиделка для бабушки после инсульта, пожалуйста перезвоните.', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertSame('client', $r['label']); + self::assertGreaterThanOrEqual(3, $r['score']); + } + + public function test_seo_spam_with_url_is_red(): void + { + $fields = [ + 'your-name' => 'Melissa Johnson', + 'your-email' => 'promo@example.com', + 'your-message' => 'Hi, we boost SEO positions, visit http://cheap-seo.net', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertSame('spam', $r['label']); + self::assertLessThanOrEqual(-2, $r['score']); + } + + public function test_honeypot_forces_spam_even_with_phone(): void + { + $fields = [ + 'your-name' => 'Анна', + 'your-tel' => '+79031234567', + 'your-message' => 'сиделка для мамы', + ]; + $r = Classifier::classify($fields, ['reason' => 'Honeypot']); + self::assertSame('spam', $r['label']); + } + + public function test_empty_fields_are_unclear(): void + { + $fields = [ + 'your-email' => 'a@b.com', + ]; + $r = Classifier::classify($fields, ['reason' => 'CF7 rules']); + self::assertSame('unclear', $r['label']); + } + + public function test_multiple_keywords_capped_at_minus_six(): void + { + $fields = [ + 'your-message' => 'SEO, SEO, SEO, backlinks, crypto, casino, займ, bitcoin', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + // 8 hits × -2 would be -16; cap is -6 + self::assertGreaterThanOrEqual(-10, $r['score']); // keyword part >= -6, plus -3 for URL? none here + } + + public function test_ru_domain_email_adds_one(): void + { + $fields = [ + 'your-name' => 'Иван', + 'your-email' => 'ivan@yandex.ru', + 'your-message' => 'Здравствуйте, интересует услуга сиделки', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + $hasRuMail = false; + foreach ($r['reasons'] as $reason) { + if (strpos($reason['text'], 'ру-домен') !== false) { + $hasRuMail = true; + } + } + self::assertTrue($hasRuMail); + } + + public function test_reasons_list_contains_sign_and_weight(): void + { + $fields = [ + 'your-tel' => '+79031234567', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertNotEmpty($r['reasons']); + self::assertArrayHasKey('sign', $r['reasons'][0]); + self::assertArrayHasKey('weight', $r['reasons'][0]); + self::assertArrayHasKey('text', $r['reasons'][0]); + } + + public function test_latin_only_name_penalty(): void + { + $fields = [ + 'your-name' => 'John Smith', + 'your-message' => 'please help me with website ranking', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + $hasLatinPenalty = false; + foreach ($r['reasons'] as $reason) { + if ($reason['sign'] === '-' && strpos($reason['text'], 'латиница') !== false) { + $hasLatinPenalty = true; + } + } + self::assertTrue($hasLatinPenalty); + } + + public function test_url_in_any_field_triggers_minus_three(): void + { + $fields = [ + 'your-name' => 'Ольга', + 'your-message' => 'посмотрите https://мойсайт.рф', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + $hasUrl = false; + foreach ($r['reasons'] as $reason) { + if (strpos($reason['text'], 'URL') !== false) { + $hasUrl = true; + self::assertSame('-', $reason['sign']); + self::assertSame(3, $reason['weight']); + } + } + self::assertTrue($hasUrl); + } + + public function test_thresholds_client_edge(): void + { + // Score exactly 3 → client + $fields = ['your-tel' => '+79031234567']; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertSame(3, $r['score']); + self::assertSame('client', $r['label']); + } + + public function test_thresholds_unclear_between_neg_one_and_two(): void + { + $fields = ['your-name' => 'Олег', 'your-message' => 'короткий']; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertGreaterThan(-2, $r['score']); + self::assertLessThan(3, $r['score']); + self::assertSame('unclear', $r['label']); + } + + public function test_keyword_matching_case_insensitive(): void + { + $fields = ['your-message' => 'Продаём ВИАГРА с доставкой']; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + $matched = false; + foreach ($r['reasons'] as $reason) { + if (strpos($reason['text'], 'виагра') !== false) $matched = true; + } + self::assertTrue($matched); + } +}