266 lines
11 KiB
PHP
266 lines
11 KiB
PHP
<?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']);
|
||
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';
|
||
}
|
||
|
||
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
|
||
*/
|
||
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).
|
||
*
|
||
* @param array<string,string|string[]> $fields CF7 posted_data
|
||
*/
|
||
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);
|
||
}
|
||
}
|