feat(classifier): weighted heuristic for client/unclear/spam scoring

This commit is contained in:
Vladimir Bryzgalov
2026-04-17 22:21:10 +05:00
parent 3dd0f41446
commit 90940dd928
2 changed files with 373 additions and 0 deletions
+216
View File
@@ -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;
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Cf7stg\Tests;
use Cf7stg\Classifier;
use PHPUnit\Framework\TestCase;
final class ClassifierTest extends TestCase
{
public function test_real_client_inquiry_is_green(): void
{
$fields = [
'your-name' => 'Анна Петровна',
'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);
}
}