feat(classifier): weighted heuristic for client/unclear/spam scoring
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
<?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,
|
||||
];
|
||||
|
||||
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<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']);
|
||||
$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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user