Files
cf7-spam-to-telegram/includes/class-classifier.php
T
Vladimir Bryzgalov 1168a733a4 test(classifier): boundary + array-value tests for has_short_latin_name; @return tags
- Add @return bool to has_valid_email_anywhere docblock
- Add @return bool to has_short_latin_name docblock
- Add Cyrillic note to has_short_latin_name docblock
- Add test_has_short_latin_name_true_for_single_letter_boundary (lower bound)
- Add test_has_short_latin_name_false_for_four_letters_boundary (upper bound)
- Add test_has_short_latin_name_false_for_array_value_with_space_join (regression)

Addresses code review feedback from commit 660a0d2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:04:18 +05:00

270 lines
11 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']);
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
* @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);
}
}