Вызов внешних 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/5xx UrlFetchApp бросает исключение и обрезает тело ответа — а именно там сервер объясняет, что не так. С этим флагом вы получаете нормальный 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 не мешает.
Проверьте себя
1. Что произойдёт при вызове UrlFetchApp.fetch(url), если сервер ответит кодом 404 и в options не задан muteHttpExceptions?
AВернётся response, у которого getResponseCode() равен 404
BБудет выброшено исключение, и тело ответа окажется обрезанным
CВернётся null
DСкрипт автоматически повторит запрос три раза
2. Куда правильно положить API-токен, который используется в заголовке Authorization?
AВ константу в начале файла скрипта
BВ отдельную ячейку таблицы на скрытом листе
CВ свойства скрипта: PropertiesService.getScriptProperties()
DВ имя проекта Apps Script