/************************************************************************************** * WILDBERRIES — АВТО-ОТВЕТЫ НА ОТЗЫВЫ + ВЫГРУЗКА В GOOGLE ТАБЛИЦУ * ------------------------------------------------------------------------------------ * Создано и скачано: Семён Якунин — isemyon.com * Что делает скрипт: * 1. При первом запуске создаёт листы: «Логи», «Templates», «Отзывы». * 2. Тянет неотвеченные отзывы из кабинета WB и автоматически отвечает на них * готовым шаблоном с листа «Templates», выбранным случайно по оценке отзыва. * 3. Выгружает все отзывы кабинета на лист «Отзывы» (историю — порциями, * чтобы не упереться в лимит времени Apps Script). * 4. Все события пишет в лист «Логи». * 5. Запускается автоматически раз в 5 часов (триггер ставится функцией setup()). * * КАК ПОДКЛЮЧИТЬ (делается один раз): * 1. Откройте вашу Google Таблицу → меню «Расширения» → «Apps Script». * 2. Вставьте этот код, сохраните. * 3. Впишите ваш API-ключ WB в константу WB_API_KEY ниже. * Ключ создаётся в кабинете WB: Профиль → Настройки → Доступ к API, * категория «Вопросы и отзывы». * 4. Запустите функцию setup() (выбрать в списке функций → «Выполнить»). * Один раз подтвердите разрешения Google. * 5. Готово. Дальше скрипт работает сам раз в 5 часов. * Запустить вручную можно через меню «WB Отзывы» в самой таблице. * * ЛИМИТЫ, КОТОРЫЕ УЧТЕНЫ: * • WB: 3 запроса/сек на все методы «Вопросы и отзывы» → пауза 400 мс между * запросами + повтор с задержкой при ошибке 429. * • Apps Script: лимит ~6 мин на выполнение → скрипт следит за временем и * корректно прерывается, продолжая работу при следующем запуске. * • Google Sheets: запись строк делается пакетно (setValues), а не по одной. **************************************************************************************/ /* ====================== НАСТРОЙКИ — РЕДАКТИРУЙТЕ ЗДЕСЬ ============================ */ // >>> ВСТАВЬТЕ ВАШ API-КЛЮЧ WILDBERRIES МЕЖДУ КАВЫЧКАМИ <<< var WB_API_KEY = 'ВСТАВЬТЕ_СЮДА_ВАШ_API_КЛЮЧ_WB'; var CONFIG = { BASE_URL: 'https://feedbacks-api.wildberries.ru', SHEET_LOGS: 'Логи', SHEET_TEMPLATES: 'Templates', SHEET_REVIEWS: 'Отзывы', TRIGGER_HOURS: 5, // как часто запускать скрипт (часов) WB_PAUSE_MS: 400, // пауза между запросами к WB (3 запр/сек = 333 мс, берём с запасом) WB_PAGE_SIZE: 5000, // отзывов за один запрос (максимум WB = 5000) MAX_RUNTIME_MS: 4.5 * 60 * 1000, // мягкий лимит времени работы (Apps Script режет на ~6 мин) HISTORY_MAX: 50000, // максимум исторических отзывов для первичной выгрузки LOG_MAX_ROWS: 3000, // лист «Логи» обрезается до этого числа строк ANSWER_MIN_LEN: 2, // минимальная длина ответа (требование WB) ANSWER_MAX_LEN: 5000 // максимальная длина ответа (требование WB) }; // Заголовок листа «Отзывы» (порядок столбцов менять нельзя — на него опирается код) var REVIEW_HEADERS = ['Дата', 'Feedback ID', 'nmId', 'imtId', 'Артикул продавца', 'Бренд', 'createdDate', 'raw', 'isAnswered', 'wasViewed', 'Источник', 'Отзыв', 'Оценка', 'Ответ на отзыв', 'Статус', 'AnsweredAt', 'Meta']; // Диапазоны шаблонов на листе «Templates» (столбец B). Индексы — это номера строк. var TEMPLATE_RANGES = { TEXT_5: { from: 2, to: 11, label: '5★ (с текстом)' }, // отзыв с текстом, оценка 5 TEXT_4: { from: 12, to: 16, label: '4★ (с текстом)' }, // отзыв с текстом, оценка 4 TEXT_3: { from: 17, to: 21, label: '3★ (с текстом)' }, // отзыв с текстом, оценка 3 TEXT_12: { from: 22, to: 24, label: '1–2★ (с текстом)' }, // отзыв с текстом, оценка 1–2 EMPTY_45: { from: 25, to: 27, label: 'Пустой отзыв + 4–5★' }, // отзыв без текста, оценка 4–5 EMPTY_13: { from: 28, to: 31, label: 'Пустой отзыв + 1–3★' } // отзыв без текста, оценка 1–3 }; // Примеры шаблонов, которыми заполняется лист «Templates» при первом создании. // Их можно и нужно отредактировать прямо в таблице — скрипт всегда читает актуальные значения. var DEFAULT_TEMPLATES = { TEXT_5: [ 'Спасибо большое за ваш отзыв и высокую оценку! Нам очень приятно, что товар вам понравился. Будем рады видеть вас снова!', 'Благодарим за тёплые слова и пять звёзд! Ваша поддержка вдохновляет нашу команду работать ещё лучше.', 'Спасибо, что выбрали наш товар и поделились впечатлениями! Желаем приятных покупок и ждём вас снова.', 'Очень рады, что покупка вас порадовала! Спасибо за оценку и за то, что нашли время оставить отзыв.', 'Благодарим вас за пять звёзд! Нам важно, что вы остались довольны. Возвращайтесь за новыми покупками!', 'Спасибо за отличный отзыв! Мы стараемся для вас и счастливы, что товар оправдал ожидания.', 'Огромное спасибо за вашу оценку и добрые слова! Будем рады снова быть вам полезными.', 'Благодарим за доверие к нашему бренду! Ваш отзыв — лучшая награда для нашей команды.', 'Спасибо, что поделились положительными впечатлениями! Хорошего вам настроения и удачных покупок.', 'Сердечно благодарим за высокую оценку! Рады, что всё подошло. Ждём вас за новыми заказами!' ], TEXT_4: [ 'Спасибо за ваш отзыв и оценку! Приятно, что товар вам в целом понравился. Будем стараться стать ещё лучше.', 'Благодарим за обратную связь! Рады, что покупка вас порадовала. Если есть пожелания — мы всегда открыты к ним.', 'Спасибо, что поделились мнением! Учтём ваши впечатления, чтобы в следующий раз заслужить все пять звёзд.', 'Благодарим за оценку и уделённое время! Нам важно ваше мнение, и мы продолжим улучшать наш товар.', 'Спасибо за отзыв! Рады, что в целом всё устроило. Будем работать, чтобы вы остались полностью довольны.' ], TEXT_3: [ 'Спасибо за честный отзыв. Жаль, что товар оправдал ожидания не полностью. Будем благодарны, если расскажете подробнее, что можно улучшить.', 'Благодарим за обратную связь. Сожалеем, что покупка вызвала смешанные впечатления. Мы обязательно учтём это в работе.', 'Спасибо, что поделились мнением. Нам важно понимать, что пошло не так — это помогает становиться лучше.', 'Благодарим за отзыв и оценку. Приносим извинения за неудобства и обязательно проанализируем ваши замечания.', 'Спасибо за уделённое время. Жаль, что товар понравился не до конца. Мы работаем над качеством и учтём вашу оценку.' ], TEXT_12: [ 'Нам очень жаль, что товар вас разочаровал. Приносим искренние извинения. Пожалуйста, свяжитесь с нами — мы хотим разобраться в ситуации и помочь.', 'Благодарим за отзыв и приносим извинения за негативный опыт. Нам важно решить проблему — расскажите, пожалуйста, подробности.', 'Сожалеем, что покупка не оправдала ожиданий. Мы серьёзно относимся к каждому замечанию и обязательно примем меры. Приносим свои извинения.' ], EMPTY_45: [ 'Спасибо за вашу высокую оценку! Рады, что покупка вам понравилась. Будем рады видеть вас снова!', 'Благодарим за оценку! Нам очень приятно. Желаем приятных покупок и хорошего настроения.', 'Спасибо, что оценили наш товар! Ждём вас за новыми заказами.' ], EMPTY_13: [ 'Благодарим за оценку. Жаль, что покупка не полностью оправдала ожидания. Будем рады, если расскажете подробнее — это поможет нам стать лучше.', 'Спасибо за обратную связь. Сожалеем, что товар вас не порадовал. Пожалуйста, свяжитесь с нами, чтобы мы могли разобраться в ситуации.', 'Нам жаль, что вы остались не полностью довольны. Приносим извинения и будем признательны за подробности — нам важно ваше мнение.', 'Благодарим за оценку. Сожалеем о негативном впечатлении. Мы обязательно учтём это в работе над качеством товара и сервиса.' ] }; /* ====================== ГЛОБАЛЬНОЕ СОСТОЯНИЕ ВЫПОЛНЕНИЯ ========================== */ var _SCRIPT_START = Date.now(); // момент старта — для контроля лимита времени var _LAST_WB_CALL = 0; // время последнего запроса к WB — для троттлинга /* ============================ ТОЧКИ ВХОДА ======================================= */ /** * Запускается ОДИН РАЗ вручную. Создаёт листы и ставит автозапуск раз в 5 часов. */ function setup() { ensureSheets_(); scheduleNext_(); log_('INFO', 'Установка завершена. Листы созданы, автозапуск раз в ' + CONFIG.TRIGGER_HOURS + ' ч активен. Заполните лист «Templates» при необходимости.'); SpreadsheetApp.getActiveSpreadsheet().toast('Готово. Скрипт настроен.', 'WB Отзывы', 6); } /** * Главная функция — её вызывает триггер раз в 5 часов. Также доступна в меню. */ function main() { _SCRIPT_START = Date.now(); ensureSheets_(); scheduleNext_(); // сразу планируем следующий запуск через 5 ч (до начала работы) log_('INFO', '▶ Запуск обработки отзывов'); if (!WB_API_KEY || WB_API_KEY.indexOf('ВСТАВЬТЕ') === 0) { log_('ERROR', 'Не указан API-ключ WB. Впишите ключ в константу WB_API_KEY.'); return; } try { var counters = answerUnanswered_(); // отвечаем на неотвеченные отзывы var imported = exportAnswered_(); // выгружаем отвеченные отзывы (история/новые) log_('INFO', '■ Завершено. Отвечено: ' + counters.answered + ', ошибок ответа: ' + counters.errors + ', без шаблона: ' + counters.noTemplate + ', импортировано отвеченных: ' + imported); } catch (e) { log_('ERROR', 'Критическая ошибка: ' + (e && e.message ? e.message : e) + ' | ' + (e && e.stack ? e.stack : '')); } } /** Меню в таблице для ручного управления. */ function onOpen() { SpreadsheetApp.getUi() .createMenu('WB Отзывы') .addItem('Запустить сейчас', 'main') .addItem('Установить автозапуск (5 ч)', 'setup') .addItem('Создать/проверить листы', 'ensureSheets_') .addToUi(); } /* ====================== РАБОТА С НЕОТВЕЧЕННЫМИ ОТЗЫВАМИ ========================== */ /** * Тянет все неотвеченные отзывы, на каждый отправляет случайный шаблон по оценке, * затем записывает/обновляет строку на листе «Отзывы». */ function answerUnanswered_() { var sheet = getSheet_(CONFIG.SHEET_REVIEWS); var idToRow = readIdIndex_(sheet); var templates = readTemplates_(); var counters = { answered: 0, errors: 0, noTemplate: 0, seen: 0 }; var newRows = []; var skip = 0; while (true) { if (timeUp_()) { log_('WARN', 'Достигнут лимит времени — продолжим неотвеченные при следующем запуске.'); break; } var page = wbGetFeedbacks_(false, skip); // isAnswered = false if (!page || page.length === 0) break; for (var i = 0; i < page.length; i++) { if (timeUp_()) { log_('WARN', 'Лимит времени внутри страницы — остановка.'); break; } var fb = page[i]; counters.seen++; var choice = pickTemplate_(fb, templates); // {group, text} либо null var answerText = '', answeredAt = '', status, isAnswered = false; if (!choice) { status = 'Нет подходящего шаблона (проверьте лист Templates)'; counters.noTemplate++; } else { var res = wbAnswerFeedback_(fb.id, choice.text); if (res.ok) { status = 'Отвечено скриптом [' + choice.group + ']'; answerText = choice.text; answeredAt = nowStr_(); isAnswered = true; counters.answered++; log_('INFO', 'Ответ отправлен. Feedback ' + fb.id + ', оценка ' + (fb.productValuation || '—') + ', группа ' + choice.group); } else { status = 'Ошибка отправки: ' + res.error; counters.errors++; log_('ERROR', 'Не удалось ответить на Feedback ' + fb.id + ': ' + res.error); } } var row = buildRow_(fb, { isAnswered: isAnswered, answerText: answerText, status: status, answeredAt: answeredAt }); if (idToRow[fb.id]) { writeRow_(sheet, idToRow[fb.id], row); // обновляем существующую строку } else { newRows.push(row); // копим для пакетной записи idToRow[fb.id] = -1; // помечаем, чтобы не задвоить в этом запуске } } if (page.length < CONFIG.WB_PAGE_SIZE) break; // последняя страница skip += CONFIG.WB_PAGE_SIZE; } if (newRows.length > 0) appendRows_(sheet, newRows); log_('INFO', 'Неотвеченных просмотрено: ' + counters.seen + ', отвечено: ' + counters.answered + ', ошибок: ' + counters.errors + ', без шаблона: ' + counters.noTemplate); return counters; } /* ====================== ВЫГРУЗКА ОТВЕЧЕННЫХ ОТЗЫВОВ ============================= */ /** * Выгружает отвеченные отзывы на лист «Отзывы». * Первый запуск: выгружает историю порциями (курсор хранится в свойствах скрипта). * Дальнейшие запуски: добавляет только новые отвеченные отзывы. * Возвращает количество добавленных строк. */ function exportAnswered_() { var sheet = getSheet_(CONFIG.SHEET_REVIEWS); var idToRow = readIdIndex_(sheet); var props = PropertiesService.getScriptProperties(); var histDone = props.getProperty('HISTORY_DONE') === '1'; var skip = parseInt(props.getProperty('HISTORY_SKIP') || '0', 10); var added = 0; var newRows = []; while (true) { if (timeUp_()) { log_('WARN', 'Лимит времени — выгрузка отвеченных продолжится при следующем запуске.'); break; } if (!histDone && skip >= CONFIG.HISTORY_MAX) { props.setProperty('HISTORY_DONE', '1'); log_('INFO', 'Первичная выгрузка истории завершена (достигнут предел HISTORY_MAX).'); break; } var page = wbGetFeedbacks_(true, skip); // isAnswered = true, сортировка по дате (новые сверху) if (!page || page.length === 0) { if (!histDone) { props.setProperty('HISTORY_DONE', '1'); log_('INFO', 'Первичная выгрузка истории завершена (отзывы закончились).'); } break; } var allKnown = true; for (var i = 0; i < page.length; i++) { var fb = page[i]; if (idToRow[fb.id]) continue; // отзыв уже есть в таблице allKnown = false; var existingAnswer = (fb.answer && fb.answer.text) ? fb.answer.text : ''; newRows.push(buildRow_(fb, { isAnswered: true, answerText: existingAnswer, status: histDone ? 'Импортирован (ответ был ранее)' : 'Импортирован (история)', answeredAt: '' })); idToRow[fb.id] = -1; added++; } if (histDone) { // Штатный режим: новые отзывы идут сверху. Если вся страница уже известна — дальше только старые. if (allKnown) break; skip += CONFIG.WB_PAGE_SIZE; } else { // Режим первичной выгрузки истории: идём вглубь, курсор сохраняем. skip += CONFIG.WB_PAGE_SIZE; props.setProperty('HISTORY_SKIP', String(skip)); } if (page.length < CONFIG.WB_PAGE_SIZE) { if (!histDone) { props.setProperty('HISTORY_DONE', '1'); log_('INFO', 'Первичная выгрузка истории завершена.'); } break; } } if (newRows.length > 0) appendRows_(sheet, newRows); if (added > 0) log_('INFO', 'Добавлено отвеченных отзывов в таблицу: ' + added); return added; } /* ====================== ВЫБОР ШАБЛОНА ОТВЕТА ==================================== */ /** * Определяет группу отзыва и возвращает случайный шаблон ответа из неё. * @return {{group:string, text:string}|null} */ function pickTemplate_(fb, templates) { var rating = Number(fb.productValuation) || 0; var hasText = isNonEmpty_(fb.text) || isNonEmpty_(fb.pros) || isNonEmpty_(fb.cons); var group; if (hasText) { if (rating >= 5) group = 'TEXT_5'; else if (rating === 4) group = 'TEXT_4'; else if (rating === 3) group = 'TEXT_3'; else if (rating >= 1) group = 'TEXT_12'; // 1–2 звезды else group = null; } else { if (rating >= 4) group = 'EMPTY_45'; else if (rating >= 1) group = 'EMPTY_13'; // 1–3 звезды 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.ANSWER_MIN_LEN) return null; if (text.length > CONFIG.ANSWER_MAX_LEN) text = text.substring(0, CONFIG.ANSWER_MAX_LEN); return { group: group, text: text }; } /** * Читает шаблоны ответов с листа «Templates» (столбец B, строки 2–31). */ function readTemplates_() { var sheet = getSheet_(CONFIG.SHEET_TEMPLATES); var result = {}; for (var key in TEMPLATE_RANGES) { var r = TEMPLATE_RANGES[key]; var values = sheet.getRange(r.from, 2, r.to - r.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', 'В группе шаблонов «' + r.label + '» нет ни одного ответа (строки ' + r.from + '–' + r.to + ' столбца B).'); } } return result; } /* ====================== ЗАПРОСЫ К API WILDBERRIES =============================== */ /** * Получает страницу отзывов. isAnswered: false — неотвеченные, true — отвеченные. */ function wbGetFeedbacks_(isAnswered, skip) { var url = CONFIG.BASE_URL + '/api/v1/feedbacks' + '?isAnswered=' + (isAnswered ? 'true' : 'false') + '&take=' + CONFIG.WB_PAGE_SIZE + '&skip=' + skip + '&order=dateDesc'; var resp = wbRequest_('get', url, null); if (!resp.ok) { log_('ERROR', 'Ошибка получения отзывов (skip=' + skip + '): ' + resp.error); return []; } var body = resp.json; if (!body || body.error) { log_('ERROR', 'WB вернул ошибку при получении отзывов: ' + (body && body.errorText ? body.errorText : 'неизвестно')); return []; } return (body.data && body.data.feedbacks) ? body.data.feedbacks : []; } /** * Отправляет ответ на отзыв. Возвращает {ok:boolean, error:string}. */ function wbAnswerFeedback_(feedbackId, text) { var url = CONFIG.BASE_URL + '/api/v1/feedbacks/answer'; var body = { id: String(feedbackId), text: String(text) }; var resp = wbRequest_('post', url, body); if (resp.ok) return { ok: true, error: '' }; var msg = resp.error; if (resp.json && resp.json.detail) msg = resp.json.detail; else if (resp.json && resp.json.errorText) msg = resp.json.errorText; return { ok: false, error: msg }; } /** * Универсальный запрос к WB с троттлингом (3 запр/сек) и повтором при ошибке 429. * @return {{ok:boolean, code:number, json:Object, error:string}} */ function wbRequest_(method, url, body) { var maxAttempts = 4; for (var attempt = 1; attempt <= maxAttempts; attempt++) { // Троттлинг: гарантируем паузу между обращениями к WB var sinceLast = Date.now() - _LAST_WB_CALL; if (sinceLast < CONFIG.WB_PAUSE_MS) Utilities.sleep(CONFIG.WB_PAUSE_MS - sinceLast); _LAST_WB_CALL = Date.now(); var options = { method: method, headers: { 'Authorization': WB_API_KEY }, muteHttpExceptions: true, followRedirects: true }; if (body) { options.contentType = 'application/json'; options.payload = JSON.stringify(body); } 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 || code === 204) { return { ok: true, code: code, json: json, error: '' }; } if (code === 429) { // Превышен лимит запросов — ждём и повторяем log_('WARN', 'WB лимит запросов (429). Попытка ' + attempt + ', пауза.'); Utilities.sleep(2000 * attempt); continue; } if ((code === 401 || code === 403)) { return { ok: false, code: code, json: json, error: 'Доступ запрещён (' + code + '). Проверьте API-ключ и его категорию.' }; } // Прочие коды (400, 5xx) — один повтор для 5xx if (code >= 500 && attempt < maxAttempts) { Utilities.sleep(1500 * attempt); continue; } return { ok: false, code: code, json: json, error: 'HTTP ' + code + (raw ? ': ' + raw.substring(0, 300) : '') }; } return { ok: false, code: 429, json: null, error: 'Превышен лимит запросов WB (429) после повторов.' }; } /* ====================== ПОСТРОЕНИЕ СТРОКИ ТАБЛИЦЫ =============================== */ /** * Собирает массив значений строки для листа «Отзывы» в порядке REVIEW_HEADERS. */ function buildRow_(fb, opts) { var pd = fb.productDetails || {}; var reviewText = composeReviewText_(fb); var rawJson = JSON.stringify(fb); if (rawJson.length > 49000) rawJson = rawJson.substring(0, 49000) + '…'; var meta = { userName: fb.userName || '', productName: pd.productName || '', subjectName: fb.subjectName || '', size: pd.size || '', color: fb.color || '', orderStatus: fb.orderStatus || '', photos: (fb.photoLinks ? fb.photoLinks.length : 0), video: fb.video ? true : false }; return [ nowStr_(), // Дата (когда строка добавлена/обновлена) fb.id || '', // Feedback ID pd.nmId || '', // nmId pd.imtId || '', // imtId pd.supplierArticle || '', // Артикул продавца pd.brandName || '', // Бренд fb.createdDate || '', // createdDate (дата отзыва в WB) rawJson, // raw (исходный JSON отзыва) opts.isAnswered ? 'Да' : 'Нет', // isAnswered fb.wasViewed ? 'Да' : 'Нет', // wasViewed mapSource_(fb.state), // Источник reviewText, // Отзыв (текст + достоинства/недостатки) fb.productValuation || '', // Оценка opts.answerText || '', // Ответ на отзыв opts.status || '', // Статус (результат работы скрипта) opts.answeredAt || '', // AnsweredAt (когда скрипт ответил) JSON.stringify(meta) // Meta (доп. сведения) ]; } /** Объединяет текст отзыва, достоинства и недостатки в одну ячейку. */ function composeReviewText_(fb) { var parts = []; if (isNonEmpty_(fb.text)) parts.push(String(fb.text).trim()); if (isNonEmpty_(fb.pros)) parts.push('Достоинства: ' + String(fb.pros).trim()); if (isNonEmpty_(fb.cons)) parts.push('Недостатки: ' + String(fb.cons).trim()); return parts.length ? parts.join('\n') : '(без текста)'; } /** Человекочитаемый источник отзыва по полю state. */ function mapSource_(state) { var m = { 'wbRu': 'Wildberries.ru', 'wbCom': 'Wildberries.com', 'none': '—' }; if (!state) return 'Wildberries'; return m[state] || ('Wildberries (' + state + ')'); } /* ====================== РАБОТА С ЛИСТАМИ ТАБЛИЦЫ ================================ */ /** Создаёт все необходимые листы, если их ещё нет. */ 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, 200); tpl.setColumnWidth(2, 720); var rows = []; var order = ['TEXT_5', 'TEXT_4', 'TEXT_3', 'TEXT_12', 'EMPTY_45', 'EMPTY_13']; for (var o = 0; o < order.length; o++) { var key = order[o]; var r = TEMPLATE_RANGES[key]; var defaults = DEFAULT_TEMPLATES[key] || []; for (var j = 0; j < (r.to - r.from + 1); j++) { rows.push([r.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; } /** Считывает индекс {Feedback 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 = Feedback 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.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; }