И/ТОП.03 · ТЕМА 07Форма → заявка → качество данных
01 / Событие и заявкаИСХОДНИК 171 · 1 / 84

Мы умеем видеть заявку в аналитике

UTM, сайт на Vercel и Google Tag уже дают события. Теперь найдём место для самой заявки.

ОДНО ДЕЙСТВИЕFORM SUBMITПользователь отправляет форму
АНАЛИТИКА ПОВЕДЕНИЯ

GA4

generate_lead

Событие попытки отправки.
Без имени и email.

ОБРАБОТКА ЗАЯВКИ

Apps Script → Sheets

request_id · status · UTM

Запись после проверки.
Имя и email — только здесь.

01 / Событие и заявкаИСХОДНИК 172 · 2 / 84

Событие и заявка — разные данные

GA4 · что сделал посетитель

generate_lead · страница · источник · кампания

Таблица · что обработать

request_id · created_at · name · email · status

01 / Событие и заявкаИСХОДНИК 173 · 3 / 84

Разделяем аналитику и бизнес-данные

ОДНО ДЕЙСТВИЕFORM SUBMITПользователь отправляет форму
АНАЛИТИКА ПОВЕДЕНИЯ

GA4

generate_lead

Событие попытки отправки.
Без имени и email.

ОБРАБОТКА ЗАЯВКИ

Apps Script → Sheets

request_id · status · UTM

Запись после проверки.
Имя и email — только здесь.

01 / Событие и заявкаИСХОДНИК 174 · 4 / 84

Маршрут занятия

  1. 01Модель заявки
  2. 02Таблица leads
  3. 03Правила качества
  4. 04Apps Script
  5. 05Форма + UTM
  6. 06POST + GA4
  7. 07Проверка записи
  8. 08Ошибки и дубли
01 / Событие и заявкаИСХОДНИК 264 · 5 / 84

Так будет выглядеть таблица в конце

Девять столбцов, закреплённая строка заголовков, формат даты у created_at и список статусов в последнем столбце.

Две строки внизу — настоящие заявки, отправленные формой сайта: одна пришла без меток, вторая по ссылке из Telegram.

264-shot.png · Результат всех восьми этапов занятия.
01 / Событие и заявкаИСХОДНИК 175 · 6 / 84

Какие данные хранит заявка

Идентичность

request_id · created_at

Содержание

name · email · direction

Источник

utm_source · utm_medium · utm_campaign

Состояние

status

01 / Событие и заявкаИСХОДНИК 176 · 7 / 84

Как будет выглядеть результат

Пример:

text
request_id   REQ-031A5470-9AE5-4305-B6A1-095B91D9C278
created_at   2026-09-23 12:40
name         Иван
email        student@example.com
direction    backend
utm_source   telegram
utm_medium   social
utm_campaign september
status       new
01 / Событие и заявкаИСХОДНИК 177 · 8 / 84

Не все данные вводит пользователь

Человек

name · email · direction

Адрес страницы

utm_source · utm_medium · utm_campaign

Код

request_id — браузер; created_at и status — сервер

01 / Событие и заявкаИСХОДНИК 178 · 9 / 84

Не доверяем пользователю системные поля

В форме

Имя, email и направление.

В коде браузера

request_id и UTM можно изменить через DevTools: сервер проверяет их.

Только на сервере

created_at и status создаёт Apps Script.

01 / Событие и заявкаИСХОДНИК 179 · 10 / 84

Сегодня не используем реальные персональные данные

В практической работе используйте тестовые значения.

Например:

text
Иван Тестов
student@example.com

Не используйте:

  • реальные телефоны
  • личные email
  • пароли
  • паспортные данные
  • другие чувствительные сведения
02 / Контролируемая таблицаИСХОДНИК 180 · 11 / 84

Шаг 1. Открыть Google Sheets

sheets.google.com→Пустая таблица

Откройте Google Таблицы и создайте новую пустую таблицу.

ОТКРЫТЬ В НОВОЙ ВКЛАДКЕsheets.google.comКнопка «Пустая таблица» в блоке «Создать таблицу»
180-shot.png · Кнопка: Blank spreadsheet / Пустая таблица
02 / Контролируемая таблицаИСХОДНИК 181 · 12 / 84

Шаг 2. Назвать документ

Клик по названию «Новая таблица»→Ввести GA4 Analytics Lab — Leads→Enter

Название:

text
GA4 Analytics Lab — Leads

или:

text
GA4 Leads — Иванов

По названию должно быть сразу понятно, для чего таблица.

Должно получитьсяВ шапке документа вместо «Новая таблица» стоит ваше название, а рядом ненадолго появляется надпись «Сохранено на Диске».

181-shot.png · Google Sheets. Выделить название документа.
02 / Контролируемая таблицаИСХОДНИК 182 · 13 / 84

Шаг 3. Переименовать лист

Двойной клик по ярлыку «Лист1»→Ввести leads→Enter

Переименуйте первый лист:

text
leads

По этому названию Apps Script найдёт лист, поэтому пишите его точно так же, маленькими латинскими буквами.

182-shot.png · leads
02 / Контролируемая таблицаИСХОДНИК 183 · 14 / 84

Шаг 4. Структура таблицы

Вставьте заголовки в A1:I1. Закрепление строки и формат даты — три следующих шага.

TSV · копировать в A1
request_id	created_at	name	email	direction	utm_source	utm_medium	utm_campaign	status

Должно получитьсяПервая строка занята девятью заголовками, по одному в каждой ячейке от A1 до I1.

183-shot.png · Первая строка Google Sheets со всеми заголовками.
02 / Контролируемая таблицаИСХОДНИК 265 · 15 / 84

Шаг 4а. Закрепить первую строку

Клик по любой ячейке→Вид→Закрепить→1 строку

Пока строка не закреплена, при прокрутке заголовки уезжают вверх и непонятно, в каком столбце вы находитесь.

Клик по любой ячейке → Вид → Закрепить → 1 строку.

Под первой строкой появится серая разделительная полоса: по ней видно, что строка закреплена.

265-shot.png · Вид → Закрепить → 1 строку
02 / Контролируемая таблицаИСХОДНИК 272 · 16 / 84

Как это выглядит при прокрутке

Прокрутите лист вниз. Строка с заголовками осталась вверху, под ней — серая разделительная полоса.

Номера строк при этом идут с большими значениями: видно, что таблица прокручена, а шапка осталась на месте.

Если полосы нет и заголовки уехали — закрепление не применилось, вернитесь на шаг назад.

272-shot.png · Серая полоса под строкой 1
02 / Контролируемая таблицаИСХОДНИК 266 · 17 / 84

Шаг 4б. Формат даты и времени

Заголовок столбца B→Формат→Числа→Дата и время

Apps Script пришлёт в столбец created_at настоящую дату, но без формата таблица покажет её как число.

Клик по заголовку столбца B → Формат → Числа → Дата и время.

Формат задаём заранее, до первой заявки: тогда первая же запись сразу читается как дата.

266-shot.png · Выделен весь столбец B
02 / Контролируемая таблицаИСХОДНИК 267 · 18 / 84

Шаг 4в. Подогнать ширину столбцов

Заголовки A…I→Правый клик→Изменить размер столбцов A–I→Автоподбор размера→ОК

request_id длинный: в столбце по умолчанию он обрезается многоточием и номер заявки не прочитать целиком.

Выделите заголовки A…I → правый клик по любому из них → Изменить размер столбцов A–I → Автоподбор размера.

Ширина подстроится под самое длинное значение в каждом столбце.

Должно получитьсяrequest_id виден целиком, без многоточия. Остальные столбцы сузились до ширины своего содержимого.

267-shot.png · Автоподбор размера
02 / Контролируемая таблицаИСХОДНИК 184 · 19 / 84

Не смешиваем разные сущности

Правило нашей таблицы:

одна строка = одна заявка

Не объединяем несколько заявок в одну ячейку.

Не вставляем комментарии между строками данных.

Не создаём отдельную таблицу для каждого источника.

02 / Контролируемая таблицаИСХОДНИК 185 · 20 / 84

Каждая заявка должна иметь ID

request_id — уникальный идентификатор конкретной заявки.

Например:

text
REQ-031A5470-9AE5-4305-B6A1-095B91D9C278

Он нужен, чтобы:

  • найти заявку
  • связать записи между системами
  • обнаружить повторную доставку
  • проводить диагностику
02 / Контролируемая таблицаИСХОДНИК 186 · 21 / 84

created_at: время получения заявки

created_at хранит дату и время появления записи.

Например:

text
24.09.2026 9:37:48

Время записывает сервер в момент, когда заявка пришла. Посетитель дату не вводит.

02 / Контролируемая таблицаИСХОДНИК 187 · 22 / 84

UTM-метки сохраняются вместе с заявкой

Если пользователь пришёл по ссылке:

text
?utm_source=telegram
&utm_medium=social
&utm_campaign=september

эти значения необходимо сохранить вместе с заявкой.

Тогда можно связать:

источник

↓

реальную заявку

↓

её дальнейший статус.

02 / Контролируемая таблицаИСХОДНИК 188 · 23 / 84

Статусы заявки

Используем ограниченный набор статусов:

text
new
in_progress
done
rejected

Не допускаем свободные варианты:

text
Новая
NEW
готово
готов
Finished
обработал
02 / Контролируемая таблицаИСХОДНИК 189 · 24 / 84

Шаг 5. Проверка данных для status

Выделить I2:I→Данные→Настроить проверку данных→Добавить правило→Раскрывающийся список

Выделите I2:I → Данные → Настроить проверку данных. Заголовок не включаем.

DROPDOWN
new
in_progress
done
rejected
I2:I
Правило начинается со второй строки: в первой лежит заголовок столбца.
Раскрывающийся список
Sheets показывает в ячейке чип со стрелкой; набрать свой текст нельзя.
Запрещать ввод данных
Любое значение, кроме этих четырёх, ячейка не примет совсем.

Должно получитьсяВ ячейках столбца status появился серый чип со стрелкой. По стрелке открываются ровно четыре значения и ничего больше.

189-shot.png · Dropdown и четыре значения.
02 / Контролируемая таблицаИСХОДНИК 190 · 25 / 84

Контролируем словарь значений

Без проверки легко получить:

text
new
NEW
New
новая

Для аналитики это четыре разных значения.

Проверка данных (Data Validation) оставляет в ячейке только значения из заранее заданного списка.

02 / Контролируемая таблицаИСХОДНИК 191 · 26 / 84

Шаг 6. Попробовать сломать правило

Вручную попробуйте написать:

text
finished

в ячейке status.

Google Sheets должен показать, что значение не соответствует правилу.

После теста удалите экспериментальную строку.

191-shot.png · Попытка ввести invalid status.
02 / Контролируемая таблицаИСХОДНИК 192 · 27 / 84

Какие поля считаем обязательными

Минимально обязательны:

text
request_id
created_at
name
email
direction
status

UTM может отсутствовать.

Например, пользователь может открыть сайт напрямую.

В этом случае используем:

text
utm_source = direct
utm_medium = none
utm_campaign = not_set
02 / Контролируемая таблицаИСХОДНИК 193 · 28 / 84

Шаг 7. Условное форматирование

Выделить A2:I→Формат → Условное форматирование→Добавить правило→Ваша формула

Для диапазона A2:I выберите «Ваша формула». Подсвечиваем только начатые строки с пропущенным обязательным полем.

GOOGLE SHEETS
=AND($A2<>"";COUNTBLANK($C2:$E2)>0)

Словами: если в строке уже есть ID, но имя, email или направление не заполнены — покрасить строку.

$A2<>""
«В ячейке A2 не пусто». Знак <> читается как «не равно», а две кавычки подряд означают пустоту.
COUNTBLANK($C2:$E2)
«Посчитай пустые ячейки с C по E» — это name, email и direction.
>0
«Пустых больше нуля», то есть хотя бы одна пустая нашлась.
AND(…;…)
«И то, и другое сразу». Знак $ закрепляет столбец, а номер строки Sheets подставляет свой.
193-shot.png · Format → Conditional formatting. Показать правило для пустой ячейки.
02 / Контролируемая таблицаИСХОДНИК 270 · 29 / 84

Как выглядит подсветка

Впишите в пустую строку только request_id и имя, а email и направление оставьте пустыми.

Строка целиком станет красной: ID в строке есть, а из трёх обязательных полей заполнено только одно.

Заполните недостающие поля, и подсветка пропадёт.

270-shot.png · Подсвечивается вся строка A–I
02 / Контролируемая таблицаИСХОДНИК 268 · 30 / 84

Шаг 8а. Выделить столбец

Клик по A2→⌘ + Shift + ↓→Добавить правило

Второе правило работает только по столбцу request_id, поэтому диапазон у него другой.

Кликните в сетке по ячейке A2, затем нажмите ⌘ + Shift + ↓ — выделение дойдёт до конца столбца.

Если в столбце уже есть заявки, сочетание сначала остановится на последней заполненной строке. Нажмите его ещё раз.

Поле «Применить к диапазону» в панели заполнится само. Набирать диапазон руками в этом поле не нужно: панель принимает только выделение из сетки.

268-shot.png · Сначала выделение в сетке, потом «Добавить правило»
02 / Контролируемая таблицаИСХОДНИК 194 · 31 / 84

Шаг 8б. Контроль request_id

Формат→Условное форматирование→Добавить правило→Ваша формула

Диапазон A2:A → Условное форматирование → Ваша формула.

GOOGLE SHEETS
=AND($A2<>"";COUNTIF(A:A;$A2)>1)

Словами: если ID заполнен и точно такой же ID уже есть в столбце — покрасить ячейку.

COUNTIF(A:A;$A2)
«Посчитай, сколько раз значение из A2 встречается в столбце A».
A:A
Весь столбец целиком, вместе со строками, которых пока нет.
>1
«Больше одного раза». Ячейка считает и саму себя, поэтому обычный ID даёт единицу, а дубль — два и больше.
$A2<>""
«В A2 не пусто». Пустые ячейки одинаковы между собой, но дублями заявок не считаются.

Должно получитьсяПравил в панели стало два. Одинаковый request_id в двух строках теперь красится оранжевым.

194-shot.png · Custom formula.
02 / Контролируемая таблицаИСХОДНИК 269 · 32 / 84

Проверяем: правил стало два

Нажмите Готово. В панели останется список из двух правил.

У первого диапазон по всем девяти столбцам — подсветка неполной заявки. У второго только столбец A — повторяющийся request_id.

Порядок в списке важен: если два правила совпадут на одной ячейке, Sheets применит то, что выше.

269-shot.png · Разные диапазоны у разных правил
02 / Контролируемая таблицаИСХОДНИК 271 · 33 / 84

Два правила на одном листе

Теперь на листе работают обе проверки, различить их можно по цвету и по ширине подсветки.

Красная строка — неполная заявка, подсвечена целиком от A до I.

Оранжевая ячейка — повторяющийся request_id, подсвечен только столбец A.

271-shot.png · Красная строка и оранжевая ячейка
02 / Контролируемая таблицаИСХОДНИК 195 · 34 / 84

Одинаковый email ≠ обязательно дубль

Пользователь может оставить несколько разных заявок.

Поэтому:

text
одинаковый email

ещё не означает технический дубль.

В нашем стенде дубль определяется как повторная запись с тем же:

text
request_id
02 / Контролируемая таблицаИСХОДНИК 196 · 35 / 84

Итог этапа: таблица подготовлена

  1. 01Структура ✓
  2. 02Статусы ✓
  3. 03Обязательные поля ✓
  4. 04Дубли ✓
03 / Приёмник Apps ScriptИСХОДНИК 197 · 36 / 84

Путь заявки от сайта до таблицы

  1. 01Форма на Vercel
  2. 02POST
  3. 03Apps Script
  4. 04Лист leads
03 / Приёмник Apps ScriptИСХОДНИК 198 · 37 / 84

Google Apps Script

Google Apps Script — среда, в которой код на JavaScript выполняется на серверах Google и работает с их сервисами, в том числе с Таблицами.

В этой теме скрипт выполняет шесть действий:

  • получить данные формы
  • проверить обязательные поля
  • проверить ID
  • добавить timestamp
  • назначить статус new
  • записать строку в Google Sheets
03 / Приёмник Apps ScriptИСХОДНИК 199 · 38 / 84

Шаг 9. Расширения → Apps Script

Расширения→Apps Script

В таблице откройте:

Расширения

↓

Apps Script

Откроется редактор скрипта, связанный с этой таблицей.

199-shot.png · Apps Script.
03 / Приёмник Apps ScriptИСХОДНИК 200 · 39 / 84

Шаг 10. Открыть Code.gs

В редакторе уже существует файл:

textCode.gs ↓
Code.gs

Удалите демонстрационную функцию myFunction, если она есть.

Мы создадим собственную функцию:

javascript
doPost(e)
Code.gs
Файл со скриптом. Он уже создан, отдельно заводить ничего не нужно.
myFunction
Демонстрационная заготовка Google. Нам она не нужна — удаляем целиком.
doPost(e)
Имя задано правилами Google: когда на адрес приходит POST, Google ищет функцию с именем doPost.

Должно получитьсяСлева остался один файл Code.gs, а в редакторе — пустое место под вашу функцию.

200-shot.png · Apps Script Editor. Слева Code.gs.
03 / Приёмник Apps ScriptИСХОДНИК 212 · 40 / 84

Шаг 10а. Вставить готовый Code.gs

Скачать Code.gs→Выделить всё в редакторе: ⌘ + A→Вставить→Подставить свой ID в строку 2→⌘ + S

Сначала скачайте Code.gs и вставьте его целиком. Затем подставьте ID таблицы — это следующие два слайда. После них идёт разбор файла по шагам.

Должно получитьсяВ редакторе 60 строк, над списком функций появилось имя doPost — код разобран без ошибок.

212-shot.png · Apps Script Editor с итоговым кодом.
03 / Приёмник Apps ScriptИСХОДНИК 273 · 41 / 84

Откуда копировать ID таблицы

Откройте свою таблицу и посмотрите на адресную строку. Адрес устроен так:

text
docs.google.com/spreadsheets/d/ВАШ_ID/edit

ID — длинная строка между /d/ и /edit. Скопируйте её целиком, без самих слэшей.

Это не название таблицы и не ссылка «Поделиться»: у копии таблицы будет свой ID.

273-shot.png · Между /d/ и /edit
03 / Приёмник Apps ScriptИСХОДНИК 202 · 42 / 84

Шаг 11. Выбираем таблицу

Code.gsстрока 2 и строка 28

Вставьте скопированный ID в константу в самом начале файла. Ниже, внутри doPost, по этому ID открывается лист.

Code.gsCode.gs ↓
// строка 2 — сюда вставляется ID
const SPREADSHEET_ID = 'ВАШ_ID_ТАБЛИЦЫ';

// ниже, внутри doPost
const sheet = SpreadsheetApp
  .openById(SPREADSHEET_ID)
  .getSheetByName('leads');
openById(SPREADSHEET_ID)
Web App работает без открытой таблицы, поэтому таблицу находим по её ID из адреса.
getSheetByName('leads')
Лист открывается по имени: листов в таблице может стать больше, и первым по порядку окажется другой.

Должно получитьсяНад списком функций появится имя doPost: редактор разобрал код и не нашёл ошибок.

202-shot.png · Начало файла Code.gs с константой SPREADSHEET_ID.
03 / Приёмник Apps ScriptИСХОДНИК 274 · 43 / 84

Как устроен Code.gs

Файл целиком — слева, шаги — справа. Сравнение для всего разбора: приёмник работает как администратор в приёмной. Он получает бланк, проверяет его и записывает в журнал — лист leads.

Code.gsCode.gs ↓
// Укажите ID своей таблицы между /d/ и /edit в её URL.const SPREADSHEET_ID = 'PASTE_YOUR_SPREADSHEET_ID';const HEADERS = ['request_id', 'created_at', 'name', 'email', 'direction',  'utm_source', 'utm_medium', 'utm_campaign', 'status'];function doPost(e) {  const p = (e && e.parameter) || {};  const clean = key => String(p[key] || '').trim();  const missing = ['request_id', 'name', 'email', 'direction'].filter(key => !clean(key));  if (missing.length) return jsonResponse({ok:false, error:'missing_required', fields:missing});  const id = clean('request_id');  if (!/^REQ-[A-F0-9]{8}-[A-F0-9]{4}-4[A-F0-9]{3}-[89AB][A-F0-9]{3}-[A-F0-9]{12}$/i.test(id)) {    return jsonResponse({ok:false, error:'invalid_request_id'});  }  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean('email'))) {    return jsonResponse({ok:false, error:'invalid_email'});  }  if (!['frontend','backend','data'].includes(clean('direction'))) {    return jsonResponse({ok:false, error:'invalid_direction'});  }  if (['name','email','utm_source','utm_medium','utm_campaign'].some(key => clean(key).length > 200)) {    return jsonResponse({ok:false, error:'value_too_long'});  }  // Проверка ID и запись выполняются под одной блокировкой.  const lock = LockService.getScriptLock();  if (!lock.tryLock(10000)) return jsonResponse({ok:false, error:'busy_retry'});  try {    const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName('leads');    if (!sheet || sheet.getRange(1,1,1,9).getValues()[0].join('|') !== HEADERS.join('|')) {      return jsonResponse({ok:false, error:'invalid_sheet_schema'});    }    const duplicate = sheet.getRange('A:A').createTextFinder(id)      .matchEntireCell(true).matchCase(false).useRegularExpression(false).findNext();    if (duplicate) return jsonResponse({ok:false, error:'duplicate_request_id'});    const createdAt = new Date();    const source = clean('utm_source') || 'direct';    const medium = clean('utm_medium') || 'none';    const campaign = clean('utm_campaign') || 'not_set';    const status = 'new'; // Присланный клиентом status игнорируется.    sheet.appendRow([id, createdAt, textCell(clean('name')), textCell(clean('email')),      clean('direction'), textCell(source), textCell(medium), textCell(campaign), status]);    SpreadsheetApp.flush();    return jsonResponse({ok:true, request_id:id});  } catch (error) {    // Не возвращаем клиенту детали таблицы и содержимое заявки.    return jsonResponse({ok:false, error:'storage_error'});  } finally {    lock.releaseLock();  }}function textCell(value) {  // Не даём введённому тексту превратиться в формулу Google Sheets.  return /^[=+@-]/.test(value) ? "'" + value : value;}function jsonResponse(data) {  return ContentService.createTextOutput(JSON.stringify(data))    .setMimeType(ContentService.MimeType.JSON);}
  1. Приём
  2. Проверка
  3. Запись под замком
  4. Строка в листе
  5. Ответ

1 / 19Строки 1–4

Настройки в начале файла

Первые строки задают значения, которые нужны остальному коду. Слово const создаёт константу: имя, за которым закреплено значение. Дальше в коде пишется короткое имя вместо длинного значения.

// Укажите ID…
Комментарий. Строку, которая начинается с //, Apps Script не выполняет — это пояснение для человека.
const SPREADSHEET_ID = '…'
ID вашей таблицы: длинная строка из её адреса между /d/ и /edit. По нему скрипт найдёт таблицу. Кавычки означают, что значение — текст, в программировании его называют строкой.
const HEADERS = ['request_id', …]
Массив — список значений в квадратных скобках через запятую. Здесь девять названий столбцов в том порядке, в каком они стоят в первой строке листа leads. В строке 29 код сверит по этому списку, что лист устроен правильно.

Как в жизниТабличка на двери приёмной: в какой журнал записывать заявки и какие графы в нём есть.

04 / Публикация Web AppИСХОДНИК 213 · 44 / 84

Шаг 12. Сохранить проект

Клик по названию проекта→Ввести имя→Переименовать→⌘ + S

Сохраните проект Apps Script.

Название проекта:

text
GA4 Analytics Lab — Leads Receiver

Сейчас код сохранён только в редакторе. Чтобы сайт мог отправлять ему запросы, проект нужно развернуть как веб-приложение.

Должно получитьсяРядом с названием проекта появится облачко с галочкой — код сохранён. В списке функций станет доступна doPost.

213-shot.png · Название проекта и кнопка Save.
04 / Публикация Web AppИСХОДНИК 214 · 45 / 84

Шаг 13. Новое развертывание

Начать развертывание→Новое развертывание

В правом верхнем углу нажмите:

Начать развертывание

↓

Новое развертывание

Должно получитьсяОткроется окно «Новое развертывание»: тип ещё не выбран, кнопка запуска серая.

214-shot.png · Apps Script с раскрытым Deploy.
04 / Публикация Web AppИСХОДНИК 215 · 46 / 84

Шаг 14. Выбрать тип

Шестерёнка «Выберите тип»→Веб-приложение

В открывшемся окне нажмите шестерёнку «Выберите тип».

Выберите:

Веб-приложение

У веб-приложения появится адрес, на который сайт сможет отправлять запросы.

Должно получитьсяСлева в окне появится строка «Веб-приложение», справа — поля описания и доступа.

215-shot.png · New deployment → Select type → Web app.
04 / Публикация Web AppИСХОДНИК 216 · 47 / 84

Шаг 15. Настройки развёртывания

Описание→Запуск от имени: от моего имени→У кого есть доступ: Все→Начать развертывание

Описание: Приём заявок с сайта GA4 Analytics Lab.

Запуск от имени

«От моего имени» — скрипт пишет в вашу таблицу.

У кого есть доступ

«Все» (Anyone) — форму отправит посетитель, не входивший в Google.

Должно получитьсяКнопка «Начать развертывание» станет активной. После нажатия Google запросит разрешения — это следующий шаг.

216-shot.png · Deployment configuration. Показать: Description Execute as Who has access
04 / Публикация Web AppИСХОДНИК 217 · 48 / 84

Шаг 16. Разрешить Apps Script работать с таблицей

Предоставить доступ→Выбрать аккаунт→Advanced→Go to … (unsafe)→Continue

При первом Deployment Google запросит разрешение.

Скрипту нужны два разрешения:

  • открыть связанную Google Sheet
  • добавлять строки

Проверьте, что выбран нужный аккаунт. Google предупредит, что приложение не проверено: скрипт ваш, и Google его не проверял. Нажмите Advanced → Go to … (unsafe) и подтвердите разрешения.

217-shot.png · Экран authorization. Не показывать личные данные аккаунта крупным планом.
04 / Публикация Web AppИСХОДНИК 218 · 49 / 84

Шаг 17. Скопировать адрес приёмника

Копировать URL→Готово

После Deployment появится адрес вида:

text
https://script.google.com/macros/s/...../exec

Скопируйте адрес веб-приложения.

Он понадобится в HTML-форме.

218-shot.png · Web app URL.
04 / Публикация Web AppИСХОДНИК 219 · 50 / 84

Адрес приёмника готов

Заявка проходит такой путь:

text
POST
https://script.google.com/.../exec

↓

text
doPost(e)

↓

text
sheet.appendRow(...)

↓

Google Sheets

05 / Форма и UTMИСХОДНИК 220 · 51 / 84

Шаг 18. Открыть проект в VS Code

Продолжаем существующий сайт и его репозиторий — новый проект не создаём.

ga4-analytics-lab-ivanov.vercel.app ↗ · репозиторий на GitHub ↗

contacts.html

Расширяем уже существующую lead-form.

js/script.js

Заменяем только прежний submit-обработчик.

Должно получитьсяВ Explorer виден тот же набор файлов, что и в репозитории: css, js, about.html, contacts.html, index.html.

220-shot.png · VS Code Explorer.
05 / Форма и UTMИСХОДНИК 221 · 52 / 84

Шаг 19. Найти форму

Откройте:

text
contacts.html

Найдите существующую форму:

html
<form id="lead-form">

Новую форму не создаём: на следующем шаге эту форму заменим обновлённой.

id="lead-form"
По этому имени форму находит JavaScript. Менять его нельзя: по нему форму находят js/script.js и js/metrics.js.
Где искать
Форма лежит внутри блока contact-grid, ближе к концу файла.

Итоговые файлы после темы 7:

contacts.html↓
js/script.js↓
221-shot.png · VS Code → contacts.html.
05 / Форма и UTMИСХОДНИК 275 · 53 / 84

Шаг 20. Обновить форму в contacts.html

Замените в contacts.html старую форму фрагментом слева — кнопка «Копировать» в его шапке. Затем по шагам: что в форме осталось, что добавилось и зачем.

contacts.html · формаcontacts.html ↓
<!-- ВАШ_APPS_SCRIPT_URL замените адресом Web App, который оканчивается на /exec --><form id="lead-form" action="ВАШ_APPS_SCRIPT_URL" method="POST" target="submission-frame">  <label>Имя    <input name="name" required maxlength="200" autocomplete="off" placeholder="Иван Тестов">  </label>  <label>Email    <input name="email" type="email" required maxlength="200" autocomplete="off" placeholder="student@example.com">  </label>  <label>Направление    <select name="direction" required>      <option value="">Выберите направление</option>      <option value="frontend">Frontend</option>      <option value="backend">Backend</option>      <option value="data">Data</option>    </select>  </label>  <input type="hidden" name="request_id" id="request_id">  <input type="hidden" name="utm_source" id="utm_source">  <input type="hidden" name="utm_medium" id="utm_medium">  <input type="hidden" name="utm_campaign" id="utm_campaign">  <button class="button" type="submit">Отправить заявку <span>↗</span></button>  <button type="reset">Новая заявка</button>  <p id="form-status" role="status"></p>  <small>Учебная форма: используйте только вымышленные имя и email.</small></form><iframe name="submission-frame" id="submission-frame" title="Ответ приёмника заявок" hidden></iframe>

+ новое в теме 7~ измененобез отметки — осталось из темы 6

1 / 6Строки 27–32

Поля «Имя» и «Email»

Форма в HTML — тег <form> с полями внутри. Поле ввода — тег <input>. Записи вида имя="значение" внутри тега называются атрибутами: они настраивают поле.

<label>Имя … </label>
Подпись поля. Поле стоит внутри label, поэтому клик по слову «Имя» ставит курсор в поле.
name="name"
Имя поля при отправке. Браузер отправит пару name=Иван, и в Apps Script значение окажется в p.name. Этот атрибут связывает форму с кодом на сервере: если переименовать поле, Code.gs его не найдёт.
required
Браузер не отправит форму, пока поле пустое, и покажет подсказку.
maxlength="200"
Больше 200 символов ввести нельзя. То же ограничение проверяет Code.gs.
type="email"
Браузер проверит, что введённое похоже на адрес почты. На телефоне откроется клавиатура с @.
autocomplete="off"
Браузер не подставляет сохранённые имена и адреса.
placeholder="…"
Серая подсказка в пустом поле. Она не отправляется.

В теме 6 эти поля уже были. Теперь подпись стоит вокруг поля, как у нового поля ниже, а ограничение длины у обоих полей — 200 символов.

Как в жизниГрафы бумажного бланка. У каждой графы есть подпись, и по ней понятно, в какой столбец журнала её переписать.

05 / Форма и UTMИСХОДНИК 276 · 54 / 84

Шаг 21. Обновить js/script.js

Удалите из js/script.js старый обработчик submit из темы 6 и вставьте блок из строк 4–39. Можно заменить файл целиком: «Копировать» берёт весь файл.

js/script.jsscript.js ↓
'use strict';// Итоговый js/script.js после темы 7: форма заявок + CTA из темы 6.// Google Tag устанавливается отдельно в head каждой HTML-страницы.(() => {  const leadForm = document.querySelector('#lead-form');  if (!leadForm) return;  const params = new URLSearchParams(window.location.search);  const defaults = {utm_source:'direct', utm_medium:'none', utm_campaign:'not_set'};  for (const [key, fallback] of Object.entries(defaults)) {    leadForm.elements.namedItem(key).value = params.get(key)?.trim() || fallback;  }  const requestId = leadForm.elements.namedItem('request_id');  leadForm.addEventListener('submit', event => {    // Это защита от случайной отправки неполностью настроенного примера.    if (!/^https:\/\/script\.google\.com\/macros\/s\/[^/]+\/exec$/.test(leadForm.action)) {      event.preventDefault();      document.querySelector('#form-status').textContent = 'Сначала укажите URL опубликованного Apps Script Web App.';      return;    }    // Повторная доставка той же заявки сохраняет ID. Новый ID — после reset.    if (!requestId.value) requestId.value = 'REQ-' + crypto.randomUUID().toUpperCase();    if (typeof window.gtag === 'function') {      window.gtag('event', 'generate_lead', {lead_source:'contact_form'});    }    document.querySelector('#form-status').textContent =      'POST отправляется. Подтвердите сохранение по request_id в таблице: ' + requestId.value;    // На штатном пути preventDefault() НЕ вызываем: браузер отправляет POST.    // Событие GA4 отражает попытку отправки, а не ответ сервера.  });  leadForm.addEventListener('reset', () => {    setTimeout(() => {      requestId.value = '';      for (const [key, fallback] of Object.entries(defaults)) {        leadForm.elements.namedItem(key).value = params.get(key)?.trim() || fallback;      }      document.querySelector('#form-status').textContent = 'Можно заполнить новую заявку.';    }, 0);  });})();// CTA из существующего сайта темы 6 — без изменений.const programCta = document.querySelector('#program-cta');if (programCta) {  programCta.addEventListener('click', () => {    document.querySelector('#program-preview').hidden = false;    if (typeof gtag === 'function') {      gtag('event', 'cta_click', {        button_name: 'program',        page_section: 'hero'      });    }  });}

+ новое в теме 7~ измененобез отметки — осталось из темы 6

1 / 10Строки 1–3, 41–53

Что в файле было и что остаётся

'use strict' включает строгий режим JavaScript: часть ошибок, которые иначе прошли бы незаметно, становится явными. Строки 2–3 — комментарии.

В теме 6 обработчик формы вызывал event.preventDefault(). Этот вызов отменяет отправку формы: данные никуда не уходили, в GA4 отправлялось только событие. Теперь форма должна отправлять данные в Apps Script, поэтому старый обработчик удаляется целиком, а на его место встаёт блок строк 4–39.

Строки 41–53 — обработчик кнопки программы (CTA) из темы 6. Он не меняется.

Если оставить старый обработчик рядом с новым, его preventDefault() отменит отправку, и заявки перестанут доходить до таблицы.

Как в жизниРаньше бланк заполняли для тренировки и не отправляли. Теперь его отправляют по почте.

05 / Форма и UTMИСХОДНИК 229 · 55 / 84

Шаг 22. Открыть тестовую ссылку

Откройте:

text
https://ВАШ-САЙТ.vercel.app/contacts.html
?utm_source=telegram
&utm_medium=social
&utm_campaign=lesson07

JavaScript должен получить:

text
telegram
social
lesson07
229-shot.png · Браузер с полной UTM-ссылкой в адресной строке.
06 / Сквозная проверкаИСХОДНИК 235 · 56 / 84

Шаг 23. Сохранить файлы

Иконка Source Control→Проверить список изменённых файлов

Сохраните:

text
contacts.html
js/script.js

Проверьте Source Control.

Должно получитьсяВ Source Control перечислены ровно два изменённых файла с пометкой M. Третьего файла быть не должно.

235-shot.png · VS Code Source Control с двумя изменёнными файлами.
06 / Сквозная проверкаИСХОДНИК 236 · 57 / 84

Шаг 24. Зафиксировать изменение

Ввести сообщение коммита→Commit→Sync Changes

Commit:

text
Add Google Sheets lead collection

После этого:

Commit

↓

Sync Changes / Push

Должно получитьсяКоммит уходит в GitHub, Vercel начинает сборку, и в Deployments появляется новая строка с вашим сообщением.

236-shot.png · Source Control с commit message.
06 / Сквозная проверкаИСХОДНИК 237 · 58 / 84

Шаг 25. Проверить публикацию

Откройте свой проект на Vercel и убедитесь, что последний deployment — это ваш коммит.

ОТКРЫТЬ В НОВОЙ ВКЛАДКЕvercel.com/dashboardProject → Deployments → статус Ready
СООБЩЕНИЕ ПОСЛЕДНЕГО DEPLOYMENT
Add Google Sheets lead collection
237-shot.png · Vercel Deployments.
06 / Сквозная проверкаИСХОДНИК 238 · 59 / 84

Шаг 26. Direct-заявка

Откройте сайт обычной ссылкой:

text
https://ВАШ-САЙТ.vercel.app/contacts.html

Заполните тестовыми данными:

text
Иван Тестов
student@example.com
Frontend

Нажмите:

Отправить заявку

238-shot.png · Заполненная форма перед отправкой.
06 / Сквозная проверкаИСХОДНИК 239 · 60 / 84

Шаг 27. Появилась новая строка?

Найдите новую строку на листе leads. Сверьте ID из сообщения под формой.

ОЖИДАЕМЫЕ ЗНАЧЕНИЯ
REQ-…  |  дата и время  |  Иван Тестов
student@example.com  |  frontend
direct  |  none  |  not_set  |  new
239-shot.png · Первая реальная тестовая строка в Google Sheets.
06 / Сквозная проверкаИСХОДНИК 240 · 61 / 84

Поля, которые посетитель не заполнял

Найдите:

text
request_id
created_at
status

Эти поля посетитель не заполнял: номер создал браузер, время и статус — Apps Script. Такие данные называют системными.

240-shot.png · request_id + created_at + status.
06 / Сквозная проверкаИСХОДНИК 241 · 62 / 84

Шаг 28. Telegram-заявка

Откройте:

text
https://ВАШ-САЙТ.vercel.app/contacts.html
?utm_source=telegram
&utm_medium=social
&utm_campaign=lesson07

Отправьте ещё одну тестовую заявку.

241-shot.png · Браузер с UTM URL.
06 / Сквозная проверкаИСХОДНИК 242 · 63 / 84

Шаг 29. Проверить source

В новой строке должны появиться:

text
utm_source = telegram
utm_medium = social
utm_campaign = lesson07

Так заявка связана с рекламной ссылкой, по которой пришёл посетитель.

242-shot.png · Три UTM-столбца.
06 / Сквозная проверкаИСХОДНИК 243 · 64 / 84

Та же отправка должна создать generate_lead

Откройте Realtime или DebugView и найдите generate_lead после теста формы.

analytics.google.com ↗

GA4

Есть событие попытки отправки.

Google Sheets

Есть строка с теми же данными и нужными UTM.

243-shot.png · GA4 Realtime / DebugView с generate_lead.
07 / Качество данныхИСХОДНИК 244 · 65 / 84

Что хранится в GA4, а что в таблице

В GA4:

text
generate_lead
utm_source
campaign
страница

В Google Sheets:

text
request_id
created_at
name
email
direction
utm_source
utm_medium
utm_campaign
status
07 / Качество данныхИСХОДНИК 245 · 66 / 84

Шаг 30. Попробовать отправить пустую форму

Удалите значение name.

Попробуйте отправить форму.

HTML required должен остановить отправку до обращения к Apps Script.

245-shot.png · Browser validation message около пустого поля.
07 / Качество данныхИСХОДНИК 246 · 67 / 84

Browser validation + server validation

Локальная модель — в Google ничего не отправляется.

Выберите сценарий: ниже появится ответ приёмника.
  1. 01HTML required
  2. 02Серверная проверка
  3. 03Запись или отказ
07 / Качество данныхИСХОДНИК 247 · 68 / 84

Шаг 31. Смоделировать duplicate ID

Скопируйте одну корректную строку Google Sheets и вставьте её ниже без изменения request_id.

Conditional Formatting должен выделить одинаковые ID.

Так выглядит проблема: одна заявка записана дважды.

247-shot.png · Повторяющиеся ID.
07 / Качество данныхИСХОДНИК 248 · 69 / 84

Повторный POST с тем же ID не должен создать строку

Code.gsстроки 32–34

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

ОЖИДАЕМЫЙ JSONCode.gs ↓
{
  "ok": false,
  "error": "duplicate_request_id"
}
createTextFinder(id)
Ищет точное совпадение ID по всему столбцу A.
matchEntireCell(true)
Ячейка должна совпасть с ID целиком.
Если нашёлся
Запись не делаем и отвечаем duplicate_request_id — число строк не меняется.
07 / Качество данныхИСХОДНИК 249 · 70 / 84

Шаг 32. Ввести неверное состояние

В тестовой строке попробуйте установить:

text
finished

вместо допустимых значений.

Data Validation должна показать ошибку или отклонить ввод.

Допустимый словарь:

text
new
in_progress
done
rejected
249-shot.png · Ошибка Data Validation.
07 / Качество данныхИСХОДНИК 250 · 71 / 84

Шаг 33. Заявка начинает обрабатываться

Клик по ячейке status→Клик по стрелке→Выбрать значение

Откройте dropdown одной тестовой заявки. Переключите статус по порядку: new, in_progress, done.

  1. 01new
  2. 02in_progress
  3. 03done
250-shot.png · Dropdown status.
08 / Итог и Power QueryИСХОДНИК 251 · 72 / 84

Признаки контролируемой таблицы

У контролируемого источника данных есть:

  • определённую структуру
  • идентификаторы
  • типы значений
  • обязательные поля
  • ограниченные справочники
  • правила качества
  • понятный процесс обновления
08 / Итог и Power QueryИСХОДНИК 252 · 73 / 84

Как выглядит неконтролируемая таблица

Проблемный набор:

text
id      status      source
1       new         Telegram
2       готово      tg
2       NEW         telegram
        done
5       закончил    Телега

Проблемы:

  • дубль ID
  • пустое поле
  • разные названия статуса
  • разные обозначения источника
08 / Итог и Power QueryИСХОДНИК 253 · 74 / 84

Одинаковые правила для всех записей

text
request_id | source   | status
REQ-001    | telegram | new
REQ-002    | telegram | done
REQ-003    | vk       | in_progress

Данные становятся пригодны для:

  • фильтрации
  • аналитики
  • Power Query
  • API
  • n8n
  • CRM
08 / Итог и Power QueryИСХОДНИК 254 · 75 / 84

Отправка формы создаёт запись в таблице

ОДНО ДЕЙСТВИЕFORM SUBMITПользователь отправляет форму
АНАЛИТИКА ПОВЕДЕНИЯ

GA4

generate_lead

Событие попытки отправки.
Без имени и email.

ОБРАБОТКА ЗАЯВКИ

Apps Script → Sheets

request_id · status · UTM

Запись после проверки.
Имя и email — только здесь.

  1. 01UTM-ссылка
  2. 02Форма
  3. 03Проверки на сервере
  4. 04Запись в leads
08 / Итог и Power QueryИСХОДНИК 255 · 76 / 84

Это ещё не production backend

Наш Apps Script — простой приёмник заявок.

Мы пока не разбираем:

  • полноценную авторизацию
  • rate limiting
  • captcha
  • защиту от спама
  • базу данных
  • сложную обработку ошибок
  • очереди
  • production API
  • Эти задачи требуют другой архитектуры
08 / Итог и Power QueryИСХОДНИК 256 · 77 / 84

Пока мы почти не анализируем HTTP-ответ

POST отправляется в скрытый iframe. Страница пока не обрабатывает JSON-ответ.

Уже есть

В ответе Apps Script — ok и error.

Пока нет

Подтверждения успешного сохранения в интерфейсе формы.

08 / Итог и Power QueryИСХОДНИК 257 · 78 / 84

Таблица заявок как источник данных

До сегодняшнего занятия мы могли искусственно создать CSV.

Теперь CSV можно получить из реальной таблицы заявок.

Поэтому в следующей теме работаем с собственным набором данных.

08 / Итог и Power QueryИСХОДНИК 258 · 79 / 84

Какие ошибки накопятся в данных

Со временем появятся:

  • лишние пробелы
  • разный регистр
  • пустые значения
  • старые записи
  • несколько файлов
  • новые источники
  • ошибочные статусы
  • дубли
  • Исправлять такие ошибки вручную при каждом обновлении долго, и часть ошибок останется незамеченной
08 / Итог и Power QueryИСХОДНИК 259 · 80 / 84

Power Query

Следующая задача:

  • взять набор заявок
  • импортировать его
  • очистить
  • нормализовать
  • выделить ошибки

повторить те же преобразования на новой версии данных нажатием:

Refresh

08 / Итог и Power QueryИСХОДНИК 260 · 81 / 84

Контрольные скриншоты

08 / Итог и Power QueryИСХОДНИК 261 · 82 / 84

Что нужно уметь объяснить после занятия

08 / Итог и Power QueryИСХОДНИК 262 · 83 / 84

Контролируемая таблица заявок v1

ОДНО ДЕЙСТВИЕFORM SUBMITПользователь отправляет форму
АНАЛИТИКА ПОВЕДЕНИЯ

GA4

generate_lead

Событие попытки отправки.
Без имени и email.

ОБРАБОТКА ЗАЯВКИ

Apps Script → Sheets

request_id · status · UTM

Запись после проверки.
Имя и email — только здесь.

08 / Итог и Power QueryИСХОДНИК 263 · 84 / 84

Power Query: данные без ручного исправления

  1. 01Google Sheets
  2. 02CSV
  3. 03Power Query
  4. 04Applied Steps
  5. 05Refresh

Следующая тема: повторяемая очистка вместо ручного исправления.

Содержание

84 экранов · исходные номера 171–263, добавленные — с 264.

Источники и материалы

Apps Script Web Apps ↗

Доступ к связанным файлам из Web App ↗

ScriptLock ↗

appendRow и формулы ↗

Content Service ↗

Официальные источники проверены 23.09.2026. Сценарий сохранён; технические уточнения — в LESSON.md.

Скриншот