Веб-приложение из скрипта
Как превратить скрипт в страницу с собственным адресом: форма для коллег и 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.