Google Forms → таблица → действие
Собираем классический конвейер: человек заполнил форму — скрипт сам обработал заявку и отправил письма.
onFormSubmit — устанавливаемый триггер, который срабатывает каждый раз, когда Google Форма записывает в таблицу новый ответ. Он получает готовые данные ответа и запускается с полными правами, поэтому может отправлять письма и ходить во внешние сервисы.
Зачем это нужно
Google Формы отлично собирают данные и на этом останавливаются. Строчка легла в таблицу — дальше тишина. А бизнесу нужно продолжение: клиенту — письмо «мы получили вашу заявку», менеджеру — уведомление, строке — статус «Новая», иногда ещё запись в CRM.
Эту дырку и закрывает Apps Script. Схема получается такая:
Google Форма → лист «Заявки» → триггер onFormSubmit → функция обработки
├─ письмо клиенту
├─ письмо менеджеру
└─ статус + метка времени в строке
Это, пожалуй, самый востребованный сценарий Apps Script вообще: он окупает изучение всего курса за один вечер.
Куда вешать триггер: на форму или на таблицу
Тут спрятана ловушка, на которой спотыкаются почти все. Триггер onFormSubmit можно повесить двумя способами — на саму форму или на таблицу с ответами. Функция сработает в обоих случаях, но объект события будет разным.
Триггер на ТАБЛИЦЕ (forSpreadsheet) | Триггер на ФОРМЕ (forForm) |
e.range — строка, куда лёг ответ | e.range отсутствует |
e.values — массив значений по колонкам листа | e.response — объект FormResponse |
e.namedValues — объект «заголовок вопроса → ответ» | значения достают через e.response.getItemResponses() |
| Удобно, когда надо писать в строку статус | Удобно, когда таблицы вообще нет |
Для нашей задачи однозначно берём триггер на таблице: мы хотим и данные, и возможность проставить статус в ту же строку. Если вы видите в чужом коде e.namedValues, а в отладке приходит undefined — почти наверняка триггер по ошибке повешен на форму.
Рабочий пример: обработка заявки
Форма спрашивает: «Ваше имя», «Email», «Курс», «Комментарий». Ответы падают на лист «Заявки». Колонка A — Timestamp (её создаёт сама форма), дальше вопросы, а справа мы вручную добавили две служебные колонки: F — Статус и G — Обработано.
const MENEDZHER = '[email protected]';
const KOL_STATUS = 6; // F
const KOL_OBRABOTANO = 7; // G
function obrabotatZayavku(e) {
const sheet = e.range.getSheet();
const row = e.range.getRow();
const otvety = e.namedValues;
// 1. Достаём поля по заголовкам вопросов
const imya = pole(otvety, 'Ваше имя');
const email = pole(otvety, 'Email');
const kurs = pole(otvety, 'Курс');
try {
// 2. Валидация: без почты писать некуда
if (!email) {
sheet.getRange(row, KOL_STATUS).setValue('Ошибка: нет e-mail');
return;
}
// 3. Письмо клиенту
MailApp.sendEmail({
to: email,
subject: 'Заявка на курс «' + kurs + '» принята',
htmlBody: 'Здравствуйте, ' + imya + '!<br><br>' +
'Мы получили вашу заявку на курс <b>' + kurs + '</b> ' +
'и свяжемся с вами в течение рабочего дня.'
});
// 4. Уведомление менеджеру
MailApp.sendEmail({
to: MENEDZHER,
subject: 'Новая заявка: ' + kurs,
body: imya + ' (' + email + ')\nСтрока ' + row + ' в таблице ' +
sheet.getParent().getUrl()
});
// 5. Отмечаем строку как обработанную
sheet.getRange(row, KOL_STATUS).setValue('Новая');
sheet.getRange(row, KOL_OBRABOTANO).setValue(new Date());
} catch (err) {
sheet.getRange(row, KOL_STATUS).setValue('Ошибка: ' + err.message);
console.error('Строка ' + row + ': ' + err.message);
}
}
// Аккуратно достаёт значение: namedValues всегда отдаёт МАССИВ
function pole(otvety, zagolovok) {
const znachenie = otvety[zagolovok];
if (!znachenie || !znachenie[0]) return '';
return String(znachenie[0]).trim();
}
Что здесь важного:
e.range.getRow()— номер строки, в которую форма только что записала ответ. Это наш «адрес» для статуса.e.namedValues— объект, где ключ это текст вопроса из формы, а значение — массив, даже если ответ один:{'Ваше имя': ['Аня'], 'Курс': ['Python']}. Массив нужен для вопросов с несколькими вариантами (флажки). Отсюда[0]в функцииpole()и её же существование: писатьotvety['Email'][0]напрямую опасно — если вопрос не был обязательным и человек его пропустил, ключа может не оказаться, и вы получитеTypeError.try/catchвокруг всего — не формальность. Если клиент опечатался в адресе,MailApp.sendEmailбросит исключение, функция оборвётся, и строка останется без статуса — вы даже не узнаете, что заявка была. Сcatchвы увидите текст ошибки прямо в таблице, в колонке F.- Служебные колонки — только справа. Форма пишет в свои колонки по фиксированным позициям. Вставите новую колонку между вопросами — сдвинете всё и сломаете сопоставление.
Установка триггера
Триггер создаём один раз, идемпотентно — по паттерну из прошлого урока:
function nastroitFormu() {
const ss = SpreadsheetApp.getActive();
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === 'obrabotatZayavku') {
ScriptApp.deleteTrigger(t);
}
});
ScriptApp.newTrigger('obrabotatZayavku')
.forSpreadsheet(ss)
.onFormSubmit()
.create();
}
Запустили nastroitFormu из редактора, выдали разрешения — и конвейер работает.
Как это работает
Когда человек нажимает «Отправить», Google Форма дописывает строку в лист ответов и параллельно бросает событие. Apps Script ловит его и вызывает вашу функцию — обычно в течение нескольких секунд, но асинхронно: пользователь уже видит «Ответ записан», пока ваш скрипт только начинает работать. Поэтому долгая обработка не тормозит человека, но и «мгновенности» ждать не стоит.
В объект события Google кладёт данные той самой строки. Ключи namedValues берутся из заголовков колонок листа, а заголовки — из текста вопросов формы. Из этого следуют два неочевидных правила:
- Переименовали вопрос в форме — сломали код. Меняете «Email» на «Ваш e-mail» — и
otvety['Email']становитсяundefined. Скрипт тихо начнёт слать письма в пустоту. Держите заголовки в константах и не трогайте формулировки без правки кода. - Лишний пробел в конце вопроса — тоже сломали код. Ключ
'Email 'и ключ'Email'— разные строки. Если поле упорно не находится, распечатайте ключи:console.log(JSON.stringify(e.namedValues)).
Статус-колонка как защита от повторов
Колонка «Статус» полезна не только менеджеру. Она работает замком: если строка уже обработана, второй раз её трогать не нужно. Это спасает, когда триггер отработал с ошибкой на середине и вы хотите «догнать» пропущенные заявки вручную, не разослав дубли тем, кому письмо уже ушло.
function dognatNeobrabotannye() {
const sheet = SpreadsheetApp.getActive().getSheetByName('Заявки');
const posledn = sheet.getLastRow();
if (posledn < 2) return;
const statusy = sheet.getRange(2, KOL_STATUS, posledn - 1, 1).getValues();
let obrabotano = 0;
for (let i = 0; i < statusy.length; i++) {
const status = statusy[i][0];
if (status !== '') continue; // уже обработана — пропускаем
const row = i + 2; // массив с 0, лист с 1, плюс шапка
// ... здесь та же логика: собрать данные строки и отправить письма ...
sheet.getRange(row, KOL_STATUS).setValue('Новая');
obrabotano++;
}
console.log('Догнали заявок: ' + obrabotano);
}
Результат:
Догнали заявок: 3
Строка const row = i + 2; — то самое место, где рождается половина багов в Apps Script. Массив нумеруется с нуля, лист — с единицы, плюс первая строка занята шапкой. Отсюда «плюс два».
Частые ошибки
- Пробуют поймать ответ формы простым триггером
onEdit. Не сработает никогда: запись формы — не правка человеком. Нужен именноonFormSubmit, и он бывает только устанавливаемым. - Тестируют обработчик кнопкой «Выполнить» в редакторе. Объект
eне подставляется, и код падает наe.range. Единственный честный тест — открыть форму и отправить настоящую тестовую заявку на свой адрес. - Триггер повешен на форму, а код читает
e.namedValues. Приходитundefined, и логи выглядят необъяснимо. Проверьте источник события в списке триггеров. - Обращаются к
e.namedValues['Email'][0]без проверки. Необязательный вопрос, который человек пропустил, роняет всю обработку. - Сортируют лист ответов. Форма продолжает дописывать строки в конец, а ваши номера строк после сортировки указывают уже не туда. Лист ответов не трогаем — для красивых отчётов делаем отдельный лист с формулой
QUERY. - Упираются в лимит писем. На личном аккаунте это 100 писем в сутки. Рекламная кампания принесла 300 заявок — и половина клиентов не получила ничего, а вы узнали об этом из жалоб. Проверяйте остаток:
MailApp.getRemainingDailyQuota(), и при нулевом остатке пишите в статус «Письмо не отправлено — квота». - Нет try/catch. Одна кривая строка обрывает обработку молча.
Итоги
- Связка «форма → таблица → действие» строится на устанавливаемом триггере
onFormSubmit; простойonEditответы формы не видит. - Вешайте триггер на таблицу (
forSpreadsheet) — тогда в событии будутe.range,e.valuesиe.namedValues. e.namedValues— это «заголовок вопроса → массив ответов»; берите[0]и всегда проверяйте наличие ключа.- Заголовки вопросов — фактически контракт с кодом: переименовали вопрос — правьте константы.
- Служебные колонки (Статус, Обработано) добавляйте только справа от колонок формы.
- Оборачивайте обработчик в
try/catchи пишите ошибку в статус-колонку — иначе сбои будут невидимыми. - Статус-колонка одновременно защищает от повторной обработки и позволяет «догнать» пропущенные заявки.
- Помните про суточный лимит писем и проверяйте остаток квоты.