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, 'drop' => -5, ]; public const DEFAULT_HARD_DROP_RULES = [ 'short_latin_name' => true, 'placeholder_fields' => true, ]; 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', ]; /** * Explicit service field keys excluded from placeholder count. * Note: keys starting with `_wpcf7` are also skipped via prefix match * (see count_placeholder_fields), so this list does not need to enumerate * every `_wpcf7_*` variant — only non-prefixed service keys. */ private const SERVICE_FIELD_KEYS = [ 'acceptance-data', '_wpcf7', '_wpcf7_version', '_wpcf7_locale', '_wpcf7_unit_tag', '_wpcf7_container_post', '_wpcf7_posted_data_hash', 'g-recaptcha-response', ]; /** * @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']); if ($name === '') $name = FieldDetector::find_name($fields); $email = self::pick_field($fields, ['your-email', 'email', 'e-mail', 'mail']); if ($email === '') $email = FieldDetector::find_email($fields); $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'; } // Drop has highest priority (overrides client/unclear/spam/honeypot) $drop_trigger = self::compute_hard_drop_trigger($fields); $is_drop_by_score = isset($thresholds['drop']) && $score <= (int)$thresholds['drop']; if ($drop_trigger !== null || $is_drop_by_score) { $label = 'drop'; $reason_text = $drop_trigger ?? ('score ≤ ' . (int)$thresholds['drop']); $reasons[] = ['sign' => '×', 'weight' => 0, 'text' => $reason_text]; } 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; } /** * Checks whether any field value contains a syntactically valid email address. * * Used by drop-logic to spare submissions that include any plausible contact email * (any TLD, including gmail.com and custom domains — not limited to RU domains). * * Handles flat string values and one level of array nesting (matches CF7 posted_data * shape). Deeper nesting is intentionally NOT supported. * * @param array $fields CF7 posted_data * @return bool True if any value contains a syntactically valid email */ public static function has_valid_email_anywhere(array $fields): bool { foreach ($fields as $v) { $values = is_array($v) ? $v : [$v]; foreach ($values as $val) { $val = trim((string)$val); if ($val === '') continue; if (preg_match_all('/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/', $val, $matches)) { foreach ($matches[0] as $candidate) { if (is_email($candidate)) return true; } } } } return false; } /** * Checks whether the form's name field contains 1-3 ASCII alphanumeric characters * (typical bot pattern: "OB", "MU", "A1B"). Used by drop hard-rule. * * Looks first at canonical CF7 keys (your-name, name, имя, fio), then falls back to * the value-based heuristic in FieldDetector::find_name(). Returns false if no name * field is present (i.e. forms without a name field are immune to this rule). * * Cyrillic look-alikes (e.g. 'ОВ') are rejected because the regex is byte-range. * * @param array $fields CF7 posted_data * @return bool True if a name field exists and is 1-3 ASCII alphanumeric chars */ public static function has_short_latin_name(array $fields): bool { $name = self::pick_field($fields, ['your-name', 'name', 'имя', 'fio']); if ($name === '') $name = FieldDetector::find_name($fields); $name = trim($name); if ($name === '') return false; return (bool)preg_match('/^[A-Za-z0-9]{1,3}$/', $name); } /** * Counts non-empty fields whose value is exactly 1-2 ASCII alphanumeric chars * (the typical "AB", "70" placeholder pattern bots fill custom-keyed fields with). * * Skips service keys (acceptance-data, _wpcf7*, g-recaptcha-response) and any key * starting with `_wpcf7`. Array values are joined without separators before length * check (so `['A','B']` collapses to "AB" — still counted). * * @param array $fields CF7 posted_data * @return int Number of fields matching the placeholder pattern */ public static function count_placeholder_fields(array $fields): int { $count = 0; foreach ($fields as $k => $v) { if (is_string($k)) { if (in_array($k, self::SERVICE_FIELD_KEYS, true)) continue; if (strpos($k, '_wpcf7') === 0) continue; } if (is_array($v)) $v = implode('', array_map('strval', $v)); $v = trim((string)$v); if ($v === '') continue; if (!preg_match('/^[A-Za-z0-9]{1,2}$/', $v)) continue; $count++; } return $count; } /** * Returns the hard-rule trigger description, or null if no rule fires. * Centralises positive-signal checks + trigger detection so callers don't * recompute. Used by evaluate_hard_drop() and classify()'s reason builder. * * @param array $fields CF7 posted_data * @return string|null e.g. "hard-rule: короткое латинское имя + ≥3 поля-плейсхолдера" */ private static function compute_hard_drop_trigger(array $fields): ?string { $rules = apply_filters('cf7stg_hard_drop_rules', self::DEFAULT_HARD_DROP_RULES); // Positive-signal short-circuits $name = self::pick_field($fields, ['your-name', 'name', 'имя', 'fio']); if ($name === '') $name = FieldDetector::find_name($fields); if ($name !== '' && self::is_cyrillic_name($name)) return null; $message = self::pick_field($fields, ['your-message', 'message', 'comment', 'сообщение', 'вопрос']); if ($message !== '' && self::is_meaningful_text($message)) return null; if (self::has_valid_email_anywhere($fields)) return null; // Triggers $hits = []; if (!empty($rules['short_latin_name']) && self::has_short_latin_name($fields)) { $hits[] = 'короткое латинское имя'; } if (!empty($rules['placeholder_fields']) && self::count_placeholder_fields($fields) >= 3) { $hits[] = '≥3 поля-плейсхолдера'; } if (empty($hits)) return null; return 'hard-rule: ' . implode(' + ', $hits); } /** * 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 { return self::compute_hard_drop_trigger($fields) !== null; } }