Files
cf7-spam-to-telegram/includes/class-classifier.php
T

217 lines
8.5 KiB
PHP
Raw 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,
];
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;
}
}