Дубли лидов: защита формы от повторной отправки
Как отсечь дубли лидов и защитить форму от повторной отправки: рабочий код для браузера и сервера, проверка по телефону, тесты на гонку…

Дубли лидов и повторные отправки формы занижают апрув и раздувают статистику. Закрыть это можно двумя скриптами: на стороне браузера ограничить число отправок с одного устройства, а на сервере отсекать заявки с одинаковым телефоном. Ниже оба, с рабочим кодом, который мы проверили, в том числе на одновременных заявках.
Обновлено 1 октября 2026. В первой версии статьи (2024 год) код был рабочим, но с тремя дырами: файл с телефонами скачивался по прямой ссылке, два одновременных лида проходили проверку оба, а номера в разном формате считались разными. Здесь всё это исправлено, разбор ошибок в конце.
Технарь в команде влияет на качество лидов не меньше байера. Байер почти не управляет тем, кто жмёт «отправить», а на сервере дубли и мусор можно резать до того, как они уйдут в партнёрку. Чистый поток держит апрув и ставку: эту же мысль мы разбирали в статье про проверку номеров телефонов по API.
- 1. Не больше двух заявок за 24 часа с устройства
- 2. Отсекаем повторные телефоны на сервере
- Как мы это проверили
- Чего эти скрипты не делают
- Частые вопросы
- Как защитить форму от повторной отправки?
- Почему проверки в браузере недостаточно?
- Как сравнивать телефоны в разном формате?
- Безопасно ли хранить телефоны в файле leads.txt?
- Что будет при двух одновременных заявках с одним номером?
- Как не потерять лид, если партнёрка его не приняла?
1. Не больше двух заявок за 24 часа с устройства
Первый скрипт считает отправки формы в localStorage браузера и после лимита уводит человека на страницу ошибки. Это защита от случайных двойных кликов и от того, кто шлёт заявку за заявкой с одного устройства. Полностью проблему он не решает: localStorage легко очистить, и он привязан к одному браузеру. Поэтому это первый рубеж, а не единственный, серверная проверка по телефону ниже обязательна.
Счётчик фиксирует попытку отправки формы, а не подтверждённый лид: ответ сервера этому обработчику неизвестен. После сетевой ошибки попытка всё равно останется в лимите. Скрипт ставится в <head>. Он слушает отправку любой формы на странице и не мешает другим обработчикам: если отправку уже отменил, например, скрипт маски телефона, счётчик её не считает.
(function () {
var LIMIT = 2; // заявок с одного браузера
var PERIOD = 24 * 60 * 60 * 1000; // за 24 часа
var KEY = 'bc_lead_sends';
var ERROR_URL = 'error.php';
window.addEventListener('submit', function (e) {
if (e.defaultPrevented) return; // отправку уже отменил другой скрипт, например маска телефона
var form = e.target;
if (form.dataset.sent) { // повторный клик, пока первая заявка уходит
e.preventDefault();
return;
}
var now = Date.now(), sends = [];
try { sends = JSON.parse(localStorage.getItem(KEY)) || []; } catch (err) {}
sends = sends.filter(function (t) { return now - t < PERIOD; });
if (sends.length >= LIMIT) {
e.preventDefault();
location.href = ERROR_URL;
return;
}
sends.push(now);
try { localStorage.setItem(KEY, JSON.stringify(sends)); } catch (err) {}
form.dataset.sent = '1';
var btn = e.submitter;
if (btn) setTimeout(function () { btn.disabled = true; }, 0);
});
})();Что в нём учтено по сравнению со старой версией:
- Повторный клик по кнопке. Пока первая заявка уходит, форма помечается, и второй клик отменяется. Так одно нажатие не создаёт два лида.
- Чужие обработчики. Проверка
e.defaultPreventedпропускает формы, отправку которых отменил другой скрипт (валидация, маска). Старый код этого не умел и мог засчитать отправку, которой не было. - Приватный режим. Любое обращение к
localStorageобёрнуто вtry: там, где хранилище запрещено, форма всё равно отправится.
Одна тонкость из документации: если у кнопки отправки стоит name="submit" или id="submit", вызов form.submit() в JavaScript ломается, потому что имя кнопки перекрывает метод формы. Поэтому код слушает событие submit и не вызывает form.submit() вручную.
2. Отсекаем повторные телефоны на сервере
Главная проверка на сервере: один и тот же номер не должен уходить в партнёрку дважды. Старый код хранил номера построчно в файле leads.txt рядом со скриптом и сверял их регулярным выражением. У этого три проблемы, их разберём ниже, а сейчас рабочая версия.
Код кладётся в файл dedup.php и подключается в начале api.php. Перед первым запуском задайте случайный DEDUP_SECRET и, если можно, вынесите папку с данными выше публичной директории.
<?php
// Защита от дублей лидов по телефону. Подключение: require __DIR__ . '/dedup.php';
const DEDUP_SECRET = 'ЗАМЕНИТЕ_НА_СЛУЧАЙНУЮ_СТРОКУ'; // php -r "echo bin2hex(random_bytes(16));"
const DEDUP_TTL = 30 * 86400; // сколько секунд номер считается дублем
const DEDUP_DIR = __DIR__ . '/dedup_data'; // лучше папка вне публичной директории
function dedup_file(string $phone): ?string
{
$digits = preg_replace('/\D+/', '', $phone);
if (strlen($digits) < 7) {
return null; // пустой или мусорный номер отсеет валидация
}
$digits = substr($digits, -10); // 8 916... и +7 916... дают одно значение
if (!is_dir(DEDUP_DIR) && !@mkdir(DEDUP_DIR, 0700, true) && !is_dir(DEDUP_DIR)) {
return null; // нет доступа к папке: лид не теряем
}
return DEDUP_DIR . '/' . hash_hmac('sha256', $digits, DEDUP_SECRET);
}
// true, если номер уже приходил за последние DEDUP_TTL секунд. Новый номер запоминается.
function is_duplicate_lead(string $phone): bool
{
$file = dedup_file($phone);
$lock = $file ? fopen(DEDUP_DIR . '/.lock', 'c') : false;
if (!$lock) {
return false;
}
flock($lock, LOCK_EX); // одновременные заявки проверяются по очереди
clearstatcache(true, $file);
$duplicate = is_file($file) && time() - filemtime($file) < DEDUP_TTL;
if (!$duplicate) {
touch($file);
}
if (mt_rand(1, 200) === 1) { // изредка чистим старые записи
foreach (glob(DEDUP_DIR . '/*') as $old) {
if (time() - filemtime($old) > DEDUP_TTL) {
@unlink($old);
}
}
}
flock($lock, LOCK_UN);
fclose($lock);
return $duplicate;
}
// Если партнёрка не приняла лид, номер нужно забыть, иначе человек не сможет отправить заявку снова.
function forget_lead(string $phone): void
{
$file = dedup_file($phone);
if ($file) {
@unlink($file);
}
}Использование в api.php:
<?php
// api.php, сразу после открывающего <?php
require __DIR__ . '/dedup.php';
$phone = $_POST['phone'] ?? '';
if (is_duplicate_lead($phone)) {
header('Location: error.php?reason=duplicate', true, 303);
exit;
}
// ... здесь обычная отправка лида в партнёрскую сеть ...
$accepted = true; // ответ партнёрки: приняла заявку или нет
if (!$accepted) {
forget_lead($phone); // чтобы человек мог отправить заявку ещё раз
}
header('Location: success.html', true, 303);
exit;Что исправлено:
- Нормализация номера. В этом примере для российских номеров остаются последние 10 цифр. Для международной формы так делать нельзя: номера разных стран могут иметь одинаковый суффикс. Там сравнивайте полный номер в едином международном формате с кодом страны. Для российского примера
+7 916 123-45-67,8 (916) 123-45-67и9161234567это один номер. Старый код сравнивал строки как есть, и те же цифры в разном виде считал разными. - Блокировка на время записи. Проверка и запись идут под
flock, поэтому две одновременные заявки с одним номером не пройдут обе. У PHP это переносимая блокировка файла. - Данные не в открытом виде. Вместо общего
leads.txtкаждый номер превращается вhash_hmac('sha256', …)с вашим секретом и становится именем пустого файла. Даже если папку скачают, телефонов там нет. Про hash_hmac в документации PHP. - Срок хранения. Номер считается дублем
DEDUP_TTLсекунд (по умолчанию 30 дней), старые записи изредка чистятся сами. Старыйleads.txtрос без ограничения, и сверка по нему со временем замедляла каждую заявку. - Откат, если ПП не приняла. Функция
forget_leadубирает номер, когда партнёрка отказала в заявке, иначе человек не смог бы отправить её повторно.
Как мы это проверили
Код гоняли локально на PHP. Разные форматы одного номера дают один результат, мусор вроде «123» проходит как не-дубль (его отсечёт валидация формы), по истечении срока номер снова считается новым. Главный тест на гонку: 20 процессов одновременно слали один номер. Старый код пропустил все 20 как новые, а новый принял одну заявку и отсёк 19 дублей. На двух одновременных заявках старый код в 10 запусках из 10 пропускал оба лида, а новый ни разу.
Полную цепочку (форма, лимит, сервер) проверили в браузере: тройной клик по кнопке дал один запрос на сервер, обновление страницы «спасибо» второй заявки не создало (переход сделан через 303-редирект, повторная отправка POST не происходит), третья заявка с устройства ушла на ошибку по лимиту, а тот же номер из другого браузера сервер отсёк как дубль.

Чего эти скрипты не делают
Это защита от грубых дублей, а не проверка качества номера. Скрипт не знает, существует ли номер и какой это оператор. Чтобы не пускать в партнёрку несуществующие номера, их проверяют отдельно, через API: разбор есть в статье про проверку номеров по API. Там же про то, как чистый поток влияет на ставку от ПП.
Ещё одна частая мера, которую можно совместить с проверкой на сервере, маска поля телефона: она не даёт отправить номер в неверном формате. Готовую маску под Keitaro мы разбирали в статье про универсальную маску для поля с номером телефона.
Частые вопросы
Как защитить форму от повторной отправки?
Двумя рубежами. В браузере, через localStorage, ограничить число отправок с устройства и гасить двойные клики. На сервере, обязательно, отсекать повторные телефоны под блокировкой файла, чтобы одновременные заявки не прошли обе.
Почему проверки в браузере недостаточно?
localStorage привязан к одному браузеру и легко очищается, а в приватном режиме может быть недоступен. Поэтому клиентский лимит это только первый рубеж, решает вопрос серверная проверка по телефону.
Как сравнивать телефоны в разном формате?
Для российских номеров в этом примере остаются последние 10 цифр. Не применяйте это правило ко всем странам: международные номера сравнивают целиком, с кодом страны. Тогда +7 916 123-45-67 и 8 916 123 45 67 станут одним номером. Без нормализации те же цифры в разном написании считаются разными.
Безопасно ли хранить телефоны в файле leads.txt?
Нет, если файл лежит в публичной папке: его могут скачать по прямой ссылке. Храните хеши вместо номеров и держите папку с данными выше корня сайта.
Что будет при двух одновременных заявках с одним номером?
Без блокировки обе пройдут проверку и уйдут дублем. В коде выше проверка и запись идут под flock, поэтому вторая заявка отсекается. На тесте с 20 параллельными запросами проходил только один.
Как не потерять лид, если партнёрка его не приняла?
После отказа ПП вызвать forget_lead с этим номером. Тогда запись о нём снимется, и человек сможет отправить заявку заново.



