/************************************************************************************** * OZON — АВТО-ОТВЕТЫ НА ОТЗЫВЫ + ВЫГРУЗКА В GOOGLE ТАБЛИЦУ * ------------------------------------------------------------------------------------ * Аналог скрипта для Wildberries, адаптированный под Ozon Seller API. * Создано и скачано: Семён Якунин — isemyon.com * ⚠️ ВАЖНО — ПОДПИСКА PREMIUM PLUS * Все методы работы с отзывами в Ozon Seller API (/v1/review/*) доступны * ТОЛЬКО при активной подписке «Premium Plus». Без неё API вернёт ошибку * доступа (HTTP 403) — скрипт это распознаёт и пишет понятное сообщение в логи. * Полноценно пользоваться этим скриптом без Premium Plus НЕЛЬЗЯ. * * ⚠️ ОГРАНИЧЕНИЕ OZON ОТ 17.04.2026 * Ozon отключил возможность отвечать через /v1/review/comment/create на отзывы * БЕЗ контента (без текста, фото и видео). Такие отзывы скрипт выгружает в * таблицу, но НЕ отвечает на них и помечает соответствующим статусом. * Поэтому, в отличие от версии для WB, шаблоны для «пустых отзывов» не нужны — * на листе «Templates» оставлены только группы по оценке (5★ / 4★ / 3★ / 1–2★). * * ЧТО ДЕЛАЕТ СКРИПТ: * 1. При первом запуске создаёт листы «Логи», «Templates», «Отзывы». * 2. Тянет необработанные отзывы и отвечает на каждый — один случайный * шаблон с листа «Templates», выбранный по оценке отзыва (1 отзыв = 1 ответ). * При ответе отзыв помечается обработанным (mark_review_as_processed = true). * 3. Выгружает все отзывы кабинета на лист «Отзывы» (историю — порциями). * 4. Пишет все события в лист «Логи». * 5. Запускается автоматически раз в 5 часов. * * КАК ПОДКЛЮЧИТЬ (один раз): * 1. Google Таблица → «Расширения» → «Apps Script». * 2. Вставьте код, сохраните. * 3. Впишите Client-Id и Api-Key Ozon в константы ниже. * Ключ создаётся в кабинете Ozon: Настройки → Seller API → сгенерировать ключ. * 4. Запустите функцию setup() и подтвердите разрешения Google. * 5. Готово — дальше скрипт работает сам. Ручной запуск — меню «Ozon Отзывы». * * УЧТЁННЫЕ ЛИМИТЫ: * • Ozon: пауза между запросами + повтор при HTTP 429 (Ozon не публикует * точный RPS для beta-методов отзывов, поэтому пауза взята с запасом). * • Apps Script: контроль времени выполнения (~6 мин), выгрузка истории * порциями с сохранением курсора между запусками. * • Google Sheets: пакетная запись строк (setValues), обрезка листа логов. **************************************************************************************/ /* ====================== НАСТРОЙКИ — РЕДАКТИРУЙТЕ ЗДЕСЬ ============================ */ // >>> ВСТАВЬТЕ ВАШИ ДАННЫЕ ДОСТУПА OZON МЕЖДУ КАВЫЧКАМИ <<< var OZON_CLIENT_ID = 'ВСТАВЬТЕ_СЮДА_CLIENT_ID'; var OZON_API_KEY = 'ВСТАВЬТЕ_СЮДА_API_KEY'; var CONFIG = { BASE_URL: 'https://api-seller.ozon.ru', SHEET_LOGS: 'Логи', SHEET_TEMPLATES: 'Templates', SHEET_REVIEWS: 'Отзывы', TRIGGER_HOURS: 5, // как часто запускать скрипт (часов) OZON_PAUSE_MS: 400, // пауза между запросами к Ozon (с запасом по лимитам) PAGE_SIZE: 100, // отзывов за один запрос (max Ozon = 100, min = 20) MAX_RUNTIME_MS: 4.5 * 60 * 1000, // мягкий лимит времени работы HISTORY_MAX: 50000, // максимум исторических отзывов при первичной выгрузке LOG_MAX_ROWS: 3000, // лист «Логи» обрезается до этого числа строк COMMENT_MIN_LEN: 2, // минимальная длина ответа COMMENT_MAX_LEN: 4000, // максимальная длина ответа (берём с запасом) MARK_PROCESSED: true // помечать отзыв обработанным при отправке ответа }; // Заголовок листа «Отзывы». Порядок столбцов менять нельзя — на него опирается код. var REVIEW_HEADERS = ['Дата', 'Review ID', 'SKU', 'published_at', 'raw', 'Статус отзыва', 'order_status', 'Источник', 'Отзыв', 'Оценка', 'Фото', 'Видео', 'Комментариев', 'Ответ на отзыв', 'Статус', 'AnsweredAt', 'Meta']; // Диапазоны шаблонов на листе «Templates» (столбец B). Числа — номера строк. // Группы «пустой отзыв» отсутствуют намеренно: Ozon запрещает отвечать на отзывы без контента. var TEMPLATE_RANGES = { R5: { from: 2, to: 11, label: '5★' }, // оценка 5 R4: { from: 12, to: 16, label: '4★' }, // оценка 4 R3: { from: 17, to: 21, label: '3★' }, // оценка 3 R12: { from: 22, to: 24, label: '1–2★' } // оценка 1–2 }; // Примеры шаблонов для первичного заполнения листа «Templates». // Их можно и нужно отредактировать прямо в таблице — скрипт всегда читает актуальные значения. var DEFAULT_TEMPLATES = { R5: [ 'Спасибо большое за ваш отзыв и высокую оценку! Нам очень приятно, что товар вам понравился. Будем рады видеть вас снова!', 'Благодарим за тёплые слова и пять звёзд! Ваша поддержка вдохновляет нашу команду работать ещё лучше.', 'Спасибо, что выбрали наш товар и поделились впечатлениями! Желаем приятных покупок и ждём вас снова.', 'Очень рады, что покупка вас порадовала! Спасибо за оценку и за то, что нашли время оставить отзыв.', 'Благодарим вас за пять звёзд! Нам важно, что вы остались довольны. Возвращайтесь за новыми покупками!', 'Спасибо за отличный отзыв! Мы стараемся для вас и счастливы, что товар оправдал ожидания.', 'Огромное спасибо за вашу оценку и добрые слова! Будем рады снова быть вам полезными.', 'Благодарим за доверие к нашему магазину! Ваш отзыв — лучшая награда для нашей команды.', 'Спасибо, что поделились положительными впечатлениями! Хорошего вам настроения и удачных покупок.', 'Сердечно благодарим за высокую оценку! Рады, что всё подошло. Ждём вас за новыми заказами!' ], R4: [ 'Спасибо за ваш отзыв и оценку! Приятно, что товар вам в целом понравился. Будем стараться стать ещё лучше.', 'Благодарим за обратную связь! Рады, что покупка вас порадовала. Если есть пожелания — мы всегда открыты к ним.', 'Спасибо, что поделились мнением! Учтём ваши впечатления, чтобы в следующий раз заслужить все пять звёзд.', 'Благодарим за оценку и уделённое время! Нам важно ваше мнение, и мы продолжим улучшать наш товар.', 'Спасибо за отзыв! Рады, что в целом всё устроило. Будем работать, чтобы вы остались полностью довольны.' ], R3: [ 'Спасибо за честный отзыв. Жаль, что товар оправдал ожидания не полностью. Будем благодарны, если расскажете подробнее, что можно улучшить.', 'Благодарим за обратную связь. Сожалеем, что покупка вызвала смешанные впечатления. Мы обязательно учтём это в работе.', 'Спасибо, что поделились мнением. Нам важно понимать, что пошло не так — это помогает становиться лучше.', 'Благодарим за отзыв и оценку. Приносим извинения за неудобства и обязательно проанализируем ваши замечания.', 'Спасибо за уделённое время. Жаль, что товар понравился не до конца. Мы работаем над качеством и учтём вашу оценку.' ], R12: [ 'Нам очень жаль, что товар вас разочаровал. Приносим искренние извинения. Пожалуйста, свяжитесь с нами — мы хотим разобраться в ситуации и помочь.', 'Благодарим за отзыв и приносим извинения за негативный опыт. Нам важно решить проблему — расскажите, пожалуйста, подробности.', 'Сожалеем, что покупка не оправдала ожиданий. Мы серьёзно относимся к каждому замечанию и обязательно примем меры. Приносим свои извинения.' ] }; /* ====================== ГЛОБАЛЬНОЕ СОСТОЯНИЕ ВЫПОЛНЕНИЯ ========================== */ var _SCRIPT_START = Date.now(); // момент старта — для контроля лимита времени var _LAST_CALL = 0; // время последнего запроса — для троттлинга /* ============================ ТОЧКИ ВХОДА ======================================= */ /** Запускается ОДИН РАЗ вручную. Создаёт листы и ставит автозапуск раз в 5 часов. */ function setup() { ensureSheets_(); scheduleNext_(); log_('INFO', 'Установка завершена. Листы созданы, автозапуск раз в ' + CONFIG.TRIGGER_HOURS + ' ч активен. Заполните лист «Templates» при необходимости.'); SpreadsheetApp.getActiveSpreadsheet().toast('Готово. Скрипт настроен.', 'Ozon Отзывы', 6); } /** Главная функция — её вызывает триггер раз в 5 часов. Также доступна в меню. */ function main() { _SCRIPT_START = Date.now(); ensureSheets_(); scheduleNext_(); // сразу планируем следующий запуск (до начала работы) log_('INFO', '▶ Запуск обработки отзывов Ozon'); if (!OZON_CLIENT_ID || OZON_CLIENT_ID.indexOf('ВСТАВЬТЕ') === 0 || !OZON_API_KEY || OZON_API_KEY.indexOf('ВСТАВЬТЕ') === 0) { log_('ERROR', 'Не указаны Client-Id и/или Api-Key Ozon. Впишите их в константы вверху скрипта.'); return; } try { // Проверка доступа и подписки Premium Plus var counts = ozonCount_(); if (counts === null) { log_('ERROR', 'Не удалось получить данные по отзывам. Обработка остановлена. ' + 'Проверьте ключ доступа и наличие подписки Ozon Premium Plus.'); return; } log_('INFO', 'Отзывов всего: ' + counts.total + ', обработано: ' + counts.processed + ', не обработано: ' + counts.unprocessed); var c = answerUnprocessed_(); // отвечаем на необработанные отзывы var imported = exportProcessed_(); // выгружаем обработанные отзывы log_('INFO', '■ Завершено. Отвечено: ' + c.answered + ', ошибок ответа: ' + c.errors + ', пустых пропущено: ' + c.skippedEmpty + ', без шаблона: ' + c.noTemplate + ', импортировано обработанных: ' + imported); } catch (e) { log_('ERROR', 'Критическая ошибка: ' + (e && e.message ? e.message : e) + ' | ' + (e && e.stack ? e.stack : '')); } } /** Меню в таблице для ручного управления. */ function onOpen() { SpreadsheetApp.getUi() .createMenu('Ozon Отзывы') .addItem('Запустить сейчас', 'main') .addItem('Установить автозапуск (5 ч)', 'setup') .addItem('Создать/проверить листы', 'ensureSheets_') .addToUi(); } /* ====================== ОБРАБОТКА НЕОБРАБОТАННЫХ ОТЗЫВОВ ======================== */ /** * Тянет все необработанные отзывы. На каждый отзыв с контентом отправляет один * случайный шаблон по оценке. Отзывы без контента пропускает (запрет Ozon). */ function answerUnprocessed_() { var sheet = getSheet_(CONFIG.SHEET_REVIEWS); var idToRow = readIdIndex_(sheet); var templates = readTemplates_(); var c = { answered: 0, errors: 0, skippedEmpty: 0, noTemplate: 0, seen: 0 }; var newRows = []; var lastId = ''; while (true) { if (timeUp_()) { log_('WARN', 'Лимит времени — необработанные продолжим при следующем запуске.'); break; } var page = ozonListReviews_('UNPROCESSED', lastId); if (!page) break; // ошибка уже залогирована if (!page.reviews || page.reviews.length === 0) break; for (var i = 0; i < page.reviews.length; i++) { if (timeUp_()) { log_('WARN', 'Лимит времени внутри страницы — остановка.'); break; } var r = page.reviews[i]; c.seen++; var hasContent = reviewHasContent_(r); var answerText = '', answeredAt = '', commentId = '', status, processed = false; if (!hasContent) { // Ozon с 17.04.2026 запрещает отвечать на отзывы без текста/фото/видео status = 'Пропущен: пустой отзыв (Ozon запрещает ответы с 17.04.2026)'; c.skippedEmpty++; } else { var choice = pickTemplate_(r, templates); // {group, text} либо null if (!choice) { status = 'Нет подходящего шаблона (проверьте лист Templates и оценку отзыва)'; c.noTemplate++; } else { var res = ozonCreateComment_(r.id, choice.text); if (res.ok) { status = 'Отвечено скриптом [' + choice.group + ']'; answerText = choice.text; answeredAt = nowStr_(); commentId = res.commentId || ''; processed = CONFIG.MARK_PROCESSED; c.answered++; log_('INFO', 'Ответ отправлен. Review ' + r.id + ', оценка ' + (r.rating || '—') + ', группа ' + choice.group); } else { status = 'Ошибка отправки: ' + res.error; c.errors++; log_('ERROR', 'Не удалось ответить на Review ' + r.id + ': ' + res.error); } } } var row = buildRow_(r, { processed: processed, answerText: answerText, status: status, answeredAt: answeredAt, commentId: commentId }); if (idToRow[r.id]) writeRow_(sheet, idToRow[r.id], row); else { newRows.push(row); idToRow[r.id] = -1; } } if (!page.hasNext || !page.lastId) break; lastId = page.lastId; } if (newRows.length > 0) appendRows_(sheet, newRows); log_('INFO', 'Необработанных просмотрено: ' + c.seen + ', отвечено: ' + c.answered + ', ошибок: ' + c.errors + ', пустых пропущено: ' + c.skippedEmpty + ', без шаблона: ' + c.noTemplate); return c; } /* ====================== ВЫГРУЗКА ОБРАБОТАННЫХ ОТЗЫВОВ =========================== */ /** * Выгружает обработанные отзывы на лист «Отзывы». * Первый запуск: история порциями (курсор last_id хранится в свойствах скрипта). * Дальнейшие запуски: добавляются только новые отзывы. * Возвращает количество добавленных строк. */ function exportProcessed_() { var sheet = getSheet_(CONFIG.SHEET_REVIEWS); var idToRow = readIdIndex_(sheet); var props = PropertiesService.getScriptProperties(); var histDone = props.getProperty('HISTORY_DONE') === '1'; var lastId = histDone ? '' : (props.getProperty('HISTORY_LAST_ID') || ''); var fetched = parseInt(props.getProperty('HISTORY_COUNT') || '0', 10); var added = 0; var newRows = []; while (true) { if (timeUp_()) { log_('WARN', 'Лимит времени — выгрузка обработанных продолжится при следующем запуске.'); break; } if (!histDone && fetched >= CONFIG.HISTORY_MAX) { props.setProperty('HISTORY_DONE', '1'); log_('INFO', 'Первичная выгрузка истории завершена (достигнут предел HISTORY_MAX).'); break; } var page = ozonListReviews_('PROCESSED', lastId); if (!page) break; if (!page.reviews || page.reviews.length === 0) { if (!histDone) { props.setProperty('HISTORY_DONE', '1'); log_('INFO', 'Первичная выгрузка истории завершена (отзывы закончились).'); } break; } var allKnown = true; for (var i = 0; i < page.reviews.length; i++) { var r = page.reviews[i]; fetched++; if (idToRow[r.id]) continue; // отзыв уже в таблице allKnown = false; newRows.push(buildRow_(r, { processed: true, answerText: '', commentId: '', status: histDone ? 'Импортирован (обработан ранее)' : 'Импортирован (история)', answeredAt: '' })); idToRow[r.id] = -1; added++; } if (histDone) { if (allKnown) break; // дальше — только известные отзывы } else { props.setProperty('HISTORY_COUNT', String(fetched)); props.setProperty('HISTORY_LAST_ID', page.lastId || ''); } if (!page.hasNext || !page.lastId) { if (!histDone) { props.setProperty('HISTORY_DONE', '1'); log_('INFO', 'Первичная выгрузка истории завершена.'); } break; } lastId = page.lastId; } if (newRows.length > 0) appendRows_(sheet, newRows); if (added > 0) log_('INFO', 'Добавлено обработанных отзывов в таблицу: ' + added); return added; } /* ====================== ВЫБОР ШАБЛОНА ОТВЕТА ==================================== */ /** Есть ли у отзыва контент (текст / фото / видео) — только на такие можно отвечать. */ function reviewHasContent_(r) { if (isNonEmpty_(r.text)) return true; if (Number(r.photos_amount) > 0) return true; if (Number(r.videos_amount) > 0) return true; return false; } /** * Определяет группу отзыва по оценке и возвращает случайный шаблон ответа. * @return {{group:string, text:string}|null} */ function pickTemplate_(r, templates) { var rating = Number(r.rating) || 0; var group; if (rating >= 5) group = 'R5'; else if (rating === 4) group = 'R4'; else if (rating === 3) group = 'R3'; else if (rating >= 1) group = 'R12'; // 1–2 звезды else group = null; if (!group) return null; var list = templates[group] || []; if (list.length === 0) return null; var text = String(list[Math.floor(Math.random() * list.length)] || '').trim(); if (text.length < CONFIG.COMMENT_MIN_LEN) return null; if (text.length > CONFIG.COMMENT_MAX_LEN) text = text.substring(0, CONFIG.COMMENT_MAX_LEN); return { group: group, text: text }; } /** Читает шаблоны ответов с листа «Templates» (столбец B, строки 2–24). */ function readTemplates_() { var sheet = getSheet_(CONFIG.SHEET_TEMPLATES); var result = {}; for (var key in TEMPLATE_RANGES) { var rng = TEMPLATE_RANGES[key]; var values = sheet.getRange(rng.from, 2, rng.to - rng.from + 1, 1).getValues(); var list = []; for (var i = 0; i < values.length; i++) { var v = String(values[i][0] || '').trim(); if (v) list.push(v); } result[key] = list; if (list.length === 0) { log_('WARN', 'В группе шаблонов «' + rng.label + '» нет ни одного ответа (строки ' + rng.from + '–' + rng.to + ' столбца B).'); } } return result; } /* ====================== ЗАПРОСЫ К OZON SELLER API =============================== */ /** Количество отзывов по статусам. @return {{total,processed,unprocessed}|null} */ function ozonCount_() { var resp = ozonRequest_('/v1/review/count', {}); if (!resp.ok) { log_('ERROR', 'Ошибка /v1/review/count: ' + resp.error); return null; } var d = resp.json || {}; return { total: d.total || 0, processed: d.processed || 0, unprocessed: d.unprocessed || 0 }; } /** * Получает страницу отзывов. status: 'UNPROCESSED' | 'PROCESSED' | 'ALL'. * @return {{reviews:Array, hasNext:boolean, lastId:string}|null} */ function ozonListReviews_(status, lastId) { var body = { limit: CONFIG.PAGE_SIZE, sort_dir: 'DESC', status: status }; if (lastId) body.last_id = lastId; var resp = ozonRequest_('/v1/review/list', body); if (!resp.ok) { log_('ERROR', 'Ошибка /v1/review/list (status=' + status + '): ' + resp.error); return null; } var d = resp.json || {}; return { reviews: d.reviews || [], hasNext: !!d.has_next, lastId: d.last_id || '' }; } /** Отправляет ответ (комментарий) на отзыв. @return {{ok,commentId,error}} */ function ozonCreateComment_(reviewId, text) { var body = { review_id: String(reviewId), text: String(text), mark_review_as_processed: CONFIG.MARK_PROCESSED }; var resp = ozonRequest_('/v1/review/comment/create', body); if (resp.ok) { return { ok: true, commentId: (resp.json && resp.json.comment_id) || '', error: '' }; } return { ok: false, commentId: '', error: resp.error }; } /** * Универсальный POST-запрос к Ozon Seller API с троттлингом и повтором при 429. * @return {{ok:boolean, code:number, json:Object, error:string}} */ function ozonRequest_(path, body) { var url = CONFIG.BASE_URL + path; var maxAttempts = 4; for (var attempt = 1; attempt <= maxAttempts; attempt++) { var sinceLast = Date.now() - _LAST_CALL; if (sinceLast < CONFIG.OZON_PAUSE_MS) Utilities.sleep(CONFIG.OZON_PAUSE_MS - sinceLast); _LAST_CALL = Date.now(); var options = { method: 'post', contentType: 'application/json', headers: { 'Client-Id': OZON_CLIENT_ID, 'Api-Key': OZON_API_KEY }, payload: JSON.stringify(body || {}), muteHttpExceptions: true, followRedirects: true }; var code, raw; try { var response = UrlFetchApp.fetch(url, options); code = response.getResponseCode(); raw = response.getContentText(); } catch (e) { if (attempt < maxAttempts) { Utilities.sleep(1500 * attempt); continue; } return { ok: false, code: 0, json: null, error: 'Сетевая ошибка: ' + e }; } var json = null; if (raw) { try { json = JSON.parse(raw); } catch (e2) { json = null; } } if (code === 200) return { ok: true, code: code, json: json, error: '' }; if (code === 429) { log_('WARN', 'Ozon: превышен лимит запросов (429). Попытка ' + attempt + ', пауза.'); Utilities.sleep(2000 * attempt); continue; } // Текст ошибки Ozon: {"code":N,"message":"...","details":[...]} var msg = (json && json.message) ? json.message : (raw ? raw.substring(0, 300) : ''); if (code === 403) { msg = 'Доступ запрещён (403). Вероятная причина — отсутствует подписка Ozon ' + 'Premium Plus или ключ не имеет прав на методы отзывов. ' + msg; return { ok: false, code: code, json: json, error: msg }; } if (code === 401) { return { ok: false, code: code, json: json, error: 'Не авторизован (401). Проверьте Client-Id и Api-Key.' }; } if (code >= 500 && attempt < maxAttempts) { Utilities.sleep(1500 * attempt); continue; } return { ok: false, code: code, json: json, error: 'HTTP ' + code + ': ' + msg }; } return { ok: false, code: 429, json: null, error: 'Превышен лимит запросов Ozon (429) после повторов.' }; } /* ====================== ПОСТРОЕНИЕ СТРОКИ ТАБЛИЦЫ =============================== */ /** Собирает массив значений строки для листа «Отзывы» в порядке REVIEW_HEADERS. */ function buildRow_(r, opts) { var rawJson = JSON.stringify(r); if (rawJson.length > 49000) rawJson = rawJson.substring(0, 49000) + '…'; var meta = { is_rating_participant: !!r.is_rating_participant, photos_amount: r.photos_amount || 0, videos_amount: r.videos_amount || 0, comments_amount: r.comments_amount || 0, review_status: r.status || '', comment_id: opts.commentId || '' }; return [ nowStr_(), // Дата (когда строка добавлена/обновлена) r.id || '', // Review ID r.sku || '', // SKU товара r.published_at || '', // published_at (дата отзыва) rawJson, // raw (исходный JSON отзыва) r.status || '', // Статус отзыва: PROCESSED / UNPROCESSED r.order_status || '', // order_status (статус заказа) 'Ozon', // Источник composeReviewText_(r), // Отзыв (текст или пометка о его отсутствии) r.rating || '', // Оценка r.photos_amount || 0, // Фото (количество) r.videos_amount || 0, // Видео (количество) r.comments_amount || 0, // Комментариев к отзыву opts.answerText || '', // Ответ на отзыв opts.status || '', // Статус (результат работы скрипта) opts.answeredAt || '', // AnsweredAt (когда скрипт ответил) JSON.stringify(meta) // Meta (доп. сведения) ]; } /** Формирует текст отзыва для ячейки. */ function composeReviewText_(r) { if (isNonEmpty_(r.text)) return String(r.text).trim(); var ph = Number(r.photos_amount) || 0, vd = Number(r.videos_amount) || 0; if (ph > 0 || vd > 0) return '(без текста; фото: ' + ph + ', видео: ' + vd + ')'; return '(без текста)'; } /* ====================== РАБОТА С ЛИСТАМИ ТАБЛИЦЫ ================================ */ /** Создаёт все необходимые листы, если их ещё нет. */ function ensureSheets_() { var ss = SpreadsheetApp.getActiveSpreadsheet(); // --- Лист «Логи» --- var logs = ss.getSheetByName(CONFIG.SHEET_LOGS); if (!logs) { logs = ss.insertSheet(CONFIG.SHEET_LOGS); logs.getRange(1, 1, 1, 3).setValues([['Время', 'Уровень', 'Сообщение']]).setFontWeight('bold'); logs.setFrozenRows(1); logs.setColumnWidth(1, 160); logs.setColumnWidth(3, 700); } // Текстовый формат: чтобы сообщения, начинающиеся с «=», не считались формулой logs.getRange(1, 1, logs.getMaxRows(), 3).setNumberFormat('@'); // --- Лист «Templates» --- if (!ss.getSheetByName(CONFIG.SHEET_TEMPLATES)) { var tpl = ss.insertSheet(CONFIG.SHEET_TEMPLATES); tpl.getRange(1, 1, 1, 2).setValues([['Группа', 'Текст ответа (шаблон)']]).setFontWeight('bold'); tpl.setFrozenRows(1); tpl.setColumnWidth(1, 160); tpl.setColumnWidth(2, 760); var rows = []; var order = ['R5', 'R4', 'R3', 'R12']; for (var o = 0; o < order.length; o++) { var key = order[o]; var rng = TEMPLATE_RANGES[key]; var defaults = DEFAULT_TEMPLATES[key] || []; for (var j = 0; j < (rng.to - rng.from + 1); j++) { rows.push([rng.label, defaults[j] || '']); } } tpl.getRange(2, 1, rows.length, 2).setValues(rows); tpl.getRange(2, 2, rows.length, 1).setWrap(true); } // --- Лист «Отзывы» --- if (!ss.getSheetByName(CONFIG.SHEET_REVIEWS)) { var rev = ss.insertSheet(CONFIG.SHEET_REVIEWS); rev.getRange(1, 1, 1, REVIEW_HEADERS.length).setValues([REVIEW_HEADERS]).setFontWeight('bold'); rev.setFrozenRows(1); log_('INFO', 'Создан лист «' + CONFIG.SHEET_REVIEWS + '» — сюда выгружаются все отзывы.'); } } /** Возвращает лист по имени, создавая его при необходимости. */ function getSheet_(name) { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sh = ss.getSheetByName(name); if (!sh) { ensureSheets_(); sh = ss.getSheetByName(name); } return sh; } /** Считывает индекс {Review ID -> номер строки} с листа «Отзывы». */ function readIdIndex_(sheet) { var index = {}; var last = sheet.getLastRow(); if (last < 2) return index; var ids = sheet.getRange(2, 2, last - 1, 1).getValues(); // столбец B = Review ID for (var i = 0; i < ids.length; i++) { var id = ids[i][0]; if (id) index[String(id)] = i + 2; } return index; } /** Пакетно добавляет строки в конец листа. */ function appendRows_(sheet, rows) { if (!rows || rows.length === 0) return; var startRow = sheet.getLastRow() + 1; sheet.getRange(startRow, 1, rows.length, REVIEW_HEADERS.length).setValues(rows); } /** Перезаписывает одну строку листа. */ function writeRow_(sheet, rowNum, row) { if (rowNum < 2) return; sheet.getRange(rowNum, 1, 1, REVIEW_HEADERS.length).setValues([row]); } /* ====================== ЛОГИРОВАНИЕ ============================================= */ /** Пишет событие в лист «Логи» и в журнал Apps Script. */ function log_(level, message) { try { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sh = ss.getSheetByName(CONFIG.SHEET_LOGS); if (!sh) { sh = ss.insertSheet(CONFIG.SHEET_LOGS); sh.getRange(1, 1, 1, 3).setValues([['Время', 'Уровень', 'Сообщение']]).setFontWeight('bold'); sh.setFrozenRows(1); sh.getRange(1, 1, sh.getMaxRows(), 3).setNumberFormat('@'); } sh.appendRow([nowStr_(), level, String(message)]); var last = sh.getLastRow(); if (last > CONFIG.LOG_MAX_ROWS + 1) sh.deleteRows(2, last - 1 - CONFIG.LOG_MAX_ROWS); } catch (e) { /* не даём ошибке логирования сломать основную работу */ } try { Logger.log(level + ': ' + message); } catch (e2) {} } /* ====================== ТРИГГЕР АВТОЗАПУСКА ===================================== */ /** * Планирует следующий запуск main() ровно через CONFIG.TRIGGER_HOURS часов. * Самоперепланирующийся триггер гарантирует точный интервал (everyHours() не * поддерживает значение 5). Старые триггеры удаляются. */ function scheduleNext_() { var triggers = ScriptApp.getProjectTriggers(); for (var i = 0; i < triggers.length; i++) { if (triggers[i].getHandlerFunction() === 'main') ScriptApp.deleteTrigger(triggers[i]); } ScriptApp.newTrigger('main') .timeBased() .after(CONFIG.TRIGGER_HOURS * 60 * 60 * 1000) .create(); } /* ====================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ================================= */ /** Прошёл ли мягкий лимит времени выполнения. */ function timeUp_() { return (Date.now() - _SCRIPT_START) > CONFIG.MAX_RUNTIME_MS; } /** Текущие дата и время в часовом поясе таблицы. */ function nowStr_() { var tz = SpreadsheetApp.getActiveSpreadsheet().getSpreadsheetTimeZone() || 'Europe/Moscow'; return Utilities.formatDate(new Date(), tz, 'yyyy-MM-dd HH:mm:ss'); } /** Непустое ли значение (с учётом пробелов). */ function isNonEmpty_(v) { return v !== null && v !== undefined && String(v).trim().length > 0; }