Веб-приложение из скрипта

Как превратить скрипт в страницу с собственным адресом: форма для коллег и JSON-эндпоинт поверх Google-таблицы.

Веб-приложение (web app) — развёрнутый скрипт, у которого есть свой URL. Открыв этот адрес, браузер или другая программа заставляет Google выполнить вашу функцию doGet или doPost и получает то, что она вернула.

Зачем это нужно

Типичная история. У вас есть таблица «Заявки на пропуск», её заполняет охрана. Коллеги шлют заявки в мессенджер, кто-то переносит их руками, к пятнице половина потеряна. Google Forms не подходит: нужно проверять, что дата не в прошлом, подставлять отдел из справочника сотрудников и сразу отвечать «принято, номер 214».

Всё это — тридцать строк кода и одна кнопка «Развернуть». На выходе получится ссылка вида https://script.google.com/macros/s/AKfy.../exec, которую можно кинуть в общий чат. Никакого сервера, домена и оплаты хостинга: код исполняется на стороне Google, данные лежат в вашей таблице.

Второй сценарий — не для людей, а для программ. Внешняя система (бот, сайт, мобильное приложение) дёргает тот же адрес и получает JSON с содержимым листа. Таблица становится маленьким бэкендом, который может править бухгалтер, не открывая редактор кода.

Две точки входа: doGet и doPost

Google ищет в проекте функции со строго определёнными именами. Открыли адрес в браузере (обычный переход по ссылке) — вызывается doGet. Отправили форму методом POST или послали запрос из другой программы — doPost. Обе получают один аргумент, объект события, и обе обязаны что-то вернуть: либо HTML-страницу через HtmlService, либо текст/JSON через ContentService. Вернёте undefined — пользователь увидит серую страницу с ошибкой.

const SHEET_ID = '1AbC...ваш_id_таблицы';

function doGet(e) {
  // e.parameter — параметры из адресной строки: ?page=ok даст { page: 'ok' }
  if (e.parameter.page === 'ok') {
    return HtmlService.createHtmlOutput('<p>Заявка принята. Можно закрыть вкладку.</p>');
  }

  return HtmlService.createTemplateFromFile('Form')
    .evaluate()
    .setTitle('Заявка на пропуск')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

Разберём построчно. createTemplateFromFile('Form') берёт из проекта HTML-файл Form.html (создаётся кнопкой «+» → HTML рядом с файлами .gs). evaluate() выполняет шаблонные вставки внутри него и превращает шаблон в готовую страницу. setTitle задаёт заголовок вкладки. addMetaTag с viewport обязателен, если ссылку будут открывать с телефона: без него страница отрисуется как десктопная, и поля формы придётся искать пальцем с лупой.

Сама форма

Обойдёмся без клиентского JavaScript: обычная HTML-форма умеет отправлять POST сама. Единственная хитрость — куда её отправлять. Адрес развёрнутого приложения знает сам скрипт, и шаблонизатор подставит его прямо в разметку конструкцией <?= ... ?>.

<!DOCTYPE html>
<html>
  <body>
    <h2>Заявка на пропуск</h2>
    <form method="post" action="<?= ScriptApp.getService().getUrl() ?>">
      <p><input name="name" placeholder="Фамилия и имя гостя" required></p>
      <p><input name="date" type="date" required></p>
      <p><button type="submit">Отправить</button></p>
    </form>
  </body>
</html>

ScriptApp.getService().getUrl() возвращает адрес текущего развёртывания — тот самый /exec. Написать URL руками нельзя: он появляется только после первой публикации, а вы получите курицу и яйцо.

Приём данных: doPost

Когда пользователь жмёт «Отправить», Google вызывает doPost(e). Поля формы приезжают в e.parameter (по одному значению на имя) и e.parameters (массивы значений — если у поля несколько одинаковых имён, например чекбоксы). Если запрос пришёл не из формы, а от программы с телом JSON, сырое тело лежит в e.postData.contents.

function doPost(e) {
  const lock = LockService.getScriptLock();
  lock.waitLock(30000); // ждём до 30 секунд, если сейчас пишет другой запрос

  try {
    const name = String(e.parameter.name || '').trim();
    const date = String(e.parameter.date || '');

    if (!name || !date) {
      return HtmlService.createHtmlOutput('<p>Не хватает данных. Вернитесь и заполните оба поля.</p>');
    }
    if (new Date(date) < new Date(new Date().toDateString())) {
      return HtmlService.createHtmlOutput('<p>Дата в прошлом — пропуск оформить нельзя.</p>');
    }

    const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Заявки');
    const number = sheet.getLastRow(); // первая строка — шапка, значит это номер заявки
    sheet.appendRow([new Date(), number, name, date, 'новая']);

    return HtmlService.createHtmlOutput('<p>Заявка №' + number + ' принята.</p>');
  } finally {
    lock.releaseLock();
  }
}

LockService здесь не украшение. Веб-приложение может обрабатывать несколько запросов одновременно, и два человека, нажавшие «Отправить» в одну секунду, спокойно получат одинаковый getLastRow() и затрут строку друг друга. Блокировка выстраивает их в очередь. finally гарантирует, что замок снимут даже при исключении, иначе следующие запросы будут ждать до таймаута.

JSON-эндпоинт поверх листа

Той же функцией doGet можно отдавать данные машинам. Отличие одно: вместо HtmlService используем ContentService и явно проставляем MIME-тип.

const TOKEN = PropertiesService.getScriptProperties().getProperty('API_TOKEN');

function doGet(e) {
  if (e.parameter.token !== TOKEN) {
    return json_({ error: 'forbidden' });
  }

  const rows = SpreadsheetApp.openById(SHEET_ID)
    .getSheetByName('Заявки')
    .getDataRange()
    .getValues();

  const header = rows.shift();
  const data = rows.map(row => {
    const item = {};
    header.forEach((title, i) => { item[title] = row[i]; });
    return item;
  });

  return json_(data);
}

function json_(obj) {
  return ContentService.createTextOutput(JSON.stringify(obj))
    .setMimeType(ContentService.MimeType.JSON);
}

Запрос .../exec?token=секрет вернёт массив объектов. Токен лежит в свойствах скрипта (Настройки проекта → Свойства скрипта), а не в коде: код видит каждый, у кого есть доступ к таблице.

Результат:

[
  { "Дата": "2026-03-02T09:14:00.000Z", "Номер": 1, "Гость": "Иванов Пётр", "Статус": "новая" }
]

Публикация и права доступа

Кнопка «Развернуть» (Deploy) → «Новое развёртывание» → тип «Веб-приложение». Два поля в этом окне решают всё.

ПолеЗначениеЧто означает на практике
Выполнять какЯ (владелец)Код работает с вашими правами. Посетитель может записать строку в таблицу, к которой у него нет доступа. Так делают формы для внешних людей.
Выполнять какПользователь, обратившийся к приложениюКод работает с правами гостя. Нет доступа к таблице — увидит ошибку. Нужно, когда каждый должен видеть только своё.
У кого есть доступТолько яОтладочный режим.
У кого есть доступВсе, у кого есть аккаунт GoogleГость проходит авторизацию, вы знаете, кто он: Session.getActiveUser().getEmail().
У кого есть доступВсеСсылка работает без входа в аккаунт. Единственный вариант для приёма запросов от внешних сервисов и ботов.

Связка «Выполнять как: я» + «Доступ: все» — самая мощная и самая опасная. Любой, кто узнал ссылку, действует в таблице от вашего имени. Поэтому в примере выше и появился токен, а doPost проверяет данные, прежде чем что-то писать.

Как это работает

У приложения два адреса. /exec — опубликованная версия: её видят пользователи, и она не меняется, пока вы не создадите новое развёртывание. /dev — текущий код прямо из редактора: доступен только вам, удобен для проверки правок. Отсюда классическое недоумение «я исправил, а на сайте по-старому»: вы смотрите на /exec, а изменения живут в /dev. Чтобы обновить боевую ссылку, откройте «Управление развёртываниями», нажмите карандаш и выберите «Новая версия».

Каждый запрос — отдельный запуск скрипта с нуля. Глобальные переменные не переживают его: посчитанное в прошлом обращении значение не сохранится. Состояние держат таблица, PropertiesService или CacheService. При этом на запросы действуют обычные квоты Apps Script, включая лимит времени выполнения, — тяжёлую работу из doPost лучше не делать, а ставить задачу в очередь (записали строку — фоновый триггер разберёт).

Ещё одна особенность: ответ приложения отдаётся с редиректом на домен googleusercontent.com. Браузеры и UrlFetchApp идут по редиректу сами, а вот наивный HTTP-клиент во внешней системе может остановиться на коде 302 — в таком клиенте нужно включить «следовать за перенаправлениями».

Частые ошибки

  • Функция ничего не вернула. В doGet есть ветка без return — пользователь получает пустую страницу с невнятной ошибкой. Возвращайте ответ из каждой ветки.
  • Правки не появляются по боевой ссылке. Забыли создать новую версию развёртывания. Проверять код удобно по /dev, показывать людям — /exec.
  • Открытый эндпоинт без проверок. «Доступ: все» плюс запись в таблицу без валидации — приглашение к мусору в данных. Токен, проверка полей, а лучше и LockService.
  • Ожидание, что doPost увидит JSON в e.parameter. Если внешний сервис шлёт тело с типом application/json, поля будут в e.postData.contents — их нужно разобрать через JSON.parse.
  • Отладка вслепую. Ошибка внутри doGet показывает пользователю только «Обнаружена ошибка сценария». Настоящий текст и стек — в журнале выполнений, о нём отдельный урок.

Итоги

  • doGet отвечает на переходы по ссылке, doPost — на отправку формы и запросы программ; обе функции обязаны вернуть HtmlService- или ContentService-ответ.
  • Параметры запроса лежат в e.parameter, тело POST — в e.postData.contents.
  • «Выполнять как» решает, чьими правами работает код; «У кого есть доступ» — кого пускают. Открытый доступ требует токена и проверки данных.
  • /exec — опубликованная версия, /dev — код из редактора; после правок создавайте новую версию развёртывания.
  • Параллельные запросы реальны: запись в таблицу оборачивайте в LockService.
Проверьте себя
1. Вы поправили doPost, сохранили файл, но по ссылке /exec коллеги видят прежнее поведение. Почему?
AНужно очистить кеш браузера — код уже обновился
B/exec отдаёт опубликованную версию; надо создать новую версию развёртывания
CdoPost нельзя менять после первой публикации
DИзменения применяются автоматически, но с задержкой в сутки
2. Внешний бот должен слать заявки в вашу таблицу, аккаунта Google у него нет. Какие настройки развёртывания нужны?
AВыполнять как: пользователь, обратившийся к приложению; доступ: все, у кого есть аккаунт Google
BВыполнять как: я; доступ: только я
CВыполнять как: я; доступ: все
DЛюбые — права на веб-приложение не влияют на запись в таблицу