Вызов внешних API (UrlFetchApp)
Как научить таблицу самой ходить в интернет: забирать курс валют, дёргать CRM, слать уведомления в мессенджер — и не падать, когда сеть подводит.
UrlFetchApp — служба Apps Script, которая делает HTTP-запросы к любому внешнему адресу: GET, POST, заголовки, тело, разбор ответа.
Зачем это нужно
Пока скрипт живёт внутри Google, его возможности ограничены Google. UrlFetchApp ломает эту стену. С ним таблица может: подтягивать курс валют или цену акции; вытаскивать сделки из CRM; проверять контрагента по ИНН; отправлять сообщение в Telegram или корпоративный чат при изменении данных; забирать данные из систем аналитики.
API (application programming interface) — это «розетка» сервиса: адрес, по которому программа отправляет запрос и получает структурированный ответ, чаще всего в формате JSON. Люди ходят на сайт глазами, программы — в API.
Первый GET-запрос
Возьмём открытый источник курсов Центробанка — ключей и регистрации он не требует.
function fetchUsdRate() {
const response = UrlFetchApp.fetch('https://www.cbr-xml-daily.ru/daily_json.js');
const data = JSON.parse(response.getContentText());
Logger.log('Дата: ' + data.Date);
Logger.log('Доллар: ' + data.Valute.USD.Value);
}
Три шага, которые повторяются в любом обращении к API: сделать запрос, получить текст ответа, разобрать его в объект. Объект response умеет:
getResponseCode() | HTTP-код: 200 — успех, 404 — не найдено, 401 — не авторизован, 429 — слишком часто |
getContentText() | тело ответа строкой (по умолчанию UTF-8) |
getContentText('windows-1251') | то же, но с указанием кодировки — спасает от «кракозябр» на старых сайтах |
getBlob() | ответ как файл — если API отдаёт PDF или картинку |
getAllHeaders() | заголовки ответа: там часто лежат лимиты и токены |
POST, заголовки и токены
Когда нужно что-то отправить, добавляется второй аргумент — объект настроек.
function notifyChat(text) {
const token = getToken();
const options = {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + token },
payload: JSON.stringify({ text: text }),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch('https://api.example.com/v1/messages', options);
if (response.getResponseCode() !== 200) {
throw new Error('Не отправилось: ' + response.getContentText());
}
}
function getToken() {
const token = PropertiesService.getScriptProperties().getProperty('API_TOKEN');
if (!token) {
throw new Error('Не задано свойство скрипта API_TOKEN');
}
return token;
}
Здесь три вещи, на которых спотыкаются почти все.
payloadдолжен быть строкой для JSON-API. Если передать объект, Apps Script отправит его как форму (multipart/form-data), и сервер ответит 400, недоумевая, где обещанный JSON. ПараcontentType: 'application/json'+JSON.stringify(...)обязательна.muteHttpExceptions: true. По умолчанию при коде 4xx/5xxUrlFetchAppбросает исключение и обрезает тело ответа — а именно там сервер объясняет, что не так. С этим флагом вы получаете нормальныйresponseи читаете описание ошибки. Ставьте его всегда.- Токен — в свойствах скрипта, а не в коде. Код видит каждый, у кого есть доступ к таблице на редактирование; копию таблицы легко сделать вместе со скриптом. Свойства (Настройки проекта → Свойства скрипта) в копию не попадают.
Пример: курс валют в таблицу
Соберём рабочий скрипт: каждый день добавляем строку с курсами трёх валют и дельтой к предыдущему дню.
function updateRates() {
const sheet = SpreadsheetApp.getActive().getSheetByName('Курсы');
const data = fetchJson('https://www.cbr-xml-daily.ru/daily_json.js');
const date = new Date(data.Date);
const codes = ['USD', 'EUR', 'CNY'];
const rows = codes.map(function (code) {
const item = data.Valute[code];
if (!item) {
throw new Error('В ответе нет валюты ' + code);
}
return [date, code, item.Value, item.Previous, item.Value - item.Previous];
});
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, 5).setValues(rows);
}
function fetchJson(url, options) {
const params = Object.assign({ muteHttpExceptions: true }, options || {});
const response = UrlFetchApp.fetch(url, params);
const code = response.getResponseCode();
const body = response.getContentText();
if (code < 200 || code >= 300) {
throw new Error('HTTP ' + code + ': ' + body.slice(0, 200));
}
try {
return JSON.parse(body);
} catch (e) {
throw new Error('Ответ не похож на JSON: ' + body.slice(0, 200));
}
}
Разберём решения.
fetchJsonвынесен отдельно. Проверка кода, разбор JSON и внятное сообщение об ошибке нужны в каждом обращении к API — не копируйте их по всему проекту.- Один
setValuesвместо трёхappendRow. Запись в таблицу — это сетевой вызов; три вызова вместо одного втрое дольше. - Проверка
if (!item). Никогда не считайте структуру чужого JSON гарантированной. Сегодня валюта есть, завтра её убрали — и вы получите загадочноеCannot read properties of undefinedвместо честного «в ответе нет CNY». body.slice(0, 200). Если сервер вернул HTML-страницу ошибки на 300 килобайт, в лог полезут все 300. Обрезайте.
Повесьте на updateRates триггер «раз в день, 10:00» — и через месяц у вас накопится собственная история курсов, из которой строится график. Так, из десяти строк кода, рождаются вещи, за которые в другом месте берут подписку.
Обработка ошибок сети
Сеть — ненадёжная штука, и ошибки бывают двух совершенно разных сортов.
Сорт первый: до сервера не достучались. DNS не отвечает, таймаут, сертификат просрочен. В этом случае UrlFetchApp.fetch бросает исключение — никакого response вы не получите, и muteHttpExceptions тут не поможет. Ловится только через try/catch.
Сорт второй: сервер ответил, но плохо. Код 429 («слишком часто») или 500-е («мне плохо») означают: попробуй ещё раз через паузу. А вот 401 или 404 повторять бессмысленно — токен не станет валидным сам по себе.
function fetchWithRetry(url, options, attempts) {
attempts = attempts || 3;
let lastError = null;
for (let i = 0; i < attempts; i++) {
try {
const params = Object.assign({ muteHttpExceptions: true }, options || {});
const response = UrlFetchApp.fetch(url, params);
const code = response.getResponseCode();
if (code === 429 || code >= 500) {
lastError = new Error('Временная ошибка, HTTP ' + code);
} else {
return response; // 2xx, 3xx и «неповторяемые» 4xx отдаём как есть
}
} catch (e) {
lastError = e; // сеть недоступна, таймаут, DNS
}
const pause = Math.pow(2, i) * 1000 + Math.floor(Math.random() * 500);
Utilities.sleep(pause);
}
throw lastError;
}
Пауза растёт экспоненциально: 1 секунда, 2, 4. Это экспоненциальная задержка — стандартный приём: если сервер перегружен, ваши частые повторы только добьют его. Случайная добавка (джиттер, Math.random()) нужна, чтобы десятки скриптов, стартовавших одновременно, не долбились в сервер синхронно.
И последнее: заверните вызов из триггера в try/catch и пишите ошибку на служебный лист или шлите себе письмо. Иначе триггер будет тихо падать неделями, а вы обнаружите это, когда кто-нибудь спросит, почему в отчёте пусто.
Как это работает
Запрос уходит с серверов Google, а не с вашего компьютера. Из этого следует почти всё, что удивляет новичков.
- «В Postman работает, в скрипте — нет». Почти всегда причина одна: API доступен только из внутренней сети компании или из-под VPN. Google туда не достучится. К
localhostи внутренним адресам обратиться нельзя в принципе. - IP-адрес — гугловский. Если API пускает только по «белому списку» адресов, ваш офисный IP там не поможет.
- CORS ни при чём. Это серверный запрос, браузерных ограничений и preflight-проверок здесь нет.
- Нужно разрешение. При первом запуске Google попросит право «подключаться к внешнему сервису» — в манифесте это скоуп
script.external_request.
Квоты тоже есть: около 20 000 запросов в сутки для обычного аккаунта и 100 000 для Workspace, размер ответа ограничен, а всё выполнение по-прежнему должно уложиться в 6 минут. Поэтому вызывать API в цикле по каждой из 500 строк — плохая идея сразу по двум причинам. Ищите пакетные методы («отдай мне все курсы одним запросом») и кэшируйте ответы через CacheService, если данные меняются раз в час, а скрипт бегает раз в минуту.
Частые ошибки
- Токен константой в коде. Утечёт с первой же копией таблицы. Только
PropertiesService. - Забыть
muteHttpExceptions. Вместо описания ошибки — обрезанное исключение и отладка вслепую. payloadобъектом при JSON-API. Уйдёт форма, вернётся 400.- Считать, что
fetchпри 404 вернёт response. БезmuteHttpExceptionsон выбросит исключение. - Слепо доверять структуре чужого JSON. Проверяйте наличие полей до обращения к ним.
- Ретраить всё подряд. Повторять 401 — значит трижды получить 401 и потерять 7 секунд.
- Кракозябры. Старый российский API отдаёт windows-1251 — читайте через
getContentText('windows-1251'). - Запрос на каждую строку. 500 строк — 500 запросов, шестиминутный лимит и съеденная квота.
Итоги
UrlFetchApp.fetch(url, options)— единственная дверь во внешний мир: метод, заголовки, тело, флаги.- Для JSON-API всегда:
contentType: 'application/json'иpayload: JSON.stringify(...). muteHttpExceptions: true— не опция, а привычка: без неё вы не увидите текст ошибки сервера.- Секреты живут в свойствах скрипта, а не в исходниках.
- Обёртка
fetchJsonс проверкой кода и разбором JSON экономит часы отладки. - Сетевые сбои и коды 429/5xx лечатся ретраями с экспоненциальной задержкой и джиттером; 401 и 404 повторять бесполезно.
- Запрос идёт с серверов Google: внутренние адреса и VPN-only API недоступны, зато CORS не мешает.