Формула ИИ для разработчиков Справочник API Каталог ошибок OpenAPI
Разделы

Блоки аналитики по ответам API

Шесть готовых блоков для ваших страниц: плитки, линия, источники, воронка, видимость и «Пульс». Каждый рисуется из ответа аналитического API без библиотек; вариант на Chart.js - отдельной вкладкой.

Общая основа

Все блоки используют один CSS и небольшие помощники. Ключ API в браузере не нужен и опасен: блоки читают данные у вашего сервера, а он обращается к аналитическому API - пример в разделе «Свой дашборд». Значение null в ответе - это «нет данных», а не ноль; помощники это учитывают.

CSS

Переменные --ab-* на корне .ab - то, что перекрашивают. Тёмная тема - по системе и по data-theme.

.ab {
  --ab-bg: #ffffff; --ab-fg: #1f2d26; --ab-muted: #4f6458; --ab-line: #e4ddcf; --ab-track: #eef1ec;
  --ab-accent: #2d6a4f; --ab-soft: #e8f5e9; --ab-up: #17703f; --ab-down: #b3261e; --ab-radius: 16px;
  --ab-d1: #1a3c2a; --ab-d2: #2d6a4f; --ab-d3: #7fb89b; --ab-d4: #cfd8d2;
  --ab-direct: #6b7a72; --ab-ai: #a9741f; --ab-social: #7a3db8; --ab-ads: #0b6bcb;
  background: var(--ab-bg); color: var(--ab-fg); border: 1px solid var(--ab-line); border-radius: var(--ab-radius);
  padding: 16px; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; min-width: 0;
}
@media (prefers-color-scheme: dark) {
  .ab:not([data-theme="light"]) {
    --ab-bg: #14201a; --ab-fg: #e6eee9; --ab-muted: #a3b9ab; --ab-line: #2b3d32; --ab-track: #1f2f26;
    --ab-accent: #6ee7b7; --ab-soft: #1b2f24; --ab-up: #6ee7b7; --ab-down: #ff8a80;
    --ab-d1: #6ee7b7; --ab-d2: #3fae85; --ab-d3: #2a7a5c; --ab-d4: #3a4b41;
    --ab-direct: #9fb1a6; --ab-ai: #e0b266; --ab-social: #c9a0f5; --ab-ads: #6cb2ff;
  }
}
.ab[data-theme="dark"] {
  --ab-bg: #14201a; --ab-fg: #e6eee9; --ab-muted: #a3b9ab; --ab-line: #2b3d32; --ab-track: #1f2f26;
  --ab-accent: #6ee7b7; --ab-soft: #1b2f24; --ab-up: #6ee7b7; --ab-down: #ff8a80;
  --ab-d1: #6ee7b7; --ab-d2: #3fae85; --ab-d3: #2a7a5c; --ab-d4: #3a4b41;
  --ab-direct: #9fb1a6; --ab-ai: #e0b266; --ab-social: #c9a0f5; --ab-ads: #6cb2ff;
}
.ab-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
.ab-tile { background: var(--ab-soft); border-radius: 12px; padding: 12px 14px; min-width: 0; }
.ab-label, .ab-sub, .ab-axis { font-size: 12px; color: var(--ab-muted); }
.ab-value { font-size: 24px; font-weight: 700; line-height: 1.25; overflow-wrap: anywhere; }
.ab-delta { font-size: 12px; font-weight: 600; color: var(--ab-muted); }
.ab-up { color: var(--ab-up); }
.ab-down { color: var(--ab-down); }
.ab-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; }
.ab-big { font-size: 26px; font-weight: 700; }
.ab-plot { height: 160px; margin-top: 10px; }
.ab-plot svg, .ab-bar svg, .ab-stack svg { display: block; width: 100%; height: 100%; }
.ab-grid { stroke: var(--ab-line); stroke-width: 1; vector-effect: non-scaling-stroke; }
.ab-line { fill: none; stroke: var(--ab-accent); stroke-width: 2.5; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.ab-area { fill: var(--ab-accent); opacity: 0.14; }
.ab-axis { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
.ab-rows { display: grid; gap: 12px; }
.ab-row { display: grid; grid-template-columns: minmax(90px, 150px) minmax(0, 1fr) minmax(96px, auto); gap: 4px 12px; align-items: center; }
.ab-name { font-weight: 600; overflow-wrap: anywhere; }
.ab-bar { height: 10px; border-radius: 5px; overflow: hidden; }
.ab-bar-none { background: var(--ab-track); opacity: 0.5; }
.ab-num { display: flex; flex-direction: column; align-items: flex-end; text-align: right; line-height: 1.3; }
.ab-track { fill: var(--ab-track); }
.ab-fill { fill: var(--ab-accent); }
.ab-k-direct { fill: var(--ab-direct); }
.ab-k-ai_assistant { fill: var(--ab-ai); }
.ab-k-social { fill: var(--ab-social); }
.ab-k-ads { fill: var(--ab-ads); }
.ab-sub-title { margin: 16px 0 8px; font-family: inherit; font-size: 13px; font-weight: 600; line-height: 1.4; color: var(--ab-fg); }
p.ab-sub { margin: 0 0 8px; }
.ab-stack { height: 14px; border-radius: 7px; overflow: hidden; }
.ab-d1 { fill: var(--ab-d1); } .ab-d2 { fill: var(--ab-d2); } .ab-d3 { fill: var(--ab-d3); } .ab-d4 { fill: var(--ab-d4); }
.ab-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; list-style: none; margin: 10px 0 0; padding: 0; }
.ab-legend li { display: flex; gap: 6px; align-items: baseline; }
.ab-legend li::before { content: ""; width: 10px; height: 10px; border-radius: 3px; background: var(--ab-d1); align-self: center; }
.ab-legend .ab-d2-key::before { background: var(--ab-d2); }
.ab-legend .ab-d3-key::before { background: var(--ab-d3); }
.ab-legend .ab-d4-key::before { background: var(--ab-d4); }
.ab-list { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 8px; }
.ab-item { display: flex; justify-content: space-between; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--ab-line); border-radius: 12px; }
.ab-item-text { display: flex; flex-direction: column; min-width: 0; }
.ab-chip { background: var(--ab-soft); color: var(--ab-up); font-weight: 700; border-radius: 999px; padding: 2px 10px; white-space: nowrap; }
.ab-note { margin: 0 0 8px; padding: 12px; border-radius: 12px; background: var(--ab-soft); color: var(--ab-muted); }
@media (max-width: 480px) {
  .ab-row { grid-template-columns: minmax(0, 1fr) auto; }
  .ab-bar { grid-column: 1 / -1; grid-row: 2; }
  .ab-num { grid-column: 2; grid-row: 1; }
}

JS помощники

// Подписи: правьте под свой сайт. Ключи (clicks, google, ...) приходят из API.
var LOCALE = 'ru-RU';
var T = {
  impressions: 'Показы', clicks: 'Клики', sessions: 'Сессии', ai_people: 'Люди из ИИ-ассистентов', revenue: 'Выручка',
  leads: 'Лиды', deals_created: 'Сделки созданы', deals_won: 'Сделки выиграны',
  google: 'Google', yandex: 'Яндекс', bing: 'Bing', chatgpt: 'ChatGPT', perplexity: 'Perplexity', ai_other: 'Другие ИИ',
  social: 'Соцсети', ads: 'Реклама', direct: 'Прямые заходы', other: 'Прочее'
};
var M = {
  dash: '—', noBase: 'нет базы', no_data: 'Нет данных за период', no_source: 'Источник не подключён',
  not_connected: 'Раздел не подключён', through: 'данные до', total: 'всего', leads: 'лиды', prevStep: 'от предыдущего шага',
  share: 'Доля видимости', spread: 'Позиции запросов', top3: 'Топ-3', top10: 'Топ-10', top30: 'Топ-30', outside: 'Ниже',
  aeo: 'Цитирование в ответах ИИ', volume: 'спрос', new7: 'Новых за 7 дней'
};

function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function el(tag, cls, text) {
  var n = document.createElement(tag);
  if (cls) n.className = cls;
  if (text != null) n.textContent = text;   // textContent, не innerHTML: текст не станет разметкой
  return n;
}
// Значение API: число, деньги {amount, currency} или null. null - это "нет данных", а не 0.
function num(v) { return v == null ? null : (typeof v === 'object' ? Number(v.amount) : v); }
function fmt(v) {
  if (v == null) return M.dash;
  if (typeof v === 'object') return new Intl.NumberFormat(LOCALE, { style: 'currency', currency: v.currency, maximumFractionDigits: 0 }).format(Number(v.amount));
  return new Intl.NumberFormat(LOCALE, { maximumFractionDigits: 1 }).format(v);
}
function delta(v) {
  if (v == null) return el('span', 'ab-delta', M.noBase);
  var sign = v > 0 ? '▲ +' : (v < 0 ? '▼ ' : '');
  return el('span', 'ab-delta' + (v > 0 ? ' ab-up' : (v < 0 ? ' ab-down' : '')), sign + fmt(v) + '%');
}
function note(root, availability) { root.appendChild(el('p', 'ab-note', M[availability] || M.no_data)); }
function bar(percent, cls) {
  var b = el('div', 'ab-bar');
  b.innerHTML = '<svg viewBox="0 0 100 10" preserveAspectRatio="none" aria-hidden="true"><rect class="ab-track" width="100" height="10"/><rect class="' + cls + '" width="' + Math.max(0.8, Math.min(100, percent)).toFixed(1) + '" height="10"/></svg>';
  return b;
}

Плитки показателей

Главные цифры периода с изменением к прошлому такому же периоду. Пропуск данных - прочерк, не ноль.

GET /dashboards/overview?blocks=kpi · право dashboards:read

Плитки показателейпример
Показы
182 400
▲ +6,1%
Клики
4 210
▲ +8,5%
Сессии
3 920
▲ +8,9%
Люди из ИИ-ассистентов
212
▲ +51,4%
Выручка
524 000 ₽
▲ +27,8%

Вид блока по примеру ответа ниже. Цифры вымышлены.

Ответ JSON

Пример: цифры вымышлены; поля сверены с контрактом.

{
  "data": {
    "from": "2026-10-01",
    "to": "2026-10-28",
    "blocks": {
      "kpi": {
        "availability": "ok",
        "data_as_of": { "g:search_console": "2026-10-25", "tracker": "2026-10-28", "crm": "2026-10-28" },
        "tiles": [
          { "key": "impressions", "current": 182400, "prev": 171900, "delta_pct": 6.1 },
          { "key": "clicks", "current": 4210, "prev": 3880, "delta_pct": 8.5 },
          { "key": "sessions", "current": 3920, "prev": 3601, "delta_pct": 8.9 },
          { "key": "ai_people", "current": 212, "prev": 140, "delta_pct": 51.4 },
          {
            "key": "revenue",
            "current": { "amount": "524000.00", "currency": "RUB" },
            "prev": { "amount": "410000.00", "currency": "RUB" },
            "delta_pct": 27.8
          }
        ]
      }
    }
  }
}

HTML

<!-- Контейнер блока. Стили - из раздела «Общая основа»; код блока подключён выше. -->
<section class="ab" id="ab-tiles" aria-label="Плитки показателей" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-tiles');
  // Адрес вашего прокси: ключ API остаётся на сервере (см. «Свой дашборд»)
  fetch('/analytics-api/overview?blocks=kpi')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderTiles(root, body.data.blocks.kpi.tiles); })
    .catch(function () { root.textContent = 'Не удалось загрузить блок'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// Плитки показателей: GET /dashboards/overview?blocks=kpi -> data.blocks.kpi.tiles
function renderTiles(root, tiles) {
  var grid = el('div', 'ab-tiles');
  tiles.forEach(function (t) {
    var tile = el('div', 'ab-tile');
    tile.appendChild(el('div', 'ab-label', T[t.key] || t.key));
    tile.appendChild(el('div', 'ab-value', fmt(t.current)));
    tile.appendChild(delta(t.delta_pct));
    grid.appendChild(tile);
  });
  root.appendChild(grid);
}

Линия по дням

Одна метрика по дням на встроенном SVG. Дни без данных рвут линию, а не тянут её к нулю.

GET /analytics/timeseries · право analytics:read

Линия по днямпример
Клики5 783▲ +11%
2026-09-15данные до 2026-09-282026-09-28

Вид блока по примеру ответа ниже. Цифры вымышлены.

Ответ JSON

Пример: цифры вымышлены; поля сверены с контрактом.

{
  "data": {
    "metric": "clicks",
    "from": "2026-09-15",
    "to": "2026-09-28",
    "granularity": "day",
    "agg": "sum",
    "engine": "google",
    "availability": "ok",
    "reason": null,
    "series": [
      { "date": "2026-09-15", "value": 388 },
      { "date": "2026-09-16", "value": 402 },
      { "date": "2026-09-17", "value": 371 },
      { "date": "2026-09-18", "value": 415 },
      { "date": "2026-09-19", "value": 440 },
      { "date": "2026-09-20", "value": 398 },
      { "date": "2026-09-21", "value": 352 },
      { "date": "2026-09-22", "value": 361 },
      { "date": "2026-09-23", "value": 409 },
      { "date": "2026-09-24", "value": 431 },
      { "date": "2026-09-25", "value": 452 },
      { "date": "2026-09-26", "value": 466 },
      { "date": "2026-09-27", "value": 447 },
      { "date": "2026-09-28", "value": 401 }
    ],
    "comparison": { "current": 5783, "prev": 5210, "delta_pct": 11, "data_through": "2026-09-28" },
    "summary": { "total": 5783, "latest": 401, "non_zero_days": 14 },
    "cluster_applied": false,
    "data_as_of": { "g:search_console": "2026-09-28" }
  }
}

HTML

<!-- Контейнер блока. Стили - из раздела «Общая основа»; код блока подключён выше. -->
<section class="ab" id="ab-timeseries" aria-label="Линия по дням" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-timeseries');
  // Адрес вашего прокси: ключ API остаётся на сервере (см. «Свой дашборд»)
  fetch('/analytics-api/timeseries?metric=clicks&from=2026-09-15&to=2026-09-28')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderTimeseries(root, body.data); })
    .catch(function () { root.textContent = 'Не удалось загрузить блок'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// Линия по дням: GET /analytics/timeseries (одна метрика) -> data
function renderTimeseries(root, item) {
  if (item.availability !== 'ok') { note(root, item.availability); return; }
  var vals = item.series.map(function (p) { return num(p.value); });
  var present = vals.filter(function (v) { return v != null; });
  var max = Math.max.apply(null, present.concat([1]));
  var W = 640, H = 200, step = vals.length > 1 ? W / (vals.length - 1) : 0, d = '', open = false, grid = '';
  vals.forEach(function (v, i) {
    if (v == null) { open = false; return; }          // пропуск в данных: линию не тянем через него
    d += (open ? 'L' : 'M') + (i * step).toFixed(1) + ' ' + (H - 8 - (v / max) * (H - 16)).toFixed(1);
    open = true;
  });
  for (var g = 0; g <= 4; g++) { var y = (8 + g * (H - 16) / 4).toFixed(1); grid += '<line class="ab-grid" x1="0" x2="' + W + '" y1="' + y + '" y2="' + y + '"/>'; }
  var area = present.length === vals.length ? '<path class="ab-area" d="' + d + 'L' + W + ' ' + H + 'L0 ' + H + 'Z"/>' : '';
  var head = el('div', 'ab-head');
  head.appendChild(el('strong', 'ab-title', T[item.metric] || item.metric));
  head.appendChild(el('span', 'ab-big', fmt(item.summary ? item.summary.total : null)));
  head.appendChild(delta(item.comparison.delta_pct));
  var plot = el('div', 'ab-plot');
  plot.setAttribute('role', 'img');
  plot.setAttribute('aria-label', (T[item.metric] || item.metric) + ', ' + item.from + ' – ' + item.to + ', ' + M.total + ' ' + fmt(item.summary ? item.summary.total : null));
  plot.innerHTML = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" aria-hidden="true">' + grid + area + '<path class="ab-line" d="' + d + '"/></svg>';
  var axis = el('div', 'ab-axis');
  axis.appendChild(el('span', '', item.series[0].date));
  axis.appendChild(el('span', '', M.through + ' ' + item.comparison.data_through));
  axis.appendChild(el('span', '', item.series[item.series.length - 1].date));
  root.appendChild(head); root.appendChild(plot); root.appendChild(axis);
}

Chart.js

Только код: на этой странице Chart.js не подключён. Ставится из npm.

// npm install chart.js
import Chart from 'chart.js/auto';
// num() - из кода блоков (значение API: число, деньги или null). null рисуется разрывом линии, а не нулём.

export function chartTimeseries(canvas, item) {
  return new Chart(canvas, {
    type: 'line',
    data: {
      labels: item.series.map(function (p) { return p.date; }),
      datasets: [{
        label: item.metric,
        data: item.series.map(function (p) { return num(p.value); }),
        borderColor: '#2d6a4f', backgroundColor: 'rgba(45, 106, 79, 0.14)',
        fill: true, tension: 0.25, spanGaps: false, pointRadius: 2
      }]
    },
    options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
  });
}

Источники трафика

Столбики по источникам: сессии и лиды. Поисковые, ИИ-ассистенты, соцсети, реклама и прямые заходы.

GET /analytics/traffic-sources · право analytics:read

Источники трафикапример
Google
820лиды: 9
Прямые заходы
410лиды: 2
Яндекс
260лиды: 3
ChatGPT
190лиды: 4
Соцсети
96лиды: 1

Вид блока по примеру ответа ниже. Цифры вымышлены.

Ответ JSON

Пример: цифры вымышлены; поля сверены с контрактом.

{
  "data": [
    { "source": "google", "kind": "search", "sessions": 820, "visitors": 640, "leads": 9 },
    { "source": "direct", "kind": "direct", "sessions": 410, "visitors": 330, "leads": 2 },
    { "source": "yandex", "kind": "search", "sessions": 260, "visitors": 214, "leads": 3 },
    {
      "source": "chatgpt",
      "kind": "ai_assistant",
      "sessions": 190,
      "visitors": 150,
      "leads": 4
    },
    { "source": "social", "kind": "social", "sessions": 96, "visitors": 88, "leads": 1 }
  ],
  "data_as_of": { "tracker": "2026-10-07" }
}

HTML

<!-- Контейнер блока. Стили - из раздела «Общая основа»; код блока подключён выше. -->
<section class="ab" id="ab-traffic-sources" aria-label="Источники трафика" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-traffic-sources');
  // Адрес вашего прокси: ключ API остаётся на сервере (см. «Свой дашборд»)
  fetch('/analytics-api/traffic-sources?from=2026-09-15&to=2026-09-28')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderSources(root, body.data); })
    .catch(function () { root.textContent = 'Не удалось загрузить блок'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// Столбики источников: GET /analytics/traffic-sources -> data (массив)
function renderSources(root, rows) {
  if (!rows.length) { note(root, 'no_data'); return; }
  var max = Math.max.apply(null, rows.map(function (r) { return r.sessions; }));
  var list = el('div', 'ab-rows');
  rows.forEach(function (r) {
    var row = el('div', 'ab-row');
    row.appendChild(el('span', 'ab-name', T[r.source] || r.source));
    row.appendChild(bar(r.sessions / max * 100, 'ab-fill ab-k-' + esc(r.kind)));
    var nums = el('span', 'ab-num');
    nums.appendChild(el('strong', '', fmt(r.sessions)));
    nums.appendChild(el('span', 'ab-sub', M.leads + ': ' + fmt(r.leads)));
    row.appendChild(nums);
    list.appendChild(row);
  });
  root.appendChild(list);
}

Chart.js

Только код: на этой странице Chart.js не подключён. Ставится из npm.

// npm install chart.js
import Chart from 'chart.js/auto';
// num() - из кода блоков (значение API: число, деньги или null). null рисуется разрывом линии, а не нулём.

export function chartSources(canvas, rows) {
  return new Chart(canvas, {
    type: 'bar',
    data: {
      labels: rows.map(function (r) { return r.source; }),
      datasets: [
        { label: 'sessions', data: rows.map(function (r) { return r.sessions; }), backgroundColor: '#2d6a4f' },
        { label: 'leads', data: rows.map(function (r) { return r.leads; }), backgroundColor: '#c9a96e' }
      ]
    },
    options: { indexAxis: 'y', responsive: true, scales: { x: { beginAtZero: true } } }
  });
}

Воронка

Шаги от показов до выигранных сделок. Долю от предыдущего шага считает клиент; шаг без данных помечен.

GET /analytics/funnel · право analytics:read

Воронкапример
Показы
182 400▲ +6,1%
Клики
4 2102,3% от предыдущего шага
Сессии
3 92093,1% от предыдущего шага
Лиды
1122,9% от предыдущего шага
Сделки созданы
—Нет данных за период
Сделки выиграны
1715,2% от предыдущего шага
Выручка
524 000 ₽▲ +27,8%

Вид блока по примеру ответа ниже. Цифры вымышлены.

Ответ JSON

Пример: цифры вымышлены; поля сверены с контрактом.

{
  "data": {
    "engine": "google",
    "from": "2026-10-01",
    "to": "2026-10-28",
    "steps": [
      {
        "key": "impressions",
        "availability": "ok",
        "sources": ["g:search_console"],
        "unit": "count",
        "current": 182400,
        "prev": 171900,
        "delta_pct": 6.1,
        "data_through": "2026-10-25"
      },
      {
        "key": "clicks",
        "availability": "ok",
        "sources": ["g:search_console"],
        "unit": "count",
        "current": 4210,
        "prev": 3880,
        "delta_pct": 8.5,
        "data_through": "2026-10-25"
      },
      {
        "key": "sessions",
        "availability": "ok",
        "sources": ["tracker"],
        "unit": "count",
        "current": 3920,
        "prev": 3601,
        "delta_pct": 8.9,
        "data_through": "2026-10-28"
      },
      {
        "key": "leads",
        "availability": "ok",
        "sources": ["crm"],
        "unit": "count",
        "current": 112,
        "prev": 94,
        "delta_pct": 19.1,
        "data_through": "2026-10-28"
      },
      {
        "key": "deals_created",
        "availability": "no_data",
        "sources": ["crm"],
        "unit": "count",
        "current": null,
        "prev": null,
        "delta_pct": null,
        "data_through": null
      },
      {
        "key": "deals_won",
        "availability": "ok",
        "sources": ["crm"],
        "unit": "count",
        "current": 17,
        "prev": 12,
        "delta_pct": 41.7,
        "data_through": "2026-10-28"
      },
      {
        "key": "revenue",
        "availability": "ok",
        "sources": ["crm"],
        "unit": "money",
        "current": { "amount": "524000.00", "currency": "RUB" },
        "prev": { "amount": "410000.00", "currency": "RUB" },
        "delta_pct": 27.8,
        "data_through": "2026-10-28"
      }
    ],
    "by_channel": [],
    "by_product": [],
    "crm": { "availability": "ok" }
  },
  "data_as_of": { "g:search_console": "2026-10-25", "crm": "2026-10-28" }
}

HTML

<!-- Контейнер блока. Стили - из раздела «Общая основа»; код блока подключён выше. -->
<section class="ab" id="ab-funnel" aria-label="Воронка" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-funnel');
  // Адрес вашего прокси: ключ API остаётся на сервере (см. «Свой дашборд»)
  fetch('/analytics-api/funnel?from=2026-09-15&to=2026-09-28')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderFunnel(root, body.data); })
    .catch(function () { root.textContent = 'Не удалось загрузить блок'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// Воронка: GET /analytics/funnel -> data. Доли шагов считает клиент.
function renderFunnel(root, data) {
  var list = el('div', 'ab-rows'), prev = null;
  data.steps.forEach(function (s) {
    var row = el('div', 'ab-row');
    var ok = s.availability === 'ok';
    row.appendChild(el('span', 'ab-name', T[s.key] || s.key));
    var cur = ok ? num(s.current) : null;
    if (ok && s.unit === 'count') {
      var rate = prev ? cur / prev * 100 : 100;      // доля от предыдущего шага с данными
      row.appendChild(bar(rate, 'ab-fill ab-k-search'));
    } else {
      row.appendChild(el('div', 'ab-bar ab-bar-none'));
    }
    var nums = el('span', 'ab-num');
    nums.appendChild(el('strong', '', ok ? fmt(s.current) : M.dash));
    if (!ok) nums.appendChild(el('span', 'ab-sub', M[s.availability] || M.no_data));
    else if (s.unit === 'count' && prev) nums.appendChild(el('span', 'ab-sub', fmt(cur / prev * 100) + '% ' + M.prevStep));
    else nums.appendChild(delta(s.delta_pct));
    row.appendChild(nums);
    list.appendChild(row);
    if (ok && s.unit === 'count' && cur > 0) prev = cur;
  });
  root.appendChild(list);
}

Chart.js

Только код: на этой странице Chart.js не подключён. Ставится из npm.

// npm install chart.js
import Chart from 'chart.js/auto';
// num() - из кода блоков (значение API: число, деньги или null). null рисуется разрывом линии, а не нулём.

export function chartFunnel(canvas, data) {
  // Только шаги-счётчики с данными: деньги и недоступные шаги в одну шкалу не кладут.
  var steps = data.steps.filter(function (s) { return s.availability === 'ok' && s.unit === 'count'; });
  return new Chart(canvas, {
    type: 'bar',
    data: {
      labels: steps.map(function (s) { return s.key; }),
      datasets: [{ label: 'count', data: steps.map(function (s) { return num(s.current); }), backgroundColor: '#2d6a4f' }]
    },
    options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false } }, scales: { x: { type: 'logarithmic' } } }
  });
}

Видимость

Пять разделов, у каждого своя доступность: у раздела может не быть данных или источника, и блок это показывает.

GET /analytics/visibility · право analytics:read

Видимостьпример
Доля видимости
14,2%
▲ +10,1%
Цитирование в ответах ИИ
10,4%
▲ +15,6%

Позиции запросов

  • Топ-318
  • Топ-1074
  • Топ-30210
  • Ниже530

Вид блока по примеру ответа ниже. Цифры вымышлены.

Ответ JSON

Пример: цифры вымышлены; поля сверены с контрактом.

{
  "data": {
    "from": "2026-10-01",
    "to": "2026-10-28",
    "engine": "google",
    "share": {
      "availability": "ok",
      "source": "g:search_console",
      "data_as_of": { "g:search_console": "2026-10-25" },
      "data": {
        "value": 14.2,
        "comparison": { "current": 14.2, "prev": 12.9, "delta_pct": 10.1 },
        "keywords": 312,
        "volume_total": 48200
      }
    },
    "distribution": {
      "availability": "ok",
      "source": "g:search_console",
      "data_as_of": { "g:search_console": "2026-10-25" },
      "data": {
        "top3": 18,
        "top10": 74,
        "top30": 210,
        "outside": 530,
        "total": 832,
        "median": 21.5,
        "average": 29.3
      }
    },
    "aeo": {
      "availability": "ok",
      "source": "aeo",
      "data_as_of": { "aeo": "2026-10-27" },
      "data": {
        "engines": [
          { "engine": "perplexity", "probes": 24, "cited": 3, "rate": 12.5 },
          { "engine": "chatgpt", "probes": 24, "cited": 2, "rate": 8.3 }
        ],
        "probes": 48,
        "cited": 5,
        "rate": 10.4,
        "comparison": { "current": 10.4, "prev": 9, "delta_pct": 15.6 }
      }
    },
    "regions": {
      "availability": "not_connected",
      "reason": "no_country_dimension",
      "source": "g:search_console",
      "data_as_of": {},
      "data": { "rows": [] }
    }
  }
}

HTML

<!-- Контейнер блока. Стили - из раздела «Общая основа»; код блока подключён выше. -->
<section class="ab" id="ab-visibility" aria-label="Видимость" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-visibility');
  // Адрес вашего прокси: ключ API остаётся на сервере (см. «Свой дашборд»)
  fetch('/analytics-api/visibility?from=2026-09-15&to=2026-09-28')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderVisibility(root, body.data); })
    .catch(function () { root.textContent = 'Не удалось загрузить блок'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// Видимость: GET /analytics/visibility -> data. Пять разделов, у каждого своя доступность.
function renderVisibility(root, v) {
  var s = v.share, dist = v.distribution, aeo = v.aeo;
  var box = el('div', 'ab-tiles');
  var a = el('div', 'ab-tile');
  a.appendChild(el('div', 'ab-label', M.share));
  if (s.availability === 'ok' && s.data.value != null) {
    a.appendChild(el('div', 'ab-value', fmt(s.data.value) + '%'));
    a.appendChild(delta(s.data.comparison ? s.data.comparison.delta_pct : null));
  } else { a.appendChild(el('div', 'ab-value', M.dash)); a.appendChild(el('span', 'ab-delta', M[s.availability] || M.no_data)); }
  box.appendChild(a);
  var b = el('div', 'ab-tile');
  b.appendChild(el('div', 'ab-label', M.aeo));
  if (aeo.availability === 'ok') {
    b.appendChild(el('div', 'ab-value', fmt(aeo.data.rate) + '%'));
    b.appendChild(delta(aeo.data.comparison ? aeo.data.comparison.delta_pct : null));
  } else { b.appendChild(el('div', 'ab-value', M.dash)); b.appendChild(el('span', 'ab-delta', M[aeo.availability] || M.no_data)); }
  box.appendChild(b);
  root.appendChild(box);
  root.appendChild(el('h4', 'ab-sub-title', M.spread));
  if (dist.availability !== 'ok') { note(root, dist.availability); return; }
  var parts = ['top3', 'top10', 'top30', 'outside'], x = 0, rects = '';
  parts.forEach(function (k, i) {
    var w = dist.data[k] / dist.data.total * 100;
    rects += '<rect class="ab-d' + (i + 1) + '" x="' + x.toFixed(1) + '" width="' + w.toFixed(1) + '" height="14"/>';
    x += w;
  });
  var stack = el('div', 'ab-stack');
  stack.innerHTML = '<svg viewBox="0 0 100 14" preserveAspectRatio="none" aria-hidden="true">' + rects + '</svg>';
  root.appendChild(stack);
  var legend = el('ul', 'ab-legend');
  parts.forEach(function (k, i) {
    var li = el('li', 'ab-d' + (i + 1) + '-key');
    li.appendChild(el('span', '', M[k]));
    li.appendChild(el('strong', '', fmt(dist.data[k])));
    legend.appendChild(li);
  });
  root.appendChild(legend);
}

Chart.js

Только код: на этой странице Chart.js не подключён. Ставится из npm.

// npm install chart.js
import Chart from 'chart.js/auto';
// num() - из кода блоков (значение API: число, деньги или null). null рисуется разрывом линии, а не нулём.

export function chartVisibility(canvas, dist) {
  var d = dist.data;   // data.distribution.data: top3, top10, top30, outside (непересекающиеся, в сумме total)
  return new Chart(canvas, {
    type: 'doughnut',
    data: {
      labels: ['top3', 'top10', 'top30', 'outside'],
      datasets: [{ data: [d.top3, d.top10, d.top30, d.outside], backgroundColor: ['#1a3c2a', '#2d6a4f', '#7fb89b', '#cfd8d2'] }]
    },
    options: { responsive: true, cutout: '62%' }
  });
}

Находки «Пульса»

Короткий список того, что заметили за последние дни: что выросло, что упало и что стоит проверить.

GET /dashboards/pulse · право dashboards:read

Находки «Пульса»пример

Новых за 7 дней: 2

  • Ниацинамид и пористостьниацинамид для пор · спрос 3 400×2,4
  • Пептиды в сывороткахпептидная сыворотка состав · спрос 1 900—

Вид блока по примеру ответа ниже. Цифры вымышлены.

Ответ JSON

Пример: цифры вымышлены; поля сверены с контрактом.

{
  "data": {
    "period": { "from": "2026-09-13", "to": "2026-10-10" },
    "new_7d": 2,
    "truncated": false,
    "alerts": [
      {
        "id": "alert_EXAMPLE_01",
        "topic_id": "topic_EXAMPLE_01",
        "topic_name": "Ниацинамид и пористость",
        "keyword": "ниацинамид для пор",
        "horizon": "short",
        "status": "open",
        "episode_start": "2026-10-04",
        "last_seen": "2026-10-10",
        "score": 0.82,
        "volume": 3400,
        "growth": 2.4,
        "sources": [{ "code": "serp_suggest", "outcome": "spike", "value": 120, "base": 50 }],
        "strategist_topic": { "id": "tpc_EXAMPLE_01", "status": "proposed" }
      },
      {
        "id": "alert_EXAMPLE_02",
        "topic_id": "topic_EXAMPLE_02",
        "topic_name": "Пептиды в сыворотках",
        "keyword": "пептидная сыворотка состав",
        "horizon": "mid",
        "status": "open",
        "episode_start": "2026-10-06",
        "last_seen": "2026-10-10",
        "score": 0.64,
        "volume": 1900,
        "growth": null,
        "sources": [{ "code": "serp_suggest", "outcome": "spike", "value": 40, "base": 0 }],
        "strategist_topic": null
      }
    ],
    "data_as_of": { "pulse": "2026-10-10" }
  }
}

HTML

<!-- Контейнер блока. Стили - из раздела «Общая основа»; код блока подключён выше. -->
<section class="ab" id="ab-pulse" aria-label="Находки «Пульса»" aria-busy="true"></section>
<script src="blocks.js"></script>
<script>
  var root = document.getElementById('ab-pulse');
  // Адрес вашего прокси: ключ API остаётся на сервере (см. «Свой дашборд»)
  fetch('/analytics-api/pulse')
    .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
    .then(function (body) { renderPulse(root, body.data); })
    .catch(function () { root.textContent = 'Не удалось загрузить блок'; })
    .then(function () { root.removeAttribute('aria-busy'); });
</script>

JS

// Находки "Пульса": GET /dashboards/pulse -> data
function renderPulse(root, data) {
  if (!data.alerts.length) { note(root, 'no_data'); return; }
  root.appendChild(el('p', 'ab-sub', M.new7 + ': ' + fmt(data.new_7d)));
  var list = el('ul', 'ab-list');
  data.alerts.forEach(function (a) {
    var li = el('li', 'ab-item');
    var text = el('span', 'ab-item-text');
    text.appendChild(el('strong', '', a.topic_name || a.keyword));
    text.appendChild(el('span', 'ab-sub', a.keyword + ' · ' + M.volume + ' ' + fmt(a.volume)));
    li.appendChild(text);
    li.appendChild(el('span', 'ab-chip', a.growth == null ? M.dash : '×' + fmt(a.growth)));
    list.appendChild(li);
  });
  root.appendChild(list);
}

Дальше

Соберите блоки в страницу с серверным прокси - свой дашборд. Поля и права - в описании дашбордов.