Перейти к содержанию

Дубли лидов: защита формы от повторной отправки

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

Дубли лидов: защита формы от повторной отправки

Дубли лидов и повторные отправки формы занижают апрув и раздувают статистику. Закрыть это можно двумя скриптами: на стороне браузера ограничить число отправок с одного устройства, а на сервере отсекать заявки с одинаковым телефоном. Ниже оба, с рабочим кодом, который мы проверили, в том числе на одновременных заявках.

Обновлено 1 октября 2026. В первой версии статьи (2024 год) код был рабочим, но с тремя дырами: файл с телефонами скачивался по прямой ссылке, два одновременных лида проходили проверку оба, а номера в разном формате считались разными. Здесь всё это исправлено, разбор ошибок в конце.

Технарь в команде влияет на качество лидов не меньше байера. Байер почти не управляет тем, кто жмёт «отправить», а на сервере дубли и мусор можно резать до того, как они уйдут в партнёрку. Чистый поток держит апрув и ставку: эту же мысль мы разбирали в статье про проверку номеров телефонов по API.

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 не происходит), третья заявка с устройства ушла на ошибку по лимиту, а тот же номер из другого браузера сервер отсёк как дубль.

Тест отсечения дублей лидов: 20 одновременных заявок с одним номером и проверка формы в браузере
Прогон на стенде: старый код пропустил 20 одинаковых заявок, новый принял одну. Тройной клик дал один запрос на сервер

Чего эти скрипты не делают

Это защита от грубых дублей, а не проверка качества номера. Скрипт не знает, существует ли номер и какой это оператор. Чтобы не пускать в партнёрку несуществующие номера, их проверяют отдельно, через API: разбор есть в статье про проверку номеров по API. Там же про то, как чистый поток влияет на ставку от ПП.

Ещё одна частая мера, которую можно совместить с проверкой на сервере, маска поля телефона: она не даёт отправить номер в неверном формате. Готовую маску под Keitaro мы разбирали в статье про универсальную маску для поля с номером телефона.

Частые вопросы

Как защитить форму от повторной отправки?

Двумя рубежами. В браузере, через localStorage, ограничить число отправок с устройства и гасить двойные клики. На сервере, обязательно, отсекать повторные телефоны под блокировкой файла, чтобы одновременные заявки не прошли обе.

Почему проверки в браузере недостаточно?

localStorage привязан к одному браузеру и легко очищается, а в приватном режиме может быть недоступен. Поэтому клиентский лимит это только первый рубеж, решает вопрос серверная проверка по телефону.

Как сравнивать телефоны в разном формате?

Для российских номеров в этом примере остаются последние 10 цифр. Не применяйте это правило ко всем странам: международные номера сравнивают целиком, с кодом страны. Тогда +7 916 123-45-67 и 8 916 123 45 67 станут одним номером. Без нормализации те же цифры в разном написании считаются разными.

Безопасно ли хранить телефоны в файле leads.txt?

Нет, если файл лежит в публичной папке: его могут скачать по прямой ссылке. Храните хеши вместо номеров и держите папку с данными выше корня сайта.

Что будет при двух одновременных заявках с одним номером?

Без блокировки обе пройдут проверку и уйдут дублем. В коде выше проверка и запись идут под flock, поэтому вторая заявка отсекается. На тесте с 20 параллельными запросами проходил только один.

Как не потерять лид, если партнёрка его не приняла?

После отказа ПП вызвать forget_lead с этим номером. Тогда запись о нём снимется, и человек сможет отправить заявку заново.

Редакция BoostClicks

Материалы о настройке рекламы, трекинге и автоматизации: инструкции, примеры кода и расчёты с указанием условий проверки.