';
// Модальное окно находится внутри отчёта, но карточка останавливает всплытие клика.
// Поэтому кнопка закрытия получает собственный обработчик непосредственно на контейнере модалки.
el.onclick = function (event) {
const closeBtn = event.target.closest('[data-help-close]');
if (closeBtn) {
event.preventDefault();
event.stopPropagation();
closeHelpModal();
return;
}
if (event.target.closest('[data-help-card]')) return;
if (event.target.closest('[data-help-backdrop]')) {
event.preventDefault();
closeHelpModal();
}
};
}
function closeHelpModal() {
const el = document.getElementById(HELP_MODAL_ID);
if (el) { el.style.display = 'none'; el.innerHTML = ''; el.onclick = null; }
}
function validateRequiredColumns(table) {
const missing = [];
const sourceColumns = getSourceColumns(table);
if (!getPaidColumn(table)) missing.push('Оплачено');
if (!sourceColumns.any) missing.push('gc_system_deal_utm_source или utm_source');
if (!getCompositionColumn(table)) missing.push('Состав заказа');
if (!getTagsColumn(table)) missing.push('Теги');
if (!getUserBaseAgeColumn(table)) missing.push('Возраст пользователя в базе');
if (!getStatusColumn(table)) missing.push('Статус');
if (ANALYSIS_MODE === MODE_CLUB_ACTION) {
if (!getClubBalanceMetricColumn(table)) missing.push('Club balance before order days');
if (!getFirstClubBaseAgeMetricColumn(table)) missing.push('Days in base before first club order');
}
return missing;
}
function showMissingColumns(missing) {
const list = (missing || []).map(function (x) { return '
' + escapeHtml(x) + '
'; }).join('');
const body = '
Не хватает колонок:
' + list + '
' +
'' +
'
Нажмите на гаечный ключ справа сверху над таблицей.
' +
'
Перенесите нужные поля в левый столбец.
' +
'
Нажмите «Сохранить».
' +
'
Перезагрузите страницу и снова откройте выборку.
' +
'
Нажмите «Получить аналитику» ещё раз.
';
openHelpModal('columns', body);
setStatus('Нужно добавить обязательные колонки. Открыта подсказка с инструкцией.');
}
function setProgressCompleteState(isComplete) {
const row = document.querySelector('#' + WRAP_ID + ' .utm-load-row');
if (row) row.classList.toggle('is-complete', Boolean(isComplete));
}
function setProgress(percent, text, visible) {
const wrap = document.getElementById(PROGRESS_WRAP_ID);
const bar = document.getElementById(PROGRESS_BAR_ID);
const label = document.getElementById(PROGRESS_TEXT_ID);
const value = Math.max(0, Math.min(100, Number(percent) || 0));
if (wrap) wrap.style.display = visible === false ? 'none' : 'block';
if (bar) bar.style.width = value + '%';
if (label) label.innerHTML = text || '';
}
function resetProgress(text) {
setProgressCompleteState(false);
setProgress(0, text || 'Выберите режим и запустите сбор.', true);
}
function setLoadingState(isLoading) {
ANALYTICS_LOADING = Boolean(isLoading);
const loadBtn = document.getElementById(LOAD_BTN_ID);
if (loadBtn) {
loadBtn.disabled = ANALYTICS_LOADING;
loadBtn.innerText = ANALYTICS_LOADING ? 'Загружаю аналитику…' : (ANALYTICS_LOADED ? 'Обновить аналитику' : 'Получить аналитику');
}
const wrap = document.getElementById(WRAP_ID);
if (wrap) Array.from(wrap.querySelectorAll('[data-analysis-mode]')).forEach(function (btn) { btn.disabled = ANALYTICS_LOADING; });
}
function setStatus(text) {
const el = document.getElementById(STATUS_ID);
if (el) el.innerHTML = text;
}
function setDebug(text) {
const el = document.getElementById(DEBUG_ID);
if (!el) return;
if (!DEBUG || !text) {
el.style.display = 'none';
el.innerHTML = '';
return;
}
el.style.display = 'block';
el.innerHTML = text;
}
function setFilterInfo(text) {
const el = document.getElementById(FILTER_ID);
if (!el) return;
if (!text) {
el.style.display = 'none';
el.innerHTML = '';
return;
}
el.style.display = 'block';
el.innerHTML = text;
const resetBtn = document.getElementById(RESET_FILTER_BTN_ID);
if (resetBtn) resetBtn.onclick = resetCurrentTableFilter;
}
function getCurrentDataTable() {
return getDataTable(document);
}
function getDataTable(doc) {
const tables = Array.from(doc.querySelectorAll('table.kv-grid-table, .kv-grid-container table, table'));
// Раньше таблица определялась только по gc_system_deal_utm_source.
// В свежем GetCourse в HAR осталась только legacy-колонка utm_source,
// поэтому ищем любую колонку источника, а не только системную.
const withSource = tables.find(function (table) {
const sourceColumns = getSourceColumns(table);
return Boolean(sourceColumns && sourceColumns.any && getPaidColumn(table));
});
if (withSource) return withSource;
// Диагностический fallback: таблица заказов есть, но источник не включён.
// Тогда buildUtmSourceAnalytics покажет точную ошибку по колонке источника.
return tables.find(function (table) {
return Boolean(getPaidColumn(table));
}) || null;
}
function getSourceColumn(table) {
return getColumnByNames(table, SOURCE_COLUMN_NAMES, function (text) {
return (
text.indexOf('gc system deal utm source') !== -1 ||
text.indexOf('gk ассистом utm source') !== -1 ||
text.indexOf('гк ассистом utm source') !== -1
);
});
}
function getLegacySourceColumn(table) {
return getColumnByNames(table, LEGACY_SOURCE_COLUMN_NAMES, function (text) {
return text === 'utm source' || text === 'utm_source';
});
}
function getSourceColumns(table) {
const systemColumn = getSourceColumn(table);
const legacyColumn = getLegacySourceColumn(table);
return {
system: systemColumn,
legacy: legacyColumn,
any: systemColumn || legacyColumn
};
}
function getSourceCellText(row, sourceColumns) {
const systemCell = sourceColumns && sourceColumns.system ? getCellByColumn(row, sourceColumns.system) : null;
const legacyCell = sourceColumns && sourceColumns.legacy ? getCellByColumn(row, sourceColumns.legacy) : null;
const rawSystem = normalizeVisibleText(systemCell ? systemCell.innerText || systemCell.textContent : '');
const rawLegacy = normalizeVisibleText(legacyCell ? legacyCell.innerText || legacyCell.textContent : '');
const systemValue = normalizeSourceValue(rawSystem);
const legacyValue = normalizeSourceValue(rawLegacy);
if (systemValue && systemValue !== EMPTY_SOURCE_LABEL) {
return {
raw: rawSystem,
value: systemValue,
sourceColumnLabel: sourceColumns.system.text || 'gc_system_deal_utm_source',
fallbackRaw: rawLegacy,
fallbackValue: legacyValue
};
}
if (legacyValue && legacyValue !== EMPTY_SOURCE_LABEL) {
return {
raw: rawLegacy,
value: legacyValue,
sourceColumnLabel: sourceColumns.legacy.text || 'utm_source',
fallbackRaw: rawSystem,
fallbackValue: systemValue
};
}
return {
raw: rawSystem || rawLegacy,
value: EMPTY_SOURCE_LABEL,
sourceColumnLabel: sourceColumns && sourceColumns.any ? sourceColumns.any.text || '' : '',
fallbackRaw: rawLegacy,
fallbackValue: legacyValue
};
}
function getPaidColumn(table) {
return getColumnByNames(table, PAID_COLUMN_NAMES, function (text) {
if (text.indexOf('стоимость') !== -1) return false;
if (text.indexOf('клиент денег принес') !== -1) return false;
if (text.indexOf('денег принес') !== -1) return false;
return text === 'оплачено' || text.indexOf('клиентом оплачено') !== -1 || text.indexOf('клиент оплачено') !== -1 || text.indexOf('сумма оплаты') !== -1;
});
}
function getTagsColumn(table) {
return getColumnByNames(table, TAG_COLUMN_NAMES, function (text) {
return text === 'теги' || text.indexOf('теги заказа') !== -1 || text.indexOf('клиент теги') !== -1 || text.indexOf('пользователь теги') !== -1;
});
}
function getCompositionColumn(table) {
return getColumnByNames(table, COMPOSITION_COLUMN_NAMES, function (text) {
return text === 'состав заказа' ||
text === 'состав' ||
text.indexOf('состав заказа') !== -1 ||
text.indexOf('состав покупки') !== -1 ||
text.indexOf('продукт') !== -1 ||
text.indexOf('товар') !== -1 ||
text.indexOf('позиции заказа') !== -1;
});
}
function getClubBalanceMetricColumn(table) {
return getColumnByNames(table, CLUB_BALANCE_COLUMN_NAMES, function (text) {
return text.indexOf('club balance') !== -1 || text.indexOf('balance before order') !== -1;
});
}
function getFirstClubBaseAgeMetricColumn(table) {
return getColumnByNames(table, FIRST_CLUB_BASE_AGE_COLUMN_NAMES, function (text) {
return text.indexOf('days in base before first club') !== -1 || text.indexOf('дней в базе до первого клуба') !== -1;
});
}
function getUserBaseAgeColumn(table) {
return getColumnByNames(table, USER_BASE_AGE_COLUMN_NAMES, function (text) {
return text.indexOf('возраст пользователя в базе') !== -1 || text === 'возраст в базе';
});
}
function getStatusColumn(table) {
return getColumnByNames(table, ['Статус', 'Статус заказа', 'Состояние'], function (text) {
return text === 'статус' || text.indexOf('статус заказа') !== -1 || text === 'состояние';
});
}
function getDateColumns(table) {
if (!table) return [];
const statusColumn = getStatusColumn(table);
if (!statusColumn) return [];
const statusKey = (statusColumn.colSeq !== null && statusColumn.colSeq !== '')
? 'status-date:seq:' + statusColumn.colSeq
: 'status-date:idx:' + statusColumn.index;
return [{
key: statusKey,
index: statusColumn.index,
colSeq: statusColumn.colSeq,
label: 'Статус: дата оплаты/завершения',
sourceLabel: statusColumn.text || 'Статус',
score: 220,
kind: 'status'
}];
}
function getDateColumnScore(text) {
if (!text) return 0;
// Основная дата покупки теперь берётся из колонки «Статус» как виртуальная дата.
// Если статус недоступен, следующим лучшим вариантом остаётся «Дата изменения».
if (text.indexOf('регистрац') !== -1) return 0;
if (text.indexOf('дата изменения') !== -1) return 140;
if (text.indexOf('изменен') !== -1 || text.indexOf('изменён') !== -1 || text.indexOf('изменена') !== -1 || text.indexOf('изменено') !== -1) return 138;
if (text.indexOf('updated') !== -1 || text.indexOf('modified') !== -1 || text.indexOf('changed') !== -1) return 136;
if (text.indexOf('дата оплаты') !== -1) return 120;
if (text.indexOf('оплачен') !== -1 && text.indexOf('дата') !== -1) return 118;
if (text.indexOf('платеж') !== -1 && text.indexOf('дата') !== -1) return 112;
if (text.indexOf('paid') !== -1 && text.indexOf('date') !== -1) return 110;
if (text.indexOf('дата создания') !== -1) return 90;
if (text.indexOf('создан') !== -1 && text.indexOf('заказ') !== -1) return 88;
if (text === 'создан' || text === 'создано') return 86;
if (text.indexOf('created') !== -1) return 84;
if (text.indexOf('дата заказа') !== -1) return 72;
if (text.indexOf('заказ создан') !== -1) return 70;
if (text === 'дата') return 50;
if (text.indexOf('date') !== -1 && text.indexOf('registration') === -1) return 45;
return 0;
}
function getColumnByNames(table, names, fallback) {
if (!table) return null;
const headers = Array.from(table.querySelectorAll('thead tr:first-child th'));
const aliases = names.map(normalizeColumnText);
for (let i = 0; i < headers.length; i++) {
const rawText = headers[i].innerText || headers[i].textContent || '';
const text = normalizeColumnText(rawText);
if (aliases.indexOf(text) !== -1) {
return {
index: i,
colSeq: headers[i].getAttribute('data-col-seq'),
text: rawText
};
}
}
for (let j = 0; j < headers.length; j++) {
const fallbackText = normalizeColumnText(headers[j].innerText || headers[j].textContent || '');
if (fallback && fallback(fallbackText)) {
return {
index: j,
colSeq: headers[j].getAttribute('data-col-seq'),
text: headers[j].innerText || headers[j].textContent || ''
};
}
}
return null;
}
function getCellByColumn(row, column) {
if (!row || !column) return null;
if (column.colSeq !== null && column.colSeq !== '') {
const bySeq = row.querySelector('td[data-col-seq="' + column.colSeq + '"]');
if (bySeq) return bySeq;
}
const cells = Array.from(row.querySelectorAll('td'));
return cells[column.index] || null;
}
function extractDealIdFromRow(row) {
if (!row) return '';
const direct = row.getAttribute('data-deal-id') || row.getAttribute('data-key') || row.getAttribute('data-id') || '';
if (/^\d+$/.test(String(direct || ''))) return String(direct);
const html = row.outerHTML || '';
const patterns = [
/\/sales\/control\/deal\/update\/id\/(\d+)/i,
/\/pl\/sales\/deal\/update\/id\/(\d+)/i,
/deal\/update\/id\/(\d+)/i,
/[?&]dealId=(\d+)/i,
/[?&]deal_id=(\d+)/i,
/data-deal-id=["']?(\d+)/i
];
for (let i = 0; i < patterns.length; i++) {
const m = html.match(patterns[i]);
if (m && m[1]) return m[1];
}
return '';
}
function extractUserIdFromDealRow(row) {
if (!row) return '';
const direct = row.getAttribute('data-user-id') || row.getAttribute('data-client-id') || '';
if (/^\d+$/.test(String(direct || ''))) return String(direct);
const html = row.outerHTML || '';
const patterns = [
/\/user\/control\/user\/update\/id\/(\d+)/i,
/\/pl\/user\/user\/update\/id\/(\d+)/i,
/user_id[=:"'\s]+(\d+)/i,
/userId[=:"'\s]+(\d+)/i,
/data-user-id=["']?(\d+)/i
];
for (let i = 0; i < patterns.length; i++) {
const m = html.match(patterns[i]);
if (m && m[1]) return m[1];
}
return '';
}
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 makePageUrl(baseUrl, page) {
const url = new URL(baseUrl || window.location.href, window.location.origin);
if (page && Number(page) > 1) {
url.searchParams.set('page', page);
} else {
url.searchParams.delete('page');
}
return url.toString();
}
function makeUrl(page) {
return makePageUrl(window.location.href, page);
}
async function fetchPage(page) {
return fetchPageFromUrl(window.location.href, page);
}
async function fetchPageFromUrl(baseUrl, page) {
const res = await fetch(makePageUrl(baseUrl, 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');
}
function normalizeReportUrl(value) {
const url = new URL(value || window.location.href, window.location.origin);
url.searchParams.delete('page');
Array.from(url.searchParams.keys()).forEach(function (key) {
if (/^r\d+$/i.test(key) || key.indexOf('formParams[') === 0 || key === '_pjax') {
url.searchParams.delete(key);
}
});
const pairs = Array.from(url.searchParams.entries()).sort(function (a, b) {
return a[0] === b[0] ? String(a[1]).localeCompare(String(b[1])) : a[0].localeCompare(b[0]);
});
return url.pathname + '?' + pairs.map(function (pair) {
return encodeURIComponent(pair[0]) + '=' + encodeURIComponent(pair[1]);
}).join('&');
}
function isSameReportUrl(a, b) {
return normalizeReportUrl(a) === normalizeReportUrl(b);
}
async function runQueue(items, worker, concurrency, onProgress) {
const results = [];
let index = 0;
let done = 0;
async function next() {
while (index < items.length) {
const currentIndex = index++;
const item = items[currentIndex];
try {
results[currentIndex] = await worker(item, currentIndex);
} catch (err) {
results[currentIndex] = {
error: err?.message || String(err),
item: item
};
}
done++;
if (onProgress) onProgress(done, items.length);
}
}
const workers = [];
for (let i = 0; i < concurrency; i++) workers.push(next());
await Promise.all(workers);
return results;
}
function parseRowsFromDoc(doc, pageNumber) {
const table = getDataTable(doc);
if (!table) {
return {
rows: [],
skipped: [{ page: pageNumber, reason: 'Не найдена таблица с колонкой UTM source' }],
dateColumns: []
};
}
const sourceColumns = getSourceColumns(table);
const paidColumn = getPaidColumn(table);
const tagsColumn = getTagsColumn(table);
const compositionColumn = getCompositionColumn(table);
const clubBalanceColumn = getClubBalanceMetricColumn(table);
const firstClubBaseAgeColumn = getFirstClubBaseAgeMetricColumn(table);
const userBaseAgeColumn = getUserBaseAgeColumn(table);
const dateColumns = getDateColumns(table);
if (!sourceColumns.any) {
return { rows: [], skipped: [{ page: pageNumber, reason: 'Не найдены колонки источника: gc_system_deal_utm_source или utm_source' }], dateColumns: dateColumns };
}
if (!paidColumn) {
return { rows: [], skipped: [{ page: pageNumber, reason: 'Не найдена колонка Оплачено' }], dateColumns: dateColumns };
}
const rows = [];
const skipped = [];
Array.from(table.querySelectorAll('tbody tr')).forEach(function (tr, rowIndex) {
const sourceInfo = getSourceCellText(tr, sourceColumns);
const paidCell = getCellByColumn(tr, paidColumn);
const tagsCell = tagsColumn ? getCellByColumn(tr, tagsColumn) : null;
const compositionCell = compositionColumn ? getCellByColumn(tr, compositionColumn) : null;
const clubBalanceCell = clubBalanceColumn ? getCellByColumn(tr, clubBalanceColumn) : null;
const firstClubBaseAgeCell = firstClubBaseAgeColumn ? getCellByColumn(tr, firstClubBaseAgeColumn) : null;
const userBaseAgeCell = userBaseAgeColumn ? getCellByColumn(tr, userBaseAgeColumn) : null;
const cells = Array.from(tr.querySelectorAll('td'));
if (!cells.length) {
skipped.push({ page: pageNumber, rowIndex: rowIndex + 1, reason: 'Пустая строка без ячеек' });
return;
}
const rawSource = sourceInfo.raw;
const source = sourceInfo.value;
const rawPaid = normalizeVisibleText(paidCell ? paidCell.innerText || paidCell.textContent : '');
const paidAmount = parseMoney(rawPaid);
const tags = tagsColumn ? parseTagsFromCell(tagsCell) : [EMPTY_TAG_LABEL];
const segmentTag = getSegmentTag(tags);
const rawComposition = normalizeVisibleText(compositionCell ? compositionCell.innerText || compositionCell.textContent : '');
const rawClubBalance = normalizeVisibleText(clubBalanceCell ? clubBalanceCell.innerText || clubBalanceCell.textContent : '');
const rawFirstClubBaseAge = normalizeVisibleText(firstClubBaseAgeCell ? firstClubBaseAgeCell.innerText || firstClubBaseAgeCell.textContent : '');
const userBaseAgeSegment = normalizeVisibleText(userBaseAgeCell ? userBaseAgeCell.innerText || userBaseAgeCell.textContent : '') || '— возраст не указан';
const marathonTariff = getMarathonTariff(rawComposition);
const clubBalanceValue = parseMetricInteger(rawClubBalance);
const firstClubBaseAgeDays = parseMetricInteger(rawFirstClubBaseAge);
const clubAccess = parseClubAccessFromComposition(rawComposition);
const dates = {};
dateColumns.forEach(function (col) {
const cell = getCellByColumn(tr, col);
const rawDate = normalizeVisibleText(cell ? cell.innerText || cell.textContent : '');
dates[col.key] = {
raw: rawDate,
label: col.label,
date: col.kind === 'status' ? parseStatusDate(rawDate) : parseDate(rawDate),
kind: col.kind || 'date'
};
});
rows.push({
source: source,
rawSource: rawSource,
sourceColumnLabel: sourceInfo.sourceColumnLabel || '',
fallbackSourceRaw: sourceInfo.fallbackRaw || '',
group: GROUPING_ENABLED ? getSmartSourceGroup(source) : source,
paidAmount: paidAmount,
isPaid: paidAmount > 0,
rawPaid: rawPaid,
tags: tags,
segmentTag: segmentTag,
rawTags: tags.join(', '),
rawComposition: rawComposition,
marathonTariff: marathonTariff,
marathonTariff: marathonTariff,
marathonTariffKey: marathonTariff.key,
marathonTariffLabel: marathonTariff.label,
userBaseAgeColumnPresent: Boolean(userBaseAgeColumn),
userBaseAgeSegment: userBaseAgeSegment,
clubBalanceColumnPresent: Boolean(clubBalanceColumn),
firstClubBaseAgeColumnPresent: Boolean(firstClubBaseAgeColumn),
rawClubBalance: rawClubBalance,
clubBalanceValue: clubBalanceValue,
clubBalanceCohort: getClubBalanceCohort(clubBalanceValue),
rawFirstClubBaseAge: rawFirstClubBaseAge,
firstClubBaseAgeDays: firstClubBaseAgeDays,
firstClubBaseAgeCohort: getFirstClubBaseAgeCohort(firstClubBaseAgeDays),
clubAccess: clubAccess,
hasClubAccess: clubAccess.hasClubAccess,
clubAccessItems: clubAccess.items,
clubAccessMonths: clubAccess.totalMonths,
dates: dates,
activeDate: null,
dayKey: 'no-date',
dayLabel: EMPTY_DAY_LABEL,
page: pageNumber,
rowIndex: rowIndex + 1,
recordId: extractDealIdFromRow(tr),
userId: extractUserIdFromDealRow(tr),
rowHTML: tr.outerHTML
});
});
return { rows: rows, skipped: skipped, dateColumns: dateColumns };
}
function normalizeSourceValue(value) {
const text = normalizeVisibleText(value);
const lower = text.toLowerCase();
if (!text || text === '-' || text === '—' || lower === 'null' || lower === 'undefined') {
return EMPTY_SOURCE_LABEL;
}
return text;
}
function parseTagsFromCell(cell) {
if (!cell) return [EMPTY_TAG_LABEL];
const tags = [];
const labelNodes = Array.from(cell.querySelectorAll('.label, [class*="label-"]'));
labelNodes.forEach(function (node) {
const text = normalizeTagValue(node.innerText || node.textContent || '');
if (text && text !== EMPTY_TAG_LABEL && tags.indexOf(text) === -1) tags.push(text);
});
if (!tags.length) {
const rawText = String(cell.innerText || cell.textContent || '')
.replace(/\u00a0/g, ' ')
.split(/[\n\r]+/)
.map(normalizeTagValue)
.filter(Boolean);
rawText.forEach(function (text) {
if (text && text !== EMPTY_TAG_LABEL && tags.indexOf(text) === -1) tags.push(text);
});
}
return tags.length ? tags : [EMPTY_TAG_LABEL];
}
function normalizeTagValue(value) {
const text = normalizeVisibleText(value);
const lower = text.toLowerCase();
if (!text || text === '-' || text === '—' || lower === 'null' || lower === 'undefined') {
return EMPTY_TAG_LABEL;
}
return text;
}
function getSegmentTag(tags) {
const cleanTags = (tags || []).filter(function (tag) { return tag && tag !== EMPTY_TAG_LABEL; });
if (!cleanTags.length) return EMPTY_TAG_LABEL;
const normalized = cleanTags.map(function (tag) {
return { original: tag, norm: normalizeTagForRule(tag) };
});
if (normalized.some(function (item) { return /^new\b/i.test(item.norm) || /^new/i.test(item.original) || /\bнович/.test(item.norm); })) return 'Новички';
if (normalized.some(function (item) { return /\b(бывш|экс|ушедш|отвал)/.test(item.norm); })) return 'Бывшие клуб';
if (normalized.some(function (item) { return /\b(актив|действующ|участник\s+клуб|клубник|в\s+клубе)/.test(item.norm); })) return 'Активный клуб';
if (normalized.some(function (item) { return isMarathonTagItem(item); })) return 'Марафонец';
if (normalized.some(function (item) { return /^база$|\bбаза\b/.test(item.norm); })) return 'База';
return cleanTags[0];
}
function isMarathonTagItem(item) {
const norm = item && item.norm ? item.norm : normalizeTagForRule(item && item.original ? item.original : item);
const original = String(item && item.original ? item.original : item || '').toLowerCase();
return norm.indexOf('марафон') !== -1 || original.indexOf('марафон') !== -1 || norm.indexOf('marathon') !== -1 || original.indexOf('marathon') !== -1;
}
function getNewbieComponent(rowOrTags) {
const tags = Array.isArray(rowOrTags)
? rowOrTags
: ((rowOrTags && rowOrTags.tags) || []);
const normalized = (tags || [])
.filter(function (tag) { return tag && tag !== EMPTY_TAG_LABEL; })
.map(function (tag) {
return { original: tag, norm: normalizeTagForRule(tag) };
});
if (!normalized.length) return '';
if (normalized.some(function (item) { return /^new\b/i.test(item.norm) || /^new/i.test(item.original) || /\bнович/.test(item.norm); })) return 'New';
if (normalized.some(function (item) { return /^база$|\bбаза\b/.test(item.norm); })) return 'База';
if (normalized.some(function (item) { return isMarathonTagItem(item); })) return 'Марафонец';
return '';
}
function isNewbieRow(row) {
return Boolean(getNewbieComponent(row));
}
function getFirstMoneyComponent(rowOrTags) {
const tags = Array.isArray(rowOrTags)
? rowOrTags
: ((rowOrTags && rowOrTags.tags) || []);
const normalized = (tags || [])
.filter(function (tag) { return tag && tag !== EMPTY_TAG_LABEL; })
.map(function (tag) {
return { original: tag, norm: normalizeTagForRule(tag) };
});
if (!normalized.length) return '';
if (normalized.some(function (item) { return /^new\b/i.test(item.norm) || /^new/i.test(item.original) || /\bнович/.test(item.norm); })) return 'New';
if (normalized.some(function (item) { return /^база$|\bбаза\b/.test(item.norm); })) return 'База';
return '';
}
function isFirstMoneyRow(row) {
return Boolean(getFirstMoneyComponent(row));
}
function splitCompositionProducts(rawText) {
const text = normalizeVisibleText(rawText || '');
if (!text) return [];
return text
.replace(/\u00a0/g, ' ')
.split(/\s+\+\s+|[\n\r;]+/)
.map(function (part) { return normalizeVisibleText(part); })
.filter(Boolean);
}
function isClubAccessProductTitle(title) {
const norm = normalizeTagForRule(title);
if (!norm) return false;
if (norm.indexOf('марафон') !== -1 || norm.indexOf('интенсив') !== -1 || norm.indexOf('чек лист') !== -1 || norm.indexOf('чеклист') !== -1) {
if (norm.indexOf('йога клуб') === -1 && norm.indexOf('йога клуба') === -1 && norm.indexOf('онлайн йога клуб') === -1) return false;
}
const hasClub = norm.indexOf('йога клуб') !== -1 ||
norm.indexOf('йога клуба') !== -1 ||
norm.indexOf('онлайн йога клуб') !== -1 ||
norm.indexOf('online yoga club') !== -1;
const hasAccess = norm.indexOf('доступ') !== -1 ||
norm.indexOf('трениров') !== -1 ||
norm.indexOf('клуб') !== -1 ||
norm.indexOf('абонемент') !== -1 ||
norm.indexOf('подписк') !== -1;
return hasClub && hasAccess;
}
function extractClubAccessMonths(title) {
const norm = normalizeTagForRule(title);
if (!norm) return 0;
if (norm.indexOf('полгода') !== -1 || norm.indexOf('пол года') !== -1) return 6;
if (norm.indexOf('12 мес') !== -1 || norm.indexOf('12 месяц') !== -1 || norm.indexOf('1 год') !== -1 || norm === 'год' || norm.indexOf(' год') !== -1) return 12;
// Важно: не используем \\b после русских слов.
// В JavaScript \\b плохо подходит для кириллицы, из-за этого «1 месяц» мог уходить в «срок не распознан».
let match = norm.match(/(?:на\s*)?(\d{1,2})\s*(?:месяц(?:а|ев)?|мес)(?:\s|$)/);
if (match && match[1]) return Number(match[1]) || 0;
match = norm.match(/(?:на\s*)?(\d{1,3})\s*(?:день|дня|дней)(?:\s|$)/);
if (match && match[1]) {
const days = Number(match[1]) || 0;
const known = [
{ days: 31, months: 1 },
{ days: 62, months: 2 },
{ days: 92, months: 3 },
{ days: 183, months: 6 },
{ days: 365, months: 12 }
];
for (let i = 0; i < known.length; i++) {
if (Math.abs(days - known[i].days) <= 2) return known[i].months;
}
return days ? Math.max(1, Math.round(days / 31)) : 0;
}
return 0;
}
function getClubAccessDurationLabel(months, title) {
const num = Number(months) || 0;
if (num > 0) return CLUB_ACCESS_LABEL + ' — ' + num + ' ' + pluralMonth(num);
return CLUB_ACCESS_LABEL + ' — срок не распознан';
}
function getClubAccessGroupKey(months) {
const num = Number(months) || 0;
return num > 0 ? 'club-months:' + num : 'club-months:unknown';
}
function pluralMonth(num) {
const n = Math.abs(Number(num) || 0);
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return 'месяц';
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'месяца';
return 'месяцев';
}
function parseClubAccessFromComposition(rawText) {
const raw = normalizeVisibleText(rawText || '');
const parts = splitCompositionProducts(raw);
const items = [];
parts.forEach(function (part) {
if (!isClubAccessProductTitle(part)) return;
const months = extractClubAccessMonths(part);
items.push({
title: part,
months: months,
label: getClubAccessDurationLabel(months, part)
});
});
return {
raw: raw,
parts: parts,
items: items,
hasClubAccess: items.length > 0,
totalMonths: items.reduce(function (sum, item) { return sum + (Number(item.months) || 0); }, 0)
};
}
function detectClubBundleInMarathonTitle(rawText) {
const text = normalizeVisibleText(rawText || '');
const norm = normalizeTagForRule(text);
const hasMarathon = /(?:^|\s)(марафон|интенсив)(?:\s|$)/.test(norm);
const hasClub = /(клуб|клуба|клубу|занятиям клуба|занятия клуба|онлайн йога клуб)/.test(norm);
const hasAccess = /(доступ|абонемент|подписк)/.test(norm);
if (!hasMarathon || !hasClub || !hasAccess) {
return { includesClub: false, clubDays: 0, clubMonths: 0 };
}
let clubDays = 0;
const clubTail = norm.match(/(?:доступ\s+к\s+занятиям\s+клуба|доступ\s+в\s+клуб|клуб(?:а|у)?)\s*(?:на\s*)?(\d{1,3})\s*(?:день|дня|дней|дн)/i);
if (clubTail && clubTail[1]) clubDays = Number(clubTail[1]) || 0;
if (!clubDays) {
const afterPlus = norm.match(/\+[^+]*(?:клуб|занятиям клуба)[^0-9]{0,30}(\d{1,3})\s*(?:день|дня|дней|дн)/i);
if (afterPlus && afterPlus[1]) clubDays = Number(afterPlus[1]) || 0;
}
const clubMonths = clubDays ? Math.max(1, Math.round(clubDays / 31)) : 1;
return { includesClub: true, clubDays: clubDays, clubMonths: clubMonths };
}
function getMarathonTariff(rawComposition) {
const text = normalizeVisibleText(rawComposition || '');
const norm = normalizeTagForRule(text);
if (!text) {
return { key: 'empty-product', label: EMPTY_COMPOSITION_LABEL, shortLabel: 'Без состава', price: 0, offerId: '', days: 0, includesClub: false, clubDays: 0, clubMonths: 0 };
}
const bundleInfo = detectClubBundleInMarathonTitle(text);
const isAntiHump = norm.indexOf('антихолка') !== -1 || norm.indexOf('анти холка') !== -1;
if (isAntiHump) {
const dayMatches = [];
const re = /(^|[^0-9])(\d{1,3})\s*(?:день|дня|дней|дн)(?=$|[^а-яё0-9])/gi;
let match;
while ((match = re.exec(norm))) dayMatches.push(Number(match[2]) || 0);
if (bundleInfo.includesClub && dayMatches.indexOf(31) !== -1) {
return Object.assign({}, MARATHON_TARIFFS[0], bundleInfo);
}
for (let i = 1; i < MARATHON_TARIFFS.length; i++) {
const tariff = MARATHON_TARIFFS[i];
if (dayMatches.indexOf(tariff.days) !== -1) return tariff;
}
if (/(^|[^0-9])31(?=$|[^0-9])/.test(norm) && bundleInfo.includesClub) return Object.assign({}, MARATHON_TARIFFS[0], bundleInfo);
if (/(^|[^0-9])15(?=$|[^0-9])/.test(norm)) return MARATHON_TARIFFS[1];
if (/(^|[^0-9])5(?=$|[^0-9])/.test(norm)) return MARATHON_TARIFFS[2];
return { key: 'anti-hump-unknown', label: 'Антихолка — тариф не распознан', shortLabel: 'Не распознан', price: 0, offerId: '', days: 0, includesClub: bundleInfo.includesClub, clubDays: bundleInfo.clubDays, clubMonths: bundleInfo.clubMonths };
}
const compact = text.length > 64 ? text.slice(0, 61) + '…' : text;
return { key: 'other-product:' + normalizeTagForRule(compact), label: compact, shortLabel: compact, price: 0, offerId: '', days: 0, includesClub: bundleInfo.includesClub, clubDays: bundleInfo.clubDays, clubMonths: bundleInfo.clubMonths };
}
function buildDimensionStats(rows, keyGetter, labelGetter) {
const map = {};
(rows || []).forEach(function (row) {
const key = String(keyGetter(row) || '—');
const label = String(labelGetter(row) || key);
if (!map[key]) map[key] = makeEmptyStats({ key: key, label: label });
addWithBreakdowns(map[key], row);
});
return Object.keys(map).map(function (key) { return finalizeStats(map[key]); }).sort(sortStats);
}
function buildTariffStats(rows) {
return buildDimensionStats(rows, function (r) { return r.marathonTariffKey; }, function (r) { return r.marathonTariffLabel; });
}
function buildBaseAgeStats(rows) {
return buildDimensionStats(rows, function (r) { return r.userBaseAgeSegment; }, function (r) { return r.userBaseAgeSegment; });
}
function renderDimensionMiniTable(items, totals, title, labelTitle, scope) {
if (!items || !items.length) return '
Нет данных.
';
const isDayScope = scope === 'day';
const titleNorm = normalizeVisibleText(title).toLowerCase();
// Цветные action-chip показываем только в аналитических мини-таблицах,
// где пользователь ожидает детализацию: тарифы, возраст, а внутри дня — ещё источники и теги.
// Дни, обзорные TOP-блоки и раскрывающиеся строки источников остаются обычными.
const showActionChips = /тариф|возраст/.test(titleNorm) || (isDayScope && /источник|тег/.test(titleNorm));
const paidShareTitle = isDayScope ? '% оплат дня' : '% оплат выборки';
const moneyShareTitle = isDayScope ? '% денег дня' : '% денег выборки';
return '
';
}
function parseMoney(value) {
let text = String(value || '')
.replace(/\u00a0/g, ' ')
.replace(/руб\.?|р\.?|₽/gi, '')
.replace(/[^\d,\.\-\s]/g, '')
.trim();
if (!text || text === '-' || text === '—') return 0;
text = text.replace(/\s+/g, '');
const hasComma = text.indexOf(',') !== -1;
const hasDot = text.indexOf('.') !== -1;
if (hasComma && hasDot) {
const lastComma = text.lastIndexOf(',');
const lastDot = text.lastIndexOf('.');
if (lastComma > lastDot) {
text = text.replace(/\./g, '').replace(',', '.');
} else {
text = text.replace(/,/g, '');
}
} else if (hasComma && !hasDot) {
text = text.replace(',', '.');
}
const num = Number(text);
return Number.isFinite(num) ? num : 0;
}
function parseMetricInteger(value) {
const raw = String(value ?? '')
.replace(/\u00a0/g, ' ')
.replace(/[^\d\-+]/g, '')
.trim();
if (!raw || raw === '-' || raw === '+' || raw === '—') return null;
const num = Number(raw);
return Number.isFinite(num) ? Math.round(num) : null;
}
function getClubBalanceCohort(value) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) {
return { key: 'unknown', label: 'Не распознано', min: null, max: null, note: 'В поле нет числового значения.' };
}
const num = Number(value);
for (let i = 0; i < CLUB_BALANCE_COHORT_DEFS.length; i++) {
const def = CLUB_BALANCE_COHORT_DEFS[i];
if (num >= def.min && num <= def.max) return def;
}
return { key: 'unknown', label: 'Не распознано', min: null, max: null, note: 'Значение не попало ни в одну когорту.' };
}
function getFirstClubBaseAgeCohort(value) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) {
return { key: 'base-age-unknown', label: 'Не распознано', min: null, max: null, note: 'В поле нет числового значения.' };
}
const num = Number(value);
if (num < 0) {
return { key: 'base-age-negative', label: 'Ошибка даты: меньше 0 дней', min: -Infinity, max: -1, note: 'Дата первой покупки получилась раньше даты регистрации пользователя.' };
}
for (let i = 0; i < FIRST_CLUB_BASE_AGE_COHORT_DEFS.length; i++) {
const def = FIRST_CLUB_BASE_AGE_COHORT_DEFS[i];
if (num >= def.min && num <= def.max) return def;
}
return { key: 'base-age-unknown', label: 'Не распознано', min: null, max: null, note: 'Значение не попало ни в одну когорту.' };
}
function getFriendlyErrorMessage(err, context) {
const raw = err && err.message ? err.message : String(err || 'Неизвестная ошибка');
const stack = err && err.stack ? String(err.stack) : '';
const text = (raw + ' ' + stack).toLowerCase();
if (text.indexOf('getclubbalancecohort') !== -1 || text.indexOf('getclubbalance cohorts') !== -1) {
return 'Не найдена функция распределения Club balance по когортам. Это была ошибка сборки аналитического скрипта v36. Поставьте v37 и удалите из расширения старые версии v34/v35/v36, чтобы они не запускались параллельно.';
}
if (text.indexOf('getfirstclubbaseagecohort') !== -1) {
return 'Не найдена функция распределения первых клубных покупок по сроку нахождения в базе. Поставьте v37 и удалите старую версию скрипта из расширения.';
}
if (text.indexOf('clubbalancecolumn') !== -1) {
return 'Ошибка поиска колонки Club balance. Обновите аналитический скрипт до v37. Для анализа также проверьте, что в таблицу заказов добавлены колонки Club balance before order days и Days in base before first club order.';
}
if (text.indexOf('parsemetricinteger') !== -1) {
return 'Ошибка парсинга числового значения метрики. Обновите аналитический скрипт до v37 и убедитесь, что поле Club balance — числовое.';
}
if (text.indexOf('highcharts') !== -1) {
return 'Не удалось построить диаграммы Highcharts. Данные могли собраться, но графики не отрисовались. Попробуйте обновить страницу или временно отключить блокировщики скриптов.';
}
if (text.indexOf('failed to fetch') !== -1 || text.indexOf('networkerror') !== -1) {
return 'Не удалось загрузить одну из страниц GetCourse. Проверьте интернет/авторизацию и попробуйте запустить отчёт ещё раз.';
}
return (context ? context + ': ' : '') + raw;
}
function showFriendlyError(context, err) {
const friendly = getFriendlyErrorMessage(err, context);
console.error('[GC UTM MONEY TAGS DAYS]', context || 'Ошибка', err);
setStatus('Ошибка: ' + escapeHtml(friendly));
setDebug(
'Диагностика ошибки ' +
escapeHtml(friendly) + '
' +
'Техническая ошибка: ' +
'' + escapeHtml(err && err.stack ? err.stack : (err && err.message ? err.message : String(err || ''))) + ''
);
}
function decorateInteractiveAnalytics(root) {
const scope = root || document.getElementById(WRAP_ID);
if (!scope) return;
function removeNativeTitles(el) {
if (!el) return;
el.removeAttribute('title');
Array.from(el.querySelectorAll('[title]')).forEach(function (node) { node.removeAttribute('title'); });
}
function wrapAsChip(el, chipClass) {
if (!el || el.querySelector(':scope > .utm-action-chip')) return;
const chip = document.createElement('span');
chip.className = 'utm-action-chip ' + chipClass;
while (el.firstChild) chip.appendChild(el.firstChild);
el.appendChild(chip);
}
Array.from(scope.querySelectorAll('[data-utm-action-chip="analysis"]')).forEach(function (el) {
el.classList.add('utm-interactive-analytics');
el.setAttribute('tabindex', '0');
el.setAttribute('aria-label', 'Открыть расширенную аналитику выбранной группы');
removeNativeTitles(el);
wrapAsChip(el, 'utm-action-chip-analysis');
});
Array.from(scope.querySelectorAll('[data-utm-action-chip="list"]')).forEach(function (el) {
el.classList.add('utm-list-link');
el.setAttribute('tabindex', '0');
el.setAttribute('aria-label', 'Показать список заказов выбранной группы');
removeNativeTitles(el);
let isPaid = false;
if (el.tagName === 'TD' && el.parentElement && el.parentElement.parentElement) {
const table = el.closest('table');
const headCells = table ? Array.from(table.querySelectorAll('thead th')) : [];
const index = Array.from(el.parentElement.children).indexOf(el);
const headText = headCells[index] ? normalizeVisibleText(headCells[index].textContent).toLowerCase() : '';
isPaid = /оплат/.test(headText);
}
wrapAsChip(el, isPaid ? 'utm-action-chip-paid' : 'utm-action-chip-orders');
});
}
function showClickTooltip() {}
function hideClickTooltip() {}
function safeRenderResult(sourceInfo) {
try {
renderResult(sourceInfo);
return true;
} catch (err) {
showFriendlyError('Ошибка отрисовки аналитики', err);
return false;
}
}
function parseStatusDate(value) {
if (!value) return null;
const text = normalizeVisibleText(value)
.replace(/,/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!text) return null;
const prioritizedParts = [];
const paidMatch = text.match(/(?:оплачено|оплачен[ао]?|оплата|paid)\s*[::]?\s*(.+)$/i);
const completedMatch = text.match(/(?:заверш[её]н(?:а|о)?|completed|finished)\s*[::]?\s*(.+)$/i);
if (paidMatch && paidMatch[1]) prioritizedParts.push(paidMatch[1]);
if (completedMatch && completedMatch[1]) prioritizedParts.push(completedMatch[1]);
prioritizedParts.push(text);
for (let i = 0; i < prioritizedParts.length; i++) {
const parsed = parseDateFromFreeText(prioritizedParts[i]);
if (parsed) return parsed;
}
return null;
}
function parseDateFromFreeText(value) {
if (!value) return null;
const text = normalizeVisibleText(value)
.toLowerCase()
.replace(/,/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const patterns = [
/(сегодня(?:\s+\d{1,2}:\d{2})?)/i,
/(вчера(?:\s+\d{1,2}:\d{2})?)/i,
/(\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}(?:[t\s]+\d{1,2}:\d{2})?)/i,
/(\d{1,2}[.\-/]\d{1,2}[.\-/]\d{2,4}(?:\s+\d{1,2}:\d{2})?)/i,
/(\d{1,2}\s+[а-яё.]+\s+\d{4}(?:\s+\d{1,2}:\d{2})?)/i,
/(\d{1,2}\s+[а-яё.]+(?:\s+\d{1,2}:\d{2})?)/i,
/(\d{1,2}[.\-/]\d{1,2}(?:\s+\d{1,2}:\d{2})?)/i
];
for (let i = 0; i < patterns.length; i++) {
const m = text.match(patterns[i]);
if (!m || !m[1]) continue;
const parsed = parseDate(m[1]);
if (parsed) return parsed;
}
return parseDate(text);
}
function parseDate(value) {
if (!value) return null;
const text = normalizeVisibleText(value)
.toLowerCase()
.replace(/,/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const now = new Date();
let m;
// GetCourse часто показывает самые свежие оплаты как «15 минут назад»,
// «час назад», «2 часа назад», «только что». Переводим это в реальное
// время относительно момента запуска отчета. Если вычитание уходит за полночь,
// дата автоматически попадет во вчерашний день.
const relativeDate = parseRelativeDate(text, now);
if (relativeDate) return relativeDate;
// GetCourse также показывает свежие даты как «сегодня 14:25» и «вчера 23:49».
// Переводим их в реальную дату относительно дня, когда открыт отчет.
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));
// 2026-06-24 15:37 / 2026-06-24T15:37
// Важно проверять ISO-формат раньше dd.mm.yy, иначе 2026-06-24 можно ошибочно прочитать как 26.06.2024.
m = text.match(/(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})(?:[t\s]+(\d{1,2}):(\d{2}))?/i);
if (m) return makeDate(m[1], Number(m[2]) - 1, m[3], m[4], m[5]);
// 24.06.2026 15:37 / 24-06-2026 15:37
m = text.match(/(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{2,4})(?:\s+(\d{1,2}):(\d{2}))?/);
if (m) return makeDate(m[3], Number(m[2]) - 1, m[1], m[4], m[5]);
// 24.06 15:37 — если год не показан, считаем текущий год.
m = text.match(/(\d{1,2})[.\-/](\d{1,2})(?:\s+(\d{1,2}):(\d{2}))?/);
if (m) return makeDate(now.getFullYear(), Number(m[2]) - 1, m[1], m[3], m[4]);
// 24 июн. 2026 15:37
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 makeDate(m[3], month, m[1], m[4], m[5]);
}
// 24 июн. 15:37 — если год не показан, считаем текущий год.
m = text.match(/(\d{1,2})\s+([а-яё.]+)(?:\s+(\d{1,2}):(\d{2}))?/i);
if (m) {
const month = getRuMonth(m[2]);
if (month !== null) return makeDate(now.getFullYear(), month, m[1], m[3], m[4]);
}
return null;
}
function parseRelativeDate(text, baseDate) {
const value = normalizeVisibleText(text)
.toLowerCase()
.replace(/,/g, ' ')
.replace(/ё/g, 'е')
.replace(/\s+/g, ' ')
.trim();
if (!value) return null;
const now = baseDate ? new Date(baseDate.getTime()) : new Date();
if (/^(только что|сейчас|только\s+что)$/i.test(value) || /\b(только что|сейчас)\b/i.test(value)) {
return now;
}
if (/\bполчаса\s+назад\b/i.test(value)) {
return new Date(now.getTime() - 30 * 60000);
}
let m;
m = value.match(/(\d+)\s*(?:сек(?:унда|унды|унд)?|сек\.?|s|sec|second(?:s)?)\s+назад/i);
if (m) return new Date(now.getTime() - Number(m[1]) * 1000);
m = value.match(/(\d+)\s*(?:мин(?:ута|уты|ут|уту)?|мин\.?|м\.?|m|min|minute(?:s)?)\s+назад/i);
if (m) return new Date(now.getTime() - Number(m[1]) * 60000);
if (/\bминуту\s+назад\b/i.test(value)) {
return new Date(now.getTime() - 60000);
}
m = value.match(/(\d+)\s*(?:час(?:а|ов)?|ч\.?|h|hour(?:s)?)\s+назад/i);
if (m) return new Date(now.getTime() - Number(m[1]) * 3600000);
if (/\bчас\s+назад\b/i.test(value)) {
return new Date(now.getTime() - 3600000);
}
m = value.match(/(\d+)\s*(?:д(?:ень|ня|ней)?|дн\.?|day(?:s)?)\s+назад/i);
if (m) return new Date(now.getFullYear(), now.getMonth(), now.getDate() - Number(m[1]), now.getHours(), now.getMinutes(), now.getSeconds());
return null;
}
function makeDate(year, month, day, hour, minute) {
year = Number(year);
if (year < 100) year += 2000;
return new Date(year, Number(month), Number(day), Number(hour || 0), Number(minute || 0));
}
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 getLessonIdFromSource(source) {
const raw = String(source || '');
if (!raw) return '';
const lower = raw.toLowerCase();
if (lower.indexOf('урок') === -1 && lower.indexOf('lesson') === -1) return '';
let match = raw.match(/(?:урок|lesson)[^\d]{0,40}(\d{5,})/i);
if (match && match[1]) return match[1];
match = raw.match(/(?:id|ид)[^\d]{0,20}(\d{5,})/i);
if (match && match[1]) return match[1];
match = raw.match(/(\d{5,})/);
return match && match[1] ? match[1] : '';
}
function isLessonSource(source) {
return Boolean(getLessonIdFromSource(source));
}
function getLessonUrl(lessonId) {
return '/pl/teach/control/lesson/view?id=' + encodeURIComponent(lessonId);
}
function getNormalizedSourceKey(source) {
const lessonId = getLessonIdFromSource(source);
if (lessonId) return 'Урок ' + lessonId;
return source || EMPTY_SOURCE_LABEL;
}
function loadLessonTitleCache() {
if (LESSON_TITLE_CACHE) return LESSON_TITLE_CACHE;
try {
LESSON_TITLE_CACHE = JSON.parse(localStorage.getItem(LESSON_TITLE_CACHE_KEY) || '{}') || {};
} catch (e) {
LESSON_TITLE_CACHE = {};
}
return LESSON_TITLE_CACHE;
}
function saveLessonTitleCache() {
try {
localStorage.setItem(LESSON_TITLE_CACHE_KEY, JSON.stringify(LESSON_TITLE_CACHE || {}));
} catch (e) {}
}
function getCachedLessonTitle(lessonId) {
const cache = loadLessonTitleCache();
return cache && cache[lessonId] ? cache[lessonId] : '';
}
function cleanLessonTitleText(text, lessonId) {
let title = normalizeVisibleText(text || '');
if (!title) return '';
title = title
.replace(/\s*-\s*GetCourse\s*$/i, '')
.replace(/\s*\/\s*GetCourse\s*$/i, '')
.replace(new RegExp('^\\s*(урок|lesson)\\s*#?\\s*' + lessonId + '\\s*[:—–-]?\\s*', 'i'), '')
.replace(new RegExp('\\s*#?\\s*' + lessonId + '\\s*$', 'i'), '')
.replace(/\s+/g, ' ')
.trim();
if (!title) return '';
if (title.length > 180) return '';
// Отсекаем служебные элементы GetCourse, которые тоже могут содержать lesson id.
if (/^(просмотр|редактировать|задание|задания|настройки|комментарии|ученики|статистика|новые ответы|ответы|показать в ленте|перенести урок|предыдущий урок|следующий урок|отправить)$/i.test(title)) return '';
if (/^(добавить|сохранить|удалить|отмена|назад|вперёд|вперед)$/i.test(title)) return '';
return title;
}
function getTextFromElementWithoutChildren(el) {
if (!el) return '';
const clone = el.cloneNode(true);
Array.from(clone.querySelectorAll('script,style,noscript,svg,.breadcrumbs,.breadcrumb,.btn,.buttons,.dropdown,.menu,.nav,.lesson-navigation,.lesson-list,.lesson-list li,.answers,.comments,.mission-form,.user-answer')).forEach(function (node) {
if (node && node.parentNode) node.parentNode.removeChild(node);
});
return normalizeVisibleText(clone.innerText || clone.textContent || '');
}
function extractLessonTitleFromDoc(doc, lessonId) {
if (!doc || !lessonId) return '';
const id = String(lessonId);
// 1. Главный источник для страницы просмотра урока GetCourse.
// В HAR реальное название лежит здесь:
//
Логика v31: база сравнения — текущая открытая выборка заказов на странице. В таблице ниже ровно четыре витрины: продление из клуба, CHL / основной сайт, марафон «Лёгкий старт» и догоняющая цепочка. Каждая витрина считается как пересечение текущей выборки с соответствующим offer-фильтром из HAR. Поэтому период, продукт, статус оплаты и другие условия берутся из того сегмента, который уже открыт в GetCourse.
Оба показателя считаются отдельными запросами поверх текущей открытой выборки GetCourse. Поэтому период, продукты, теги и прочие условия полностью сохраняются. «Доля оплат» = количество оплаченных заказов с этим способом / все оплаченные заказы текущей выборки.
';
}
function renderPaymentsView(totals) {
return renderPaymentsPanel(totals);
}
function renderCohortsView(totals) {
return '
Витрины считают не фиксированный месяц из HAR, а текущую выборку на странице GetCourse. HAR здесь используется только как источник offer-фильтров для четырёх витрин: продление из клуба, CHL, марафон «Лёгкий старт» и догоняющая цепочка.
' +
renderCohortComparisonPanel(totals);
}
function getUniqueOptions(rows, getter) {
const map = {};
(rows || []).forEach(function (row) { const v = String(getter(row) || ''); if (v) map[v] = true; });
return Object.keys(map).sort(function (a,b) { return a.localeCompare(b, 'ru'); });
}
function renderSelectOptions(values, allLabel) {
return '' + values.map(function (v) { return ''; }).join('');
}
function renderCrossFiltersView(rows, totals) {
const tags = getUniqueOptions(rows, function (r) { return r.segmentTag; });
const ages = getUniqueOptions(rows, function (r) { return r.userBaseAgeSegment; });
const tariffs = getUniqueOptions(rows, function (r) { return r.marathonTariffLabel; });
const sources = getUniqueOptions(rows, function (r) { return r.group || r.source; });
const days = getUniqueOptions(rows, function (r) { return r.dayLabelFull; });
return '
Пересекаемые фильтры
Выберите одновременно несколько характеристик. Например: «База» + «0–30 дней» + «15 дней — 990 ₽». После применения в основной таблице останутся только заказы, которые подходят сразу под все выбранные условия.
Режим «Марафон». Отчёт анализирует один запуск без дополнительных витрин предложений и без клубных метрик. Основные слои: тарифы, дни, источники, теги, возраст базы и способы оплаты.
' + escapeHtml(FIRST_MONEY_GROUP_LABEL) + ' — люди, которые впервые принесли деньги: New + База. ' + escapeHtml(FIRST_CLUB_GROUP_LABEL) + ' — люди, которые впервые купили клуб/подписку: New + База + Марафонец. Строки кликабельны и фильтруют основную таблицу.
В текущей выборке не найден доступ к онлайн-йога-клубу в колонке «Состав заказа». Если такие покупки есть, проверь, что колонка «Состав заказа» включена в таблице.
Есть доступы клуба, где срок не удалось распознать: ' + formatNumber(stats.unknownDurationCount) + '. Они попали в количество доступов, но не добавлены в месяцы.
'
: '';
return '
' +
'
Что купили: онлайн-йога-клуб
' +
'
Этот блок смотрит только колонку «Состав заказа» и объединяет покупки по длительности доступа к онлайн-йога-клубу: 1, 2, 3, 6, 12 месяцев и т.д. Если в заказе вместе с клубом есть марафон, интенсив или другой продукт, они здесь игнорируются и в месяцы клуба не попадают.
' +
'
' +
'
' +
'
Заказов с клубом
' +
'
' + formatNumber(stats.ordersCount) + '
' +
'
из них ' + formatNumber(stats.paidOrdersCount) + ' оплачено · CR ' + formatConversion(stats.paidOrdersCount, stats.ordersCount) + '
' +
'
' +
'
' +
'
Оплаченных доступов
' +
'
' + formatNumber(stats.paidAccessCount) + '
' +
'
единиц доступа к клубу
' +
'
' +
'
' +
'
Продано месяцев клуба
' +
'
' + formatNumber(stats.paidMonthsTotal) + '
' +
'
считаются только оплаченные доступы
' +
'
' +
'
' +
'
' +
'
' +
'
Доступ
' +
'
Найдено
' +
'
Оплачено
' +
'
Конверсия
' +
'
Месяцев продано
' +
'
Мес. в доступе
' +
'
' +
'' + rows + '' +
'
' +
unknownNote +
'
';
}
function renderSimpleStatsTable(items, totals, type, firstColTitle, hint) {
if (!items || !items.length) return '
Это верхнеуровневая картина: из каких людей состоит выручка. Сверху — две круговые диаграммы: одна по количеству оплаченных заказов, вторая по деньгам. Нажмите на сегмент, чтобы увидеть эти заказы в основной таблице. Плюс раскрывает, из каких источников пришёл сегмент.
' + formatMoney(day.paidAmount) + ' ' + formatPercentValue(day.paidAmount, totals.totalPaidAmount) + ' денег периода
' +
'
' + (topTariffs || '—') + '
' +
'
' + formatMoney(day.avgPaidCheck) + '
';
if (isExpanded) {
const dayTotals = getTotals(day.rows);
html += '
' +
renderDimensionMiniTable(tariffs, dayTotals, 'Тарифы внутри дня', 'Тариф', 'day') +
renderDimensionMiniTable(ages, dayTotals, 'Возраст в базе внутри дня', 'Возраст', 'day') +
renderDimensionMiniTable(day.sourceStats || [], dayTotals, 'Источники внутри дня', 'Источник', 'day') +
renderDimensionMiniTable(day.tagStats || [], dayTotals, 'Теги внутри дня', 'Тег', 'day') +
'
';
}
return html;
}).join('');
return '
Главный дневной экран для запуска: в каждой строке видны продажи тарифов и их доля среди оплат дня. Плюс раскрывает четыре среза этого дня: тарифы, возраст пользователя в базе, источники и теги. Каждая строка кликабельна.
Здесь детализация источников: группа → подисточники → сегменты внутри каждого источника. Сверху — две общие круговые диаграммы по источникам, а внутри каждого раскрытого источника есть компактные диаграммы по подисточникам и сегментам. Нажимайте на строку или сектор для фильтра в основной таблице.
В текущей выборке нет заказов, которые попали в старую теговую когорту первого вступления в клуб: New + База + Марафонец.
' +
'
' + renderFirstClubBaseAgePanel(metricFirstAge, totals, 'Факт первой клубной покупки по Club balance');
}
const components = cohort.components || [];
const sources = buildSourceStats(rows);
const days = buildDayStats(rows);
const clubStats = buildClubAccessStats(rows);
const filterId = registerFilterRows(rows, FIRST_CLUB_GROUP_LABEL + ': вся когорта первого вступления в клуб');
const metricFirstAge = totals && totals.clubBalance ? totals.clubBalance.firstBaseAge : makeEmptyFirstClubBaseAgeStats();
return '
Эта вкладка отдельно показывает приток свежей аудитории в клуб. Верхний блок — факт первой клубной покупки по допполям заказа. Ниже остаётся старый теговый срез New + База + Марафонец.
' +
renderFirstClubBaseAgePanel(metricFirstAge, totals, 'Факт первой клубной покупки по Club balance') +
'
В текущей собранной выборке пока нет значений Club balance before order days. Для анализа прошлых месяцев добавь в таблицу заказов две колонки: Club balance before order days и Days in base before first club order. Обогащение теперь вынесено в отдельный админский инструмент: сначала заполни поля там, затем обнови отчёт.
' +
'
' +
renderKpiCard('Поле Club balance', CLUB_BALANCE_FIELD_ID, '99999 = первый клубный заказ', '') +
renderKpiCard('Поле дней в базе', FIRST_CLUB_BASE_AGE_FIELD_ID, 'заполняется только для первых клубных заказов', '') +
'
' +
'
';
}
const firstEntryFilterId = registerFilterRows(stats.firstEntryRows, 'Первый клубный заказ: ' + formatNumber(stats.firstEntryRows.length) + ' заказов');
const extensionFilterId = registerFilterRows(stats.extensionRows, 'Продления до окончания доступа: ' + formatNumber(stats.extensionRows.length) + ' заказов');
const reactivationFilterId = registerFilterRows(stats.reactivationRows, 'Возвраты после паузы: ' + formatNumber(stats.reactivationRows.length) + ' заказов');
const zeroFilterId = registerFilterRows(stats.zeroBalanceRows, 'Покупка в день окончания доступа: ' + formatNumber(stats.zeroBalanceRows.length) + ' заказов');
return '
Эта вкладка анализирует допполя заказа: Club balance before order days и Days in base before first club order. Код ' + CLUB_BALANCE_FIRST_ENTRY_CODE + ' считается отдельной категорией и не участвует в средних значениях по дням.
' +
'
' +
renderKpiCard('Заказов с Club balance', formatNumber(stats.rows.length), (stats.hasClubBalanceColumn ? 'значения взяты из колонки таблицы' : 'значения рассчитаны в текущей сессии'), '') +
renderKpiCard('Первые клубные покупки', formatNumber(stats.firstEntryRows.length), formatMoney(sumPaidAmount(stats.firstEntryRows)) + ' · код ' + CLUB_BALANCE_FIRST_ENTRY_CODE, firstEntryFilterId) +
renderKpiCard('Продления до окончания', formatNumber(stats.extensionRows.length), 'медиана остатка ' + formatDaysNumber(stats.extensionMedianDays), extensionFilterId) +
renderKpiCard('Возвраты после паузы', formatNumber(stats.reactivationRows.length), 'медиана паузы ' + formatDaysNumber(stats.reactivationMedianGapDays), reactivationFilterId) +
renderKpiCard('В день окончания', formatNumber(stats.zeroBalanceRows.length), 'Club balance = 0', zeroFilterId) +
renderKpiCard('Дней в базе / первые', firstAge.hasData ? formatDaysNumber(firstAge.medianDays) : '—', 'медиана до первого клуба', '') +
'
' +
renderClubBalanceInsights(stats, firstAge, totals) +
renderChartPanel('Club balance — визуально', [
{ id: 'utm-club-balance-count', title: 'Оплаченные заказы по состоянию клуба' },
{ id: 'utm-club-balance-money', title: 'Деньги по состоянию клуба' },
{ id: 'utm-first-club-base-age-count', title: 'Первые покупки: скорость' },
{ id: 'utm-first-club-base-age-money', title: 'Первые покупки: деньги' }
], true) +
renderClubBalanceCohortTable(stats, totals) +
renderFirstClubBaseAgePanel(firstAge, totals, 'Скорость первой покупки клуба');
}
function renderClubBalanceInsights(stats, firstAge, totals) {
const pieces = [];
const totalMoney = totals && totals.totalPaidAmount ? totals.totalPaidAmount : 0;
const firstMoney = sumPaidAmount(stats.firstEntryRows);
const extensionMoney = sumPaidAmount(stats.extensionRows);
const reactivationMoney = sumPaidAmount(stats.reactivationRows);
if (stats.rows.length) {
pieces.push('В выборке найдено ' + formatNumber(stats.rows.length) + ' оплаченных заказов с Club balance.');
}
if (stats.firstEntryRows.length) {
pieces.push('Первые клубные покупки: ' + formatNumber(stats.firstEntryRows.length) + ' заказов, ' + formatMoney(firstMoney) + ' (' + formatPercentValue(firstMoney, totalMoney) + ' денег).');
}
if (stats.extensionRows.length) {
pieces.push('Продления до окончания доступа: ' + formatNumber(stats.extensionRows.length) + ' заказов, медианный остаток ' + formatDaysNumber(stats.extensionMedianDays) + '.');
}
if (stats.reactivationRows.length) {
pieces.push('Возвраты после паузы: ' + formatNumber(stats.reactivationRows.length) + ' заказов, медианная пауза ' + formatDaysNumber(stats.reactivationMedianGapDays) + '.');
}
if (firstAge && firstAge.hasData) {
pieces.push('До первой покупки клуба медианно проходит ' + formatDaysNumber(firstAge.medianDays) + '; в первые 30 дней покупают ' + formatNumber(firstAge.firstMonthRows.length) + ' человек.');
}
if (!pieces.length) return '';
return '
Группировка по числу в поле Club balance. Положительные значения — человек продлился до окончания доступа. Отрицательные — вернулся после паузы. 99999 — первая клубная покупка.
' +
'
' +
'
Когорта
Заказов
Оплаченных
CR
Оплачено
% денег
Средний чек
Дни
' +
'' + rows + '' +
'
' +
'
';
}
function renderFirstClubBaseAgePanel(firstAge, totals, title) {
const stats = firstAge || makeEmptyFirstClubBaseAgeStats();
const panelTitle = title || 'Скорость первой покупки клуба';
if (!stats.hasData) {
return '
' +
'
' + escapeHtml(panelTitle) + '
' +
'
Нет заполненных значений Days in base before first club order. Это поле заполняется только у заказов, где Club balance = ' + CLUB_BALANCE_FIRST_ENTRY_CODE + '. Проверь, что в таблицу добавлена колонка с этим допполем или сначала заполни это поле отдельным админским инструментом.
' +
'
';
}
const fastFilterId = registerFilterRows(stats.fast0to7Rows, 'Первая покупка клуба за 0–7 дней: ' + formatNumber(stats.fast0to7Rows.length));
const monthFilterId = registerFilterRows(stats.firstMonthRows, 'Первая покупка клуба за 0–30 дней: ' + formatNumber(stats.firstMonthRows.length));
const oldFilterId = registerFilterRows(stats.oldBaseRows, 'Первая покупка клуба из старой базы 181+ дней: ' + formatNumber(stats.oldBaseRows.length));
const cards = '
' +
renderKpiCard('Первые покупки с возрастом базы', formatNumber(stats.rows.length), 'значений в поле ' + FIRST_CLUB_BASE_AGE_FIELD_ID, '') +
renderKpiCard('Медиана дней в базе', formatDaysNumber(stats.medianDays), 'среднее ' + formatDaysNumber(stats.avgDays), '') +
renderKpiCard('0–7 дней', formatNumber(stats.fast0to7Rows.length), formatPercentValue(stats.fast0to7Rows.length, stats.rows.length) + ' первых покупок', fastFilterId) +
renderKpiCard('0–30 дней', formatNumber(stats.firstMonthRows.length), formatPercentValue(stats.firstMonthRows.length, stats.rows.length) + ' первых покупок', monthFilterId) +
renderKpiCard('181+ дней', formatNumber(stats.oldBaseRows.length), 'старая база / реактивация', oldFilterId) +
'
Анализируются только первые клубные покупки: Club balance = ' + CLUB_BALANCE_FIRST_ENTRY_CODE + '. Поле Days in base before first club order показывает, сколько дней прошло от регистрации клиента в базе до первой покупки клуба.
Вкладка «Клуб» отвечает на отдельный бизнес-вопрос: сколько именно доступа к клубу продали. Главный показатель здесь — месяцы клуба, деньги показываются в других вкладках.
Матрица показывает, какие сегменты дали деньги в каждый день. В ячейке сверху сумма «Оплачено», снизу количество оплаченных заказов. Любая непустая ячейка кликабельна.