(function () { const SUPPORTED_PATHS = ['/pl/user/user/index']; if (!SUPPORTED_PATHS.includes(location.pathname)) { console.warn('[АНАЛИЗ СЕГМЕНТА] Неподдерживаемая страница:', location.pathname); return; } const CONFIG = { writeUrl: '/chtm/sdk_probe/~activity-profile', fieldIds: { club_end_at: '13849515', activity_updated_at: '13849787', activity_score: '13849788', activity_segment: '13849789', activity_profile_json: '13849790' }, fieldNames: { club_end_at: 'club_end_at', activity_updated_at: 'activity_updated_at', activity_score: 'activity_score', activity_segment: 'activity_segment', activity_profile_json: 'activity_profile_json' }, clubProductTitleRegex: /\[?\s*онлайн\s+йога\s+клуб\s*\]?/i, pageConcurrency: 4, userConcurrency: 4, writeBatchSize: 20, maxVisitPagesPerUser: 1, buttonFastId: 'segment-fast-analyzer-btn', buttonDeepId: 'segment-deep-analyzer-btn', wrapId: 'segment-profile-analyzer-wrap', chartId: 'segment-profile-analyzer-chart', statusId: 'segment-profile-analyzer-status', summaryId: 'segment-profile-analyzer-summary', controlsId: 'segment-profile-analyzer-controls', filterId: 'segment-profile-analyzer-filter', logId: 'segment-profile-analyzer-log', ratingHelpId: 'segment-profile-analyzer-rating-help', segmentHelpId: 'segment-profile-analyzer-segment-help', uxVersion: '2026-07-11-user-activity-ux-v1', uiScaleStorageKey: 'gc_user_activity_ui_scale_v1', modeStorageKey: 'gc_user_activity_mode_v1', progressId: 'segment-profile-analyzer-progress', progressBarId: 'segment-profile-analyzer-progress-bar', progressTextId: 'segment-profile-analyzer-progress-text', modalId: 'segment-profile-analyzer-modal', scaleId: 'segment-profile-analyzer-scale', runButtonId: 'segment-profile-analyzer-run', debug: true }; const CLIENT_TYPE_LABELS = { active_member: 'Действующий участник клуба', expired_member: 'Бывший участник клуба', lead: 'Потенциальный клиент', unknown: 'Не определено' }; const ACTIVITY_SEGMENT_LABELS = { active_core: 'Действующий: занимается регулярно', active_regular: 'Действующий: есть активность', active_risk: 'Действующий: риск оттока', active_sleeping: 'Действующий: почти не занимается', active_dead: 'Действующий: нет активности', expired_hot: 'Бывший: горячий к возврату', expired_warm: 'Бывший: тёплый к возврату', expired_watching: 'Бывший: наблюдает', expired_cold: 'Бывший: холодный', expired_dead: 'Бывший: нет активности', lead_hot: 'Потенциальный: горячий', lead_warm: 'Потенциальный: тёплый', lead_watching: 'Потенциальный: наблюдает', lead_dead: 'Потенциальный: нет активности', hot: 'Горячий', warm: 'Тёплый', watching: 'Наблюдает', cold: 'Холодный', dead: 'Нет активности', unknown: 'Не определено' }; const SOURCE_LABELS = { tg: 'Телеграм', telegram: 'Телеграм', vk: 'ВКонтакте', mail: 'Почта', email: 'Почта', mailtg: 'Почта + Телеграм', mailvk: 'Почта + ВКонтакте', direct: 'Прямой заход', unknown: 'Не определено', '': 'Не определено' }; const REPORT_LABELS = { segments: 'Сегменты активности', profiles: 'Тип клиента', rating: 'Рейтинг активности', registration_year: 'Регистрация по годам', registration_month: 'Регистрация по месяцам', purchases: 'Покупки клуба', club_end_month: 'Окончание клуба по месяцам', club_end_day: 'Окончание клуба по дням', source: 'Источники', updated: 'Дата обновления данных' }; const STATE = { running: false, writing: false, grouping: false, stopped: false, mode: '', selectedMode: 'table', uiScale: 100, currentReport: 'segments', sourceRows: [], analyzedRows: [], uniqueUsers: [], userAnalysisById: {}, filters: {}, diagnostics: { totalRows: 0, rowsWithId: 0, rowsWithoutId: 0, uniqueIds: 0, duplicateIds: 0, duplicateGroups: [] }, analysisFinishedAt: '', fieldsWrittenAt: '', originalHeadHtml: '', originalBodyHtml: '', originalSummaryHtml: '', currentTableIsFiltered: false }; if (window.GC_ACTIVITY_PROFILE_ANALYZER?.destroy) { window.GC_ACTIVITY_PROFILE_ANALYZER.destroy(); } function cleanText(value) { return String(value || '') .replace(/\u00a0/g, ' ') .replace(/\s+/g, ' ') .trim(); } function normalizeText(value) { return cleanText(value).toLowerCase(); } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function formatNumber(value) { return new Intl.NumberFormat('ru-RU').format(Math.round(Number(value) || 0)); } function pad(value) { value = Number(value); return value < 10 ? '0' + value : String(value); } function nowString() { const d = new Date(); return [ d.getFullYear(), pad(d.getMonth() + 1), pad(d.getDate()) ].join('-') + ' ' + [ pad(d.getHours()), pad(d.getMinutes()), pad(d.getSeconds()) ].join(':'); } function formatDateForField(date) { if (!(date instanceof Date) || Number.isNaN(date.getTime())) return ''; return [ date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate()) ].join('-') + ' ' + [ pad(date.getHours()), pad(date.getMinutes()), pad(date.getSeconds()) ].join(':'); } function daysBetween(fromDate, toDate = new Date()) { if (!(fromDate instanceof Date) || Number.isNaN(fromDate.getTime())) return ''; return Math.max(0, Math.floor((toDate - fromDate) / 86400000)); } function parseNumber(value) { const text = cleanText(value); if (!text || text === '-') return null; const normalized = text .replace(',', '.') .replace(/[^\d.-]/g, ''); if (!normalized) return null; const number = Number(normalized); return Number.isFinite(number) ? number : null; } function getRuMonth(monthText) { const key = String(monthText || '') .toLowerCase() .replace('.', '') .trim(); const months = { 'янв': 0, 'январь': 0, 'января': 0, 'фев': 1, 'февр': 1, 'февраль': 1, 'февраля': 1, 'мар': 2, 'март': 2, 'марта': 2, 'апр': 3, 'апрель': 3, 'апреля': 3, 'май': 4, 'мая': 4, 'июн': 5, 'июнь': 5, 'июня': 5, 'июл': 6, 'июль': 6, 'июля': 6, 'авг': 7, 'август': 7, 'августа': 7, 'сен': 8, 'сент': 8, 'сентябрь': 8, 'сентября': 8, 'окт': 9, 'октябрь': 9, 'октября': 9, 'ноя': 10, 'нояб': 10, 'ноябрь': 10, 'ноября': 10, 'дек': 11, 'декабрь': 11, 'декабря': 11 }; return months.hasOwnProperty(key) ? months[key] : null; } function parseDate(value, options = {}) { const text = cleanText(value); if (!text) return null; const endOfDay = Boolean(options.endOfDay); const now = new Date(); let m; m = text.match(/(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?/); if (m) { return new Date( Number(m[1]), Number(m[2]) - 1, Number(m[3]), m[4] ? Number(m[4]) : endOfDay ? 23 : 0, m[5] ? Number(m[5]) : endOfDay ? 59 : 0, m[6] ? Number(m[6]) : endOfDay ? 59 : 0 ); } m = text.match(/(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{2,4})(?:\s+(\d{1,2}):(\d{2}))?/); if (m) { let year = Number(m[3]); if (year < 100) year += 2000; return new Date( year, Number(m[2]) - 1, Number(m[1]), m[4] ? Number(m[4]) : endOfDay ? 23 : 0, m[5] ? Number(m[5]) : endOfDay ? 59 : 0, endOfDay ? 59 : 0 ); } m = text.match(/(\d{1,2})\s+([а-яё.]+)\s+(\d{4})(?:\s+(\d{1,2}):(\d{2}))?/i); if (m) { const month = getRuMonth(m[2]); if (month !== null) { return new Date( Number(m[3]), month, Number(m[1]), m[4] ? Number(m[4]) : endOfDay ? 23 : 0, m[5] ? Number(m[5]) : endOfDay ? 59 : 0, endOfDay ? 59 : 0 ); } } m = text.match(/сегодня(?:\s+(\d{1,2}):(\d{2}))?/i); if (m) { return new Date( now.getFullYear(), now.getMonth(), now.getDate(), Number(m[1] || 0), Number(m[2] || 0) ); } m = text.match(/вчера(?:\s+(\d{1,2}):(\d{2}))?/i); if (m) { return new Date( now.getFullYear(), now.getMonth(), now.getDate() - 1, Number(m[1] || 0), Number(m[2] || 0) ); } return null; } function labelClientType(value) { return CLIENT_TYPE_LABELS[value] || value || 'Не определено'; } function labelActivitySegment(value) { return ACTIVITY_SEGMENT_LABELS[value] || value || 'Не определено'; } function labelSource(value) { const raw = cleanText(value); const key = raw.toLowerCase(); return SOURCE_LABELS[key] || raw || 'Не определено'; } function getRowKey(row) { if (row.rowKey) return row.rowKey; if (row.userId) { return 'user:' + row.userId; } return [ 'row', row.recordId || '', row.page || '', row.rowIndex || '', row.email || '' ].join(':'); } function log() { if (CONFIG.debug) { console.log.apply(console, ['[АНАЛИЗ СЕГМЕНТА]'].concat(Array.from(arguments))); } const el = document.getElementById(CONFIG.logId); if (!el) return; const time = new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); const line = document.createElement('div'); line.textContent = '[' + time + '] ' + Array.from(arguments).join(' '); line.style.padding = '2px 0'; el.prepend(line); } function setStatus(text) { const el = document.getElementById(CONFIG.statusId); if (el) el.innerHTML = text; } function setFilterInfo(text) { const el = document.getElementById(CONFIG.filterId); if (!el) return; if (!text) { el.style.display = 'none'; el.innerHTML = ''; return; } el.style.display = 'block'; el.innerHTML = text; } function loadHighcharts(callback) { if (window.Highcharts) { callback(); return; } const existing = document.querySelector('script[data-segment-profile-highcharts="1"]'); if (existing) { existing.addEventListener('load', callback); return; } const script = document.createElement('script'); script.src = 'https://code.highcharts.com/highcharts.js'; script.setAttribute('data-segment-profile-highcharts', '1'); script.onload = callback; document.head.appendChild(script); } function injectStyles() { if (document.getElementById('segment-profile-analyzer-style')) return; const style = document.createElement('style'); style.id = 'segment-profile-analyzer-style'; style.textContent = ` table.kv-grid-table td.segment-analyzer-long-cell, .kv-grid-container table td.segment-analyzer-long-cell, table td.segment-analyzer-long-cell { max-width: 260px !important; min-width: 120px !important; max-height: 42px !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; vertical-align: top !important; cursor: zoom-in !important; } table.kv-grid-table td.segment-analyzer-long-cell.segment-analyzer-expanded, .kv-grid-container table td.segment-analyzer-long-cell.segment-analyzer-expanded, table td.segment-analyzer-long-cell.segment-analyzer-expanded { max-width: 760px !important; max-height: 280px !important; overflow: auto !important; white-space: pre-wrap !important; word-break: break-word !important; cursor: zoom-out !important; background: #fffdf2 !important; z-index: 2 !important; position: relative !important; } table.kv-grid-table td, .kv-grid-container table td, table td { vertical-align: top !important; } #segment-profile-analyzer-filter .segment-filter-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; margin: 3px; border-radius: 999px; background: #e8f0fe; border: 1px solid #9bbcff; font-size: 12px; } #segment-profile-analyzer-filter .segment-filter-chip button { border: 0; background: transparent; color: #8a0000; font-weight: 700; cursor: pointer; padding: 0 2px; } .segment-profile-report-btn.btn-primary { color:#fff !important; } #segment-profile-analyzer-wrap { --gc-user-scale:1; font-size:calc(14px * var(--gc-user-scale)); } #segment-profile-analyzer-wrap .gc-user-shell { border:1px solid #d8dee8;border-radius:14px;background:#fff;box-shadow:0 8px 28px rgba(31,45,61,.08);overflow:hidden; } #segment-profile-analyzer-wrap .gc-user-head { padding:16px 18px;border-bottom:1px solid #e8edf3;background:linear-gradient(180deg,#fff,#f8fbff); } #segment-profile-analyzer-wrap .gc-user-title { font-size:calc(20px * var(--gc-user-scale));font-weight:800;color:#182230; } #segment-profile-analyzer-wrap .gc-user-sub { margin-top:4px;color:#667085;font-size:calc(12px * var(--gc-user-scale)); } #segment-profile-analyzer-wrap .gc-user-mode { display:flex;gap:8px;flex-wrap:wrap;margin-top:12px; } #segment-profile-analyzer-wrap .gc-user-mode button { border:1px solid #cdd7e5;background:#fff;border-radius:999px;padding:8px 13px;font-weight:700;cursor:pointer; } #segment-profile-analyzer-wrap .gc-user-mode button.is-active { background:#2f6fed;color:#fff;border-color:#2f6fed;box-shadow:0 4px 12px rgba(47,111,237,.25); } #segment-profile-analyzer-wrap .gc-user-toolbar { display:flex;justify-content:space-between;gap:10px;align-items:center;flex-wrap:wrap;margin-top:12px; } #segment-profile-analyzer-wrap .gc-user-scale { display:inline-flex;align-items:center;border:1px solid #d7deea;border-radius:10px;overflow:hidden;background:#fff; } #segment-profile-analyzer-wrap .gc-user-scale button { border:0;background:#fff;padding:7px 10px;cursor:pointer;font-weight:800; } #segment-profile-analyzer-wrap .gc-user-scale span { min-width:52px;text-align:center;font-weight:700;color:#344054; } #segment-profile-analyzer-wrap .gc-user-run { border:0;border-radius:10px;background:#1677ff;color:#fff;padding:10px 18px;font-weight:800;cursor:pointer;box-shadow:0 5px 14px rgba(22,119,255,.25); } #segment-profile-analyzer-wrap .gc-user-run:disabled { opacity:.55;cursor:wait; } #segment-profile-analyzer-wrap .gc-user-help { border:1px solid #cfd8e6;background:#fff;border-radius:50%;width:32px;height:32px;font-weight:800;cursor:pointer; } #segment-profile-analyzer-progress { display:none;margin:14px 18px;padding:12px;border:1px solid #d7e2f0;border-radius:12px;background:#f7faff; } #segment-profile-analyzer-progress.is-visible { display:block; } #segment-profile-analyzer-progress.is-complete { background:#ecfdf3;border-color:#86d9a8;color:#176b3a; } #segment-profile-analyzer-progress.is-error { background:#fff3f2;border-color:#f3a29b;color:#a52a22; } #segment-profile-analyzer-progress .gc-progress-track { height:10px;border-radius:999px;background:#e6edf7;overflow:hidden;margin-top:8px; } #segment-profile-analyzer-progress .gc-progress-bar { width:0;height:100%;background:#2f6fed;transition:width .25s ease,background .25s ease; } #segment-profile-analyzer-progress.is-complete .gc-progress-bar { background:#22a35a; } #segment-profile-analyzer-wrap .gc-user-actions { display:flex;gap:7px;flex-wrap:wrap;padding:12px 18px;border-bottom:1px solid #edf1f5; } #segment-profile-analyzer-wrap .gc-user-summary { display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px!important;padding:0 18px; } #segment-profile-analyzer-wrap .gc-user-summary > div { min-width:0!important;border-radius:12px!important;padding:12px!important; } #segment-profile-analyzer-wrap .gc-user-chart-wrap { padding:0 18px 16px; } #segment-profile-analyzer-wrap .gc-user-log-toggle { margin:0 18px 16px; } #segment-profile-analyzer-log { display:none; } #segment-profile-analyzer-log.is-open { display:block; } #segment-profile-analyzer-modal { position:fixed;inset:0;z-index:100050;background:rgba(15,23,42,.45);display:none;align-items:center;justify-content:center;padding:20px; } #segment-profile-analyzer-modal.is-open { display:flex; } #segment-profile-analyzer-modal .gc-modal-card { width:min(680px,100%);max-height:85vh;overflow:auto;background:#fff;border-radius:16px;box-shadow:0 20px 70px rgba(0,0,0,.25); } #segment-profile-analyzer-modal .gc-modal-head { position:sticky;top:0;z-index:2;background:#fff;padding:16px 18px;border-bottom:1px solid #e7ebf0;display:flex;justify-content:space-between;gap:12px;align-items:center; } #segment-profile-analyzer-modal .gc-modal-body { padding:18px;line-height:1.55; } #segment-profile-analyzer-modal .gc-modal-close { border:0;background:#eef2f7;border-radius:9px;padding:8px 12px;font-weight:700;cursor:pointer; } @media(max-width:900px){ #segment-profile-analyzer-wrap .gc-user-summary{grid-template-columns:repeat(2,minmax(0,1fr));} } @media(max-width:640px){ #segment-profile-analyzer-wrap .gc-user-summary{grid-template-columns:1fr;} #segment-profile-analyzer-wrap .gc-user-head{padding:14px;} } `; document.head.appendChild(style); } function getStoredUiScale() { try { const value = Number(localStorage.getItem(CONFIG.uiScaleStorageKey) || 100); return [80, 90, 100, 110, 120, 130].includes(value) ? value : 100; } catch (err) { return 100; } } function getStoredMode() { try { const value = localStorage.getItem(CONFIG.modeStorageKey); return value === 'deep' ? 'deep' : 'table'; } catch (err) { return 'table'; } } function applyUiScale(value) { const allowed = [80, 90, 100, 110, 120, 130]; const scale = allowed.includes(Number(value)) ? Number(value) : 100; STATE.uiScale = scale; const wrap = document.getElementById(CONFIG.wrapId); if (wrap) wrap.style.setProperty('--gc-user-scale', String(scale / 100)); const label = document.getElementById(CONFIG.scaleId); if (label) label.textContent = scale + '%'; try { localStorage.setItem(CONFIG.uiScaleStorageKey, String(scale)); } catch (err) {} if (window.Highcharts && document.getElementById(CONFIG.chartId)) { setTimeout(function(){ try { Highcharts.charts.filter(Boolean).forEach(c => c.reflow()); } catch (err) {} }, 30); } } function changeUiScale(direction) { const values = [80, 90, 100, 110, 120, 130]; let index = values.indexOf(STATE.uiScale); if (index < 0) index = 2; index = Math.max(0, Math.min(values.length - 1, index + direction)); applyUiScale(values[index]); } function setAnalysisMode(mode) { STATE.selectedMode = mode === 'deep' ? 'deep' : 'table'; try { localStorage.setItem(CONFIG.modeStorageKey, STATE.selectedMode); } catch (err) {} const wrap = document.getElementById(CONFIG.wrapId); if (!wrap) return; Array.from(wrap.querySelectorAll('[data-analysis-mode]')).forEach(function(btn){ btn.classList.toggle('is-active', btn.getAttribute('data-analysis-mode') === STATE.selectedMode); }); const run = document.getElementById(CONFIG.runButtonId); if (run) run.textContent = STATE.selectedMode === 'deep' ? 'Обновить и получить аналитику' : 'Получить аналитику'; } function setProgress(percent, text, state) { const box = document.getElementById(CONFIG.progressId); const bar = document.getElementById(CONFIG.progressBarId); const label = document.getElementById(CONFIG.progressTextId); if (!box || !bar || !label) return; box.classList.add('is-visible'); box.classList.toggle('is-complete', state === 'complete'); box.classList.toggle('is-error', state === 'error'); bar.style.width = Math.max(0, Math.min(100, Number(percent) || 0)) + '%'; label.innerHTML = text; } function setRunBusy(busy) { const btn = document.getElementById(CONFIG.runButtonId); if (btn) { btn.disabled = Boolean(busy); btn.textContent = busy ? 'Собираю аналитику…' : (STATE.selectedMode === 'deep' ? 'Обновить и получить аналитику' : 'Получить аналитику'); } const wrap = document.getElementById(CONFIG.wrapId); if (wrap) Array.from(wrap.querySelectorAll('[data-analysis-mode]')).forEach(btn => btn.disabled = Boolean(busy)); } function showUxModal(title, bodyHtml) { let modal = document.getElementById(CONFIG.modalId); if (!modal) { modal = document.createElement('div'); modal.id = CONFIG.modalId; modal.innerHTML = '
Для режима ' + (mode === 'deep' ? '«Глубокое обновление»' : '«Быстрый анализ»') + ' текущей таблицы недостаточно.
' + missingHtml + recommendedHtml + 'Быстрый анализ читает уже записанный activity_profile_json и строит отчёт без обхода карточек пользователей.
Глубокое обновление заново проверяет покупки, метрику и визиты каждого пользователя. После проверки результат можно записать в дополнительные поля.
' + 'Клик по столбцу диаграммы добавляет фильтр. Фильтры можно комбинировать, затем скопировать ID или добавить выбранных пользователей в новую группу.
'); } function getCurrentDataTable() { return getDataTable(document); } function getDataTable(doc) { const tables = Array.from(doc.querySelectorAll('table.kv-grid-table, .kv-grid-container table, table')); return tables.find(function (table) { return table.querySelector('tbody tr[data-user-id], tbody tr[data-deal-id], tbody tr[data-key]'); }) || tables[0] || null; } function getTableHeaders(table) { const headers = Array.from(table.querySelectorAll('thead th')); return headers.map(function (th, index) { return { index: index, text: cleanText(th.innerText || th.textContent), normalized: normalizeText(th.innerText || th.textContent), colSeq: th.getAttribute('data-col-seq') }; }); } function getCellByHeader(row, header) { if (!row || !header) return null; if (header.colSeq !== null && header.colSeq !== '') { const bySeq = row.querySelector('td[data-col-seq="' + header.colSeq + '"]'); if (bySeq) return bySeq; } const cells = Array.from(row.querySelectorAll('td')); return cells[header.index] || null; } function findHeader(headers, candidates) { for (const candidate of candidates) { const normalizedCandidate = normalizeText(candidate); let found = headers.find(function (h) { return h.normalized === normalizedCandidate; }); if (found) return found; found = headers.find(function (h) { return h.normalized.includes(normalizedCandidate); }); if (found) return found; } return null; } function getColumnByPossibleNames(columns, names) { const keys = Object.keys(columns); for (const name of names) { const normalizedName = normalizeText(name); let found = keys.find(function (key) { return normalizeText(key) === normalizedName; }); if (found) return columns[found]; found = keys.find(function (key) { return normalizeText(key).includes(normalizedName); }); if (found) return columns[found]; } return ''; } function applyCompactLongCells() { injectStyles(); const table = getCurrentDataTable(); if (!table) return; const headers = getTableHeaders(table); const longColumnIndexes = []; headers.forEach(function (header) { const name = normalizeText(header.text); const isLongColumn = name.includes('activity_profile_json') || name.includes('profile_json') || name.includes('activity_last_pages_json') || name.includes('last_pages_json') || name.includes('json'); if (isLongColumn) { longColumnIndexes.push(header.index); } }); Array.from(table.querySelectorAll('tbody tr')).forEach(function (row) { const cells = Array.from(row.querySelectorAll('td')); cells.forEach(function (cell, index) { const text = cleanText(cell.innerText || cell.textContent); const isJsonColumn = longColumnIndexes.includes(index); const isLongText = text.length > 180; if (!isJsonColumn && !isLongText) return; cell.classList.add('segment-analyzer-long-cell'); if (!cell.getAttribute('title')) { cell.setAttribute('title', text.slice(0, 1000)); } if (cell.getAttribute('data-segment-compact-bound') === '1') return; cell.setAttribute('data-segment-compact-bound', '1'); cell.addEventListener('dblclick', function (event) { event.preventDefault(); event.stopPropagation(); cell.classList.toggle('segment-analyzer-expanded'); }); }); }); } function parseIdFromCell(cell) { if (!cell) return ''; const text = cleanText(cell.innerText || cell.textContent); const textMatch = text.match(/\d{3,}/); if (textMatch) return textMatch[0]; const html = cell.innerHTML || ''; const htmlMatch = html.match(/\d{5,}/); if (htmlMatch) return htmlMatch[0]; return ''; } function findEmailInRow(row, columns) { const emailColumn = Object.keys(columns).find(function (key) { return /email|e-mail|эл\.?\s*почта|почта/i.test(key); }); if (emailColumn && /@/.test(columns[emailColumn])) { const match = columns[emailColumn].match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); return match ? match[0] : columns[emailColumn]; } const text = cleanText(row.innerText || row.textContent); const match = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i); return match ? match[0] : ''; } function detectSource(columns) { const keys = Object.keys(columns); const sourceKey = keys.find(function (key) { const n = normalizeText(key); return ( n === 'utm_source' || n.includes('utm_source') || n.includes('источник') || n.includes('source') ); }); const value = sourceKey ? cleanText(columns[sourceKey]) : ''; return value || 'Не определено'; } function detectRegistrationDate(columns) { const keys = Object.keys(columns); const regKey = keys.find(function (key) { const n = normalizeText(key); return n.includes('дата') && n.includes('регистрац'); }); if (!regKey) return null; return parseDate(columns[regKey]); } function getColumnNumber(columns, names) { const keys = Object.keys(columns); for (const name of names) { const foundKey = keys.find(function (key) { return normalizeText(key).includes(normalizeText(name)); }); if (foundKey) { return parseNumber(columns[foundKey]); } } return null; } function classifyClientFromRow(columns) { const daysLeft = getColumnNumber(columns, [ 'Сколько дней клуба осталось', 'Сколько дней осталось', 'Дней осталось' ]); const boughtDays = getColumnNumber(columns, [ 'Сколько дней куплено клуба', 'Сколько дней куплено', 'Дней куплено' ]); const usedDays = getColumnNumber(columns, [ 'Дней в клубе использовано', 'Дней использовано' ]); const purchasesCount = getColumnNumber(columns, [ 'Сколько покупок клуба у пользователя', 'Сколько покупок клуба', 'Покупок клуба' ]); let profile = 'lead'; if (daysLeft !== null && daysLeft > 0) { profile = 'active_member'; } else if ( (daysLeft !== null && daysLeft <= 0) || (boughtDays !== null && boughtDays > 0) || (usedDays !== null && usedDays > 0) || (purchasesCount !== null && purchasesCount > 0) ) { profile = 'expired_member'; } return { profile, daysLeft, boughtDays, usedDays, purchasesCount }; } function parseRowsFromDoc(doc, pageNumber) { const table = getDataTable(doc); if (!table) { return { rows: [], skipped: [{ page: pageNumber, reason: 'Не найдена таблица' }] }; } const headers = getTableHeaders(table); const idHeader = findHeader(headers, ['ID']); const rows = []; const skipped = []; Array.from(table.querySelectorAll('tbody tr')).forEach(function (tr, index) { const cells = Array.from(tr.querySelectorAll('td')); if (!cells.length) return; const columns = {}; headers.forEach(function (header) { const cell = getCellByHeader(tr, header); columns[header.text || ('Колонка ' + header.index)] = cleanText(cell ? cell.innerText || cell.textContent : ''); }); const idCell = getCellByHeader(tr, idHeader); const userId = tr.getAttribute('data-user-id') || parseIdFromCell(idCell) || ''; const recordId = tr.getAttribute('data-deal-id') || tr.getAttribute('data-key') || userId || ''; const email = findEmailInRow(tr, columns); const registrationDate = detectRegistrationDate(columns); const source = detectSource(columns); const client = classifyClientFromRow(columns); const row = { page: pageNumber, rowIndex: index + 1, userId, recordId, email, registrationDate, registrationYear: registrationDate ? registrationDate.getFullYear() : null, registrationMonth: registrationDate ? registrationDate.getFullYear() + '-' + pad(registrationDate.getMonth() + 1) : '', source, profileFromRow: client.profile, daysLeft: client.daysLeft, boughtDays: client.boughtDays, usedDays: client.usedDays, purchasesCount: client.purchasesCount, columns, rowHTML: tr.outerHTML }; row.rowKey = getRowKey(row); if (!userId) { skipped.push({ page: pageNumber, rowIndex: index + 1, reason: 'Не найден ID пользователя', rowText: cleanText(tr.innerText || tr.textContent).slice(0, 300) }); } rows.push(row); }); return { rows, skipped }; } function getSummaryTotal(doc) { const text = (doc.querySelector('.summary')?.innerText || '').replace(/\u00a0/g, ' '); const match = text.match(/из\s+([\d\s]+)/i); return match ? Number(match[1].replace(/\s/g, '')) : 0; } function getRuleTotal(doc) { const bodyText = (doc.body?.innerText || '').replace(/\u00a0/g, ' '); const match = bodyText.match(/Под правило попадает:\s*([\d\s]+)\s+(пользовател|запис|заказ)/i); return match ? Number(match[1].replace(/\s/g, '')) : 0; } function getTotal(doc) { return getRuleTotal(doc) || getSummaryTotal(doc); } function getPageSize(doc) { const text = (doc.querySelector('.summary')?.innerText || '').replace(/\u00a0/g, ' '); const match = text.match(/Показаны\s+(\d+)-(\d+)/i); if (match) { return Number(match[2]) - Number(match[1]) + 1; } const table = getDataTable(doc); return table ? table.querySelectorAll('tbody tr').length || 100 : 100; } function getCurrentPageNumber() { const url = new URL(location.href); return Number(url.searchParams.get('page') || '1') || 1; } function makePageUrl(page) { const url = new URL(window.location.href); url.searchParams.set('page', page); return url.toString(); } async function fetchPage(page) { const res = await fetch(makePageUrl(page), { credentials: 'include' }); if (!res.ok) { throw new Error('Страница ' + page + ': HTTP ' + res.status); } const html = await res.text(); return new DOMParser().parseFromString(html, 'text/html'); } async function runQueue(items, worker, concurrency, onProgress) { const results = []; let index = 0; let done = 0; async function next() { while (index < items.length) { if (STATE.stopped) break; const currentIndex = index++; const item = items[currentIndex]; try { results[currentIndex] = await worker(item, currentIndex); } catch (err) { results[currentIndex] = { error: err?.message || String(err), item }; } done++; if (onProgress) { onProgress(done, items.length); } } } const workers = []; for (let i = 0; i < Math.min(concurrency, items.length); i++) { workers.push(next()); } await Promise.all(workers); return results; } function buildDiagnostics(rows) { const ids = rows.filter(r => r.userId).map(r => r.userId); const uniqueIds = Array.from(new Set(ids)); const byId = {}; rows.forEach(function (row) { if (!row.userId) return; if (!byId[row.userId]) byId[row.userId] = []; byId[row.userId].push(row); }); const duplicateGroups = Object.keys(byId) .filter(id => byId[id].length > 1) .map(id => ({ id, count: byId[id].length, rows: byId[id] })); STATE.diagnostics = { totalRows: rows.length, rowsWithId: rows.filter(r => r.userId).length, rowsWithoutId: rows.filter(r => !r.userId).length, uniqueIds: uniqueIds.length, duplicateIds: ids.length - uniqueIds.length, duplicateGroups }; return STATE.diagnostics; } async function collectRowsFromAllPages() { const total = getTotal(document); const pageSize = getPageSize(document); const pages = Math.max(1, Math.ceil((total || pageSize) / pageSize)); const currentPage = getCurrentPageNumber(); const pageNumbers = []; for (let page = 1; page <= pages; page++) { pageNumbers.push(page); } let allRows = []; let allSkipped = []; const docs = await runQueue( pageNumbers, async function (page) { const doc = page === currentPage ? document : await fetchPage(page); return { page, doc }; }, CONFIG.pageConcurrency, function (done, totalPages) { setStatus('Загружаю страницы сегмента: ' + done + ' / ' + totalPages + ''); const base = STATE.selectedMode === 'deep' ? 5 : 10; const span = STATE.selectedMode === 'deep' ? 20 : 65; setProgress(base + Math.round(span * done / Math.max(1, totalPages)), 'Загружаю страницы выборки: ' + done + ' из ' + totalPages + ''); } ); docs.forEach(function (item) { if (!item || item.error) { allSkipped.push({ page: item?.item || '?', reason: item?.error || 'Ошибка загрузки страницы' }); return; } const parsed = parseRowsFromDoc(item.doc, item.page); allRows = allRows.concat(parsed.rows); allSkipped = allSkipped.concat(parsed.skipped); }); buildDiagnostics(allRows); return { rows: allRows, skipped: allSkipped, totalFromSummary: total, pageSize, pages }; } function deduplicateUsers(rows) { const seen = {}; const result = []; rows.forEach(function (row) { if (!row.userId) return; if (seen[row.userId]) { if (!seen[row.userId].email && row.email) { seen[row.userId].email = row.email; } return; } const copy = Object.assign({}, row); seen[row.userId] = copy; result.push(copy); }); return result; } async function fetchUserEmailFallback(userId) { const response = await fetch('/user/control/user/update/id/' + encodeURIComponent(userId), { credentials: 'same-origin' }); if (!response.ok) return ''; const html = await response.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const emailInput = doc.querySelector('input[name="User[email]"]') || doc.querySelector('input[type="email"]') || doc.querySelector('#user-email'); return cleanText(emailInput?.value || ''); } async function fetchUserProductsHtml(userId) { const url = '/pl/user/user/get-user-product-data?id=' + encodeURIComponent(userId); let response = await fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); if (!response.ok) { response = await fetch(url, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); } if (!response.ok) { throw new Error('Покупки пользователя не загрузились: HTTP ' + response.status); } return await response.text(); } function parseClubEndFromProductsHtml(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const items = Array.from(doc.querySelectorAll('.user-product-item')); const clubItems = []; items.forEach(function (item) { const text = cleanText(item.textContent || ''); const title = cleanText(item.querySelector('b')?.textContent || text); if (!CONFIG.clubProductTitleRegex.test(title) && !CONFIG.clubProductTitleRegex.test(text)) { return; } const rangeText = cleanText(item.querySelector('.small.text-muted')?.textContent || '') || text; const dateMatch = rangeText.match(/до\s+(\d{1,2}\s+[А-Яа-яЁё.]+\s+\d{4})/i) || rangeText.match(/до\s+(\d{4}-\d{2}-\d{2})/i) || rangeText.match(/до\s+(\d{1,2}[.\-/]\d{1,2}[.\-/]\d{2,4})/i); const endAt = dateMatch ? parseDate(dateMatch[1], { endOfDay: true }) : null; clubItems.push({ title, rangeText, endAt, endAtString: formatDateForField(endAt) }); }); clubItems.sort(function (a, b) { const at = a.endAt instanceof Date ? a.endAt.getTime() : 0; const bt = b.endAt instanceof Date ? b.endAt.getTime() : 0; return bt - at; }); return { found: clubItems.length > 0, selected: clubItems[0] || null, all: clubItems }; } async function getClubEndForUser(userId) { const html = await fetchUserProductsHtml(userId); return parseClubEndFromProductsHtml(html); } function parseMetricSummary(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const result = { totalVisits: 0, firstVisitAt: '', lastVisitAt: '', lastBrowser: '' }; const rows = Array.from(doc.querySelectorAll('table.table_last-view tr, table tr')); rows.forEach(function (row) { const th = cleanText(row.querySelector('th')?.textContent || '').replace(/:$/, ''); const td = cleanText(row.querySelector('td')?.textContent || ''); if (!th || !td) return; if (th.includes('Всего визитов')) { result.totalVisits = Number(td.replace(/[^\d]/g, '')) || 0; } if (th.includes('Первый вход')) { result.firstVisitAt = formatDateForField(parseDate(td)); } if (th.includes('Последний вход')) { result.lastVisitAt = formatDateForField(parseDate(td)); } if (th.includes('Последний браузер')) { result.lastBrowser = td; } }); return result; } async function fetchMetricSummary(userId) { const response = await fetch('/pl/metrika/user/' + encodeURIComponent(userId), { credentials: 'same-origin' }); if (!response.ok) { throw new Error('Метрика пользователя не загрузилась: HTTP ' + response.status); } const html = await response.text(); return parseMetricSummary(html); } function normalizeUrlFromCell(cell) { if (!cell) return ''; const link = cell.querySelector('a[href^="http"]') || cell.querySelector('a[href^="/"]'); if (link?.href) return link.href; const text = cleanText(cell.textContent || ''); const match = text.match(/https?:\/\/[^\s]+|\/[^\s]+/); return match ? match[0] : text; } function classifyPage(url) { const value = String(url || '').toLowerCase(); if ( value.includes('/teach/control/lesson') || value.includes('/pl/teach/control/lesson') || value.includes('/teach/control/stream/view') || value.includes('/pl/teach/control/stream/view') || value.includes('/lesson/view') ) { return 'Урок'; } if ( value.includes('/sales/') || value.includes('/pl/sales/') || value.includes('/dealpay') || value.includes('/deal/pay') || value.includes('/payments') || value.includes('/payment') || value.includes('/payform') || value.includes('/pay') || value.includes('/tarif') || value.includes('/tariff') || value.includes('/abonement') || value.includes('/prodlen') || value.includes('/prolong') || value.includes('/way') ) { return 'Продажа'; } if ( value.includes('/teach/control') || value.includes('/pl/teach/control') || value.includes('/stream') || value.includes('/club') || value.includes('/cms/system/login') || value.includes('/c/s/index') ) { return 'Клуб'; } return 'Другое'; } function parseVisitsHtml(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const rows = Array.from(doc.querySelectorAll('table tr')); const visits = []; rows.forEach(function (row) { const cells = Array.from(row.querySelectorAll('td')); if (cells.length < 4) return; const startedAtText = cleanText(cells[1]?.textContent || ''); const startedAt = parseDate(startedAtText); if (!startedAt) return; const hits = Number(cleanText(cells[3]?.textContent || '').replace(/[^\d]/g, '')) || 0; const browser = cleanText(cells[5]?.textContent || ''); const pageUrl = normalizeUrlFromCell(cells[6]); visits.push({ startedAt, startedAtText: formatDateForField(startedAt), hits, browser, pageUrl, pageType: classifyPage(pageUrl) }); }); return visits; } async function fetchMetricVisits(userId) { const allVisits = []; const seen = {}; for (let page = 1; page <= CONFIG.maxVisitPagesPerUser; page++) { if (STATE.stopped) break; const url = '/pl/metrika/visit/' + encodeURIComponent(userId) + '?userId=' + encodeURIComponent(userId) + (page > 1 ? '&page=' + page : ''); const response = await fetch(url, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); if (!response.ok) { if (page === 1) { throw new Error('Визиты не загрузились: HTTP ' + response.status); } break; } const html = await response.text(); const visits = parseVisitsHtml(html); if (!visits.length) break; visits.forEach(function (visit) { const key = visit.startedAtText + '|' + visit.hits + '|' + visit.pageUrl; if (seen[key]) return; seen[key] = true; allVisits.push(visit); }); const hasOldEnough = visits.some(function (visit) { return daysBetween(visit.startedAt) > 30; }); if (hasOldEnough) break; } allVisits.sort(function (a, b) { return b.startedAt - a.startedAt; }); return allVisits; } function countActiveDays(visits) { return new Set(visits.map(function (v) { return v.startedAtText.slice(0, 10); })).size; } function sumHits(visits) { return visits.reduce(function (sum, visit) { return sum + (Number(visit.hits) || 0); }, 0); } function calculateActivityRating(data) { let rating = 0; const days = Number(data.activity.days_since_last_visit); const visits7 = Number(data.periods.last_7_days.visits) || 0; const visits30 = Number(data.periods.last_30_days.visits) || 0; const activeDays30 = Number(data.periods.last_30_days.active_days) || 0; const lessonViews30 = Number(data.periods.last_30_days.lesson_views) || 0; const salesPages30 = Number(data.periods.last_30_days.sales_pages) || 0; const paymentInterest30 = Number(data.periods.last_30_days.payment_interest) || 0; if (days !== '' && !Number.isNaN(days)) { if (days <= 1) rating += 25; else if (days <= 3) rating += 20; else if (days <= 7) rating += 15; else if (days <= 14) rating += 8; else if (days <= 30) rating += 4; } if (visits7 >= 5) rating += 15; else if (visits7 >= 2) rating += 10; else if (visits7 >= 1) rating += 5; if (visits30 >= 10) rating += 15; else if (visits30 >= 5) rating += 10; else if (visits30 >= 1) rating += 5; if (activeDays30 >= 8) rating += 15; else if (activeDays30 >= 4) rating += 10; else if (activeDays30 >= 2) rating += 5; if (lessonViews30 >= 5) rating += 15; else if (lessonViews30 >= 2) rating += 10; else if (lessonViews30 >= 1) rating += 5; if (salesPages30 >= 2) rating += 10; else if (salesPages30 >= 1) rating += 6; if (paymentInterest30) rating += 10; return Math.min(100, rating); } function getSegmentByClientTypeAndRating(profile, rating, data) { const days = Number(data.activity.days_since_last_visit); const visits30 = Number(data.periods.last_30_days.visits) || 0; const lessonViews30 = Number(data.periods.last_30_days.lesson_views) || 0; const paymentInterest30 = Number(data.periods.last_30_days.payment_interest) || 0; if (profile === 'active_member') { if (rating >= 70 && lessonViews30 >= 2) return labelActivitySegment('active_core'); if (rating >= 45 || lessonViews30 >= 1 || visits30 >= 5) return labelActivitySegment('active_regular'); if (!Number.isNaN(days) && days > 14) return labelActivitySegment('active_risk'); if (rating > 0) return labelActivitySegment('active_sleeping'); return labelActivitySegment('active_dead'); } if (profile === 'expired_member') { if (paymentInterest30 || rating >= 70) return labelActivitySegment('expired_hot'); if (rating >= 45 || lessonViews30 >= 2) return labelActivitySegment('expired_warm'); if (rating >= 20 || (!Number.isNaN(days) && days <= 30)) return labelActivitySegment('expired_watching'); if (rating > 0) return labelActivitySegment('expired_cold'); return labelActivitySegment('expired_dead'); } if (profile === 'lead') { if (paymentInterest30 || rating >= 70) return labelActivitySegment('lead_hot'); if (rating >= 45) return labelActivitySegment('lead_warm'); if (rating > 0) return labelActivitySegment('lead_watching'); return labelActivitySegment('lead_dead'); } if (paymentInterest30 || rating >= 70) return labelActivitySegment('hot'); if (rating >= 45) return labelActivitySegment('warm'); if (rating >= 20) return labelActivitySegment('watching'); if (rating > 0) return labelActivitySegment('cold'); return labelActivitySegment('dead'); } function buildActivityProfile(summary, visits, row, clubAccess) { const now = new Date(); const updatedAt = nowString(); const last7Start = new Date(now.getTime() - 7 * 86400000); const last30Start = new Date(now.getTime() - 30 * 86400000); const visits7 = visits.filter(v => v.startedAt >= last7Start); const visits30 = visits.filter(v => v.startedAt >= last30Start); const lessonViews30 = visits30.filter(v => v.pageType === 'Урок').length; const clubPages30 = visits30.filter(v => v.pageType === 'Клуб' || v.pageType === 'Урок').length; const salesPages30 = visits30.filter(v => v.pageType === 'Продажа').length; const paymentInterest30 = salesPages30 > 0 ? 1 : 0; const endAt = clubAccess?.selected?.endAt || null; const hasEndDate = endAt instanceof Date && !Number.isNaN(endAt.getTime()); let profileCode = row.profileFromRow || 'lead'; if (hasEndDate && endAt > now) profileCode = 'active_member'; if (hasEndDate && endAt <= now) profileCode = 'expired_member'; const afterEndVisits30 = hasEndDate ? visits30.filter(v => v.startedAt > endAt) : []; const lastAfterEndVisit = afterEndVisits30[0]?.startedAt || null; const lastVisitDate = parseDate(summary.lastVisitAt); const profile = { version: 1, updated_at: updatedAt, user: { id: row.userId || '', email: row.email || '', source: row.source || 'Не определено', registration_at: row.registrationDate ? formatDateForField(row.registrationDate) : '' }, club: { status_code: profileCode, status: labelClientType(profileCode), end_at: hasEndDate ? formatDateForField(endAt) : '', days_left: row.daysLeft, bought_days: row.boughtDays, used_days: row.usedDays, purchases_count: row.purchasesCount }, activity: { rating: 0, segment: '', total_visits: summary.totalVisits || 0, first_visit_at: summary.firstVisitAt || '', last_visit_at: summary.lastVisitAt || '', days_since_last_visit: lastVisitDate ? daysBetween(lastVisitDate, now) : '', last_browser: summary.lastBrowser || '' }, periods: { last_7_days: { visits: visits7.length, hits: sumHits(visits7), active_days: countActiveDays(visits7) }, last_30_days: { visits: visits30.length, hits: sumHits(visits30), active_days: countActiveDays(visits30), lesson_views: lessonViews30, club_pages: clubPages30, sales_pages: salesPages30, payment_interest: paymentInterest30 }, after_club_end_30_days: { visits: afterEndVisits30.length, hits: sumHits(afterEndVisits30), active_days: countActiveDays(afterEndVisits30), last_visit_at: formatDateForField(lastAfterEndVisit) } }, last_pages: visits.slice(0, 15).map(function (v) { return { at: v.startedAtText, hits: v.hits, type: v.pageType, url: v.pageUrl }; }) }; profile.activity.rating = calculateActivityRating(profile); profile.activity.segment = getSegmentByClientTypeAndRating(profileCode, profile.activity.rating, profile); return profile; } async function analyzeUserDeep(row) { if (!row.email) { row.email = await fetchUserEmailFallback(row.userId); } const [clubAccess, summary, visits] = await Promise.all([ getClubEndForUser(row.userId), fetchMetricSummary(row.userId), fetchMetricVisits(row.userId) ]); const profile = buildActivityProfile(summary, visits, row, clubAccess); return { userId: row.userId, email: row.email, profile }; } function safeJsonParse(value) { const text = cleanText(value); if (!text) return null; try { return JSON.parse(text); } catch (err) { try { const fixed = text .replace(/"/g, '"') .replace(/"/g, '"') .replace(/"/g, '"') .replace(/&/g, '&'); return JSON.parse(fixed); } catch (err2) { return null; } } } function profileFromTableRow(row) { const columns = row.columns || {}; const profileJsonText = getColumnByPossibleNames(columns, [ CONFIG.fieldNames.activity_profile_json, 'activity_profile_json', 'profile_json' ]); let profile = safeJsonParse(profileJsonText); const updatedAt = getColumnByPossibleNames(columns, [ CONFIG.fieldNames.activity_updated_at, 'activity_updated_at' ]); const score = getColumnByPossibleNames(columns, [ CONFIG.fieldNames.activity_score, 'activity_score' ]); const segment = getColumnByPossibleNames(columns, [ CONFIG.fieldNames.activity_segment, 'activity_segment' ]); const clubEndAt = getColumnByPossibleNames(columns, [ CONFIG.fieldNames.club_end_at, 'club_end_at' ]); if (!profile) { profile = { version: 1, updated_at: updatedAt || '', user: { id: row.userId || '', email: row.email || '', source: row.source || 'Не определено', registration_at: row.registrationDate ? formatDateForField(row.registrationDate) : '' }, club: { status_code: row.profileFromRow || 'unknown', status: labelClientType(row.profileFromRow || 'unknown'), end_at: clubEndAt || '', days_left: row.daysLeft, bought_days: row.boughtDays, used_days: row.usedDays, purchases_count: row.purchasesCount }, activity: { rating: parseNumber(score), segment: segment || 'Не определено', total_visits: null, first_visit_at: '', last_visit_at: '', days_since_last_visit: null, last_browser: '' }, periods: { last_7_days: { visits: null, hits: null, active_days: null }, last_30_days: { visits: null, hits: null, active_days: null, lesson_views: null, club_pages: null, sales_pages: null, payment_interest: null }, after_club_end_30_days: { visits: null, hits: null, active_days: null, last_visit_at: '' } }, last_pages: [] }; } if (!profile.updated_at && updatedAt) profile.updated_at = updatedAt; if (!profile.activity) profile.activity = {}; if ((profile.activity.rating === null || profile.activity.rating === undefined || profile.activity.rating === '') && score) { profile.activity.rating = parseNumber(score); } if (!profile.activity.segment && segment) { profile.activity.segment = segment; } if (!profile.club) { profile.club = {}; } if (!profile.club.end_at && clubEndAt) { profile.club.end_at = clubEndAt; } return profile; } function rowWithProfile(row, profile) { const clubEndDate = parseDate(profile?.club?.end_at || ''); const enhanced = Object.assign({}, row, { profileJson: profile, profile: profile?.club?.status_code || row.profileFromRow || 'unknown', profileLabel: profile?.club?.status || labelClientType(row.profileFromRow || 'unknown'), activitySegment: profile?.activity?.segment || 'Не определено', activityRating: profile?.activity?.rating ?? null, activityUpdatedAt: profile?.updated_at || '', clubEndAt: profile?.club?.end_at || '', clubEndDate, visits30: profile?.periods?.last_30_days?.visits ?? null, visits7: profile?.periods?.last_7_days?.visits ?? null, activeDays30: profile?.periods?.last_30_days?.active_days ?? null, lessonViews30: profile?.periods?.last_30_days?.lesson_views ?? null, afterEnd30: profile?.periods?.after_club_end_30_days?.visits ?? null, paymentInterest30: profile?.periods?.last_30_days?.payment_interest ?? null, sourceLabel: labelSource(profile?.user?.source || row.source) }); enhanced.rowKey = getRowKey(enhanced); return enhanced; } function fieldsFromProfile(profile) { return { club_end_at: profile?.club?.end_at || '', activity_updated_at: profile?.updated_at || '', activity_score: profile?.activity?.rating ?? '', activity_segment: profile?.activity?.segment || '', activity_profile_json: JSON.stringify(profile) }; } async function saveBatchToServer(items) { const response = await fetch(CONFIG.writeUrl, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items }) }); const text = await response.text(); let json; try { json = JSON.parse(text); } catch (err) { throw new Error('Сервер вернул не JSON: ' + text.slice(0, 500)); } if (!response.ok || !json.success) { throw new Error(json.error || json.message || 'Сервер не сохранил пачку'); } return json; } function getFilteredRows() { const baseRows = STATE.analyzedRows.length ? STATE.analyzedRows : STATE.sourceRows; return applyFilters(baseRows, null); } function getCurrentSelectionIds() { const rows = getFilteredRows(); return Array.from(new Set( rows .map(function (row) { return cleanText(row.userId || ''); }) .filter(Boolean) )); } function copyCurrentSelectionIds() { const ids = getCurrentSelectionIds(); if (!ids.length) { alert('В текущей выборке не найдено ID клиентов.'); return; } const text = ids.join(','); function fallbackCopy(value) { const textarea = document.createElement('textarea'); textarea.value = value; textarea.setAttribute('readonly', 'readonly'); textarea.style.position = 'fixed'; textarea.style.left = '-9999px'; textarea.style.top = '-9999px'; document.body.appendChild(textarea); textarea.select(); try { document.execCommand('copy'); setStatus( 'ID текущей выборки скопированы в буфер: ' + formatNumber(ids.length) + ' клиентов.' ); log('Скопировано ID клиентов:', ids.length); } catch (err) { console.error('[АНАЛИЗ СЕГМЕНТА] Не удалось скопировать ID:', err); alert('Не удалось скопировать автоматически. ID выведены в консоль.'); console.log(text); } document.body.removeChild(textarea); } if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text) .then(function () { setStatus( 'ID текущей выборки скопированы в буфер: ' + formatNumber(ids.length) + ' клиентов.' ); log('Скопировано ID клиентов:', ids.length); }) .catch(function () { fallbackCopy(text); }); return; } fallbackCopy(text); } function getStoredSessionParam(key) { try { return localStorage.getItem(key) || ''; } catch (err) { return ''; } } function appendGetCourseSessionParams(body) { const session = getStoredSessionParam('session'); const visit = getStoredSessionParam('visit'); const visitor = getStoredSessionParam('visitor'); const hash = getStoredSessionParam('hash'); if (session) body.set('gcSession', session); if (visit) body.set('gcVisit', visit); if (visitor) body.set('gcVisitor', visitor); if (hash) body.set('gcSessionHash', hash); } function parseGroupFromResponse(raw, groupName) { if (!raw) { return { id: '', name: groupName, raw }; } const possibleIds = [ raw.value, raw.id, raw.group_id, raw.groupId, raw.data?.id, raw.data?.value, raw.data?.group_id, raw.data?.groupId, raw.result?.id, raw.result?.value, raw.result?.group_id, raw.result?.groupId ]; let id = ''; for (const value of possibleIds) { if (value === null || value === undefined) continue; if (typeof value === 'object') { const nested = value.id || value.value || value.group_id || value.groupId; if (nested) { id = String(nested); break; } } else { const text = String(value); const match = text.match(/\d+/); if (match) { id = match[0]; break; } } } return { id, name: raw.name || raw.title || raw.data?.name || raw.data?.title || groupName, raw }; } function createGetCourseGroup(groupName) { return new Promise(function (resolve, reject) { const name = cleanText(groupName); if (!name) { reject(new Error('Название группы пустое')); return; } if (typeof window.ajaxCall === 'function') { window.ajaxCall( '/pl/user/group/new', { 'UserGroup[name]': name }, {}, function (response) { if (!response || response.success === false) { reject(new Error(response?.message || 'GetCourse не создал группу')); return; } resolve(parseGroupFromResponse(response, name)); } ); return; } const body = new URLSearchParams(); body.set('UserGroup[name]', name); appendGetCourseSessionParams(body); fetch('/pl/user/group/new', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest' }, body: body.toString() }) .then(function (response) { return response.text().then(function (text) { let json = null; try { json = text ? JSON.parse(text) : null; } catch (err) { throw new Error('GetCourse вернул не JSON при создании группы: ' + text.slice(0, 300)); } if (!response.ok || json?.success === false) { throw new Error(json?.message || 'GetCourse не создал группу'); } return parseGroupFromResponse(json, name); }); }) .then(resolve) .catch(reject); }); } function buildIdsRuleForUsers(ids) { return { type: 'idsrule', inverted: 0, params: { value: ids.join(','), valueMode: null } }; } function getClarityUid() { try { const url = new URL(location.href); const fromUrl = url.searchParams.get('formParams[clarity_uid]'); if (fromUrl) return fromUrl; } catch (err) {} if (window.clarity_uid) return String(window.clarity_uid); if (window.clarityUid) return String(window.clarityUid); return ''; } async function addUserIdsToGroup(ids, groupId) { if (!ids.length) { throw new Error('Нет ID клиентов для добавления в группу'); } if (!groupId) { throw new Error('Нет ID группы'); } const formData = new FormData(); formData.append('segmentId', ''); formData.append('rule', JSON.stringify(buildIdsRuleForUsers(ids))); formData.append('AddToGroupOperation[groupIds][]', String(groupId)); formData.append('UserGroup[name]', ''); formData.append('execute', '1'); const clarityUid = getClarityUid(); if (clarityUid) { formData.append('formParams[clarity_uid]', clarityUid); } const response = await fetch('/pl/logic/operation/prepare?contextType=UserContext&operationType=user_addtogroup', { method: 'POST', credentials: 'include', body: formData }); const html = await response.text(); if (!response.ok) { throw new Error('GetCourse не выполнил добавление в группу: HTTP ' + response.status); } const doc = new DOMParser().parseFromString(html, 'text/html'); const pageText = cleanText(doc.body?.innerText || ''); const title = cleanText(doc.querySelector('title')?.textContent || ''); const hasResultPage = title.toLowerCase().includes('результат') || pageText.toLowerCase().includes('результат выполнения') || pageText.toLowerCase().includes('выполнен') || pageText.toLowerCase().includes('успешно'); const successCount = Array.from(doc.querySelectorAll('.label-success, .text-success, .alert-success')) .filter(function (el) { return cleanText(el.textContent).length > 0; }).length; const errorCount = Array.from(doc.querySelectorAll('.label-danger, .label-error, .text-danger, .alert-danger')) .filter(function (el) { return cleanText(el.textContent).length > 0; }).length; return { success: hasResultPage || errorCount === 0, requested: ids.length, successCount, errorCount, title, pageText: pageText.slice(0, 2000), html }; } async function addCurrentSelectionToNewGroup() { if (STATE.grouping) { log('Добавление в группу уже запущено'); return; } const ids = getCurrentSelectionIds(); if (!ids.length) { alert('В текущей выборке не найдено ID клиентов.'); return; } const groupName = prompt( 'Введите название новой группы для текущей выборки (' + ids.length + ' клиентов):', 'Выборка ' + nowString() ); if (groupName === null) return; const cleanGroupName = cleanText(groupName); if (!cleanGroupName) { alert('Название группы не может быть пустым.'); return; } const ok = confirm( 'Создать группу «' + cleanGroupName + '» и добавить туда ' + ids.length + ' клиентов из текущей выборки?' ); if (!ok) return; STATE.grouping = true; try { setStatus( 'Создаю группу «' + escapeHtml(cleanGroupName) + '» для ' + formatNumber(ids.length) + ' клиентов...' ); log('Создаю группу:', cleanGroupName); const group = await createGetCourseGroup(cleanGroupName); if (!group.id) { console.warn('[АНАЛИЗ СЕГМЕНТА] Ответ создания группы без ID:', group.raw); throw new Error('Группа создана, но GetCourse не вернул ID группы'); } setStatus( 'Группа создана: ' + escapeHtml(group.name) + '. Добавляю клиентов...' ); log('Группа создана:', group.id, group.name); const result = await addUserIdsToGroup(ids, group.id); if (!result.success) { throw new Error('GetCourse вернул страницу результата, но успешное выполнение не распознано'); } setStatus( 'Готово. Группа: ' + escapeHtml(group.name) + '. ' + 'Отправлено ID: ' + formatNumber(result.requested) + '.' ); log( 'Добавление в группу завершено. Группа:', group.id, group.name, 'ID:', ids.length ); alert( 'Готово.\n\n' + 'Группа: ' + group.name + '\n' + 'ID группы: ' + group.id + '\n' + 'Отправлено клиентов: ' + ids.length ); } catch (err) { console.error('[АНАЛИЗ СЕГМЕНТА] Ошибка добавления в группу:', err); setStatus( 'Ошибка добавления в группу: ' + escapeHtml(err?.message || String(err)) ); alert('Ошибка добавления в группу: ' + (err?.message || String(err))); } finally { STATE.grouping = false; } } function saveOriginalTableState() { const table = getCurrentDataTable(); if (!table) return; const theadRow = table.querySelector('thead tr'); const tbody = table.querySelector('tbody'); const summary = document.querySelector('.summary'); if (theadRow && !STATE.originalHeadHtml) { STATE.originalHeadHtml = theadRow.innerHTML; } if (tbody && !STATE.originalBodyHtml) { STATE.originalBodyHtml = tbody.innerHTML; } if (summary && !STATE.originalSummaryHtml) { STATE.originalSummaryHtml = summary.innerHTML; } } function resetOriginalTable() { const table = getCurrentDataTable(); if (!table) return; const theadRow = table.querySelector('thead tr'); const tbody = table.querySelector('tbody'); const summary = document.querySelector('.summary'); if (theadRow && STATE.originalHeadHtml) { theadRow.innerHTML = STATE.originalHeadHtml; } if (tbody && STATE.originalBodyHtml) { tbody.innerHTML = STATE.originalBodyHtml; } if (summary && STATE.originalSummaryHtml) { summary.innerHTML = STATE.originalSummaryHtml; } STATE.currentTableIsFiltered = false; applyCompactLongCells(); } function renderFilteredTable(rows) { const table = getCurrentDataTable(); if (!table) return; saveOriginalTableState(); const tbody = table.querySelector('tbody'); const summary = document.querySelector('.summary'); if (!tbody) return; tbody.innerHTML = rows.map(function (row) { return row.rowHTML; }).join(''); if (summary) { summary.innerHTML = 'Показана выборка из анализа: ' + formatNumber(rows.length) + ' строк'; } STATE.currentTableIsFiltered = true; applyCompactLongCells(); } function applyFilters(rows, exceptReportKey) { const activeFilters = Object.keys(STATE.filters || {}) .filter(function (key) { return key !== exceptReportKey; }) .map(function (key) { return STATE.filters[key]; }); if (!activeFilters.length) { return rows.slice(); } return rows.filter(function (row) { const rowKey = getRowKey(row); return activeFilters.every(function (filter) { return filter.keys && filter.keys[rowKey]; }); }); } function setReportFilter(reportKey, label, rows) { const keys = {}; rows.forEach(function (row) { keys[getRowKey(row)] = true; }); STATE.filters[reportKey] = { reportKey, reportLabel: REPORT_LABELS[reportKey] || reportKey, label, keys, count: rows.length }; renderCurrentReport(); } function removeReportFilter(reportKey) { delete STATE.filters[reportKey]; renderCurrentReport(); } function resetAllFilters() { STATE.filters = {}; renderCurrentReport(); } function renderActiveFilters() { const filterKeys = Object.keys(STATE.filters || {}); const rows = getFilteredRows(); if (!filterKeys.length) { setFilterInfo( 'Фильтры не выбраны. Клик по любому столбцу графика добавит фильтр. ' + 'Можно выбрать несколько условий подряд: год регистрации + покупки + рейтинг активности.' ); if (STATE.sourceRows.length || STATE.analyzedRows.length) { if (STATE.currentTableIsFiltered) { resetOriginalTable(); } } return; } const chips = filterKeys.map(function (key) { const filter = STATE.filters[key]; return ( '' + '' + escapeHtml(filter.reportLabel) + ': ' + escapeHtml(filter.label) + ' ' + '' ); }).join(''); setFilterInfo( '' + 'Рейтинг активности показывает, насколько человек сейчас взаимодействует с клубом, уроками и продающими страницами. ' + 'Это не прогноз и не оценка человека, а сумма конкретных действий. Максимум — 100 баллов.' + '
' + '| Что учитывается | ' + 'Условие | ' + 'Баллы | ' + '
|---|---|---|
| Последний визит | ' + 'сегодня или вчера | ' + '+25 | ' + '
| 2–3 дня назад | +20 | |
| 4–7 дней назад | +15 | |
| 8–14 дней назад | +8 | |
| 15–30 дней назад | +4 | |
| Визиты за 7 дней | ' + '5+ визитов | ' + '+15 | ' + '
| 2–4 визита | +10 | |
| 1 визит | +5 | |
| 0 визитов | +0 | |
| Визиты за 30 дней | ' + '10+ визитов | ' + '+15 | ' + '
| 5–9 визитов | +10 | |
| 1–4 визита | +5 | |
| 0 визитов | +0 | |
| Активные дни за 30 дней | ' + '8+ дней с визитами | ' + '+15 | ' + '
| 4–7 дней с визитами | +10 | |
| 2–3 дня с визитами | +5 | |
| 0–1 день с визитами | +0 | |
| Заходы на уроки за 30 дней | ' + '5+ заходов | ' + '+15 | ' + '
| 2–4 захода | +10 | |
| 1 заход | +5 | |
| 0 заходов | +0 | |
| Продающие страницы | ' + '2+ захода | ' + '+10 | ' + '
| 1 заход | +6 | |
| 0 заходов | +0 | |
| Интерес к оплате | ' + 'был хотя бы 1 заход на продающую страницу | ' + '+10 | ' + '
| заходов не было | +0 |