Заметки и сниппеты

Личная коллекция команд и запросов

PostgreSQL / Supabase

Колонки таблицы одной строкой

Список колонок через запятую — для копирования в SELECT, INSERT или быстрой инспекции схемы.

SQL
SELECT string_agg(column_name, ', ' ORDER BY ordinal_position) AS columns
FROM information_schema.columns
WHERE table_schema = 'public'
  AND table_name = 'workflow_costs';

Перед кликом «Copy» скопируйте имя нужной таблицы — оно подставится автоматически.

Полная структура таблицы

Имя, тип, nullable и default для каждой колонки. Используется при дебаге миграций и проверке после ALTER TABLE.

SQL
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public'
  AND table_name = 'llm_generated_articles'
ORDER BY ordinal_position;

Атомарная архивация статьи

Удаление из основной таблицы с одновременной вставкой в архив. Атомарно — без промежуточных состояний.

SQL
WITH deleted AS (
  DELETE FROM llm_generated_articles
  WHERE article_id = 'ARTICLE_UUID'
    AND batch_id = 'BATCH_ID'
  RETURNING *
)
INSERT INTO llm_generated_articles_archive
SELECT * FROM deleted;

Перед копированием положите в буфер UUID статьи — он подставится в article_id. batch_id отредактируйте после вставки в SQL Editor.

Восстановление статьи из архива

Обратная операция — переносит запись из _archive назад в основную таблицу.

SQL
WITH restored AS (
  DELETE FROM llm_generated_articles_archive
  WHERE article_id = 'ARTICLE_UUID'
    AND batch_id = 'BATCH_ID'
  RETURNING *
)
INSERT INTO llm_generated_articles
SELECT * FROM restored;

Стоимость и длительность последнего запуска

Агрегат по последнему run_id в llm_usage_log: длительность, число вызовов, токены, USD.

SQL
SELECT
  run_id,
  MIN(created_at) AS начало,
  MAX(created_at) AS конец,
  MAX(created_at)::timestamp - MIN(created_at)::timestamp AS длительность,
  COUNT(*)        AS вызовов_llm,
  SUM(input_tokens)        AS input_tok,
  SUM(output_tokens)       AS output_tok,
  ROUND(SUM(estimated_cost_usd)::numeric, 4) AS стоимость_usd
FROM llm_usage_log
WHERE run_id = (
  SELECT run_id FROM llm_usage_log
  ORDER BY created_at DESC
  LIMIT 1
)
GROUP BY run_id;

Полная сумма за сегодня

Сводка по всем запускам с начала суток — завершённым и незавершённым: общий cost, токены, число уникальных run_id и LLM-вызовов.

SQL
SELECT
  ROUND(SUM(estimated_cost_usd)::numeric, 4) AS total_cost_usd,
  SUM(input_tokens)                          AS total_in_tok,
  SUM(output_tokens)                         AS total_out_tok,
  COUNT(DISTINCT run_id)                     AS distinct_runs,
  COUNT(*)                                   AS total_llm_calls
FROM llm_usage_log
WHERE created_at >= CURRENT_DATE;

CURRENT_DATE — полночь по таймзоне Postgres-сессии. Если БД в UTC, «сегодня» отсчитывается с 00:00 UTC.

PowerShell

Функции Push-Site и Edit-Site в профиль

Запись в $PROFILE, чтобы любой статический сайт из ~/Sites/<name>/ заливался одной командой Push-Site <name>.

PowerShell
function Push-Site {
    param([Parameter(Mandatory)][string]$Name)
    $local = "$HOME\Sites\$Name\index.html"
    if (-not (Test-Path $local)) {
        Write-Host "Файл не найден: $local" -ForegroundColor Red
        return
    }
    scp $local "n8n:/opt/static/$Name/index.html"
    if ($LASTEXITCODE -eq 0) {
        Write-Host "✓ https://$Name.peniaze-ai.work" -ForegroundColor Green
    }
}

function Edit-Site {
    param([Parameter(Mandatory)][string]$Name)
    notepad "$HOME\Sites\$Name\index.html"
}

Цикл правок: Edit-Site notes → правите → сохраняете → Push-Site notesCtrl+F5 в браузере.

Сервер (n8n / Caddy)

Перезагрузка Caddy после правки конфига

Без рестарта контейнера. Выполнять после редактирования /opt/n8n/Caddyfile.

Bash
docker exec n8n-caddy-1 caddy reload --config /etc/caddy/Caddyfile

Поиск в выводе Windows Terminal

Поиск по буферу в Windows Terminal

Ctrl + Shift + F — открывает строку поиска. Вводите UA='Mozilla (или любую уникальную часть), Enter — терминал перематывает к найденному месту и подсвечивает совпадение. Стрелки / — следующее/предыдущее вхождение.

Дальше выделяете мышью с того места, где курсор остановился, до конца нужного фрагмента, и Ctrl + Shift + C — копирует выделенное.

Если используете старый conhost — там Ctrl + F в Mark mode, аналогично.

Запись сессии в файл с поиском по контексту

Для длинных диагностических сессий — пишем всё в файл, потом ищем с захватом N строк после маркера.

PowerShell
# Записать всю сессию в файл
Start-Transcript -Path "$HOME\Downloads\diag-$(Get-Date -Format 'yyyyMMdd-HHmm').log"

# ... ваши ssh-команды ...

Stop-Transcript

# Затем искать в файле:
Select-String -Path "$HOME\Downloads\diag-*.log" -Pattern "UA='Mozilla" -Context 0,200

-Context 0,200 — совпадение + 200 строк после.

Разовый SSH с записью вывода в файл

Команды из bash-скрипта пайпятся через ssh, вывод одновременно идёт на экран и в лог.

PowerShell
ssh n8n 'bash -s' < commands.sh `
  | Tee-Object -FilePath "$HOME\Downloads\out.log"

Telegram-экспорт (JS в консоли)

Сбор сообщений и навигационных меню из веб-версии Telegram в JSON, Markdown и CSV. Текущая версия — V7.6.1: архив между заходами, прямые ссылки на сообщения и каталог вложений. Источник и история правок — исходный чат ChatGPT.

Telegram Collector V7.6.1 — полный код

Вставить целиком в консоль браузера на веб-версии Telegram. Сохраняет все сообщения, вытаскивает дату и время публикации, восстанавливает название канала по chat_id и — с версии 7.3 — копит записи в localStorage отдельно по каждому чату, поэтому повторные заходы дополняют архив, а не начинают с нуля. В 7.5 добавились прямой адрес каждого сообщения (t.me/c/чат/номер) и имя файла-вложения, в 7.6 — сообщения-файлы без подписи, которые прошлая версия пропускала целиком; 7.6.1 чинит падение на таких сообщениях.

JavaScript
(() => {
  // ============================================================
  // TELEGRAM COLLECTOR V7
  // ============================================================
  //
  // Новое относительно V6:
  //
  // 1. Сохраняет ВСЕ сообщения, а не только сообщения со ссылками.
  // 2. Собирает дату публикации:
  //      published_date: "2026-08-01"
  //
  // 3. Собирает дату + время:
  //      published_at: "2026-08-01 16:46"
  //
  // 4. Понимает разделители:
  //      1 августа
  //      1 августа 2026
  //      Сегодня
  //      Вчера
  //
  // 5. Восстанавливает название канала по Telegram chat_id.
  //
  // 6. Не использует cloneNode().
  // 7. Не загружает blob:-ресурсы.
  // 8. Не изменяет DOM Telegram.
  // 9. Работает с виртуализированным скроллингом.
  //
  // ============================================================

  // ------------------------------------------------------------
  // STOP PREVIOUS VERSION
  // ------------------------------------------------------------

  try {
    window.TG_EXPORT?.observer?.disconnect?.();
    clearInterval(window.TG_EXPORT?.safetyTimer);
    clearTimeout(window.TG_EXPORT?.scanTimer);
  } catch (e) {}

  // ------------------------------------------------------------
  // STATE
  // ------------------------------------------------------------

  const S = {
    version: '7.6.1',

    records: new Map(),
    links: new Map(),

    observer: null,
    safetyTimer: null,
    scanTimer: null,

    scans: 0,
    mutations: 0,

    startedAt: new Date()
  };

  window.TG_EXPORT = S;

  // ============================================================
  // TEXT HELPERS
  // ============================================================

  function clean(text = '') {
    return String(text)
      .replace(/\u00A0/g, ' ')
      .replace(/\r/g, '')
      .replace(/[ \t]+/g, ' ')
      .replace(/[ \t]*\n[ \t]*/g, '\n')
      .replace(/\n{3,}/g, '\n\n')
      .trim();
  }

  function oneLine(text = '') {
    return clean(text)
      .replace(/\n+/g, ' ')
      .replace(/\s+/g, ' ')
      .trim();
  }

  // ------------------------------------------------------------
  // Читаем DOM напрямую.
  //
  // Telegram emoji:
  //
  // <img class="emoji" alt="🧩">
  //
  // превращается обратно в Unicode emoji.
  // ------------------------------------------------------------

  function readNode(node) {
    if (!node) return '';

    if (node.nodeType === Node.TEXT_NODE) {
      return node.nodeValue || '';
    }

    if (node.nodeType !== Node.ELEMENT_NODE) {
      return '';
    }

    const el = node;

    if (el.tagName === 'BR') {
      return '\n';
    }

    if (el.tagName === 'IMG') {
      return el.getAttribute('alt') || '';
    }

    let out = '';

    for (const child of el.childNodes) {
      out += readNode(child);
    }

    return out;
  }

  function textOf(el) {
    return clean(readNode(el));
  }

  function oneLineOf(el) {
    return oneLine(readNode(el));
  }

  // ============================================================
  // CHANNEL NORMALIZATION
  // ============================================================

  function normalizeChannel(name = '') {
    let s = oneLine(name);

    if (!s) return '';

    // Ошибка предыдущих версий:
    //
    // [SW AI] Navigation[SW AI] AIWARI
    //
    // →
    //
    // [SW AI] AIWARI

    s = s.replace(
      /^\[SW AI\]\s*Navigation\s*/i,
      ''
    );

    // [SW] AIWARI
    // →
    // [SW AI] AIWARI

    s = s.replace(
      /^\[SW\]\s*/i,
      '[SW AI] '
    );

    // SW AI AIWARI
    // SW   AI   AIWARI
    // [SW AI] AIWARI
    //
    // →
    //
    // [SW AI] AIWARI

    if (/^\[?\s*SW\s+AI\s*\]?/i.test(s)) {
      s = s.replace(
        /^\[?\s*SW\s+AI\s*\]?\s*/i,
        '[SW AI] '
      );
    }

    return oneLine(s);
  }

  function isNavigationLabel(text = '') {
    return /^\[?\s*SW\s+AI\s*\]?\s*Navigation$/i
      .test(oneLine(text));
  }

  function looksLikeChannel(text = '') {
    const s = oneLine(text);

    if (!s) return false;

    if (isNavigationLabel(s)) {
      return false;
    }

    return /^\[?\s*SW\s+AI\s*\]?/i.test(s);
  }

  // ------------------------------------------------------------
  // V7.1 — ЧИСТОТА ИМЕНИ КАНАЛА
  //
  // Баг V6/V7: в channel попадал текст сообщения, приклеенный
  // к имени канала без пробела:
  //   "[SW AI] AIWARIПоделюсь хостингами, которыми..."
  //
  // Отделяем ведущий тег [..] и проверяем остаток.
  // ------------------------------------------------------------

  function splitChannelTag(name = '') {
    const s = oneLine(name);

    const m = s.match(
      /^(\[[^\]]{1,20}\]\s*)?([\s\S]*)$/
    );

    return {
      tag: (m && m[1]) || '',
      rest: (m && m[2]) || ''
    };
  }

  function looksLikeCleanChannel(name = '') {
    const s = oneLine(name);

    if (!s || s.length > 40) return false;

    const { rest } = splitChannelTag(s);

    if (!rest) return false;

    // знаки препинания из тела сообщения
    if (/[.,!?;:…«»"/\\]/.test(rest)) return false;

    // склеенный таймстамп "20:59"
    if (/\d{1,2}:\d{2}/.test(rest)) return false;

    // второй тег внутри имени
    if (/[[\]]/.test(rest)) return false;

    // "AIWARIПоделюсь" / "AIWARIhttps"
    if (/[A-ZА-ЯЁ]{2,}[a-zа-яё]/.test(rest)) return false;
    if (/[A-ZА-ЯЁ]{2,}[A-ZА-ЯЁ][a-zа-яё]/.test(rest)) return false;

    return true;
  }

  // ============================================================
  // TELEGRAM CHAT ID
  // ============================================================

  function getChatIdFromUrl(url = '') {
    const m = String(url).match(
      /https?:\/\/t\.me\/c\/(\d+)/
    );

    return m ? m[1] : '';
  }

  function getMessageIdFromUrl(url = '') {
    const m = String(url).match(
      /https?:\/\/t\.me\/c\/\d+\/(?:\d+\/)*(\d+)/
    );

    return m ? m[1] : '';
  }

  // ------------------------------------------------------------
  // V7.1 — chat_id ОТКРЫТОГО ЧАТА
  //
  // V7 брал chat_id только из ссылок t.me/c/<id> ВНУТРИ сообщения.
  // Такие ссылки есть у единиц сообщений, поэтому маппинг почти
  // не работал. Читаем id прямо из адресной строки.
  //
  // web.telegram.org/k/#-1003760061495  ->  3760061495
  // (совпадает с форматом t.me/c/3760061495)
  // ------------------------------------------------------------

  function getCurrentChatId() {
    let h = '';

    try {
      h = String(
        location.hash || ''
      ).replace(/^#/, '').trim();
    } catch (e) {
      return '';
    }

    if (!h) return '';

    // приватный канал: -1003760061495
    let m = h.match(/^-?100(\d{5,})$/);
    if (m) return m[1];

    // обычный числовой id
    m = h.match(/^-?(\d{5,})$/);
    if (m) return m[1];

    // @username
    m = h.match(/^@([A-Za-z0-9_]{3,})$/);
    if (m) return '@' + m[1].toLowerCase();

    return '';
  }

  // ------------------------------------------------------------
  // V7.2 — chat_id ИЗ ПУЗЫРЯ
  //
  // <div class="bubble" data-peer-id="-3760061495">
  // ------------------------------------------------------------

  function normalizePeerId(raw = '') {
    const v = String(raw).trim();

    let m = v.match(/^-?100(\d{5,})$/);
    if (m) return m[1];

    m = v.match(/^-?(\d{5,})$/);
    if (m) return m[1];

    return '';
  }

  // ------------------------------------------------------------
  // V7.2 — ТОЧНОЕ ВРЕМЯ ИЗ data-timestamp
  //
  // <div class="bubble" data-timestamp="1787652539">
  //
  // Заменяет разбор разделителей дат и span.time:
  // ни промахов на длительности голосовых, ни зависимости
  // от того, отрисован ли разделитель при виртуализации.
  // ------------------------------------------------------------

  function readBubbleTimestamp(bubble) {
    if (!bubble || !bubble.getAttribute) {
      return null;
    }

    const raw =
      bubble.getAttribute(
        'data-timestamp'
      );

    if (!raw) return null;

    const n = Number(raw);

    if (
      !Number.isFinite(n) ||
      n < 1000000000 ||
      n > 4000000000
    ) {
      return null;
    }

    const d = new Date(n * 1000);

    return {
      date: localDateISO(d),

      time:
        pad2(d.getHours()) + ':' +
        pad2(d.getMinutes()),

      epoch: n
    };
  }

  function dominantChatId(links = []) {
    const counts = new Map();

    for (const link of links) {
      const id = getChatIdFromUrl(link.url);

      if (!id) continue;

      counts.set(
        id,
        (counts.get(id) || 0) + 1
      );
    }

    if (!counts.size) {
      return '';
    }

    return [...counts.entries()]
      .sort((a, b) => b[1] - a[1])[0][0];
  }

  // ============================================================
  // DATE PARSER
  // ============================================================

  const MONTHS_RU = {
    'января': 1,
    'февраля': 2,
    'марта': 3,
    'апреля': 4,
    'мая': 5,
    'июня': 6,
    'июля': 7,
    'августа': 8,
    'сентября': 9,
    'октября': 10,
    'ноября': 11,
    'декабря': 12
  };

  const MONTHS_EN = {
    january: 1,
    february: 2,
    march: 3,
    april: 4,
    may: 5,
    june: 6,
    july: 7,
    august: 8,
    september: 9,
    october: 10,
    november: 11,
    december: 12
  };

  function pad2(n) {
    return String(n).padStart(2, '0');
  }

  function localDateISO(date) {
    return (
      `${date.getFullYear()}-` +
      `${pad2(date.getMonth() + 1)}-` +
      `${pad2(date.getDate())}`
    );
  }

  function parseDateSeparator(text = '') {
    let s = oneLine(text)
      .toLowerCase()
      .replace(',', '')
      .trim();

    if (!s) return null;

    // ----------------------------------------------------------
    // Сегодня
    // ----------------------------------------------------------

    if (
      s === 'сегодня' ||
      s === 'today'
    ) {
      const d = new Date();

      return {
        iso: localDateISO(d),
        source: text
      };
    }

    // ----------------------------------------------------------
    // Вчера
    // ----------------------------------------------------------

    if (
      s === 'вчера' ||
      s === 'yesterday'
    ) {
      const d = new Date();

      d.setDate(
        d.getDate() - 1
      );

      return {
        iso: localDateISO(d),
        source: text
      };
    }

    // ----------------------------------------------------------
    // DD.MM.YYYY
    // DD.MM.YY
    // ----------------------------------------------------------

    let m = s.match(
      /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/
    );

    if (m) {
      let day = Number(m[1]);
      let month = Number(m[2]);
      let year = Number(m[3]);

      if (year < 100) {
        year += 2000;
      }

      const d =
        new Date(year, month - 1, day);

      if (
        d.getFullYear() === year &&
        d.getMonth() === month - 1 &&
        d.getDate() === day
      ) {
        return {
          iso:
            `${year}-` +
            `${pad2(month)}-` +
            `${pad2(day)}`,

          source: text
        };
      }
    }

    // ----------------------------------------------------------
    // 1 августа
    // 1 августа 2026
    // ----------------------------------------------------------

    m = s.match(
      /^(\d{1,2})\s+([а-яё]+)(?:\s+(\d{4}))?$/
    );

    if (m) {
      const day = Number(m[1]);

      const month =
        MONTHS_RU[m[2]];

      if (month) {
        let year =
          m[3]
            ? Number(m[3])
            : new Date().getFullYear();

        /*
          Telegram иногда не показывает год.

          Если сейчас январь, а на экране декабрь,
          скорее всего это прошлый год.
        */

        const now = new Date();

        if (
          !m[3] &&
          month > now.getMonth() + 1 + 6
        ) {
          year--;
        }

        const d =
          new Date(year, month - 1, day);

        if (
          d.getMonth() === month - 1 &&
          d.getDate() === day
        ) {
          return {
            iso:
              `${year}-` +
              `${pad2(month)}-` +
              `${pad2(day)}`,

            source: text
          };
        }
      }
    }

    // ----------------------------------------------------------
    // August 1
    // August 1 2026
    // ----------------------------------------------------------

    m = s.match(
      /^([a-z]+)\s+(\d{1,2})(?:\s+(\d{4}))?$/
    );

    if (m) {
      const month =
        MONTHS_EN[m[1]];

      const day =
        Number(m[2]);

      if (month) {
        let year =
          m[3]
            ? Number(m[3])
            : new Date().getFullYear();

        return {
          iso:
            `${year}-` +
            `${pad2(month)}-` +
            `${pad2(day)}`,

          source: text
        };
      }
    }

    return null;
  }

  // ============================================================
  // TIME PARSER
  // ============================================================

  function parseTime(text = '') {
    const m = oneLine(text).match(
      /\b([01]?\d|2[0-3]):([0-5]\d)\b/
    );

    if (!m) {
      return '';
    }

    return (
      `${pad2(Number(m[1]))}:` +
      `${m[2]}`
    );
  }

  // ============================================================
  // FIND TELEGRAM MESSAGE CONTAINER
  // ============================================================

  function getMessageContainer(message) {
    if (
      !message ||
      !message.nodeType
    ) {
      return null;
    }

    let el = message;

    for (
      let level = 0;
      level < 8 && el;
      level++
    ) {
      if (
        el.matches?.(
          '[data-mid], ' +
          '[data-message-id], ' +
          '.bubble, ' +
          '.message'
        )
      ) {
        return el;
      }

      el = el.parentElement;
    }

    return message;
  }

  // ============================================================
  // MESSAGE TIME
  // ============================================================

  function detectMessageTime(message) {
    if (
      !message ||
      !message.nodeType
    ) {
      return '';
    }

    const container =
      getMessageContainer(message);

    if (!container) return '';

    const selectors = [
      'time',
      '.time',
      '.message-time',
      '.bubble-time',
      '[class*="time"]'
    ];

    for (const selector of selectors) {
      const elements =
        container.querySelectorAll?.(
          selector
        ) || [];

      for (const el of elements) {
        // datetime attribute
        const dt =
          el.getAttribute?.('datetime');

        if (dt) {
          const tm = parseTime(dt);

          if (tm) return tm;
        }

        const tm =
          parseTime(
            el.textContent || ''
          );

        if (tm) return tm;
      }
    }

    /*
      Fallback.

      Берём время ближе к концу текста контейнера.
    */

    const text =
      container.textContent || '';

    const matches = [
      ...text.matchAll(
        /\b([01]?\d|2[0-3]):([0-5]\d)\b/g
      )
    ];

    if (matches.length) {
      const last =
        matches.at(-1);

      return (
        `${pad2(Number(last[1]))}:` +
        `${last[2]}`
      );
    }

    return '';
  }

  // ============================================================
  // DATE SEPARATOR CANDIDATES
  // ============================================================

  function findDateElements() {
    const result = [];

    /*
      Не привязываемся жёстко к одному Telegram class.

      Ищем короткие видимые элементы,
      текст которых похож на дату.
    */

    const root =
      document.querySelector(
        '#column-center'
      ) || document.body;

    const all =
      root.querySelectorAll(
        'div, span'
      );

    for (const el of all) {
      const text =
        oneLine(
          el.textContent || ''
        );

      if (
        !text ||
        text.length > 35
      ) {
        continue;
      }

      const parsed =
        parseDateSeparator(text);

      if (!parsed) {
        continue;
      }

      /*
        Не берём элемент, содержащий другие большие
        текстовые блоки.
      */

      if (
        el.querySelectorAll(
          '.translatable-message'
        ).length
      ) {
        continue;
      }

      result.push({
        el,
        date: parsed.iso,
        source: text
      });
    }

    return result;
  }

  // ============================================================
  // FIND DATE BEFORE MESSAGE
  // ============================================================

  function detectPublishedDate(
    message,
    dateElements
  ) {
    if (
      !message ||
      !message.nodeType
    ) {
      return { date: '', source: '' };
    }

    const container =
      getMessageContainer(message);

    if (!container) {
      return { date: '', source: '' };
    }

    let best = null;

    for (const candidate of dateElements) {
      const relation =
        candidate.el.compareDocumentPosition(
          container
        );

      /*
        candidate находится перед message
      */

      if (
        relation &
        Node.DOCUMENT_POSITION_FOLLOWING
      ) {
        best = candidate;
      }
    }

    return best
      ? {
          date: best.date,
          source: best.source
        }
      : {
          date: '',
          source: ''
        };
  }

  // ============================================================
  // V7.5 — ССЫЛКА НА СООБЩЕНИЕ И ВЛОЖЕНИЕ
  // ============================================================
  //
  // Telegram Web K хранит в data-mid глобальный идентификатор.
  // Локальный номер, который стоит в ссылке t.me/c/<чат>/<номер>,
  // это data-mid минус 2^32. Проверено: mid 4294988859
  // соответствует t.me/c/3760061495/21563.
  //
  // Файлы шаблонов приложены документами, а не ссылками,
  // поэтому забираем имя файла и строим прямой адрес
  // сообщения — чтобы до вложения можно было дойти в один клик.
  // ------------------------------------------------------------

  const MID_BASE = 4294967296;

  function localMessageId(mid) {
    const n = Number(mid);

    if (!Number.isFinite(n)) return 0;

    return n > MID_BASE
      ? n - MID_BASE
      : n;
  }

  function buildMessageLink(
    chatId,
    mid
  ) {
    const local =
      localMessageId(mid);

    if (
      !chatId ||
      !local ||
      !/^\d+$/.test(String(chatId))
    ) {
      return '';
    }

    return (
      'https://t.me/c/' +
      chatId + '/' + local
    );
  }

  const DOC_NAME_SELECTORS = [
    '.document-name',
    '.document-title',
    '[class*="document-name"]',
    '.audio-title',
    '.media-caption-name'
  ];

  const DOC_SIZE_SELECTORS = [
    '.document-size',
    '[class*="document-size"]',
    '.audio-subtitle'
  ];

  function detectAttachment(bubble) {
    if (!bubble) return null;

    let name = '';

    for (
      const sel
      of DOC_NAME_SELECTORS
    ) {
      const el =
        bubble.querySelector(sel);

      if (el) {
        const t = oneLineOf(el);

        if (t) { name = t; break; }
      }
    }

    if (!name) return null;

    let size = '';

    for (
      const sel
      of DOC_SIZE_SELECTORS
    ) {
      const el =
        bubble.querySelector(sel);

      if (el) {
        const t = oneLineOf(el);

        if (t) { size = t; break; }
      }
    }

    /*
      V7.6: в .document-size Telegram держит и прогресс
      загрузки — «22 КБ · / 22 КБ». Берём первую величину.
    */

    size = oneLine(
      size.split(/[·/]/)[0]
    );

    /*
      Тип берём по расширению — он полезнее,
      чем класс контейнера.
    */

    const m =
      name.match(/\.([A-Za-z0-9]{1,6})$/);

    return {
      name,
      size,
      ext: m ? m[1].toLowerCase() : ''
    };
  }

  // ============================================================
  // V7.2 — РАЗБОР ПУЗЫРЯ
  // ============================================================
  //
  // Разметка Telegram Web K:
  //
  // <div class="bubble is-reply" data-mid=".."
  //      data-peer-id=".." data-timestamp="..">
  //   <div class="bubble-content-wrapper">
  //     <div class="bubble-content">
  //       <div class="colored-name name floating-part">…</div>
  //       <div class="reply quote-like …">…</div>   <- ЦИТАТА
  //       <div class="message spoilers-container">
  //         <span class="translatable-message">…</span>  <- ТЕЛО
  //         <span class="time">…</span>
  //
  // V7 брал ЛЮБОЙ .translatable-message, включая тот, что
  // внутри блока цитаты. У ответа оба элемента дают один
  // data-mid, и скоринг оставлял более длинный — то есть
  // цитату вместо собственного текста ответа.
  // ------------------------------------------------------------

  const QUOTE_SELECTOR =
    '.reply, .quote-like, .quote, .reply-content';

  function isInsideQuote(el, bubble) {
    let cur = el;

    while (
      cur &&
      cur !== bubble
    ) {
      if (
        cur.matches?.(QUOTE_SELECTOR)
      ) {
        return true;
      }

      cur = cur.parentElement;
    }

    return false;
  }

  function findBubbleBody(bubble) {
    const all = [
      ...bubble.querySelectorAll(
        '.translatable-message'
      )
    ];

    if (!all.length) return null;

    const own =
      all.filter(
        el =>
          !isInsideQuote(
            el,
            bubble
          )
      );

    const pool =
      own.length
        ? own
        : [];

    if (!pool.length) {
      return null;
    }

    /*
      Обычно тело одно. Если их несколько,
      берём самое содержательное.
    */

    return pool.reduce(
      (a, b) =>
        (b.textContent || '').length >
        (a.textContent || '').length
          ? b
          : a
    );
  }

  function findBubbleChannel(bubble) {
    const content =
      bubble.querySelector(
        '.bubble-content'
      );

    if (!content) return '';

    for (
      const el
      of content.children
    ) {
      if (
        el.matches?.(
          '.colored-name, .name'
        ) &&
        !el.matches?.(
          QUOTE_SELECTOR
        )
      ) {
        const t =
          oneLineOf(el);

        if (t) {
          return normalizeChannel(t);
        }
      }
    }

    return '';
  }

  // ============================================================
  // LINKS
  // ============================================================

  function extractLinks(message) {
    return [
      ...message.querySelectorAll(
        'a.anchor-url[href]'
      )
    ]
      .map(a => ({
        text:
          oneLineOf(a),

        url:
          a.href,

        chat_id:
          getChatIdFromUrl(a.href),

        message_id:
          getMessageIdFromUrl(a.href)
      }))
      .filter(
        x =>
          x.text &&
          x.url
      );
  }

  // ============================================================
  // CHANNEL DETECTION
  // ============================================================

  function nearbyTexts(message) {
    const result = [];

    let cur =
      getMessageContainer(message);

    for (
      let level = 0;
      level < 5 && cur;
      level++
    ) {
      let prev =
        cur.previousElementSibling;

      for (
        let i = 0;
        i < 5 && prev;
        i++
      ) {
        const txt =
          textOf(prev);

        if (txt) {
          result.push(txt);
        }

        prev =
          prev.previousElementSibling;
      }

      cur =
        cur.parentElement;
    }

    return result;
  }

  function detectChannel(message) {
    const lines =
      textOf(message)
        .split('\n')
        .map(oneLine)
        .filter(Boolean);

    for (const line of lines) {
      if (looksLikeChannel(line)) {
        return normalizeChannel(line);
      }
    }

    for (
      const nearby
      of nearbyTexts(message)
    ) {
      const parts =
        nearby
          .split('\n')
          .map(oneLine)
          .filter(Boolean);

      for (const line of parts) {
        if (looksLikeChannel(line)) {
          return normalizeChannel(line);
        }
      }
    }

    return '';
  }

  // ============================================================
  // MENU UPDATE DATE
  // ============================================================

  function parseMenuDate(text = '') {
    const m =
      oneLine(text).match(
        /\b(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})\b/
      );

    if (!m) {
      return null;
    }

    let day = Number(m[1]);
    let month = Number(m[2]);
    let year = Number(m[3]);

    if (year < 100) {
      year += 2000;
    }

    const d =
      new Date(
        year,
        month - 1,
        day
      );

    if (
      d.getFullYear() !== year ||
      d.getMonth() !== month - 1 ||
      d.getDate() !== day
    ) {
      return null;
    }

    if (
      year < 2020 ||
      year > 2100
    ) {
      return null;
    }

    return {
      raw:
        `${pad2(day)}.` +
        `${pad2(month)}.` +
        `${String(year).slice(-2)}`,

      time:
        d.getTime()
    };
  }

  function detectMenuDate(message) {
    for (
      const line
      of textOf(message).split('\n')
    ) {
      const d =
        parseMenuDate(line);

      if (d) return d;
    }

    for (
      const nearby
      of nearbyTexts(message)
    ) {
      for (
        const line
        of nearby.split('\n')
      ) {
        const d =
          parseMenuDate(line);

        if (d) return d;
      }
    }

    return null;
  }

  // ============================================================
  // RECORD KEY
  // ============================================================

  function getRecordKey(
    message,
    links
  ) {
    let el =
      getMessageContainer(message);

    for (
      let level = 0;
      level < 7 && el;
      level++
    ) {
      /*
        V7.2: data-peer-id УБРАН.

        Это id чата, он одинаков у всех сообщений
        (подтверждено разметкой: <div class="bubble"
        data-mid=".." data-peer-id="-3760061495">).
        В ключе он схлопнул бы весь канал в одну запись.
      */

      for (const attr of [
        'data-mid',
        'data-message-id'
      ]) {
        const value =
          el.getAttribute?.(attr);

        if (value) {
          return `${attr}:${value}`;
        }
      }

      if (
        el.id &&
        /message|bubble/i.test(el.id)
      ) {
        return `id:${el.id}`;
      }

      el =
        el.parentElement;
    }

    if (links.length) {
      return [
        'links',
        links[0].url,
        links.at(-1).url,
        links.length
      ].join('|');
    }

    const text =
      textOf(message);

    if (text) {
      return (
        'text:' +
        text.slice(0, 400)
      );
    }

    return '';
  }

  // ============================================================
  // BLOCK PARSER
  // ============================================================

  function parseBlocks(
    message,
    channel
  ) {
    const blocks = [];

    function push(
      type,
      data = {}
    ) {
      blocks.push({
        type,
        ...data
      });
    }

    function walk(node) {
      if (
        node.nodeType ===
        Node.TEXT_NODE
      ) {
        const raw =
          node.nodeValue || '';

        raw
          .split('\n')
          .forEach(
            (
              part,
              index,
              arr
            ) => {
              const t =
                oneLine(part);

              if (t) {
                push(
                  'text',
                  { text: t }
                );
              }

              if (
                index <
                arr.length - 1
              ) {
                push('break');
              }
            }
          );

        return;
      }

      if (
        node.nodeType !==
        Node.ELEMENT_NODE
      ) {
        return;
      }

      const el = node;
      const tag = el.tagName;

      if (tag === 'BR') {
        push('break');
        return;
      }

      if (tag === 'IMG') {
        const alt =
          el.getAttribute('alt') ||
          '';

        if (alt) {
          push(
            'emoji',
            { text: alt }
          );
        }

        return;
      }

      if (
        tag === 'A' &&
        el.classList.contains(
          'anchor-url'
        )
      ) {
        const text =
          oneLineOf(el);

        if (
          text &&
          el.href
        ) {
          push(
            'link',
            {
              text,
              url: el.href
            }
          );
        }

        return;
      }

      if (
        tag === 'STRONG' ||
        tag === 'B'
      ) {
        const text =
          oneLineOf(el);

        if (!text) return;

        if (
          isNavigationLabel(text)
        ) {
          return;
        }

        if (
          channel &&
          normalizeChannel(text)
            .toLowerCase() ===
          normalizeChannel(channel)
            .toLowerCase()
        ) {
          return;
        }

        push(
          'heading',
          { text }
        );

        return;
      }

      for (
        const child
        of el.childNodes
      ) {
        walk(child);
      }
    }

    for (
      const child
      of message.childNodes
    ) {
      walk(child);
    }

    return blocks;
  }

  // ============================================================
  // RECORD CLASSIFICATION
  // ============================================================

  function classifyRecord(
    channel,
    links
  ) {
    if (
      links.length >= 10
    ) {
      const swLinks =
        links.filter(
          x =>
            /^\[SW AI\]/i.test(
              normalizeChannel(
                x.text
              )
            )
        ).length;

      if (
        swLinks /
        links.length >
        0.65
      ) {
        return 'navigation-index';
      }
    }

    if (links.length) {
      return 'linked-message';
    }

    return 'message';
  }

  // ============================================================
  // BUILD CHAT_ID → CHANNEL MAP
  // ============================================================

  function buildChatChannelMap() {
    const map =
      new Map();

    const votes =
      new Map();

    for (
      const record
      of S.records.values()
    ) {
      const channel =
        normalizeChannel(
          record.channel
        );

      /*
        V7.1: голосовать могут ТОЛЬКО чистые имена.

        В V7 голосовало любое имя, начинающееся с [SW AI],
        включая сломанные — каждое давало свой голос
        и засоряло карту.
      */

      if (
        !looksLikeCleanChannel(
          channel
        )
      ) {
        continue;
      }

      const chatId =
        record.chat_id;

      if (!chatId) {
        continue;
      }

      if (
        !votes.has(chatId)
      ) {
        votes.set(
          chatId,
          new Map()
        );
      }

      const inner =
        votes.get(chatId);

      inner.set(
        channel,
        (inner.get(channel) || 0)
        + 1
      );
    }

    for (
      const [chatId, choices]
      of votes
    ) {
      const winner =
        [...choices.entries()]
          .sort(
            (a, b) =>
              b[1] - a[1]
          )[0]?.[0];

      if (winner) {
        map.set(
          chatId,
          winner
        );
      }
    }

    return map;
  }

  // ------------------------------------------------------------
  // V7.1 — КОРПУС ЧИСТЫХ ИМЁН
  //
  // Правильное имя канала уже есть в самих данных: оно
  // встречается десятки раз как отдельное сообщение-заголовок.
  // Поэтому сломанное имя не угадываем регулярками, а ищем
  // самый длинный чистый префикс среди уже виденных имён.
  // ------------------------------------------------------------

  function buildCleanChannelNames() {
    const votes =
      new Map();

    for (
      const record
      of S.records.values()
    ) {
      const c =
        normalizeChannel(
          record.channel
        );

      if (
        looksLikeCleanChannel(c)
      ) {
        votes.set(
          c,
          (votes.get(c) || 0) + 1
        );
      }
    }

    return [...votes.entries()]
      .filter(
        ([, n]) => n >= 2
      )
      .map(
        ([name]) => name
      )
      .sort(
        (a, b) =>
          b.length - a.length
      );
  }

  function matchCleanPrefix(
    name,
    cleanNames
  ) {
    const s =
      normalizeChannel(name)
        .toLowerCase();

    if (!s) return '';

    for (
      const c
      of cleanNames
    ) {
      if (
        s.startsWith(
          c.toLowerCase()
        )
      ) {
        return c;
      }
    }

    return '';
  }

  /*
    Кэш: resolvedChannel вызывается на каждый текстовый блок
    при экспорте, а обе карты перебирают все записи.
    Без кэша экспорт квадратичный по числу записей.
  */

  S._cacheToken = 0;
  let cacheAt = -1;
  let cacheMap = null;
  let cacheNames = null;
  let cacheInherited = null;

  function channelCaches() {
    if (
      cacheAt !== S._cacheToken
    ) {
      cacheMap =
        buildChatChannelMap();

      cacheNames =
        buildCleanChannelNames();

      cacheInherited =
        buildInheritedChannels(
          cacheMap,
          cacheNames
        );

      cacheAt =
        S._cacheToken;
    }

    return {
      mapping: cacheMap,
      cleanNames: cacheNames,
      inherited: cacheInherited
    };
  }

  function recordMid(record) {
    const m =
      String(record.key || '')
        .match(/(\d+)\s*$/);

    return m ? Number(m[1]) : 0;
  }

  /*
    V7.4 — НАСЛЕДОВАНИЕ КАНАЛА

    Длинное меню разбивается на несколько сообщений, и в
    продолжении нет шапки "[SW AI] ...". Такая запись уезжала
    под случайное имя — например, 48 ссылок попали под
    заголовок раздела "📌 С ЧЕГО НАЧАТЬ".

    Идём по сообщениям в порядке mid и, если имя не похоже
    на канал, берём последнее нормальное — ровно так это
    читается глазами.
  */

  function buildInheritedChannels(
    mapping,
    cleanNames
  ) {
    const map =
      new Map();

    const ordered =
      [...S.records.values()]
        .sort(
          (a, b) =>
            recordMid(a) -
            recordMid(b)
        );

    let last = '';

    for (
      const record
      of ordered
    ) {
      const base =
        baseResolvedChannel(
          record,
          mapping,
          cleanNames
        );

      if (
        looksLikeChannel(base)
      ) {
        last = base;

        map.set(record.key, base);

        continue;
      }

      map.set(
        record.key,
        last || base
      );
    }

    return map;
  }

  function resolvedChannel(
    record
  ) {
    const {
      mapping,
      cleanNames,
      inherited
    } = channelCaches();

    return (
      inherited.get(record.key) ||
      baseResolvedChannel(
        record,
        mapping,
        cleanNames
      )
    );
  }

  function baseResolvedChannel(
    record,
    mapping,
    cleanNames
  ) {
    const original =
      normalizeChannel(
        record.channel
      );

    /*
      V7.1, порядок разрешения:

      1. имя уже чистое
      2. самый длинный чистый префикс из корпуса
      3. карта chat_id -> канал
      4. как есть

      В V7 шаг 1 был проверкой startsWith('[SW AI]'),
      поэтому сломанное имя проходило его первым
      и до восстановления дело не доходило.
    */

    /*
      V7.2: префикс проверяется ПЕРВЫМ.

      В V7.1 сначала шла проверка "имя уже чистое", и мимо
      проходили имена вида "[SW AI] AIWARIGIF" или
      "[SW AI] AIWARI1 ссылка" — короткие, без пунктуации,
      без склейки заглавной со строчной.
    */

    const byPrefix =
      matchCleanPrefix(
        original,
        cleanNames
      );

    if (byPrefix) {
      return byPrefix;
    }

    if (
      looksLikeCleanChannel(
        original
      )
    ) {
      return original;
    }

    if (
      record.chat_id &&
      mapping.has(
        record.chat_id
      )
    ) {
      return mapping.get(
        record.chat_id
      );
    }

    return (
      original ||
      '[SW AI] UNKNOWN'
    );
  }

  // ============================================================
  // V7.3 — АРХИВ МЕЖДУ СЕССИЯМИ
  // ============================================================
  //
  // Причина: состояние коллектора жило только внутри одного
  // запуска. Три прогона по одному каналу дали 658 / 726 / 742
  // разных сообщения, и лишь их объединение покрыло канал.
  // Каждый заход недобирал случайный кусок из-за виртуализации.
  //
  // Теперь записи копятся в localStorage отдельно по каждому
  // чату, и новый проход только дополняет архив.
  // ------------------------------------------------------------

  const ARCHIVE_PREFIX =
    'tg_collector_v73::';

  /*
    Блоки занимают почти половину объёма записи, поэтому
    в архив они кладутся компактно: [код, текст, url].
  */

  const B_NAMES = [
    'text',
    'break',
    'emoji',
    'heading',
    'link'
  ];

  const B_CODES = {
    text: 0,
    break: 1,
    emoji: 2,
    heading: 3,
    link: 4
  };

  function packBlocks(blocks = []) {
    return blocks.map(b => {
      const c =
        B_CODES[b.type] ?? 0;

      if (c === 1) {
        return [1];
      }

      if (c === 4) {
        return [
          4,
          b.text || '',
          b.url || ''
        ];
      }

      return [
        c,
        b.text || ''
      ];
    });
  }

  function unpackBlocks(arr = []) {
    return arr.map(a => {
      const type =
        B_NAMES[a[0]] || 'text';

      if (type === 'break') {
        return { type };
      }

      if (type === 'link') {
        return {
          type,
          text: a[1] || '',
          url: a[2] || ''
        };
      }

      return {
        type,
        text: a[1] || ''
      };
    });
  }

  function packRecord(record) {
    const out = {};

    for (
      const [k, v]
      of Object.entries(record)
    ) {
      if (k === 'blocks') continue;
      out[k] = v;
    }

    out.b =
      packBlocks(record.blocks);

    return out;
  }

  function unpackRecord(packed) {
    const out = {};

    for (
      const [k, v]
      of Object.entries(packed)
    ) {
      if (k === 'b') continue;
      out[k] = v;
    }

    out.blocks =
      unpackBlocks(packed.b || []);

    return out;
  }

  S.archiveKey = '';
  S.fromArchive = 0;
  S.archiveSaved = null;

  let archiveDirty = false;
  let archiveTimer = null;

  function ensureArchiveLoaded() {
    if (S.archiveKey) return true;

    let chatId =
      getCurrentChatId();

    if (!chatId) {
      /*
        Адресная строка не дала id — берём самый частый
        chat_id уже собранных записей.
      */

      const counts =
        new Map();

      for (
        const r
        of S.records.values()
      ) {
        if (!r.chat_id) continue;

        counts.set(
          r.chat_id,
          (counts.get(r.chat_id) || 0) + 1
        );
      }

      chatId =
        [...counts.entries()]
          .sort(
            (a, b) => b[1] - a[1]
          )[0]?.[0] || '';
    }

    if (!chatId) return false;

    S.archiveKey =
      ARCHIVE_PREFIX + chatId;

    let raw = '';

    try {
      raw =
        localStorage.getItem(
          S.archiveKey
        ) || '';
    } catch (e) {
      console.warn(
        'localStorage недоступен — архив отключён'
      );

      return true;
    }

    if (!raw) {
      console.log(
        `Архив пуст, начинаем с нуля (${S.archiveKey})`
      );

      return true;
    }

    let data = null;

    try {
      data = JSON.parse(raw);
    } catch (e) {
      console.warn(
        'Архив повреждён, игнорирую'
      );

      return true;
    }

    let loaded = 0;

    for (
      const packed
      of (data.records || [])
    ) {
      const record =
        unpackRecord(packed);

      if (!record.key) continue;

      if (
        !S.records.has(record.key)
      ) {
        S.records.set(
          record.key,
          record
        );

        loaded++;
      }

      for (
        const link
        of (record.links || [])
      ) {
        if (
          !S.links.has(link.url)
        ) {
          S.links.set(
            link.url,
            {
              ...link,

              channel:
                record.channel,

              source_record_key:
                record.key,

              source_published_at:
                record.published_at
            }
          );
        }
      }
    }

    S.fromArchive = loaded;
    S._cacheToken++;

    console.log(
      `Из архива поднято ${loaded} сообщений ` +
      `(сохранён ${data.saved_at || '?'})`
    );

    return true;
  }

  function serializeArchive(
    withBlocks
  ) {
    const records = [];

    for (
      const r
      of S.records.values()
    ) {
      if (withBlocks) {
        records.push(
          packRecord(r)
        );
      } else {
        const p =
          packRecord(r);

        p.b = [];

        records.push(p);
      }
    }

    return JSON.stringify({
      v: 3,

      chat_id:
        S.archiveKey.replace(
          ARCHIVE_PREFIX,
          ''
        ),

      saved_at:
        new Date().toISOString(),

      records
    });
  }

  function saveArchive() {
    if (!S.archiveKey) {
      return false;
    }

    archiveDirty = false;

    for (
      const withBlocks
      of [true, false]
    ) {
      try {
        localStorage.setItem(
          S.archiveKey,
          serializeArchive(
            withBlocks
          )
        );

        S.archiveSaved =
          new Date();

        if (!withBlocks) {
          console.warn(
            'Места не хватило — архив сохранён без блоков. ' +
            'Разметка Markdown у поднятых записей будет проще. ' +
            'Выгрузите JSON и вызовите TG_EXPORT.forget().'
          );
        }

        return true;
      } catch (e) {
        if (withBlocks) continue;

        console.warn(
          'Архив НЕ сохранён: в localStorage нет места. ' +
          'Выгрузите данные через TG_EXPORT.exportJSON().'
        );

        return false;
      }
    }

    return false;
  }

  function scheduleArchiveSave() {
    archiveDirty = true;

    if (archiveTimer) return;

    archiveTimer =
      setTimeout(
        () => {
          archiveTimer = null;

          if (archiveDirty) {
            saveArchive();
          }
        },
        4000
      );
  }

  // ============================================================
  // SAVE MESSAGE
  // ============================================================

  /*
    V7.3 — слияние двух версий одного сообщения.
  */

  const KEEP_IF_EMPTY = [
    'channel',
    'chat_id',
    'message_link',
    'local_id',
    'attachment',
    'menu_updated',
    'menu_updated_timestamp',
    'published_date',
    'published_time',
    'published_at',
    'published_epoch',
    'date_separator_source'
  ];

  function mergeRecords(old, next) {
    const out = {
      ...old,
      ...next
    };

    for (
      const k
      of KEEP_IF_EMPTY
    ) {
      if (
        !next[k] &&
        old[k]
      ) {
        out[k] = old[k];
      }
    }

    /*
      time_source: 'data-timestamp' точнее запасного пути.
    */

    if (
      old.time_source ===
        'data-timestamp' &&
      next.time_source !==
        'data-timestamp'
    ) {
      out.time_source =
        old.time_source;
    }

    if (
      (old.text || '').length >
      (next.text || '').length
    ) {
      out.text = old.text;
      out.chars = old.chars;
    }

    if (
      (old.links || []).length >
      (next.links || []).length
    ) {
      out.links = old.links;
    }

    if (
      (old.blocks || []).length >
      (next.blocks || []).length
    ) {
      out.blocks = old.blocks;
    }

    return out;
  }

  function saveMessage(
    message,
    dateElements,
    bubble
  ) {
    /*
      V7.6: сообщение-файл без подписи.

      Файлы шаблонов выкладывают отдельными сообщениями:
      подпись у первого, остальные без текста. Такие пузыри
      V7.5 выбрасывал целиком, потому что в них нет
      .translatable-message — так терялся второй файл у
      «Шаблон №2. Сразу 2 шаблона» и 26 сообщений подряд
      у «Шаблона №25».
    */

    const attachment =
      detectAttachment(bubble);

    if (
      !message &&
      !attachment
    ) {
      return false;
    }

    const text =
      message
        ? textOf(message)
        : '';

    const links =
      message
        ? extractLinks(message)
        : [];

    /*
      Пустой служебный элемент
    */

    if (
      !text &&
      !links.length &&
      !attachment
    ) {
      return false;
    }

    const key =
      message
        ? getRecordKey(
            message,
            links
          )
        : 'data-mid:' +
          (bubble.getAttribute?.(
            'data-mid'
          ) || '');

    if (!key) {
      return false;
    }

    const channel =
      findBubbleChannel(bubble) ||
      (message
        ? detectChannel(message)
        : '');

    const menuDate =
      message
        ? detectMenuDate(message)
        : null;

    /*
      V7.2: сначала точный data-timestamp пузыря.
      Разбор разделителей дат и span.time остаётся
      запасным вариантом.
    */

    const stamp =
      readBubbleTimestamp(bubble);

    /*
      V7.6: у сообщения-файла нет .translatable-message,
      поэтому запасной путь получал null и падал на
      compareDocumentPosition. Опорным элементом в этом
      случае служит сам пузырь.
    */

    const anchorEl =
      message || bubble || null;

    const published =
      stamp
        ? { date: stamp.date, source: 'data-timestamp' }
        : anchorEl
          ? detectPublishedDate(
              anchorEl,
              dateElements
            )
          : { date: '', source: '' };

    const messageTime =
      stamp
        ? stamp.time
        : anchorEl
          ? detectMessageTime(
              anchorEl
            )
          : '';

    const chatId =
      (bubble &&
        normalizePeerId(
          bubble.getAttribute?.(
            'data-peer-id'
          ) || ''
        )) ||
      getCurrentChatId() ||
      dominantChatId(links);

    const publishedDate =
      published.date || '';

    const publishedAt =
      publishedDate &&
      messageTime

        ? `${publishedDate} ${messageTime}`

        : publishedDate;

    const record = {
      key,

      channel,

      chat_id:
        chatId,

      menu_updated:
        menuDate?.raw || '',

      menu_updated_timestamp:
        menuDate?.time || null,

      published_date:
        publishedDate,

      published_time:
        messageTime,

      published_at:
        publishedAt,

      message_link:
        buildMessageLink(
          chatId,
          bubble &&
            bubble.getAttribute?.('data-mid')
        ),

      local_id:
        localMessageId(
          bubble &&
            bubble.getAttribute?.('data-mid')
        ),

      attachment:
        attachment,

      published_epoch:
        stamp ? stamp.epoch : null,

      time_source:
        stamp
          ? 'data-timestamp'
          : 'разделитель+span.time',

      date_separator_source:
        published.source || '',

      type:
        classifyRecord(
          channel,
          links
        ),

      text,

      links,

      blocks:
        message
          ? parseBlocks(
              message,
              channel
            )
          : [],

      chars:
        text.length,

      collected_at:
        new Date().toISOString()
    };

    const old =
      S.records.get(key);

    /*
      V7.3: СЛИЯНИЕ вместо замены.

      Раньше запись заменялась целиком, и при равном
      скоринге более полная версия могла быть затёрта
      более бедной — например, потерять уже собранную
      дату. Теперь каждое поле берётся из той версии,
      где оно содержательнее. Это же нужно и для записей,
      поднятых из архива.
    */

    const merged =
      old
        ? mergeRecords(
            old,
            record
          )
        : record;

    S.records.set(
      key,
      merged
    );

    S._cacheToken++;

    scheduleArchiveSave();

    for (
      const link
      of links
    ) {
      const oldLink =
        S.links.get(
          link.url
        );

      if (
        !oldLink ||
        link.text.length >
        oldLink.text.length
      ) {
        S.links.set(
          link.url,
          {
            ...link,

            channel,

            source_record_key:
              key,

            source_published_at:
              publishedAt
          }
        );
      }
    }

    return !old;
  }

  // ============================================================
  // SCAN
  // ============================================================

  function scan() {
    ensureArchiveLoaded();

    const beforeRecords =
      S.records.size;

    const beforeLinks =
      S.links.size;

    /*
      Сначала собираем видимые разделители дат.
    */

    const dateElements =
      findDateElements();

    /*
      Затем сообщения.
    */

    /*
      V7.2: обходим ПУЗЫРИ, а не .translatable-message.

      Это даёт data-timestamp, data-peer-id и возможность
      отличить тело сообщения от блока цитаты.
    */

    const bubbles = [
      ...document.querySelectorAll(
        '[data-mid]'
      )
    ];

    let handled = 0;

    const seenMids =
      new Set();

    for (
      const bubble
      of bubbles
    ) {
      const mid =
        bubble.getAttribute(
          'data-mid'
        );

      if (
        !mid ||
        seenMids.has(mid)
      ) {
        continue;
      }

      seenMids.add(mid);

      const body =
        findBubbleBody(bubble);

      /*
        V7.6: пузырь без текста, но с файлом — тоже запись.
      */

      if (
        !body &&
        !detectAttachment(bubble)
      ) {
        continue;
      }

      saveMessage(
        body,
        dateElements,
        bubble
      );

      handled++;
    }

    /*
      Запасной путь: если разметка изменилась и пузырей
      не нашлось, работаем по-старому.
    */

    if (!handled) {
      const messages = [
        ...document.querySelectorAll(
          '.translatable-message'
        )
      ];

      for (
        const message
        of messages
      ) {
        saveMessage(
          message,
          dateElements,
          message.closest?.(
            '[data-mid]'
          ) || null
        );
      }
    }

    S.scans++;

    const addedRecords =
      S.records.size -
      beforeRecords;

    const addedLinks =
      S.links.size -
      beforeLinks;

    if (
      addedRecords > 0 ||
      addedLinks > 0 ||
      S.scans === 1
    ) {
      console.log(
        `V7.6 | +${addedRecords} сообщений | ` +
        `+${addedLinks} ссылок | ` +
        `всего ${S.records.size} сообщений / ` +
        `${S.links.size} ссылок`
      );
    }
  }

  function scheduleScan() {
    clearTimeout(
      S.scanTimer
    );

    S.scanTimer =
      setTimeout(
        scan,
        250
      );
  }

  // Первый scan
  scan();

  // ============================================================
  // MUTATION OBSERVER
  // ============================================================

  const observer =
    new MutationObserver(
      mutations => {
        S.mutations +=
          mutations.length;

        scheduleScan();
      }
    );

  /*
    Нам нужны только изменения структуры DOM.

    Никакого attributes / blob / src.
  */

  observer.observe(
    document.body,
    {
      subtree: true,
      childList: true
    }
  );

  S.observer =
    observer;

  /*
    Страховочный скан.
  */

  S.safetyTimer =
    setInterval(
      scan,
      3000
    );

  // ============================================================
  // FIX CHANNELS USING CHAT_ID
  // ============================================================

  S.resolveChannels = () => {
    const mapping =
      buildChatChannelMap();

    let fixed = 0;

    for (
      const record
      of S.records.values()
    ) {
      const original =
        normalizeChannel(
          record.channel
        );

      if (
        original &&
        original.startsWith(
          '[SW AI]'
        )
      ) {
        continue;
      }

      if (
        record.chat_id &&
        mapping.has(
          record.chat_id
        )
      ) {
        record.channel_resolved =
          mapping.get(
            record.chat_id
          );

        fixed++;
      }
    }

    console.log(
      `Названия восстановлены по chat_id: ${fixed}`
    );

    return fixed;
  };

  // ============================================================
  // STATUS
  // ============================================================

  S.status = () => {
    const mapping =
      buildChatChannelMap();

    const resolvedNames =
      new Set();

    let withDate = 0;
    let withTime = 0;
    let withStamp = 0;

    for (
      const record
      of S.records.values()
    ) {
      const ch =
        resolvedChannel(
          record
        );

      if (
        ch &&
        ch !==
        '[SW AI] UNKNOWN'
      ) {
        resolvedNames.add(ch);
      }

      if (
        record.published_date
      ) {
        withDate++;
      }

      if (
        record.published_time
      ) {
        withTime++;
      }

      if (
        record.published_epoch
      ) {
        withStamp++;
      }
    }

    console.table({
      'Версия':
        S.version,

      'Сообщений':
        S.records.size,

      'Уникальных ссылок':
        S.links.size,

      'Каналов':
        resolvedNames.size,

      'chat_id → channel':
        mapping.size,

      'С датой публикации':
        withDate,

      'Со временем':
        withTime,

      'Из data-timestamp':
        withStamp,

      'DOM-событий':
        S.mutations,

      'Сканов':
        S.scans,

      'Старт':
        S.startedAt
          .toLocaleTimeString()
    });

    return {
      messages:
        S.records.size,

      links:
        S.links.size,

      channels:
        resolvedNames.size,

      published_dates:
        withDate,

      published_times:
        withTime
    };
  };

  // ============================================================
  // PREVIEW MESSAGES
  // ============================================================

  S.preview = (
    n = 20
  ) => {
    const rows =
      [...S.records.values()]
        .slice(-n)
        .map(
          record => ({
            channel:
              resolvedChannel(
                record
              ),

            chat_id:
              record.chat_id,

            published_at:
              record.published_at,

            menu_updated:
              record.menu_updated,

            links:
              record.links.length,

            text:
              oneLine(
                record.text
              ).slice(0, 80)
          })
        );

    console.table(rows);

    return rows;
  };

  // ============================================================
  // PREVIEW CHANNELS
  // ============================================================

  S.previewChannels = () => {
    const groups =
      new Map();

    for (
      const record
      of S.records.values()
    ) {
      const channel =
        resolvedChannel(
          record
        );

      if (
        !groups.has(channel)
      ) {
        groups.set(
          channel,
          {
            messages: 0,
            links:
              new Set(),

            dates:
              new Set(),

            chatIds:
              new Set()
          }
        );
      }

      const g =
        groups.get(channel);

      g.messages++;

      for (
        const link
        of record.links
      ) {
        g.links.add(
          link.url
        );
      }

      if (
        record.published_date
      ) {
        g.dates.add(
          record.published_date
        );
      }

      if (
        record.chat_id
      ) {
        g.chatIds.add(
          record.chat_id
        );
      }
    }

    const rows =
      [...groups.entries()]
        .map(
          ([channel, g]) => ({
            channel,

            clean:
              looksLikeCleanChannel(
                channel
              )
                ? 'да'
                : 'НЕТ',

            messages:
              g.messages,

            links:
              g.links.size,

            publication_dates:
              g.dates.size,

            chat_id:
              [...g.chatIds]
                .join(', ')
          })
        );

    console.table(rows);

    return rows;
  };

  // ============================================================
  // DOWNLOAD HELPER
  // ============================================================

  function downloadText(
    text,
    filename,
    mime
  ) {
    const blob =
      new Blob(
        [text],
        {
          type:
            `${mime};charset=utf-8`
        }
      );

    const url =
      URL.createObjectURL(
        blob
      );

    const a =
      document.createElement(
        'a'
      );

    a.href =
      url;

    a.download =
      filename;

    document.body.appendChild(
      a
    );

    a.click();
    a.remove();

    setTimeout(
      () =>
        URL.revokeObjectURL(
          url
        ),
      5000
    );
  }

  // ============================================================
  // RAW JSON EXPORT
  // ============================================================

  S.exportJSON = () => {
    saveArchive();

    const mapping =
      buildChatChannelMap();

    const records =
      [...S.records.values()]
        .map(
          record => ({
            ...record,

            channel_resolved:
              resolvedChannel(
                record
              )
          })
        );

    const output = {
      collector:
        'Telegram Collector V7.6.1',

      exported_at:
        new Date()
          .toISOString(),

      stats: {
        records:
          records.length,

        links:
          S.links.size,

        chat_channel_mappings:
          mapping.size
      },

      chat_channel_map:
        Object.fromEntries(
          mapping
        ),

      records
    };

    downloadText(
      JSON.stringify(
        output,
        null,
        2
      ),

      `telegram-v76-RAW-${S.links.size}-links.json`,

      'application/json'
    );

    console.log(
      `V7 RAW JSON сохранён: ` +
      `${records.length} сообщений / ` +
      `${S.links.size} ссылок`
    );
  };

  // ============================================================
  // CSV EXPORT
  // ============================================================

  function csvCell(value = '') {
    return (
      '"' +
      String(value)
        .replace(
          /"/g,
          '""'
        ) +
      '"'
    );
  }

  S.exportCSV = () => {
    saveArchive();

    const rows = [
      [
        'channel',
        'chat_id',
        'published_date',
        'published_time',
        'published_at',
        'menu_updated',
        'message_key',
        'links_count',
        'text'
      ]
    ];

    for (
      const record
      of S.records.values()
    ) {
      rows.push([
        resolvedChannel(
          record
        ),

        record.chat_id,

        record.published_date,

        record.published_time,

        record.published_at,

        record.menu_updated,

        record.key,

        record.links.length,

        oneLine(
          record.text
        )
      ]);
    }

    const csv =
      '\uFEFF' +
      rows
        .map(
          row =>
            row
              .map(csvCell)
              .join(',')
        )
        .join('\n');

    downloadText(
      csv,

      `telegram-v76-messages-${S.records.size}.csv`,

      'text/csv'
    );

    console.log(
      `CSV сохранён: ${S.records.size} сообщений`
    );
  };

  // ============================================================
  // MARKDOWN HELPERS
  // ============================================================

  /*
    V7.4: URL со скобками ломает синтаксис Markdown.
    Такие адреса оборачиваем в угловые скобки.
  */

  function escapeUrl(url = '') {
    const u =
      String(url).trim();

    return /[()\s]/.test(u)
      ? `<${u}>`
      : u;
  }

  function escapeMd(
    text = ''
  ) {
    return oneLine(text)
      .replace(
        /\\/g,
        '\\\\'
      )
      .replace(
        /\[/g,
        '\\['
      )
      .replace(
        /\]/g,
        '\\]'
      );
  }

  function isSeparator(
    text = ''
  ) {
    const s =
      oneLine(text);

    return (
      s === '•' ||
      /^[⎯—\-_=─]{6,}$/.test(
        s
      )
    );
  }

  function isEmojiOnly(
    text = ''
  ) {
    const s =
      oneLine(text);

    if (!s) return false;

    if (
      /[A-Za-zА-Яа-яЁё0-9]/.test(
        s
      )
    ) {
      return false;
    }

    return [...s].some(
      c =>
        c.codePointAt(0) >
        0x2500
    );
  }

  function recordToMarkdownLines(
    record,
    seenUrls
  ) {
    let blocks =
      record.blocks || [];

    /*
      V7.3: страховка. Если архив пришлось сохранить без
      блоков, восстанавливаем минимальную структуру из
      text и links, чтобы запись не выпала из Markdown.
    */

    if (
      !blocks.length &&
      (record.text ||
        (record.links || []).length)
    ) {
      blocks = [];

      const used =
        new Set();

      for (
        const line
        of String(record.text || '')
          .split('\n')
      ) {
        const t = oneLine(line);

        if (!t) continue;

        const hit =
          (record.links || []).find(
            l =>
              !used.has(l.url) &&
              oneLine(l.text) === t
          );

        if (hit) {
          used.add(hit.url);

          blocks.push({
            type: 'link',
            text: hit.text,
            url: hit.url
          });
        } else {
          blocks.push({
            type: 'text',
            text: t
          });
        }

        blocks.push({ type: 'break' });
      }

      for (
        const l
        of (record.links || [])
      ) {
        if (used.has(l.url)) continue;

        blocks.push({
          type: 'link',
          text: l.text,
          url: l.url
        });
      }
    }

    const lines = [];

    /*
      V7.5: файл-вложение и прямой адрес сообщения.
    */

    if (record.attachment) {
      lines.push(
        '**Файл:** ' +
        escapeMd(record.attachment.name) +
        (record.attachment.size
          ? ' (' + escapeMd(record.attachment.size) + ')'
          : '')
      );
    }

    if (
      record.message_link &&
      record.attachment
    ) {
      lines.push(
        '- [Открыть сообщение с файлом](' +
        escapeUrl(record.message_link) + ')'
      );
    }

    /*
      V7.4 — СБОРКА ПО СТРОКАМ

      Раньше каждый текстовый блок выводился отдельной
      строкой Markdown, а блоки break игнорировались.
      Одно предложение разрывалось на куски:

        ### 1️⃣
        Заходим на
        ### сайт Google

      Теперь части копятся в буфер и сбрасываются на break.
      Жирный текст становится заголовком, только если
      занимает строку целиком; иначе остаётся **жирным**.
    */

    const skipText = text => (
      !text ||
      isSeparator(text) ||
      isNavigationLabel(text) ||
      normalizeChannel(text)
        .toLowerCase() ===
        resolvedChannel(record)
          .toLowerCase() ||
      !!parseMenuDate(text)
    );

    const atLineStart = idx => {
      let j = idx - 1;

      while (j >= 0) {
        const p = blocks[j];

        if (p.type === 'break') {
          return true;
        }

        if (
          p.type === 'emoji' ||
          (p.type === 'link' &&
            isEmojiOnly(
              oneLine(p.text)
            )) ||
          (p.type === 'text' &&
            skipText(
              oneLine(p.text)
            ))
        ) {
          j--;
          continue;
        }

        return false;
      }

      return true;
    };

    const atLineEnd = idx => {
      let j = idx + 1;

      while (j < blocks.length) {
        const n = blocks[j];

        if (n.type === 'break') {
          return true;
        }

        if (
          n.type === 'text' &&
          skipText(
            oneLine(n.text)
          )
        ) {
          j++;
          continue;
        }

        return false;
      }

      return true;
    };

    /*
      isEmojiOnly не ловит клавишные эмодзи вроде 1️⃣ —
      у них первый код это обычная цифра. Для решения
      "заголовок или значок" достаточно проверить,
      есть ли в тексте хоть одна буква.
    */

    const isIconOnly = t =>
      !!t &&
      !/\p{L}/u.test(t);

    let cur = [];

    const takeIcon = () => {
      const icon =
        cur.join(' ')
          .replace(/\s+/g, ' ')
          .trim();

      cur = [];

      return icon ? icon + ' ' : '';
    };

    const flush = () => {
      const line =
        cur.join(' ')
          .replace(/\s+/g, ' ')
          .trim();

      cur = [];

      if (line) {
        lines.push(line);
      }
    };

    for (
      let i = 0;
      i < blocks.length;
      i++
    ) {
      const b = blocks[i];

      if (b.type === 'break') {
        flush();
        continue;
      }

      if (b.type === 'emoji') {
        const t =
          oneLine(b.text);

        if (t) cur.push(t);

        continue;
      }

      if (b.type === 'text') {
        const t =
          oneLine(b.text);

        if (skipText(t)) continue;

        cur.push(t);
        continue;
      }

      if (b.type === 'heading') {
        const t =
          oneLine(b.text);

        if (!t) continue;

        const standalone =
          atLineStart(i) &&
          atLineEnd(i) &&
          !isIconOnly(t);

        if (standalone) {
          const level =
            /ГЛАВНОЕ\s+МЕНЮ/i
              .test(t)

              ? '##'
              : '###';

          lines.push(
            `${level} ${takeIcon()}${t}`
          );
        } else {
          cur.push(
            isIconOnly(t)
              ? t
              : `**${t}**`
          );
        }

        continue;
      }

      if (b.type === 'link') {
        const t =
          oneLine(b.text);

        const url =
          b.url || '';

        if (!t || !url) continue;

        /*
          Повторный URL больше не выбрасывается молча —
          остаётся текстом, чтобы фраза не теряла кусок.
        */

        if (seenUrls.has(url)) {
          cur.push(escapeMd(t));
          continue;
        }

        seenUrls.add(url);

        const md =
          `[${escapeMd(t)}](${escapeUrl(url)})`;

        /*
          Эмодзи-ссылка перед заголовком — его иконка.
        */

        if (
          isEmojiOnly(t) &&
          blocks[i + 1] &&
          blocks[i + 1].type ===
            'heading'
        ) {
          cur.push(md);
          continue;
        }

        if (
          atLineStart(i) &&
          atLineEnd(i)
        ) {
          lines.push(
            `- ${takeIcon()}${md}`
          );
        } else {
          cur.push(md);
        }

        continue;
      }
    }

    flush();

    return lines;
  }

  // ============================================================
  // CLEAN MARKDOWN EXPORT
  // ============================================================

  S.exportMD = () => {
    saveArchive();

    const records =
      [...S.records.values()];

    const navigation =
      records.filter(
        r =>
          r.type ===
          'navigation-index'
      );

    const regular =
      records.filter(
        r =>
          r.type !==
          'navigation-index'
      );

    const groups =
      new Map();

    for (
      const record
      of regular
    ) {
      const channel =
        resolvedChannel(
          record
        );

      const key =
        channel.toLowerCase();

      if (
        !groups.has(key)
      ) {
        groups.set(
          key,
          {
            name:
              channel,

            records: []
          }
        );
      }

      groups
        .get(key)
        .records
        .push(record);
    }

    const out = [];

    // ----------------------------------------------------------
    // GLOBAL NAVIGATION
    // ----------------------------------------------------------

    if (
      navigation.length
    ) {
      out.push(
        '# [SW AI] Navigation'
      );

      out.push('');

      const seen =
        new Set();

      for (
        const record
        of navigation
      ) {
        for (
          const link
          of record.links
        ) {
          if (
            seen.has(
              link.url
            )
          ) {
            continue;
          }

          seen.add(
            link.url
          );

          out.push(
            `- [${escapeMd(link.text)}](${link.url})`
          );
        }
      }

      out.push('');
      out.push('---');
      out.push('');
    }

    // ----------------------------------------------------------
    // CHANNELS
    // ----------------------------------------------------------

    for (
      const group
      of groups.values()
    ) {
      out.push(
        `# ${group.name}`
      );

      const menuDates =
        group.records
          .map(
            r => ({
              text:
                r.menu_updated,

              time:
                r.menu_updated_timestamp
            })
          )
          .filter(
            x =>
              x.text &&
              x.time
          )
          .sort(
            (a, b) =>
              b.time -
              a.time
          );

      if (
        menuDates.length
      ) {
        out.push(
          `Обновлено: ${menuDates[0].text}`
        );
      }

      out.push('');

      const seenUrls =
        new Set();

      const seenLines =
        new Set();

      for (
        const record
        of group.records
      ) {
        const lines =
          recordToMarkdownLines(
            record,
            seenUrls
          );

        for (
          const line
          of lines
        ) {
          if (!line) {
            continue;
          }

          if (
            line.startsWith(
              '- ['
            )
          ) {
            out.push(
              line
            );

            continue;
          }

          const key =
            line
              .replace(
                /^#{2,3}\s*/,
                ''
              )
              .toLowerCase()
              .trim();

          if (
            seenLines.has(key)
          ) {
            continue;
          }

          seenLines.add(
            key
          );

          out.push(
            line
          );
        }

        out.push('');
      }

      out.push('---');
      out.push('');
    }

    let md =
      out
        .join('\n')
        .replace(
          /\n{3,}/g,
          '\n\n'
        )
        .trim();

    downloadText(
      md,

      `telegram-v76-CLEAN-${S.links.size}-links.md`,

      'text/markdown'
    );

    console.log(
      `V7 CLEAN MD сохранён | ` +
      `${groups.size} каналов | ` +
      `${S.links.size} ссылок`
    );
  };

  // ============================================================
  // PUBLICATION TIMELINE MARKDOWN
  // ============================================================

  S.exportTimeline = () => {
    const records =
      [...S.records.values()]
        .filter(
          r =>
            r.published_date
        )
        .sort(
          (a, b) =>
            String(
              a.published_at
            ).localeCompare(
              String(
                b.published_at
              )
            )
        );

    const out = [];

    let currentDate = '';

    for (
      const record
      of records
    ) {
      if (
        record.published_date !==
        currentDate
      ) {
        currentDate =
          record.published_date;

        out.push('');
        out.push(
          `## ${currentDate}`
        );
        out.push('');
      }

      const time =
        record.published_time
          ? `${record.published_time} — `
          : '';

      const preview =
        oneLine(
          record.text
        ).slice(
          0,
          250
        );

      out.push(
        `- **${time}${resolvedChannel(record)}** — ${preview}`
      );
    }

    downloadText(
      out
        .join('\n')
        .trim(),

      `telegram-v76-TIMELINE-${records.length}-messages.md`,

      'text/markdown'
    );

    console.log(
      `Timeline сохранён: ${records.length} сообщений с датой`
    );
  };

  // ============================================================
  // V7.5 — КАТАЛОГ ВЛОЖЕНИЙ
  // ============================================================

  S.attachments = () => {
    const rows =
      [...S.records.values()]
        .filter(r => r.attachment)
        .sort(
          (a, b) =>
            (a.local_id || 0) -
            (b.local_id || 0)
        )
        .map(r => ({
          id: r.local_id,

          file:
            r.attachment.name,

          size:
            r.attachment.size,

          date:
            r.published_at,

          text:
            (r.text || '')
              .slice(0, 60),

          link:
            r.message_link
        }));

    console.table(rows);

    console.log(
      `Вложений: ${rows.length}`
    );

    return rows;
  };

  S.exportAttachmentsCSV = () => {
    saveArchive();

    const rows =
      S.attachments();

    const q = v =>
      '"' +
      String(v ?? '')
        .replace(/"/g, '""') +
      '"';

    const csv = [
      [
        'local_id',
        'file',
        'size',
        'published_at',
        'text',
        'message_link'
      ].join(',')
    ];

    for (const r of rows) {
      csv.push([
        q(r.id), q(r.file), q(r.size),
        q(r.date), q(r.text), q(r.link)
      ].join(','));
    }

    downloadText(
      '\uFEFF' + csv.join('\n'),
      `telegram-v76-ATTACHMENTS-${rows.length}.csv`,
      'text/csv'
    );
  };

  // ============================================================
  // V7.3 — УПРАВЛЕНИЕ АРХИВОМ
  // ============================================================

  S.save = () => {
    const ok = saveArchive();

    console.log(
      ok
        ? `Архив сохранён: ${S.records.size} сообщений`
        : 'Архив сохранить не удалось'
    );

    return ok;
  };

  S.stats = () => {
    let size = 0;

    try {
      size =
        (localStorage.getItem(
          S.archiveKey
        ) || '').length;
    } catch (e) {}

    const rows = {
      'Ключ архива':
        S.archiveKey || '(не определён)',

      'Поднято из архива':
        S.fromArchive,

      'Добрано в этом заходе':
        S.records.size -
        S.fromArchive,

      'Всего сообщений':
        S.records.size,

      'Уникальных ссылок':
        S.links.size,

      'Размер архива, символов':
        size,

      'Последнее сохранение':
        S.archiveSaved
          ? S.archiveSaved
              .toLocaleTimeString()
          : 'ещё не было'
    };

    console.table(rows);

    return rows;
  };

  S.forget = () => {
    if (!S.archiveKey) {
      console.warn(
        'Архив не определён'
      );

      return false;
    }

    try {
      localStorage.removeItem(
        S.archiveKey
      );
    } catch (e) {}

    S.records.clear();
    S.links.clear();

    S.fromArchive = 0;
    S.archiveSaved = null;
    S._cacheToken++;

    console.log(
      `Архив ${S.archiveKey} удалён, память очищена. ` +
      'Прокрутите канал заново.'
    );

    return true;
  };

  // ============================================================
  // RESCAN
  // ============================================================

  S.rescan = () => {
    scan();

    return S.status();
  };

  // ============================================================
  // STOP
  // ============================================================

  S.stop = () => {
    saveArchive();

    S.observer?.disconnect?.();

    clearInterval(
      S.safetyTimer
    );

    clearTimeout(
      S.scanTimer
    );

    console.log(
      `Collector V7 остановлен | ` +
      `${S.records.size} сообщений | ` +
      `${S.links.size} ссылок`
    );
  };

  // ============================================================
  // READY
  // ============================================================

  console.log(
    '%cTelegram Collector V7.6.1 запущен',
    'font-size:15px;font-weight:bold'
  );

  console.log(
    'Во время работы прокручивайте Telegram.'
  );

  console.log(
    'Статус: TG_EXPORT.status()'
  );

  console.log(
    'Последние сообщения: TG_EXPORT.preview(20)'
  );

  console.log(
    'Каналы: TG_EXPORT.previewChannels()'
  );

  console.log(
    'Восстановить названия: TG_EXPORT.resolveChannels()'
  );

  console.log(
    'RAW JSON: TG_EXPORT.exportJSON()'
  );

  console.log(
    'CLEAN MD: TG_EXPORT.exportMD()'
  );

  console.log(
    'CSV сообщений: TG_EXPORT.exportCSV()'
  );

  console.log(
    'Timeline: TG_EXPORT.exportTimeline()'
  );

  console.log(
    'Вложения: TG_EXPORT.attachments()'
  );

  console.log(
    'Вложения в CSV: TG_EXPORT.exportAttachmentsCSV()'
  );

  console.log(
    'Архив: TG_EXPORT.stats()'
  );

  console.log(
    'Сохранить архив сейчас: TG_EXPORT.save()'
  );

  console.log(
    'Очистить архив канала: TG_EXPORT.forget()'
  );

  console.log(
    'Остановить: TG_EXPORT.stop()'
  );
})();

Старую версию останавливать не нужно — скрипт сам снимает observer и таймеры предыдущего TG_EXPORT. После вставки прокручивайте ленту: в консоль пишется V7.6 | +N сообщений | +N ссылок.

Экспорт RAW JSON

Сырые записи со всеми blocks[], датами и картой chat_id → channel — из них можно пересобрать любой формат без повторного скрапинга.

JavaScript
TG_EXPORT.exportJSON()

Файл: telegram-v76-RAW-N-links.json. Делать до остальных экспортов.

Экспорт CLEAN Markdown

Читаемый Markdown: глобальный индекс [SW AI] Navigation, затем каждый канал с датой обновления меню и дедуплицированным списком ссылок.

JavaScript
TG_EXPORT.exportMD()

Файл: telegram-v76-CLEAN-N-links.md.

Остановка сбора

Отключает observer и таймеры. Выполнять последним — после всех экспортов.

JavaScript
TG_EXPORT.stop()

Данные остаются и в памяти вкладки, и в архиве, поэтому экспорт можно повторить позже.

Проверка статуса сбора

Таблица со счётчиками: версия, сообщения, уникальные ссылки, каналы, сопоставления chat_id → channel, сколько записей получили дату и время.

JavaScript
TG_EXPORT.status()

Форсировать пересканирование текущего DOM и сразу получить статус: TG_EXPORT.rescan().

Последние собранные сообщения

Таблица по последним N записям: канал, chat_id, дата публикации, дата меню, число ссылок и первые 80 символов текста. Аргумент необязателен, по умолчанию 20.

JavaScript
TG_EXPORT.preview(20)

Сводка по каналам

По каждому каналу — сколько сообщений, уникальных ссылок, различных дат публикации и какие chat_id к нему относятся.

JavaScript
TG_EXPORT.previewChannels()

Несколько chat_id в одной строке — признак того, что под одним именем склеились разные каналы.

Восстановление названий по chat_id

Для записей, у которых имя канала не распозналось, подставляет название по голосованию среди сообщений с тем же chat_id.

JavaScript
TG_EXPORT.resolveChannels()

Возвращает число исправленных записей. Экспорты вызывают эту логику и сами — команда нужна, чтобы увидеть цифру.

Экспорт сообщений в CSV

Плоская таблица: канал, chat_id, дата, время, дата меню, ключ сообщения, число ссылок и текст одной строкой.

JavaScript
TG_EXPORT.exportCSV()

Пишется с BOM, поэтому Excel открывает кириллицу корректно. Файл: telegram-v76-messages-N.csv.

Каталог вложений

Таблица по сообщениям с прикреплёнными файлами: локальный номер, имя файла, размер, дата, начало текста и прямая ссылка на сообщение. С 7.6 сюда попадают и файлы, выложенные без подписи.

JavaScript
TG_EXPORT.attachments()

Ссылка строится из data-mid минус 232 — Telegram Web K хранит там глобальный идентификатор, а в адресе используется локальный.

Экспорт вложений в CSV

Тот же каталог файлом: local_id, имя, размер, дата публикации, текст и ссылка на сообщение.

JavaScript
TG_EXPORT.exportAttachmentsCSV()

Перед выгрузкой принудительно сохраняет архив. Файл: telegram-v76-ATTACHMENTS-N.csv.

Экспорт хронологии публикаций

Markdown, сгруппированный по датам: под каждым днём — время, канал и начало текста. Попадают только записи, у которых определилась дата.

JavaScript
TG_EXPORT.exportTimeline()

Файл: telegram-v76-TIMELINE-N-messages.md.

Состояние архива

Ключ архива, сколько записей поднято из прошлых заходов, сколько добрано сейчас, размер архива в символах и время последнего сохранения.

JavaScript
TG_EXPORT.stats()

Архив живёт в localStorage под ключом tg_collector_v73::CHAT_ID — свой на каждый чат.

Принудительное сохранение архива

Скрипт пишет архив сам, но перед закрытием вкладки или после долгой прокрутки надёжнее вызвать вручную.

JavaScript
TG_EXPORT.save()

Если места в localStorage не хватило, скрипт повторит запись без blocks[] и предупредит: разметка Markdown у поднятых записей станет проще.

Очистка архива канала

Удаляет архив текущего чата и очищает память — сбор начнётся с нуля при следующей прокрутке.

JavaScript
TG_EXPORT.forget()

Необратимо. Сначала TG_EXPORT.exportJSON(), потом уже это.

Ничего не найдено