Skip to main content

GetCourse: сбор статистики в Google Таблице

GetCourse: сбор статистики в Google Таблице

Скрипт для Google Sheets, который собирает статистику рассылок ирассылок, количество пользователей в сегментах и статистику заказов из GetCourse.

Возможности

  • Письма — выгрузка статистики рассылок (всего, доставлено, просмотры, клики, отписки, ошибки, запрещено)
  • Сегменты пользователей — подсчёт пользователей в сегменте по ссылке
  • Сегменты заказов — статистика заказов: общее количество, платных, сумма платных, оплаченных, сумма оплаченных
Мульти-аккаунт — поддержка нескольких аккаунтов GetCourse

Требования

    Язык интерфейса пользователя GetCourse должен быть русским

    Установка

    1. Открыть Google Таблицу
    2. Расширения → Apps Script
    Удалить содержимое файла Code.gs Вставить код из раздела Код скрипта Сохранить (Ctrl+S) Вернуться в таблицу, обновить страницу В меню появится вкладка GetCourse

    Первый запуск

    1. GetCourse → Управление аккаунтами → добавить аккаунт (имя, email, пароль, домен вида https://your-school.getcourse.ru)
    2. GetCourse → Проверить подключение — убедиться, что авторизация работает
    3. При первом запуске Google попросит разрешения — нажать "Разрешить"

    Использование

    Статистика рассылок

    1. В ячейки вписать ID рассылок (число) или ссылки на рассылки
    2. Выделить эти ячейки
    3. GetCourse → Письма: обновить выбранные
    4. В ячейках справа от ID появятся данные (название, всего, доставлено и т.д.), каждая ячейка с примечанием

    Количество пользователей в сегменте

    1. В ячейку вставить ссылку на сегмент (или гиперссылку)
    2. Выделить ячейку
    3. GetCourse → Пользователи: кол-во в сегментах
    4. В ячейке справа — количество, через одну — дата обновления

    Статистика заказов в сегменте

      В ячейку вставить ссылку на сегмент заказов (URL, гиперссылка или формула HYPERLINK с динамическими параметрами) Выделить ячейку GetCourse → Заказы: статистика сегментов В ячейках справа появятся: кол-во заказов, платных, сумма платных, оплаченных, сумма оплаченных, дата обновления

      Поддерживаются ссылки с segment_id (сохранённые сегменты) и с rule_string (произвольные выборки).

      Код скрипта

      /**
       * GC: Email Stats
       *
       * Сбор статистики рассылок GetCourse прямо в Google Таблице.
       *
       * Установка:
       * 1. Расширения → Apps Script → вставить этот код
       * 2. Вписать ID или ссылки рассылок в ячейки
       * 3. Выделить ячейки → меню GC: Email Stats → Обновить выбранные
       */
      
      // ============================================================
      // Меню
      // ============================================================
      
      function onOpen() {
        var menuName = 'GetCourse';
        var accounts = getAccounts();
        if (accounts.length > 0) {
          var idx = getActiveAccountIndex();
          var acc = accounts[idx];
          menuName = 'GetCourse [' + (acc.name || acc.email) + ']';
        }
      
        SpreadsheetApp.getUi()
          .createMenu(menuName)
          .addItem('Письма: обновить выбранные', 'updateSelectedRows')
          .addItem('Пользователи: кол-во в сегментах', 'updateSegmentCounts')
          .addSeparator()
          .addItem('Управление аккаунтами', 'showAccountManager')
          .addItem('Проверить подключение', 'testConnection')
          .addToUi();
      }
      
      // ============================================================
      // Управление аккаунтами
      // ============================================================
      
      function getAccounts() {
        var props = PropertiesService.getScriptProperties();
        var json = props.getProperty('gc_accounts');
        if (!json) return [];
        try { return JSON.parse(json); } catch (e) { return []; }
      }
      
      function getActiveAccountIndex() {
        var props = PropertiesService.getScriptProperties();
        var idx = parseInt(props.getProperty('gc_active_account') || '0', 10);
        var accounts = getAccounts();
        if (idx >= accounts.length) idx = 0;
        return idx;
      }
      
      function saveAccount(accountData) {
        var accounts = getAccounts();
        var props = PropertiesService.getScriptProperties();
      
        if (accountData.index !== undefined && accountData.index !== null && accountData.index >= 0 && accountData.index < accounts.length) {
          accounts[accountData.index] = {
            name: accountData.name,
            email: accountData.email,
            password: accountData.password,
            domain: accountData.domain
          };
        } else {
          accounts.push({
            name: accountData.name,
            email: accountData.email,
            password: accountData.password,
            domain: accountData.domain
          });
        }
      
        props.setProperty('gc_accounts', JSON.stringify(accounts));
      
        if (accounts.length === 1) {
          props.setProperty('gc_active_account', '0');
        }
      
        return accounts.length - 1;
      }
      
      function deleteAccount(index) {
        var accounts = getAccounts();
        var props = PropertiesService.getScriptProperties();
      
        if (index < 0 || index >= accounts.length) return;
        accounts.splice(index, 1);
        props.setProperty('gc_accounts', JSON.stringify(accounts));
      
        var activeIdx = getActiveAccountIndex();
        if (index === activeIdx) {
          props.setProperty('gc_active_account', '0');
          CacheService.getScriptCache().remove('gc_session');
        } else if (index < activeIdx) {
          props.setProperty('gc_active_account', String(activeIdx - 1));
        }
      }
      
      function setActiveAccount(index) {
        var accounts = getAccounts();
        if (index < 0 || index >= accounts.length) return;
        PropertiesService.getScriptProperties().setProperty('gc_active_account', String(index));
        CacheService.getScriptCache().remove('gc_session');
      }
      
      function getAccountsForDialog() {
        var accounts = getAccounts();
        var activeIdx = getActiveAccountIndex();
        return { accounts: accounts, activeIndex: activeIdx };
      }
      
      function getConfig() {
        var accounts = getAccounts();
        if (accounts.length === 0) {
          throw new Error('NO_ACCOUNTS: Нет сохранённых аккаунтов. Добавьте аккаунт через меню GetCourse → Управление аккаунтами.');
        }
        var idx = getActiveAccountIndex();
        var acc = accounts[idx];
        return {
          email: acc.email,
          password: acc.password,
          domain: acc.domain
        };
      }
      
      // ============================================================
      // Диалог управления аккаунтами
      // ============================================================
      
      function showAccountManager() {
        var html = HtmlService.createHtmlOutput(getAccountManagerHtml_())
          .setWidth(520)
          .setHeight(480)
          .setTitle('Управление аккаунтами');
        SpreadsheetApp.getUi().showModalDialog(html, 'Управление аккаунтами GetCourse');
      }
      
      function getAccountManagerHtml_() {
        return '\
      <!DOCTYPE html>\
      <html>\
      <head>\
      <style>\
        * { box-sizing: border-box; font-family: "Google Sans", Roboto, Arial, sans-serif; }\
        body { margin: 0; padding: 16px; font-size: 14px; color: #202124; }\
        h3 { margin: 0 0 12px; font-size: 16px; }\
        .account-list { margin-bottom: 16px; }\
        .account-item {\
          display: flex; align-items: center; padding: 10px 12px;\
          border: 1px solid #dadce0; border-radius: 8px; margin-bottom: 8px;\
          cursor: pointer; transition: background 0.15s;\
        }\
        .account-item:hover { background: #f1f3f4; }\
        .account-item.active { border-color: #1a73e8; background: #e8f0fe; }\
        .account-item .info { flex: 1; margin-left: 10px; }\
        .account-item .name { font-weight: 500; }\
        .account-item .email { font-size: 12px; color: #5f6368; }\
        .account-item .domain { font-size: 11px; color: #80868b; }\
        .account-item .actions { display: flex; gap: 4px; }\
        .btn { padding: 6px 16px; border-radius: 4px; border: 1px solid #dadce0;\
          background: #fff; cursor: pointer; font-size: 13px; }\
        .btn:hover { background: #f1f3f4; }\
        .btn-primary { background: #1a73e8; color: #fff; border: none; }\
        .btn-primary:hover { background: #1557b0; }\
        .btn-danger { color: #d93025; border-color: #d93025; }\
        .btn-danger:hover { background: #fce8e6; }\
        .btn-sm { padding: 4px 10px; font-size: 12px; }\
        .form-group { margin-bottom: 12px; }\
        .form-group label { display: block; margin-bottom: 4px; font-size: 12px; color: #5f6368; }\
        .form-group input { width: 100%; padding: 8px 10px; border: 1px solid #dadce0;\
          border-radius: 4px; font-size: 14px; }\
        .form-group input:focus { outline: none; border-color: #1a73e8; }\
        .form-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }\
        .radio { width: 16px; height: 16px; accent-color: #1a73e8; cursor: pointer; }\
        .empty { text-align: center; padding: 24px; color: #80868b; }\
        #form-section { display: none; }\
      </style>\
      </head>\
      <body>\
      \
      <div id="list-section">\
        <h3>Аккаунты</h3>\
        <div id="account-list" class="account-list"><div class="empty">Загрузка...</div></div>\
        <button class="btn btn-primary" onclick="showForm(-1)">+ Добавить аккаунт</button>\
      </div>\
      \
      <div id="form-section">\
        <h3 id="form-title">Добавить аккаунт</h3>\
        <input type="hidden" id="edit-index" value="-1">\
        <div class="form-group">\
          <label>Название (для удобства)</label>\
          <input type="text" id="f-name" placeholder="Например: Фитнес Мама">\
        </div>\
        <div class="form-group">\
          <label>Email</label>\
          <input type="email" id="f-email" placeholder="user@example.com">\
        </div>\
        <div class="form-group">\
          <label>Пароль</label>\
          <input type="password" id="f-password" placeholder="Пароль от GetCourse">\
        </div>\
        <div class="form-group">\
          <label>Домен школы</label>\
          <input type="url" id="f-domain" placeholder="https://myschool.getcourse.ru">\
        </div>\
        <div class="form-actions">\
          <button class="btn" onclick="showList()">Отмена</button>\
          <button class="btn btn-primary" onclick="saveForm()">Сохранить</button>\
        </div>\
      </div>\
      \
      <script>\
      var currentData = { accounts: [], activeIndex: 0 };\
      \
      function load() {\
        google.script.run.withSuccessHandler(function(data) {\
          currentData = data;\
          render();\
        }).getAccountsForDialog();\
      }\
      \
      function render() {\
        var list = document.getElementById("account-list");\
        if (currentData.accounts.length === 0) {\
          list.innerHTML = "<div class=\\"empty\\">Нет аккаунтов. Добавьте первый аккаунт.</div>";\
          return;\
        }\
        var html = "";\
        for (var i = 0; i < currentData.accounts.length; i++) {\
          var a = currentData.accounts[i];\
          var isActive = (i === currentData.activeIndex);\
          html += "<div class=\\"account-item" + (isActive ? " active" : "") + "\\">";\
          html += "<input type=\\"radio\\" class=\\"radio\\" name=\\"active\\" " + (isActive ? "checked" : "") + " onclick=\\"activate(" + i + ")\\">";\
          html += "<div class=\\"info\\">";\
          html += "<div class=\\"name\\">" + esc(a.name || "Без названия") + (isActive ? " ✓" : "") + "</div>";\
          html += "<div class=\\"email\\">" + esc(a.email) + "</div>";\
          html += "<div class=\\"domain\\">" + esc(a.domain) + "</div>";\
          html += "</div>";\
          html += "<div class=\\"actions\\">";\
          html += "<button class=\\"btn btn-sm\\" onclick=\\"event.stopPropagation();showForm(" + i + ")\\">Изм.</button>";\
          html += "<button class=\\"btn btn-sm btn-danger\\" onclick=\\"event.stopPropagation();del(" + i + ")\\">✕</button>";\
          html += "</div></div>";\
        }\
        list.innerHTML = html;\
      }\
      \
      function esc(s) { var d = document.createElement("div"); d.textContent = s; return d.innerHTML; }\
      \
      function activate(idx) {\
        google.script.run.withSuccessHandler(function() {\
          currentData.activeIndex = idx;\
          render();\
        }).setActiveAccount(idx);\
      }\
      \
      function del(idx) {\
        if (!confirm("Удалить аккаунт \\"" + (currentData.accounts[idx].name || currentData.accounts[idx].email) + "\\"?")) return;\
        google.script.run.withSuccessHandler(function() { load(); }).deleteAccount(idx);\
      }\
      \
      function showForm(idx) {\
        document.getElementById("list-section").style.display = "none";\
        document.getElementById("form-section").style.display = "block";\
        document.getElementById("edit-index").value = idx;\
        if (idx >= 0 && idx < currentData.accounts.length) {\
          var a = currentData.accounts[idx];\
          document.getElementById("form-title").textContent = "Редактировать аккаунт";\
          document.getElementById("f-name").value = a.name || "";\
          document.getElementById("f-email").value = a.email || "";\
          document.getElementById("f-password").value = a.password || "";\
          document.getElementById("f-domain").value = a.domain || "";\
        } else {\
          document.getElementById("form-title").textContent = "Добавить аккаунт";\
          document.getElementById("f-name").value = "";\
          document.getElementById("f-email").value = "";\
          document.getElementById("f-password").value = "";\
          document.getElementById("f-domain").value = "";\
        }\
      }\
      \
      function showList() {\
        document.getElementById("form-section").style.display = "none";\
        document.getElementById("list-section").style.display = "block";\
      }\
      \
      function saveForm() {\
        var idx = parseInt(document.getElementById("edit-index").value, 10);\
        var data = {\
          index: idx >= 0 ? idx : null,\
          name: document.getElementById("f-name").value.trim(),\
          email: document.getElementById("f-email").value.trim(),\
          password: document.getElementById("f-password").value,\
          domain: document.getElementById("f-domain").value.trim().replace(/\\/+$/, "")\
        };\
        if (!data.email || !data.password || !data.domain) {\
          alert("Заполните Email, Пароль и Домен.");\
          return;\
        }\
        google.script.run.withSuccessHandler(function() {\
          showList();\
          load();\
        }).saveAccount(data);\
      }\
      \
      load();\
      </script>\
      </body>\
      </html>';
      }
      
      // ============================================================
      // Авторизация
      // ============================================================
      
      /**
       * Логин в GetCourse по email/паролю через AJAX API.
       * GetCourse использует React-форму и эндпоинт /user/public/user/json
       * Возвращает строку cookies для авторизованной сессии.
       */
      function login() {
        var config = getConfig();
        var ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
      
        // Шаг 1: GET любой страницы для получения начального cookie сессии
        var getResp = UrlFetchApp.fetch(config.domain + '/cms/system/login', {
          method: 'get',
          headers: { 'User-Agent': ua },
          muteHttpExceptions: true,
          followRedirects: true
        });
        var initialCookies = extractAllCookies_(getResp);
      
        // Шаг 2: AJAX-логин через /user/public/user/json
        var ajaxUrl = config.domain + '/user/public/user/json';
        var postResp = UrlFetchApp.fetch(ajaxUrl, {
          method: 'post',
          headers: {
            'User-Agent': ua,
            'Content-Type': 'application/x-www-form-urlencoded',
            'X-Requested-With': 'XMLHttpRequest',
            'Referer': config.domain + '/cms/system/login',
            'Cookie': initialCookies
          },
          payload: 'action=login&email=' + encodeURIComponent(config.email) +
                   '&password=' + encodeURIComponent(config.password),
          muteHttpExceptions: true,
          followRedirects: false
        });
      
        var postCode = postResp.getResponseCode();
        var postBody = postResp.getContentText();
        var postCookies = extractAllCookies_(postResp);
        var finalCookies = mergeCookies_(initialCookies, postCookies);
      
        // Парсим JSON-ответ
        var json;
        try {
          json = JSON.parse(postBody);
        } catch (e) {
          throw new Error('LOGIN_FAILED: Неожиданный ответ сервера (HTTP ' + postCode + '): ' + postBody.substring(0, 200));
        }
      
        // Проверяем результат
        if (json.error) {
          throw new Error('LOGIN_FAILED: ' + (json.error.message || json.error.text || JSON.stringify(json.error)));
        }
      
        // Шаг 3: Если в ответе есть redirectUrl — переходим по нему (устанавливает финальные cookies)
        if (json.data && json.data.redirectUrl) {
          var redirectUrl = json.data.redirectUrl;
          if (redirectUrl.indexOf('http') !== 0) {
            redirectUrl = config.domain + redirectUrl;
          }
          var redirResp = UrlFetchApp.fetch(redirectUrl, {
            method: 'get',
            headers: { 'User-Agent': ua, 'Cookie': finalCookies },
            muteHttpExceptions: true,
            followRedirects: true
          });
          var redirCookies = extractAllCookies_(redirResp);
          finalCookies = mergeCookies_(finalCookies, redirCookies);
        }
      
        // Шаг 4: Проверяем что авторизация работает
        var testResp = UrlFetchApp.fetch(config.domain + '/notifications/control/mailings/active', {
          method: 'get',
          headers: { 'User-Agent': ua, 'Cookie': finalCookies },
          muteHttpExceptions: true,
          followRedirects: true
        });
        var testCookies = extractAllCookies_(testResp);
        finalCookies = mergeCookies_(finalCookies, testCookies);
      
        if (isLoginPage_(testResp.getContentText())) {
          throw new Error('LOGIN_FAILED: Логин прошёл, но сессия не активна. Ответ JSON: ' + postBody.substring(0, 300));
        }
      
        // Сохраняем cookies в кеш на 2 часа
        CacheService.getScriptCache().put('gc_session', finalCookies, 7200);
        return finalCookies;
      }
      
      /**
       * Извлекает ВСЕ cookies из заголовков Set-Cookie ответа.
       * Возвращает строку "name1=val1; name2=val2"
       */
      function extractAllCookies_(response) {
        var allHeaders = response.getAllHeaders();
        var setCookieHeaders = allHeaders['Set-Cookie'];
        if (!setCookieHeaders) return '';
      
        if (typeof setCookieHeaders === 'string') {
          setCookieHeaders = [setCookieHeaders];
        }
      
        var cookies = {};
        for (var i = 0; i < setCookieHeaders.length; i++) {
          var pair = setCookieHeaders[i].split(';')[0]; // берём только name=value
          var eqIdx = pair.indexOf('=');
          if (eqIdx > 0) {
            var name = pair.substring(0, eqIdx).trim();
            var value = pair.substring(eqIdx + 1).trim();
            cookies[name] = value;
          }
        }
      
        var parts = [];
        for (var key in cookies) {
          parts.push(key + '=' + cookies[key]);
        }
        return parts.join('; ');
      }
      
      /**
       * Объединяет две строки cookies. Новые перезаписывают старые.
       */
      function mergeCookies_(existing, fresh) {
        if (!fresh) return existing;
        if (!existing) return fresh;
      
        var cookies = {};
      
        // Парсим существующие
        var parts = existing.split(';');
        for (var i = 0; i < parts.length; i++) {
          var eqIdx = parts[i].indexOf('=');
          if (eqIdx > 0) {
            cookies[parts[i].substring(0, eqIdx).trim()] = parts[i].substring(eqIdx + 1).trim();
          }
        }
      
        // Перезаписываем новыми
        parts = fresh.split(';');
        for (var j = 0; j < parts.length; j++) {
          var eqIdx2 = parts[j].indexOf('=');
          if (eqIdx2 > 0) {
            cookies[parts[j].substring(0, eqIdx2).trim()] = parts[j].substring(eqIdx2 + 1).trim();
          }
        }
      
        var result = [];
        for (var key in cookies) {
          result.push(key + '=' + cookies[key]);
        }
        return result.join('; ');
      }
      
      /**
       * Возвращает актуальный cookie сессии.
       * Сначала проверяет кеш, если нет — логинится заново.
       */
      function getSessionCookie() {
        var cached = CacheService.getScriptCache().get('gc_session');
        if (cached) return cached;
        return login();
      }
      
      // ============================================================
      // HTTP-запрос к GetCourse
      // ============================================================
      
      function fetchMailingPage(mailingId) {
        var config = getConfig();
        var url = config.domain + '/notifications/control/mailings/update/id/' + mailingId + '/part/main';
        var sessionCookie = getSessionCookie();
      
        var html = fetchWithSession_(url, sessionCookie);
      
        // Проверяем, не попали ли на страницу логина
        if (isLoginPage_(html)) {
          // Сессия истекла — перелогиниваемся
          CacheService.getScriptCache().remove('gc_session');
          sessionCookie = login();
          html = fetchWithSession_(url, sessionCookie);
      
          if (isLoginPage_(html)) {
            throw new Error('LOGIN_FAILED: Не удалось авторизоваться');
          }
        }
      
        return html;
      }
      
      function fetchWithSession_(url, sessionCookie) {
        var response = UrlFetchApp.fetch(url, {
          method: 'get',
          headers: {
            'Cookie': sessionCookie,
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
            'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7'
          },
          muteHttpExceptions: true,
          followRedirects: true
        });
      
        var code = response.getResponseCode();
        if (code !== 200) {
          throw new Error('HTTP_ERROR_' + code);
        }
      
        return response.getContentText();
      }
      
      function isLoginPage_(html) {
        return html.indexOf('action="/cms/system/login"') !== -1 ||
               html.indexOf('id="loginForm"') !== -1 ||
               html.indexOf('LoginForm[email]') !== -1 ||
               html.indexOf('LoginForm%5Bemail%5D') !== -1 ||
               html.indexOf('Pagina de logare') !== -1 ||
               html.indexOf('page-cms_system-login') !== -1 ||
               (html.indexOf('login') !== -1 && html.indexOf('Статистика рассылки') === -1 && html.indexOf('gc-user-logined') === -1);
      }
      
      // ============================================================
      // Парсинг HTML
      // ============================================================
      
      function parseMailingStats(html) {
        var stats = {
          title: '',
          total: 0,
          delivered: 0,
          views: 0,
          clicks: 0,
          unsubscribes: 0,
          errors: 0,
          restricted: 0
        };
      
        // Название рассылки из <title>
        var titleMatch = html.match(/<title>([^<]+)<\/title>/);
        if (titleMatch) {
          stats.title = titleMatch[1].trim();
        }
      
        // --- Панель 1: "Статистика рассылки" (values-table) ---
      
        // Всего сообщений
        var totalMatch = html.match(/Всего сообщений<\/td>\s*<td[^>]*>\s*<a[^>]*>\s*([\d\s]+)\s*<\/a>/);
        if (totalMatch) {
          stats.total = parseInt(totalMatch[1].replace(/\s/g, ''), 10) || 0;
        }
      
        // доставлено
        var deliveredMatch = html.match(/<td class="subkey">\s*доставлено\s*<\/td>\s*<td[^>]*>\s*<a[^>]*>\s*([\d\s]+)\s*<\/a>/);
        if (deliveredMatch) {
          stats.delivered = parseInt(deliveredMatch[1].replace(/\s/g, ''), 10) || 0;
        }
      
        // ошибка
        var errorsMatch = html.match(/<td class="subkey">\s*ошибка\s*<\/td>\s*<td[^>]*>\s*<a[^>]*>\s*([\d\s]+)\s*<\/a>/);
        if (errorsMatch) {
          stats.errors = parseInt(errorsMatch[1].replace(/\s/g, ''), 10) || 0;
        }
      
        // запрещено
        var restrictedMatch = html.match(/<td class="subkey">\s*запрещено\s*<\/td>\s*<td[^>]*>\s*<a[^>]*>\s*([\d\s]+)\s*<\/a>/);
        if (restrictedMatch) {
          stats.restricted = parseInt(restrictedMatch[1].replace(/\s/g, ''), 10) || 0;
        }
      
        // --- Панель 2: "Статистика пользователей" ---
      
        // просмотров (открытия)
        var viewsMatch = html.match(/([\d\s]+)\s*просмотр[а-я]*\s*\(/);
        if (viewsMatch) {
          stats.views = parseInt(viewsMatch[1].replace(/\s/g, ''), 10) || 0;
        }
      
        // кликов
        var clicksMatch = html.match(/([\d\s]+)\s*клик[а-я]*\s*\(/);
        if (clicksMatch) {
          stats.clicks = parseInt(clicksMatch[1].replace(/\s/g, ''), 10) || 0;
        }
      
        // отписки
        var unsubMatch = html.match(/([\d\s]+)\s*отписк[а-яё]*\s*\(/);
        if (unsubMatch) {
          stats.unsubscribes = parseInt(unsubMatch[1].replace(/\s/g, ''), 10) || 0;
        }
      
        return stats;
      }
      
      // ============================================================
      // Извлечение ID из ячейки
      // ============================================================
      
      function extractMailingId_(cellValue) {
        if (!cellValue) return null;
      
        var str = String(cellValue).split(/\s*\|\s*/)[0].trim();
      
        // Если чистое число
        if (/^\d+$/.test(str)) return str;
      
        // URL с /id/ЧИСЛО
        var urlMatch = str.match(/\/id\/(\d+)/);
        if (urlMatch) return urlMatch[1];
      
        // Первое число в строке
        var numMatch = str.match(/(\d{5,})/);
        if (numMatch) return numMatch[1];
      
        return null;
      }
      
      // ============================================================
      // Обновление статистики
      // ============================================================
      
      function updateSelectedRows() {
        var ss = SpreadsheetApp.getActiveSpreadsheet();
        var sheet = ss.getActiveSheet();
        var selection = sheet.getActiveRange();
      
        if (!selection) {
          SpreadsheetApp.getUi().alert('Выделите ячейки с ID рассылок.');
          return;
        }
      
        var updated = 0;
        var numRows = selection.getNumRows();
        var numCols = selection.getNumColumns();
        var startRow = selection.getRow();
        var startCol = selection.getColumn();
      
        for (var r = 0; r < numRows; r++) {
          for (var c = 0; c < numCols; c++) {
            var cell = sheet.getRange(startRow + r, startCol + c);
            var cellValue = cell.getValue();
            if (!cellValue) continue;
      
            var mailingId = extractMailingId_(cellValue);
            if (!mailingId) continue;
      
            ss.toast('Обработка ID ' + mailingId + '...', 'GetCourse', 3);
      
            var row = startRow + r;
            var col = startCol + c;
      
            try {
              var html = fetchMailingPage(mailingId);
              var stats = parseMailingStats(html);
              var now = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'dd.MM.yyyy HH:mm');
      
              var fields = [
                [stats.title, 'Название'],
                [stats.total, 'Всего'],
                [stats.delivered, 'Доставлено'],
                [stats.views, 'Просмотры'],
                [stats.clicks, 'Клики'],
                [stats.unsubscribes, 'Отписки'],
                [stats.errors, 'Ошибки'],
                [stats.restricted, 'Запрещено'],
                [now, 'Обновлено']
              ];
      
              for (var i = 0; i < fields.length; i++) {
                sheet.getRange(row, col + 1 + i).setValue(fields[i][0]).setNote(fields[i][1]);
              }
              SpreadsheetApp.flush();
              updated++;
            } catch (e) {
              if (e.message.indexOf('LOGIN_FAILED') !== -1) {
                SpreadsheetApp.getUi().alert('Ошибка авторизации: ' + e.message);
                return;
              }
              sheet.getRange(row, col + 1).setValue('Ошибка: ' + e.message).setNote('Ошибка');
              SpreadsheetApp.flush();
            }
      
            Utilities.sleep(500);
          }
        }
      
        ss.toast('Обновлено: ' + updated, 'GC: Email Stats', 5);
      }
      
      // ============================================================
      // Сегменты: количество пользователей
      // ============================================================
      
      function extractSegmentUrl_(cell) {
        // HYPERLINK-формула: =HYPERLINK("url", "text")
        var formula = cell.getFormula();
        if (formula) {
          var m = formula.match(/HYPERLINK\s*\(\s*"([^"]+)"/i);
          if (m) return m[1];
        }
      
        // Rich-text ссылка
        var richText = cell.getRichTextValue();
        if (richText) {
          var url = richText.getLinkUrl();
          if (url) return url;
        }
      
        // Обычный текст URL
        var val = String(cell.getValue()).trim();
        if (val.match(/^https?:\/\//)) return val;
      
        return null;
      }
      
      function fetchSegmentPage(url) {
        var sessionCookie = getSessionCookie();
        var html = fetchWithSession_(url, sessionCookie);
      
        if (isLoginPage_(html)) {
          CacheService.getScriptCache().remove('gc_session');
          sessionCookie = login();
          html = fetchWithSession_(url, sessionCookie);
      
          if (isLoginPage_(html)) {
            throw new Error('LOGIN_FAILED: Не удалось авторизоваться');
          }
        }
      
        return html;
      }
      
      function parseSegmentCount(html) {
        // <div class="summary">Показаны <b>1-30</b> из <b>1 941</b> записи.</div>
        var m = html.match(/<div class="summary">Показаны\s*<b>[^<]+<\/b>\s*из\s*<b>([\d\s]+)<\/b>/);
        if (m) return parseInt(m[1].replace(/\s/g, ''), 10);
        return null;
      }
      
      function updateSegmentCounts() {
        var ss = SpreadsheetApp.getActiveSpreadsheet();
        var sheet = ss.getActiveSheet();
        var selection = sheet.getActiveRange();
      
        if (!selection) {
          SpreadsheetApp.getUi().alert('Выделите ячейки со ссылками на сегменты.');
          return;
        }
      
        var updated = 0;
        var numRows = selection.getNumRows();
        var numCols = selection.getNumColumns();
        var startRow = selection.getRow();
        var startCol = selection.getColumn();
      
        for (var r = 0; r < numRows; r++) {
          for (var c = 0; c < numCols; c++) {
            var cell = sheet.getRange(startRow + r, startCol + c);
            if (!cell.getValue()) continue;
      
            var url = extractSegmentUrl_(cell);
            if (!url) continue;
      
            ss.toast('Обработка сегмента...', 'GC: Сегменты', 3);
      
            try {
              var html = fetchSegmentPage(url);
              var count = parseSegmentCount(html);
      
              if (count === null) {
                ss.toast('Не удалось найти количество пользователей', 'GC: Сегменты', 3);
                continue;
              }
      
              var now = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'dd.MM.yyyy HH:mm');
              var col = startCol + c;
      
              sheet.getRange(startRow + r, col + 1).setValue(count);
              sheet.getRange(startRow + r, col + 2).setValue(now);
              SpreadsheetApp.flush();
              updated++;
            } catch (e) {
              if (e.message.indexOf('LOGIN_FAILED') !== -1) {
                SpreadsheetApp.getUi().alert('Ошибка авторизации: ' + e.message);
                return;
              }
              sheet.getRange(startRow + r, startCol + c + 1).setValue('Ошибка: ' + e.message);
              SpreadsheetApp.flush();
            }
      
            Utilities.sleep(500);
          }
        }
      
        ss.toast('Обновлено: ' + updated, 'GC: Сегменты', 5);
      }
      
      // ============================================================
      // Тест подключения
      // ============================================================
      
      function testConnection() {
        var ui = SpreadsheetApp.getUi();
        var config = getConfig();
      
        try {
          CacheService.getScriptCache().remove('gc_session');
          var sessionCookie = login();
          ui.alert('Подключение успешно!',
            'Email: ' + config.email + '\n' +
            'Домен: ' + config.domain + '\n' +
            'Cookie: ' + sessionCookie.substring(0, 40) + '...\n\n' +
            'Авторизация работает. Можно обновлять статистику.',
            ui.ButtonSet.OK);
        } catch (e) {
          ui.alert('Ошибка подключения', e.message, ui.ButtonSet.OK);
        }
      }
      
      /**
       * Отладка сегмента: показывает что возвращает сервер для URL сегмента.
       * Замените testUrl на нужную ссылку, запустите из Apps Script.
       */
      function debugSegment() {
        var testUrl = 'https://fitnessmama.school/pl/user/user/index?uc%5Bsegment_id%5D=0&uc%5Brule_string%5D=%7B%22type%22%3A%22user_createdat%22%2C%22inverted%22%3A0%2C%22params%22%3A%7B%22value%22%3A%7B%22from%22%3Anull%2C%22to%22%3Anull%2C%22toNDays%22%3A%2230%22%2C%22fromNDays%22%3Anull%2C%22dateType%22%3A%22last_n_days%22%2C%22withTime%22%3Afalse%7D%2C%22valueMode%22%3Anull%7D%2C%22maxSize%22%3A%22%22%7D&formParams%5Bclarity_uid%5D=JoSTNr9IVThIbcbL_OLoVFNrnDiOXnbX';
      
        try {
          var html = fetchSegmentPage(testUrl);
          var title = html.match(/<title>([^<]+)<\/title>/);
          var isLogin = isLoginPage_(html);
          var hasFilterCount = html.indexOf('filter-count') !== -1;
          var hasDataCount = html.indexOf('data-count') !== -1;
          var count = parseSegmentCount(html);
      
          Logger.log('=== DEBUG segment ===');
          Logger.log('URL: ' + testUrl.substring(0, 80) + '...');
          Logger.log('Title: ' + (title ? title[1] : 'не найден'));
          Logger.log('isLoginPage: ' + isLogin);
          Logger.log('hasFilterCount: ' + hasFilterCount);
          Logger.log('hasDataCount: ' + hasDataCount);
          Logger.log('parsedCount: ' + count);
          Logger.log('HTML length: ' + html.length);
          Logger.log('First 2000 chars: ' + html.substring(0, 2000));
        } catch (e) {
          Logger.log('ERROR: ' + e.message);
        }
      }
      
      /**
       * Отладка: показывает что возвращает сервер для конкретного mailing ID.
       * Запустите вручную из Apps Script, изменив ID ниже.
       */
      function debugMailing() {
        var testId = 4508232; // Замените на нужный ID
        CacheService.getScriptCache().remove('gc_session');
      
        try {
          var html = fetchMailingPage(testId);
          var title = html.match(/<title>([^<]+)<\/title>/);
          var hasStats = html.indexOf('Статистика рассылки') !== -1;
          var hasUserStats = html.indexOf('просмотр') !== -1;
          var isLogin = isLoginPage_(html);
          var bodyClass = html.match(/class="([^"]*page-[^"]*)"/) || ['', 'не найден'];
      
          Logger.log('=== DEBUG mailing ID: ' + testId + ' ===');
          Logger.log('Title: ' + (title ? title[1] : 'не найден'));
          Logger.log('Body class: ' + bodyClass[1]);
          Logger.log('isLoginPage: ' + isLogin);
          Logger.log('hasStats: ' + hasStats);
          Logger.log('hasUserStats: ' + hasUserStats);
          Logger.log('HTML length: ' + html.length);
          Logger.log('First 1000 chars: ' + html.substring(0, 1000));
        } catch (e) {
          Logger.log('ERROR: ' + e.message);
        }
      }