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 и пишите ошибку в статус-колонку — иначе сбои будут невидимыми.
  • Статус-колонка одновременно защищает от повторной обработки и позволяет «догнать» пропущенные заявки.
  • Помните про суточный лимит писем и проверяйте остаток квоты.
Проверьте себя
1. В обработчике ответов формы код читает e.namedValues, но в журнале выполнений там undefined. Какая причина наиболее вероятна?
AТриггер onFormSubmit повешен на форму, а не на таблицу — у такого события другой набор полей
BВ форме больше пяти вопросов, и namedValues перестаёт заполняться
CНужно вызвать SpreadsheetApp.flush() перед чтением события
DnamedValues доступен только владельцу таблицы
2. В форме есть необязательный вопрос «Комментарий». Почему строка const c = e.namedValues['Комментарий'][0]; опасна?
AnamedValues хранит числа, а не строки, поэтому нужен parseInt
BИндекс [0] всегда указывает на Timestamp, а не на ответ
CЕсли человек пропустил вопрос, ключа может не быть — обращение к [0] у undefined уронит обработчик
DКлючи namedValues всегда в нижнем регистре, поэтому такой ключ не найдётся