diff --git a/includes/class-classifier.php b/includes/class-classifier.php index 6874382..e1a667d 100644 --- a/includes/class-classifier.php +++ b/includes/class-classifier.php @@ -20,6 +20,12 @@ final class Classifier public const DEFAULT_THRESHOLDS = [ 'client' => 3, 'spam' => -2, + 'drop' => -5, + ]; + + public const DEFAULT_HARD_DROP_RULES = [ + 'short_latin_name' => true, + 'placeholder_fields' => true, ]; public const DEFAULT_KEYWORDS = [ @@ -153,9 +159,28 @@ final class Classifier $label = 'spam'; } + // Drop has highest priority (overrides client/unclear/spam/honeypot) + $is_drop_by_rule = self::evaluate_hard_drop($fields); + $is_drop_by_score = isset($thresholds['drop']) && $score <= (int)$thresholds['drop']; + if ($is_drop_by_rule || $is_drop_by_score) { + $label = 'drop'; + $reasons[] = ['sign' => '×', 'weight' => 0, 'text' => self::drop_reason_label($fields, $thresholds, $is_drop_by_rule)]; + } + return ['score' => $score, 'label' => $label, 'reasons' => $reasons]; } + private static function drop_reason_label(array $fields, array $thresholds, bool $by_rule): string + { + if ($by_rule) { + $hits = []; + if (self::has_short_latin_name($fields)) $hits[] = 'короткое латинское имя'; + if (self::count_placeholder_fields($fields) >= 3) $hits[] = '≥3 поля-плейсхолдера'; + return 'hard-rule: ' . implode(' + ', $hits); + } + return 'score ≤ ' . (int)$thresholds['drop']; + } + private static function concat_fields(array $fields): string { $out = []; @@ -307,4 +332,35 @@ final class Classifier } return $count; } + + /** + * Hard-rule decision: returns true if the submission has zero positive signals + * (no cyrillic name, no meaningful text, no valid email anywhere) AND at least + * one trigger fires (short latin name OR ≥3 placeholder fields). + * + * Triggers can be disabled individually via filter `cf7stg_hard_drop_rules`. + * + * @param array $fields CF7 posted_data + * @return bool True if the submission should be silently dropped by hard-rule. + */ + public static function evaluate_hard_drop(array $fields): bool + { + $rules = apply_filters('cf7stg_hard_drop_rules', self::DEFAULT_HARD_DROP_RULES); + + // Любой позитивный сигнал — НЕ drop + $name = self::pick_field($fields, ['your-name', 'name', 'имя', 'fio']); + if ($name === '') $name = FieldDetector::find_name($fields); + if ($name !== '' && self::is_cyrillic_name($name)) return false; + + $message = self::pick_field($fields, ['your-message', 'message', 'comment', 'сообщение', 'вопрос']); + if ($message !== '' && self::is_meaningful_text($message)) return false; + + if (self::has_valid_email_anywhere($fields)) return false; + + // Триггеры + if (!empty($rules['short_latin_name']) && self::has_short_latin_name($fields)) return true; + if (!empty($rules['placeholder_fields']) && self::count_placeholder_fields($fields) >= 3) return true; + + return false; + } } diff --git a/includes/class-message-formatter.php b/includes/class-message-formatter.php index 17bf409..b22f5e8 100644 --- a/includes/class-message-formatter.php +++ b/includes/class-message-formatter.php @@ -11,6 +11,7 @@ final class MessageFormatter 'client' => ['emoji' => '🟢', 'text' => 'похоже клиент'], 'unclear' => ['emoji' => '🟡', 'text' => 'неясно'], 'spam' => ['emoji' => '🔴', 'text' => 'похоже спам'], + 'drop' => ['emoji' => '⛔', 'text' => 'авто-дроп'], ]; /** diff --git a/tests/ClassifierDropTest.php b/tests/ClassifierDropTest.php index f8ed6f3..907dfef 100644 --- a/tests/ClassifierDropTest.php +++ b/tests/ClassifierDropTest.php @@ -156,4 +156,131 @@ final class ClassifierDropTest extends Cf7stgTestCase $fields = ['a' => 'Ия', 'b' => 'a b', 'c' => 'XY']; self::assertSame(1, Classifier::count_placeholder_fields($fields)); } + + public function test_screenshot_1_yields_drop(): void + { + $fields = require __DIR__ . '/fixtures/spam-screenshot-1.php'; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertSame('drop', $r['label']); + } + + public function test_screenshot_2_yields_drop(): void + { + $fields = require __DIR__ . '/fixtures/spam-screenshot-2.php'; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertSame('drop', $r['label']); + } + + public function test_screenshot_3_yields_drop(): void + { + $fields = require __DIR__ . '/fixtures/spam-screenshot-3.php'; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertSame('drop', $r['label']); + } + + public function test_drop_skipped_when_cyrillic_name_present(): void + { + $fields = [ + 'your-name' => 'Иван', + 'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertNotSame('drop', $r['label']); + } + + public function test_drop_skipped_when_meaningful_text_present(): void + { + $fields = [ + 'your-name' => 'AB', + 'your-message' => 'Здравствуйте, нужна уборка квартиры в субботу с 10 утра', + 'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertNotSame('drop', $r['label']); + } + + /** @dataProvider valid_email_provider */ + public function test_drop_skipped_when_valid_email_present(string $email): void + { + $fields = [ + 'your-name' => 'AB', + 'your-email' => $email, + 'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertNotSame('drop', $r['label']); + } + + public function valid_email_provider(): array + { + return [ + 'gmail' => ['pasha@gmail.com'], + 'doménный' => ['info@some-domain.com'], + 'yandex' => ['name@yandex.ru'], + ]; + } + + public function test_drop_invalid_email_does_not_protect(): void + { + $fields = [ + 'your-name' => 'OB', + 'email' => 'not-an-email', + 'text-1' => 'AB', 'text-2' => 'CD', 'text-3' => 'EF', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertSame('drop', $r['label']); + } + + public function test_drop_skipped_on_form_without_name_when_no_placeholder_rule(): void + { + // Форма «обратный звонок»: только телефон, имя нет, < 3 placeholder + $fields = ['tel-1' => '+79991234567']; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertNotSame('drop', $r['label']); + } + + public function test_drop_triggers_on_form_without_name_via_placeholder_rule(): void + { + $fields = ['a' => 'AB', 'b' => 'CD', 'c' => 'EF', 'd' => 'GH']; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertSame('drop', $r['label']); + } + + public function test_score_drop_threshold_minus_5(): void + { + // keyword_cap (-6) + latin_only (-1) = -7, без позитивных → score-based drop + // Имя длинное (Melissa Smith), сообщение latin_only → НЕ hard-rule, чисто по score. + $fields = [ + 'your-name' => 'Melissa Smith', + 'your-message' => 'SEO, backlinks, crypto, casino, займ, bitcoin, viagra, porno', + ]; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertLessThanOrEqual(-5, $r['score']); + self::assertSame('drop', $r['label']); + } + + public function test_filter_disables_short_latin_name_rule(): void + { + add_filter('cf7stg_hard_drop_rules', static function ($rules) { + $rules['short_latin_name'] = false; + return $rules; + }); + // Только короткое имя, < 3 placeholder, нет позитивных → правило отключено → не drop + $fields = ['your-name' => 'OB', 'tel' => '+79991234567']; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertNotSame('drop', $r['label']); + } + + public function test_filter_thresholds_can_change_drop_score(): void + { + add_filter('cf7stg_classifier_thresholds', static function ($t) { + $t['drop'] = -3; + return $t; + }); + // URL даёт -3, нет hard-rule (нет короткого латинского имени, нет 3+ placeholder), + // но -3 ≤ -3 → drop по score-порогу. + $fields = ['msg' => 'http://x.com']; + $r = Classifier::classify($fields, ['reason' => 'Akismet']); + self::assertSame('drop', $r['label']); + } } diff --git a/tests/ClassifierTest.php b/tests/ClassifierTest.php index 17ac355..4395f25 100644 --- a/tests/ClassifierTest.php +++ b/tests/ClassifierTest.php @@ -4,9 +4,8 @@ declare(strict_types=1); namespace Cf7stg\Tests; use Cf7stg\Classifier; -use PHPUnit\Framework\TestCase; -final class ClassifierTest extends TestCase +final class ClassifierTest extends Cf7stgTestCase { public function test_real_client_inquiry_is_green(): void { @@ -23,14 +22,16 @@ final class ClassifierTest extends TestCase public function test_seo_spam_with_url_is_red(): void { + // score = SEO(-2) + URL(-3) + latin_only(-1) = -6, which is ≤ drop threshold(-5) + // → label promoted from spam to drop (score-based drop, no hard-rule since email is valid) $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']); + self::assertSame('drop', $r['label']); + self::assertLessThanOrEqual(-5, $r['score']); } public function test_honeypot_forces_spam_even_with_phone(): void diff --git a/tests/MessageFormatterTest.php b/tests/MessageFormatterTest.php index 4c284c2..a2226ed 100644 --- a/tests/MessageFormatterTest.php +++ b/tests/MessageFormatterTest.php @@ -5,9 +5,8 @@ namespace Cf7stg\Tests; use Cf7stg\MessageFormatter; use Cf7stg\Classifier; -use PHPUnit\Framework\TestCase; -final class MessageFormatterTest extends TestCase +final class MessageFormatterTest extends Cf7stgTestCase { public function test_full_client_payload(): void { @@ -123,6 +122,8 @@ final class MessageFormatterTest extends TestCase public function test_spam_label_emoji(): void { + // score = SEO(-2) + URL(-3) + latin_only(-1) = -6 ≤ drop threshold(-5) → label=drop + // Fields with no valid email and name 'Melissa' (7 chars, not short latin) → score-based drop. $fields = [ 'your-name' => 'Melissa', 'your-message' => 'SEO promotion, visit http://spam.net', @@ -135,6 +136,28 @@ final class MessageFormatterTest extends TestCase 'ip' => '', 'user_agent' => '', 'is_retro' => false, ]); + self::assertStringContainsString('⛔ авто-дроп', $r['text']); + } + + public function test_spam_label_emoji_pure_spam(): void + { + // Score -3 (URL) + -2 (SEO keyword) = -5, but no drop because cyrillic name protects + // from hard-rule, and score -5 is NOT ≤ drop threshold -5 (it equals it, so it IS drop). + // Use URL(-3) only → score=-3, between spam(-2) and drop(-5) → spam label. + $fields = [ + 'your-name' => 'Nikolay', + 'your-email' => 'nick@example.com', + 'your-message' => 'visit http://promo.net for deals', + ]; + $classification = Classifier::classify($fields, ['reason' => 'Akismet']); + // valid email prevents hard-rule; score = URL(-3) + latin_only(-1) = -4 → spam + $r = MessageFormatter::build([ + 'fields' => $fields, 'classification' => $classification, + 'site_title' => 's.ru', 'form_title' => 'f', + 'submitted_at' => '17.04.2026', 'reason' => 'Akismet', + 'ip' => '', 'user_agent' => '', 'is_retro' => false, + ]); + self::assertStringContainsString('🔴 похоже спам', $r['text']); }