/************************************************************************************** * ЯНДЕКС МАРКЕТ — АВТО-ОТВЕТЫ НА ОТЗЫВЫ + ВЫГРУЗКА В GOOGLE ТАБЛИЦУ * ------------------------------------------------------------------------------------ * Аналог скриптов для Wildberries и Ozon, адаптированный под Yandex Market Partner API. * Создано и скачано: Семён Якунин — isemyon.com * ДОСТУП (подписка НЕ нужна): * В отличие от Ozon, для отзывов Яндекс Маркета платная подписка не требуется. * Нужен только API-Key-токен с доступом «Общение с покупателями» (communication). * Токен создаётся в кабинете продавца: иконка аккаунта → Настройки → API и модули → * Токены авторизации → Создать новый токен → отметить доступ «Общение с покупателями». * * ЧТО ДЕЛАЕТ СКРИПТ: * 1. При первом запуске создаёт листы «Логи», «Templates», «Отзывы». * 2. Сканирует ВСЕ отзывы кабинета и отвечает на каждый, у которого ещё нет * ответа магазина (commentsCount = 0) — одним случайным шаблоном с листа * «Templates», выбранным по оценке и наличию текста (1 отзыв = 1 ответ). * 3. Параллельно выгружает все отзывы на лист «Отзывы». * 4. Пишет все события в лист «Логи». * 5. Запускается автоматически раз в 5 часов. * * ВАЖНО — ПОЧЕМУ СКАНИРУЮТСЯ ВСЕ ОТЗЫВЫ, А НЕ ТОЛЬКО «ТРЕБУЮЩИЕ РЕАКЦИИ»: * У Яндекс Маркета поле needReaction становится false, как только магазин просто * ПРОСМОТРЕЛ отзыв в кабинете (даже без ответа). Поэтому фильтр NEED_REACTION * пропускал бы просмотренные, но неотвеченные отзывы. Скрипт ориентируется не на * needReaction, а на наличие ответа магазина: отвечает на любой отзыв, где * comments­Count = 0. Так просмотренные старые отзывы без ответа тоже получают ответ. * * КАК ПОДКЛЮЧИТЬ (один раз): * 1. Google Таблица → «Расширения» → «Apps Script». * 2. Вставьте код, сохраните. * 3. Впишите токен в YANDEX_API_KEY. BUSINESS_ID можно не указывать — * скрипт определит идентификатор кабинета автоматически через GET /v2/campaigns. * 4. Запустите функцию setup() и подтвердите разрешения Google. * 5. Готово. Ручной запуск — меню «Маркет Отзывы». * * УЧТЁННЫЕ ЛИМИТЫ: * • Яндекс Маркет: получение отзывов — 10 000 запросов/час; добавление * комментария — 1 000 запросов/час (это главное ограничение). Скрипт делает * паузу между запросами, ограничивает число ответов за один запуск и * повторяет запрос при ошибке 420/429. * • Apps Script: контроль времени (~6 мин), выгрузка истории порциями. * • Google Sheets: пакетная запись строк, обрезка листа логов. **************************************************************************************/ /* ====================== НАСТРОЙКИ — РЕДАКТИРУЙТЕ ЗДЕСЬ ============================ */ // >>> ВСТАВЬТЕ ВАШ API-KEY-ТОКЕН ЯНДЕКС МАРКЕТА МЕЖДУ КАВЫЧКАМИ <<< var YANDEX_API_KEY = 'ВСТАВЬТЕ_СЮДА_ВАШ_API_KEY_ТОКЕН'; // Идентификатор кабинета. Можно оставить пустым ('') — определится автоматически. var BUSINESS_ID = ''; var CONFIG = { BASE_URL: 'https://api.partner.market.yandex.ru', SHEET_LOGS: 'Логи', SHEET_TEMPLATES: 'Templates', SHEET_REVIEWS: 'Отзывы', TRIGGER_HOURS: 5, // как часто запускать скрипт (часов) PAUSE_MS: 400, // пауза между запросами к API PAGE_SIZE: 50, // отзывов за один запрос (max Яндекс Маркета = 50) MAX_RUNTIME_MS: 4.5 * 60 * 1000, // мягкий лимит времени работы MAX_ANSWERS_PER_RUN: 900, // максимум ответов за один запуск (лимит API — 1000/час) MAX_SCAN: 60000, // предохранитель: максимум отзывов за один цикл сканирования LOG_MAX_ROWS: 3000, // лист «Логи» обрезается до этого числа строк COMMENT_MIN_LEN: 2, // минимальная длина ответа COMMENT_MAX_LEN: 4000 // максимальная длина ответа (лимит API — 4096) }; // Заголовок листа «Отзывы». Порядок столбцов менять нельзя — на него опирается код. var REVIEW_HEADERS = ['Дата', 'Feedback ID', 'Артикул продавца', 'Order ID', 'Автор', 'createdAt', 'raw', 'Нужен ответ', 'Рекомендует', 'Источник', 'Отзыв', 'Оценка', 'Комментариев', 'Ответ на отзыв', 'Статус', '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». // ВАЖНО: текст ответа не должен содержать ссылки на сторонние сайты и контакты магазина — // иначе Яндекс Маркет вернёт ошибку «Illegal url in comment text». var DEFAULT_TEMPLATES = { TEXT_5: [ 'Спасибо большое за ваш отзыв и высокую оценку! Нам очень приятно, что товар вам понравился. Будем рады видеть вас снова!', 'Благодарим за тёплые слова и пять звёзд! Ваша поддержка вдохновляет нашу команду работать ещё лучше.', 'Спасибо, что выбрали наш товар и поделились впечатлениями! Желаем приятных покупок и ждём вас снова.', 'Очень рады, что покупка вас порадовала! Спасибо за оценку и за то, что нашли время оставить отзыв.', 'Благодарим вас за пять звёзд! Нам важно, что вы остались довольны. Возвращайтесь за новыми покупками!', 'Спасибо за отличный отзыв! Мы стараемся для вас и счастливы, что товар оправдал ожидания.', 'Огромное спасибо за вашу оценку и добрые слова! Будем рады снова быть вам полезными.', 'Благодарим за доверие к нашему магазину! Ваш отзыв — лучшая награда для нашей команды.', 'Спасибо, что поделились положительными впечатлениями! Хорошего вам настроения и удачных покупок.', 'Сердечно благодарим за высокую оценку! Рады, что всё подошло. Ждём вас за новыми заказами!' ], TEXT_4: [ 'Спасибо за ваш отзыв и оценку! Приятно, что товар вам в целом понравился. Будем стараться стать ещё лучше.', 'Благодарим за обратную связь! Рады, что покупка вас порадовала. Если есть пожелания — мы всегда открыты к ним.', 'Спасибо, что поделились мнением! Учтём ваши впечатления, чтобы в следующий раз заслужить все пять звёзд.', 'Благодарим за оценку и уделённое время! Нам важно ваше мнение, и мы продолжим улучшать наш товар.', 'Спасибо за отзыв! Рады, что в целом всё устроило. Будем работать, чтобы вы остались полностью довольны.' ], TEXT_3: [ 'Спасибо за честный отзыв. Жаль, что товар оправдал ожидания не полностью. Будем благодарны, если расскажете подробнее, что можно улучшить.', 'Благодарим за обратную связь. Сожалеем, что покупка вызвала смешанные впечатления. Мы обязательно учтём это в работе.', 'Спасибо, что поделились мнением. Нам важно понимать, что пошло не так — это помогает становиться лучше.', 'Благодарим за отзыв и оценку. Приносим извинения за неудобства и обязательно проанализируем ваши замечания.', 'Спасибо за уделённое время. Жаль, что товар понравился не до конца. Мы работаем над качеством и учтём вашу оценку.' ], TEXT_12: [ 'Нам очень жаль, что товар вас разочаровал. Приносим искренние извинения. Пожалуйста, свяжитесь с нами через чат Маркета — мы хотим разобраться и помочь.', 'Благодарим за отзыв и приносим извинения за негативный опыт. Нам важно решить проблему — напишите нам, пожалуйста, в чат на Маркете.', 'Сожалеем, что покупка не оправдала ожиданий. Мы серьёзно относимся к каждому замечанию и обязательно примем меры. Приносим свои извинения.' ], EMPTY_45: [ 'Спасибо за вашу высокую оценку! Рады, что покупка вам понравилась. Будем рады видеть вас снова!', 'Благодарим за оценку! Нам очень приятно. Желаем приятных покупок и хорошего настроения.', 'Спасибо, что оценили наш товар! Ждём вас за новыми заказами.' ], EMPTY_13: [ 'Благодарим за оценку. Жаль, что покупка не полностью оправдала ожидания. Будем рады, если расскажете подробнее — это поможет нам стать лучше.', 'Спасибо за обратную связь. Сожалеем, что товар вас не порадовал. Пожалуйста, напишите нам в чат на Маркете, чтобы мы могли разобраться.', 'Нам жаль, что вы остались не полностью довольны. Приносим извинения и будем признательны за подробности — нам важно ваше мнение.', 'Благодарим за оценку. Сожалеем о негативном впечатлении. Мы обязательно учтём это в работе над качеством товара и сервиса.' ] }; /* ====================== ГЛОБАЛЬНОЕ СОСТОЯНИЕ ВЫПОЛНЕНИЯ ========================== */ var _SCRIPT_START = Date.now(); // момент старта — для контроля лимита времени var _LAST_CALL = 0; // время последнего запроса — для троттлинга /* ============================ ТОЧКИ ВХОДА ======================================= */ /** Запускается ОДИН РАЗ вручную. Создаёт листы и ставит автозапуск раз в 5 часов. */ function setup() { ensureSheets_(); scheduleNext_(); log_('INFO', 'Установка завершена. Листы созданы, автозапуск раз в ' + CONFIG.TRIGGER_HOURS + ' ч активен. Заполните лист «Templates» при необходимости.'); SpreadsheetApp.getActiveSpreadsheet().toast('Готово. Скрипт настроен.', 'Маркет Отзывы', 6); } /** Главная функция — её вызывает триггер раз в 5 часов. Также доступна в меню. */ function main() { _SCRIPT_START = Date.now(); ensureSheets_(); scheduleNext_(); // сразу планируем следующий запуск (до начала работы) log_('INFO', '▶ Запуск обработки отзывов Яндекс Маркета'); if (!YANDEX_API_KEY || YANDEX_API_KEY.indexOf('ВСТАВЬТЕ') === 0) { log_('ERROR', 'Не указан API-Key-токен. Впишите его в константу YANDEX_API_KEY.'); return; } try { var businessId = resolveBusinessId_(); if (!businessId) { log_('ERROR', 'Не удалось определить идентификатор кабинета (businessId). ' + 'Укажите его вручную в константе BUSINESS_ID или проверьте токен и его доступы.'); return; } log_('INFO', 'Идентификатор кабинета (businessId): ' + businessId); var c = syncFeedbacks_(businessId); // сканируем все отзывы, отвечаем на отзывы без ответа log_('INFO', '■ Завершено. Просмотрено отзывов: ' + c.scanned + ', отвечено: ' + c.answered + ', ошибок ответа: ' + c.errors + ', без шаблона: ' + c.noTemplate + ', уже с ответом: ' + c.hasAnswer + ', ожидают (лимит/время): ' + c.pending); } catch (e) { log_('ERROR', 'Критическая ошибка: ' + (e && e.message ? e.message : e) + ' | ' + (e && e.stack ? e.stack : '')); } } /** Меню в таблице для ручного управления. */ function onOpen() { SpreadsheetApp.getUi() .createMenu('Маркет Отзывы') .addItem('Запустить сейчас', 'main') .addItem('Установить автозапуск (5 ч)', 'setup') .addItem('Создать/проверить листы', 'ensureSheets_') .addToUi(); } /* ====================== ОПРЕДЕЛЕНИЕ businessId ================================== */ /** * Возвращает идентификатор кабинета. Берёт его из константы BUSINESS_ID, * из кэша свойств скрипта или определяет автоматически через GET /v2/campaigns. */ function resolveBusinessId_() { if (BUSINESS_ID && String(BUSINESS_ID).replace(/\D/g, '').length > 0) { return String(BUSINESS_ID).replace(/\D/g, ''); } var props = PropertiesService.getScriptProperties(); var cached = props.getProperty('BUSINESS_ID'); if (cached) return cached; var resp = ymRequest_('get', '/v2/campaigns', null); if (!resp.ok) { log_('ERROR', 'Ошибка GET /v2/campaigns: ' + resp.error); return null; } var campaigns = (resp.json && resp.json.campaigns) ? resp.json.campaigns : []; var ids = {}; for (var i = 0; i < campaigns.length; i++) { var b = campaigns[i].business; if (b && b.id) ids[b.id] = (b.name || ''); } var keys = Object.keys(ids); if (keys.length === 0) { log_('ERROR', 'GET /v2/campaigns не вернул ни одного кабинета.'); return null; } if (keys.length > 1) { log_('WARN', 'У токена несколько кабинетов: ' + keys.join(', ') + '. Используется первый. При необходимости задайте нужный в BUSINESS_ID.'); } props.setProperty('BUSINESS_ID', keys[0]); return keys[0]; } /* ====================== СКАНИРОВАНИЕ ОТЗЫВОВ И ОТВЕТЫ ========================== */ /** * Сканирует ВСЕ отзывы кабинета (reactionStatus = ALL) и: * • отвечает на каждый отзыв, у которого ещё нет ответа магазина * (statistics.commentsCount = 0) — одним случайным шаблоном по оценке; * • выгружает/обновляет строки на листе «Отзывы». * * Скрипт ориентируется на наличие ответа, а НЕ на флаг needReaction. Это нужно, * чтобы отвечать в том числе на старые отзывы, которые магазин уже просмотрел в * кабинете (после просмотра needReaction = false, и фильтр NEED_REACTION их теряет). * * Сканирование идёт постранично. Если запуск упирается в лимит времени, позиция * (pageToken) сохраняется в свойствах скрипта, и при следующем запуске скан * продолжается с того же места. После полного прохода курсор сбрасывается — * следующий цикл снова просматривает все отзывы (новые + оставшиеся без ответа). */ function syncFeedbacks_(businessId) { var sheet = getSheet_(CONFIG.SHEET_REVIEWS); var idToRow = readIdIndex_(sheet); var templates = readTemplates_(); var props = PropertiesService.getScriptProperties(); var c = { scanned: 0, answered: 0, errors: 0, noTemplate: 0, pending: 0, hasAnswer: 0 }; var newRows = []; var pageToken = props.getProperty('SCAN_PAGE_TOKEN') || ''; while (true) { if (timeUp_()) { props.setProperty('SCAN_PAGE_TOKEN', pageToken); // сохраняем позицию для продолжения log_('WARN', 'Лимит времени — сканирование продолжится при следующем запуске.'); break; } if (c.scanned >= CONFIG.MAX_SCAN) { props.deleteProperty('SCAN_PAGE_TOKEN'); log_('WARN', 'Достигнут предел MAX_SCAN (' + CONFIG.MAX_SCAN + ').'); break; } var page = ymGetFeedbacks_(businessId, { reactionStatus: 'ALL' }, pageToken); if (!page) { // ошибка уже залогирована props.setProperty('SCAN_PAGE_TOKEN', pageToken); break; } if (!page.feedbacks || page.feedbacks.length === 0) { props.deleteProperty('SCAN_PAGE_TOKEN'); break; } for (var i = 0; i < page.feedbacks.length; i++) { var f = page.feedbacks[i]; c.scanned++; var commentsCount = (f.statistics && f.statistics.commentsCount) ? f.statistics.commentsCount : 0; var hasAnswer = commentsCount > 0; // у отзыва уже есть ответ магазина var answerText = '', answeredAt = '', commentId = '', commentStatus = ''; var status, answered = false; if (hasAnswer) { c.hasAnswer++; status = 'Ответ уже есть'; } else if (c.answered >= CONFIG.MAX_ANSWERS_PER_RUN || timeUp_()) { // Отзыв без ответа, но лимит ответов за запуск исчерпан — ответим в следующий раз status = 'Ожидает ответа (будет обработан при следующем запуске)'; c.pending++; } else { var choice = pickTemplate_(f, templates); // {group, text} либо null if (!choice) { status = 'Нет подходящего шаблона (проверьте лист Templates и оценку отзыва)'; c.noTemplate++; } else { var res = ymCreateComment_(businessId, f.feedbackId, choice.text); if (res.ok) { status = 'Отвечено скриптом [' + choice.group + ']'; answerText = choice.text; answeredAt = nowStr_(); commentId = res.commentId || ''; commentStatus = res.status || ''; answered = true; c.answered++; log_('INFO', 'Ответ отправлен. Feedback ' + f.feedbackId + ', оценка ' + ((f.statistics && f.statistics.rating) || '—') + ', группа ' + choice.group); } else { status = 'Ошибка отправки: ' + res.error; c.errors++; log_('ERROR', 'Не удалось ответить на Feedback ' + f.feedbackId + ': ' + res.error); } } } var row = buildRow_(f, { answered: (answered || hasAnswer), answerText: answerText, status: status, answeredAt: answeredAt, commentId: commentId, commentStatus: commentStatus }); if (!idToRow[f.feedbackId]) { newRows.push(row); // новый отзыв — добавим пакетно idToRow[f.feedbackId] = -1; } else if (answered || !hasAnswer) { // Обновляем строку, только если ответили сейчас или отзыв всё ещё без ответа. // Уже отвеченные ранее отзывы не переписываем — экономим обращения к таблице. writeRow_(sheet, idToRow[f.feedbackId], row); } } if (!page.nextPageToken) { props.deleteProperty('SCAN_PAGE_TOKEN'); // полный проход завершён break; } pageToken = page.nextPageToken; props.setProperty('SCAN_PAGE_TOKEN', pageToken); } if (newRows.length > 0) appendRows_(sheet, newRows); log_('INFO', 'Просмотрено: ' + c.scanned + ', отвечено: ' + c.answered + ', уже с ответом: ' + c.hasAnswer + ', ошибок: ' + c.errors + ', без шаблона: ' + c.noTemplate + ', ожидают: ' + c.pending); return c; } /* ====================== ВЫБОР ШАБЛОНА ОТВЕТА ==================================== */ /** Есть ли у отзыва текстовая часть (комментарий / достоинства / недостатки). */ function reviewHasText_(f) { var d = f.description || {}; return isNonEmpty_(d.comment) || isNonEmpty_(d.advantages) || isNonEmpty_(d.disadvantages); } /** * Определяет группу отзыва по оценке и наличию текста, возвращает случайный шаблон. * @return {{group:string, text:string}|null} */ function pickTemplate_(f, templates) { var rating = Number(f.statistics && f.statistics.rating) || 0; var hasText = reviewHasText_(f); 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.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–31). */ 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; } /* ====================== ЗАПРОСЫ К YANDEX MARKET PARTNER API ===================== */ /** * Получает страницу отзывов. * @return {{feedbacks:Array, nextPageToken:string}|null} */ function ymGetFeedbacks_(businessId, bodyFilters, pageToken) { var path = '/v2/businesses/' + businessId + '/goods-feedback?limit=' + CONFIG.PAGE_SIZE; if (pageToken) path += '&pageToken=' + encodeURIComponent(pageToken); var resp = ymRequest_('post', path, bodyFilters || {}); if (!resp.ok) { log_('ERROR', 'Ошибка получения отзывов: ' + resp.error); return null; } var result = (resp.json && resp.json.result) ? resp.json.result : {}; return { feedbacks: result.feedbacks || [], nextPageToken: (result.paging && result.paging.nextPageToken) ? result.paging.nextPageToken : '' }; } /** Отправляет ответ (комментарий) на отзыв. @return {{ok,commentId,status,error}} */ function ymCreateComment_(businessId, feedbackId, text) { var path = '/v2/businesses/' + businessId + '/goods-feedback/comments/update'; var body = { feedbackId: Number(feedbackId), comment: { text: String(text) } }; var resp = ymRequest_('post', path, body); if (resp.ok) { var r = (resp.json && resp.json.result) ? resp.json.result : {}; return { ok: true, commentId: r.id || '', status: r.status || '', error: '' }; } return { ok: false, commentId: '', status: '', error: resp.error }; } /** * Универсальный запрос к Yandex Market Partner API с троттлингом и повтором при 420/429. * @return {{ok:boolean, code:number, json:Object, error:string}} */ function ymRequest_(method, 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.PAUSE_MS) Utilities.sleep(CONFIG.PAUSE_MS - sinceLast); _LAST_CALL = Date.now(); var options = { method: method, headers: { 'Api-Key': YANDEX_API_KEY }, muteHttpExceptions: true, followRedirects: true }; if (method === 'post') { 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) return { ok: true, code: code, json: json, error: '' }; // 420 Method Failure и 429 — превышение лимита запросов if (code === 420 || code === 429) { log_('WARN', 'Яндекс Маркет: превышен лимит запросов (' + code + '). Попытка ' + attempt + ', пауза.'); Utilities.sleep(3000 * attempt); continue; } var msg = errText_(json, raw); if (code === 401) { return { ok: false, code: code, json: json, error: 'Не авторизован (401). Проверьте API-Key-токен. ' + msg }; } if (code === 403) { return { ok: false, code: code, json: json, error: 'Доступ запрещён (403). У токена должен быть доступ «Общение с покупателями» (communication). ' + msg }; } 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: 420, json: null, error: 'Превышен лимит запросов API после повторов.' }; } /** Извлекает текст ошибки из ответа Яндекс Маркета {status, errors:[{code,message}]}. */ function errText_(json, raw) { if (json && json.errors && json.errors.length) { var parts = []; for (var i = 0; i < json.errors.length; i++) { parts.push((json.errors[i].code || '') + ' ' + (json.errors[i].message || '')); } return parts.join('; '); } return raw ? String(raw).substring(0, 300) : ''; } /* ====================== ПОСТРОЕНИЕ СТРОКИ ТАБЛИЦЫ =============================== */ /** Собирает массив значений строки для листа «Отзывы» в порядке REVIEW_HEADERS. */ function buildRow_(f, opts) { var ident = f.identifiers || {}; var stat = f.statistics || {}; var media = f.media || {}; var rawJson = JSON.stringify(f); if (rawJson.length > 49000) rawJson = rawJson.substring(0, 49000) + '…'; var meta = { photos: (media.photos ? media.photos.length : 0), videos: (media.videos ? media.videos.length : 0), paidAmount: stat.paidAmount || 0, comment_id: opts.commentId || '', comment_state: opts.commentStatus || '' }; // «Нужен ответ»: если скрипт только что ответил — реакция больше не нужна var needReaction = opts.answered ? false : (f.needReaction === true); return [ nowStr_(), // Дата (когда строка добавлена/обновлена) f.feedbackId || '', // Feedback ID ident.offerId || '', // Артикул продавца (ваш SKU) ident.orderId || '', // Order ID f.author || '', // Автор отзыва f.createdAt || '', // createdAt (дата отзыва) rawJson, // raw (исходный JSON отзыва) needReaction ? 'Да' : 'Нет', // Нужен ответ (stat.recommended === true) ? 'Да' : (stat.recommended === false ? 'Нет' : ''), // Рекомендует 'Яндекс Маркет', // Источник composeReviewText_(f), // Отзыв (текст / достоинства / недостатки) stat.rating || '', // Оценка stat.commentsCount || 0, // Комментариев к отзыву opts.answerText || '', // Ответ на отзыв opts.status || '', // Статус (результат работы скрипта) opts.answeredAt || '', // AnsweredAt (когда скрипт ответил) JSON.stringify(meta) // Meta (доп. сведения) ]; } /** Объединяет комментарий, достоинства и недостатки отзыва в одну ячейку. */ function composeReviewText_(f) { var d = f.description || {}; var parts = []; if (isNonEmpty_(d.comment)) parts.push(String(d.comment).trim()); if (isNonEmpty_(d.advantages)) parts.push('Достоинства: ' + String(d.advantages).trim()); if (isNonEmpty_(d.disadvantages)) parts.push('Недостатки: ' + String(d.disadvantages).trim()); return parts.length ? parts.join('\n') : '(без текста)'; } /* ====================== РАБОТА С ЛИСТАМИ ТАБЛИЦЫ ================================ */ /** Создаёт все необходимые листы, если их ещё нет. */ 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, 760); 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 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; } /** Считывает индекс {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 !== '' && id !== null && id !== undefined) 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_() { return Utilities.formatDate(new Date(), tz_(), 'yyyy-MM-dd HH:mm:ss'); } /** Часовой пояс таблицы. */ function tz_() { return SpreadsheetApp.getActiveSpreadsheet().getSpreadsheetTimeZone() || 'Europe/Moscow'; } /** Непустое ли значение (с учётом пробелов). */ function isNonEmpty_(v) { return v !== null && v !== undefined && String(v).trim().length > 0; }