Files
2026-04-21 16:20:28 +05:00

375 lines
15 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Cf7stg;
final class Classifier
{
public const DEFAULT_WEIGHTS = [
'phone_ru' => 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<string,mixed> $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<int,array{sign:string,weight:int,text:string}>}
*/
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<string,string|string[]> $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<string,string|string[]> $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<string,string|string[]|null> $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<string,string|string[]> $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<string,string|string[]> $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;
}
}