Мой блог
Бесплатный AI-сервер на Google Таблицах: настраиваем параллельные запросы к Gemini для обработки новостей
Мне потребовался умный автоматизированный фильтр для мониторинга новостных лент по узкоспециализированной тематике — применению искусственного интеллекта в сферах энергосбережения и энергобезопасности. Главная задача состояла в том, чтобы получать в удобный Telegram-чат выжимку по настоящему целевых материалов. Формат карточки должен быть максимальным лаконичным: заголовок, суть технологии, конкретно достигнутые числовые показатели (KPI) и ссылка на первоисточник. При этом порядка 90% публикаций выходят на английском языке, а итоговый текст мне нужен на русском.
Вместе с нейросетями я несколько месяцев дорабатывал этот инструмент. В этой статье я детально разберу один из ключевых инженерных приёмов, который позволил кардинально ускорить работу всей системы — организацию параллельной отправки независимых запросов к API Gemini из среды Google Apps Script.

Сразу сделаю оговорку по поводу термина «сервер» в заголовке. Выделенного физического или виртуального сервера в этой архитектуре нет. Это классический бессерверный подход (serverless): скрипт запускается по расписанию через триггер Google Apps Script, обрабатывает накопившуюся порцию данных, при необходимости планирует следующий запуск и завершает работу. Полноценным сервером данное решение можно назвать по его функциональной роли — оно полностью автономно принимает внешнюю информацию, фильтрует её, преобразует и выдаёт готовый результат.
Потребность: почему не подошли стандартные RSS-агрегаторы
Обычные приложения для чтения RSS-лент не справляются с качественным отбором. Какие бы ключевые слова и фильтры я ни задавал, в ленту всё равно просачивается огромный поток информационного шума: пресс-релизы, рекламные статьи без технической конкретики и громкие анонсы без реальных цифр. Мне требовалась смысловая фильтрация. Нейросеть должна оценить, содержится ли в тексте описание конкретного внедрения с измеренными результатами, или перед нами очередной маркетинговый материал.
Из отбранных новостей скрипт формирует структурированную заметку на русском языке со следующими блоками:
- название решения и площадка, где его применили;
- техническая суть реализованного проекта;
- числовые KPI и зафиксированный экономический эффект;
- прямая ссылка на исходную публикацию;
- краткий экспертный комментарий о перспективах внедрения.
Если в исходном материале нет конкретных цифр и показателей, нейросеть обязана честно указать на их отсутствие. Задача AI — извлечь факты, а не додумывать несуществующую экономию. Окончательное решение о публикации я оставляю за собой. Бот отправляет мне предварительный черновик с кнопками «Опубликовать» и «Отклонить». При нажатии первой кнопки сообщение мгновенно пересылается в рабочий канал с нужным форматированием.
Архитектура системы на базе Google Apps Script
В основе связки работают Google Apps Script, Google Таблица, Gemini API и Telegram Bot API. Такое сочетание позволяет обходиться без платного хостинга и круглосуточно работающих сторонних сервисов.
Вся база данных находится внутри Google Таблицы, содержащей четыре ключевых листа:
- RSS — каталог источников (у меня подключено порядка 100 лент);
- NewsInbox — входящий буфер анонсов, ожидающих первичной проверки;
- NewsQueue — очередь отобранных новостей со сгенерированными черновиками;
- Log — архив обработанных URL для исключения повторов.
Для оптимизации расходов и скорости я разделил задачи между двумя моделями Gemini:
- Gemini 1.5 Flash-Lite (или актуальная Lite-версия) — используется для первичного быстрый фильтрации анонсов («подходит / не подходит»). Модель имеет высокий лимит по количеству запросов в минуту (RPM 15) и в сутки (RPD 500).
- Gemini 1.5 Flash — привлекается на этапе формирования полного текста публикации на русском языке. Модель работает с более строгими лимитами (RPM 5, RPD 20), поэтому запросы к ней отправляются последовательно только для тех новостей, которые успешно прошли первичный отбор.
Система очередей необходима для того, чтобы не терять данные при сбоях API или при превышении лимитов времени выполнения скрипта. Актуальность контента контролируется строго: публикации с распознанной датой старше 7 дней автоматически отсеиваются.
Поиск узкого места: почему тормозила последовательная обработка
На первых этапах тестирования скрипт обрабатывал анонсы строго по очереди пачками. Вот реальная выдержка из отчёта одного из запусков:
Оценено Lite: 10 | Ожидают Lite (NewsInbox): 22 | Время: подготовка 3 с; RSS 126 с; Lite 92 с | Перенос: Закончено время для Lite
Главная проблема заключается в жёстком ограничении лимитов Google Apps Script: максимальное время выполнения одного запуска по триггеру составляет 6 минут (360 секунд). Потеря 90 секунд на ожидание ответов сетевого API существенно сокращает полезный объём работы, который скрипт способен выполнить за один прогон.
При последовательной схеме отправка следующего запроса не начинается до тех пор, пока не получен ответ на предыдущий. Однако между отдельными пачками новостей нет никакой зависимой логики. Чтобы оценить новости с 11 по 20, нейросети совершенно не обязательно знать результат оценки статей с 1 по 10. Критерии анализа едины, а исходные тексты абсолютно независимы. Именно эти паузы на сетевое ожидание я и решил устранить.
Разделение на пачки и параллельная отправка
Для ускорения я объединил два приёма: группировку анонсов внутри одного запроса (batching) и одновременную отправку нескольких таких запросов (parallel processing).
- Размер пачки (BATCH_SIZE = 10) — передача нейросети сразу десяти анонсов в одном промпте. Вместо 10 отдельных обращений к API выполняется всего 1.
- Число параллельных запросов (LITE_PARALLEL_BATCHES = 5) — групповая отправка до 5 пачек одновременно через один сетевой вызов.
При наличии свободного лимита квот одна группа позволяет мгновенно отправить на обработку до 50 анонсов новостей.
1. Формирование группы запросов
На первом этапе входящий массив публикаций разбивается на подмассивы по 10 штук:
function splitIntoBatches(articles) {
const maxArticles = BATCH_SIZE * LITE_PARALLEL_BATCHES;
if (articles.length > maxArticles) {
throw new Error(`В одной группе должно быть не более ${maxArticles} анонсов`);
}
const batches = [];
for (let i = 0; i < articles.length; i += BATCH_SIZE) {
batches.push(articles.slice(i, i + BATCH_SIZE));
}
return batches;
}
Далее для каждой пачки формируется JSON-структура промпта. С помощью параметра responseSchema мы заставляем Gemini возвращать строго структурированный JSON-массив с ID новости и флагом решения (shouldProcess):
function makeLitePayload(batch) {
const input = batch.map((article, id) => ({
id,
title: String(article.title || "").slice(0, 500),
text: String(article.snippet || "").slice(0, 3000)
}));
const prompt = `
Оцени анонсы новостей на предмет применения AI в энергосбережении и энергобезопасности.
Верни решение для каждого входящего id.
Анонсы: ${JSON.stringify(input)}
`;
return {
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
responseMimeType: "application/json",
maxOutputTokens: 4096,
responseSchema: {
type: "ARRAY",
items: {
type: "OBJECT",
properties: {
id: { type: "INTEGER" },
shouldProcess: { type: "BOOLEAN" }
},
required: ["id", "shouldProcess"]
}
}
}
};
}
Затем подготавливается массив конфигураций для HTTP-запросов. Имя модели рекомендуется выносить в настройки ScriptProperties, чтобы иметь возможность быстро переключаться на новые версии Gemini без изменения исходного кода:
function makeLiteRequest(batch) {
const props = PropertiesService.getScriptProperties();
const key = props.getProperty("GEMINI_API_KEY");
const model = props.getProperty("GEMINI_LITE_MODEL");
return {
url: `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`,
method: "post",
contentType: "application/json",
headers: { "x-goog-api-key": key },
payload: JSON.stringify(makeLitePayload(batch)),
muteHttpExceptions: true
};
}
2. Отправка группы через UrlFetchApp.fetchAll()
Главный секрет высокой скорости заключается в использовании встроенного метода UrlFetchApp.fetchAll() вместо циклического вызова UrlFetchApp.fetch():
function analyzeLiteGroup(articles) {
const batches = splitIntoBatches(articles);
if (batches.length === 0) return [];
const requests = batches.map(makeLiteRequest);
let responses;
try {
responses = UrlFetchApp.fetchAll(requests);
} catch (error) {
return batches.map(batch => ({
ok: false,
batch,
error: "Транспортная ошибка при вызове fetchAll()"
}));
}
return responses.map((response, index) => {
const batch = batches[index];
try {
return { ok: true, decisions: readLiteAnswer(response, batch) };
} catch (error) {
return { ok: false, batch, error: error.message };
}
});
}
Метод fetchAll() отправляет сразу всю пачку сетевых запросов на инфраструктуру Google, перекрывая время ожидания ответов. Сам интерпретатор JavaScript продолжает выполнение кода только после получения всех результатов.
Проверка ответов: почему статус HTTP 200 не гарантирует результат
Даже если сервер вернул успешный статус HTTP 200, это не означает, что модель выдала корректную структуру данных. В ответе может оказаться сбой генерации, неполный JSON или дублирующиеся идентификаторы. Каждая пачка должна проходительную валидацию перед тем, как результаты будут внесены в базовые листы Google Таблицы.
function readLiteAnswer(response, batch) {
const status = response.getResponseCode();
if (status < 200 || status >= 300) {
throw new Error(`Ошибка Gemini API: HTTP ${status}`);
}
const body = JSON.parse(response.getContentText());
if (body.error) throw new Error("API вернуло ошибку в теле ответа");
const candidate = body.candidates?.[0];
if (candidate?.finishReason !== "STOP") {
throw new Error("Генерация была прервана или не завершена");
}
const text = (candidate.content?.parts || [])
.filter(part => !part.thought && typeof part.text === "string")
.map(part => part.text)
.join("");
const decisions = JSON.parse(text);
if (!Array.isArray(decisions) || decisions.length !== batch.length) {
throw new Error("Количество решений не совпадает с количеством анонсов");
}
const seen = new Set();
return decisions.map(item => {
if (!item || !Number.isInteger(item.id) || item.id < 0 || item.id >= batch.length || seen.has(item.id) || typeof item.shouldProcess !== "boolean") {
throw new Error("Некорректный формат элемента ответа");
}
seen.add(item.id);
return {
url: batch[item.id].url,
shouldProcess: item.shouldProcess
};
});
}
Каждая пачка оценивается изолированно. Если из пяти параллельных запросов четыре выполнились успешно, а один вернул ошибку (например, HTTP 503), скрипт сохранит 40 успешных решений, а не прошедшие 10 анонсов оставит в листе NewsInbox для повторной обработки при следующем запуске.
Управление квотами и лимитами API
Метод fetchAll() отправляет 5 отдельных обращений к API Gemini. Каждое из них расходует доступные квоты по запросам в минуту (RPM), запросам в день (RPD) и токенам (TPM).
Для исключения ошибок типа HTTP 429 (Too Many Requests) я настроил предварительный учёт расхода ресурсов через параметры ScriptProperties. Перед вызовом сетевого метода скрипт проверяет, сколько доступных запросов осталось в текущей минуте. Если лимит позволяет отправить только 3 пачки из 5, формируется уменьшенная группа. Операции с регистрацией квот защищены блокировкой LockService.getScriptLock() для предотвращения конфликтов при одновременных запусках.
Итоговый выигрыш и сферы применения
Сравним условные показатели времени выполнения для обработки 50 новостей (5 пачек по 10 анонсов при среднем времени ответа API в 20 секунд):
| Режим отправки | Количество запросов | Время ожидания сети |
|---|---|---|
| Последовательный (fetch) | 5 | ~100 секунд |
| Параллельный (fetchAll) | 5 | ~20-25 секунд |
Выигрыш по времени составляет порядка 80 секунд за один триггерный запуск. Параллельность сама по себе не снижает объём потребляемых токенов, но позволяет уместить значительно больший объём аналитической работы в отведённый 6-минутный лимит Google Apps Script.
Описанный подход отлично подходит для любых задач массовой первичной обработки информации: классификации поступающих заявок, проверки карточек товаров в интернет-магазинах, извлечения метрик из документов или составления кратких аннотаций. Главное условие — отсутствие взаимных зависимостей между обрабатываемыми объектами.
Источник: habr.com
