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

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

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 notes → Ctrl+F5 в браузере.

Сервер (n8n / Caddy)

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

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

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

YouTube: комментарии и субтитры

Всё работает на компьютере: комментарии — ytc, субтитры — yts. Результат — текстовые файлы в Downloads\yt-comments, сразу открываются в редакторе. Сервер больше не нужен: YouTube держит его IP в блоке.

Не задан — в командах будет VIDEO_ID

1. Кнопки в браузере

Перетащите кнопки мышью на панель закладок. На странице любого видео YouTube нажмите нужную — откроется окно PowerShell, скачает и откроет результат. При первом нажатии Chrome спросит «Открыть приложение?» — отметьте «Всегда разрешать для youtube.com».

Закладка: комменты
javascript:(()=>{const m=location.href.match(/(?:v=|youtu\.be\/|shorts\/|live\/|embed\/)([A-Za-z0-9_-]{11})/);if(m)location.href='ytc:c/'+m[1];else alert('Откройте страницу видео YouTube')})()
Закладка: субтитры
javascript:(()=>{const m=location.href.match(/(?:v=|youtu\.be\/|shorts\/|live\/|embed\/)([A-Za-z0-9_-]{11})/);if(m)location.href='ytc:s/'+m[1];else alert('Откройте страницу видео YouTube')})()

Если панель закладок скрыта — Ctrl+Shift+B. Вместо перетаскивания можно создать закладку вручную и вставить код выше в поле «URL». Кнопки на этой странице (под полем ID) делают то же самое для ID из поля.

2. Комментарии из PowerShell

Скачивает все комментарии и ответы, сортирует ветки по лайкам и открывает файл. Большие видео дополнительно режутся на части по ~340 КБ для загрузки в ИИ.

PowerShell
ytc "VIDEO_ID" -Open

Без -Open файл просто ляжет в папку. -Server — выгрузка через сервер n8n (сейчас не работает: IP в блоке YouTube).

3. Субтитры из PowerShell

Скачивает субтитры (ручные, а если их нет — автоматические), чистит от таймкодов и повторов, разбивает на абзацы по минуте с метками [мм:сс]. Сервер PO-токенов bgutil поднимается и закрывается сам.

PowerShell
yts "VIDEO_ID" -Open
PowerShell
yts "VIDEO_ID" -Lang en -Open

-KeepVtt — сохранить и исходный .vtt; -KeepBgutil — не закрывать bgutil (если качаете несколько видео подряд).

4. Какие субтитры есть у видео

Если yts пишет, что субтитров на нужном языке нет.

PowerShell
& "$HOME\Downloads\YTAudio\yt-dlp.exe" --list-subs "https://youtu.be/VIDEO_ID"

5. Файлы этого видео

Что уже скачано, и удаление (сначала посмотреть, потом удалить).

PowerShell
Get-ChildItem "$HOME\Downloads\yt-comments" -Recurse -Filter "VIDEO_ID*" | Select-Object FullName, Length
PowerShell
Get-ChildItem "$HOME\Downloads\yt-comments" -Recurse -Filter "VIDEO_ID*" | Remove-Item

Сырой JSON комментариев лежит в подпапке raw.

Установка (один раз)

Python-библиотека для комментариев

Ставится в Python пользователя, права администратора не нужны.

PowerShell
python -m pip install --user youtube-comment-downloader

Файлы в Scripts\YT

В папке C:\Users\User\Scripts\YT должны лежать три файла: yt-functions.ps1, ytc.py, yt-handler.ps1 (код — ниже). После копирования — разблокировать.

PowerShell
New-Item -ItemType Directory -Path "$HOME\Scripts\YT" -Force | Out-Null
Get-ChildItem "$HOME\Scripts\YT\*" | Unblock-File
Get-ChildItem "$HOME\Scripts\YT" | Select-Object Name

Файлы .ps1 сохранять в UTF-8 с BOM: без него Windows PowerShell 5.1 ломает кириллицу.

Подключить функции в профиль

Один раз. Если строка с yt-functions в профиле уже есть — не добавлять повторно.

PowerShell
Select-String -Path $PROFILE -Pattern "yt-functions"
Add-Content $PROFILE "`n. `"$HOME\Scripts\YT\yt-functions.ps1`""
. $PROFILE
Get-Command ytc, yts | Select-Object Name

Зарегистрировать протокол ytc:

Нужен для кнопок в браузере. Только для текущего пользователя, без прав администратора.

PowerShell
$key = "HKCU:\Software\Classes\ytc"
New-Item -Path "$key\shell\open\command" -Force | Out-Null
Set-ItemProperty -Path $key -Name "(default)" -Value "URL:YouTube tools"
Set-ItemProperty -Path $key -Name "URL Protocol" -Value ""
Set-ItemProperty -Path "$key\shell\open\command" -Name "(default)" -Value "`"$PSHOME\powershell.exe`" -NoProfile -ExecutionPolicy Bypass -File `"$HOME\Scripts\YT\yt-handler.ps1`" `"%1`""
Start-Process "ytc:c/VIDEO_ID"

Последняя строка — проверка без браузера. Удалить протокол: Remove-Item "HKCU:\Software\Classes\ytc" -Recurse.

yt-functions.ps1

Функции ytc, yts, запуск/остановка bgutil, очистка субтитров.

PowerShell
# yt-functions.ps1 — YouTube: комментарии и субтитры (локально)
#
#   ytc <ссылка или ID> [-Open] [-Server]           — комментарии  -> Downloads\yt-comments\ID_comments.txt
#   yts <ссылка или ID> [-Lang en] [-Open] [-KeepVtt] [-KeepBgutil]
#                                                     — субтитры    -> Downloads\yt-comments\ID_subtitles.txt
#
# Файл сохранять в UTF-8 с BOM (иначе Windows PowerShell 5.1 ломает кириллицу).

# папка, где лежат этот файл, ytc.py и yt-handler.ps1
$YtToolsDir = $PSScriptRoot

function Get-YtId {
    param([string]$Url)
    if ($Url -match '(?:v=|youtu\.be/|shorts/|live/|embed/)([A-Za-z0-9_-]{11})') { return $Matches[1] }
    if ($Url -match '^[A-Za-z0-9_-]{11}$') { return $Url }
    return $null
}

# ---------- Комментарии ----------
# По умолчанию — локально (python + ytc.py рядом с этим файлом).
# -Server — по-старому, через сервер n8n (если его IP не в блоке YouTube).

function ytc {
    param(
        [Parameter(Mandatory = $true, Position = 0)][string]$Url,
        [string]$Dest = "$HOME\Downloads\yt-comments",
        [switch]$Open,
        [switch]$Server
    )

    $id = Get-YtId $Url
    if (-not $id) {
        Write-Host "Не удалось извлечь ID видео из: $Url" -ForegroundColor Red
        return
    }

    if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest | Out-Null }

    if ($Server) {
        Write-Host "Видео $id — выгрузка на сервере..." -ForegroundColor Cyan
        ssh -t n8n "/opt/yt-comments/ytc.py '$id'"
        if ($LASTEXITCODE -ne 0) { Write-Host "Сервер вернул ошибку (код $LASTEXITCODE) — файлы не скачаны." -ForegroundColor Red; return }
        scp "n8n:/opt/yt-comments/${id}_*.txt" "$Dest\"
        if ($LASTEXITCODE -ne 0) { Write-Host "Ошибка при скачивании через scp." -ForegroundColor Red; return }
    } else {
        $script = Join-Path $YtToolsDir "ytc.py"
        if (-not (Test-Path $script)) { Write-Host "Не найден $script" -ForegroundColor Red; return }
        Write-Host "Видео $id — выгрузка комментариев..." -ForegroundColor Cyan
        $env:PYTHONIOENCODING = "utf-8"
        python $script $id $Dest
        if ($LASTEXITCODE -ne 0) { Write-Host "Выгрузка не удалась (код $LASTEXITCODE)." -ForegroundColor Red; return }
    }

    Write-Host "`nГотово. Файлы в $Dest :" -ForegroundColor Green
    Get-ChildItem -Path $Dest -Filter "${id}_*.txt" |
        ForEach-Object { "{0,10:N0} КБ  {1}" -f ($_.Length / 1KB), $_.Name }

    if ($Open) { Invoke-Item (Join-Path $Dest "${id}_comments.txt") }
}

# ---------- Субтитры (локально: yt-dlp + bgutil) ----------

function Start-Bgutil {
    # Возвращает: 'running' — сервер уже работал (его не трогаем);
    #             объект процесса — запустили сами (yts остановит его по окончании);
    #             $null — запустить не удалось.
    # Сервер запускается скрытно, без окна; его вывод пишется в %TEMP%\bgutil-provider.log
    $dir = "$HOME\bgutil-ytdlp-pot-provider\server"
    $isUp = { [bool](netstat -an | Select-String ':4416\s+\S+\s+LISTENING') }
    if (& $isUp) { return 'running' }

    if (-not (Test-Path "$dir\build\main.js")) {
        Write-Host "Не найден $dir\build\main.js" -ForegroundColor Red
        return $null
    }
    $node = Get-Command node -ErrorAction SilentlyContinue
    if (-not $node) {
        Write-Host "node не найден в PATH этого окна PowerShell." -ForegroundColor Red
        return $null
    }

    $log = Join-Path $env:TEMP "bgutil-provider.log"
    $err = Join-Path $env:TEMP "bgutil-provider.err.log"
    Write-Host "Запускаю bgutil-provider..." -ForegroundColor Cyan
    try {
        $p = Start-Process $node.Source -ArgumentList 'build\main.js' -WorkingDirectory $dir `
            -WindowStyle Hidden -PassThru -RedirectStandardOutput $log -RedirectStandardError $err
    } catch {
        Write-Host "Не удалось запустить node: $($_.Exception.Message)" -ForegroundColor Red
        return $null
    }

    for ($i = 0; $i -lt 30; $i++) {
        Start-Sleep 1
        if (& $isUp) { return $p }
        if ($p.HasExited) { break }
    }

    # не поднялся — показываем причину
    if ($p.HasExited) {
        Write-Host "bgutil-provider завершился сам (код $($p.ExitCode))." -ForegroundColor Red
    } else {
        Write-Host "bgutil-provider не открыл порт 4416 за 30 с." -ForegroundColor Red
        taskkill /PID $p.Id /T /F 2>&1 | Out-Null
    }
    Start-Sleep -Milliseconds 300
    foreach ($f in $log, $err) {
        if ((Test-Path $f) -and (Get-Item $f).Length) {
            Write-Host "--- $f ---" -ForegroundColor DarkGray
            Get-Content $f -Tail 15
        }
    }
    Write-Host "node: $($node.Source)" -ForegroundColor DarkGray
    return $null
}

function Get-Bgutil4416Pids {
    # PID процессов, которые слушают порт 4416 (последняя колонка netstat -ano)
    netstat -ano | Select-String ':4416\s+\S+\s+LISTENING\s+(\d+)' |
        ForEach-Object { [int]$_.Matches[0].Groups[1].Value } | Sort-Object -Unique
}

function Stop-Bgutil {
    param($Proc)
    if ($Proc -isnot [System.Diagnostics.Process]) { return }   # сервер работал до нас — не трогаем
    taskkill /PID $Proc.Id /T /F 2>&1 | Out-Null
    # подстраховка: если PID из Start-Process не тот (перехват окна терминалом), гасим владельца порта
    foreach ($procId in (Get-Bgutil4416Pids)) { taskkill /PID $procId /T /F 2>&1 | Out-Null }
    Write-Host "bgutil-provider остановлен." -ForegroundColor DarkGray
}

function ConvertFrom-YtVtt {
    param(
        [Parameter(Mandatory = $true)][string]$Path,
        [int]$ParagraphSeconds = 60
    )
    $start = $null
    $last1 = ''; $last2 = ''
    $kept = New-Object System.Collections.Generic.List[object]

    foreach ($l in (Get-Content -LiteralPath $Path -Encoding UTF8)) {
        if ($l -match '^(?:(\d+):)?(\d{2}):(\d{2})\.\d{3}\s+-->') {
            $h = 0; if ($Matches[1]) { $h = [int]$Matches[1] }
            $start = $h * 3600 + [int]$Matches[2] * 60 + [int]$Matches[3]
            continue
        }
        if ($null -eq $start) { continue }
        $t = $l -replace '<[^>]+>', ''
        $t = [System.Net.WebUtility]::HtmlDecode($t)
        $t = ($t -replace '\s+', ' ').Trim()
        if (-not $t) { continue }
        if ($t -eq $last1 -or $t -eq $last2) { continue }
        $kept.Add([pscustomobject]@{ s = $start; t = $t })
        $last2 = $last1; $last1 = $t
    }

    $sb = New-Object System.Text.StringBuilder
    $paraStart = -1
    foreach ($k in $kept) {
        if ($paraStart -lt 0 -or $k.s -ge $paraStart + $ParagraphSeconds) {
            if ($sb.Length) { [void]$sb.Append("`r`n`r`n") }
            $ts = [TimeSpan]::FromSeconds($k.s)
            $label = if ($ts.Hours -gt 0) { '{0}:{1:00}:{2:00}' -f $ts.Hours, $ts.Minutes, $ts.Seconds } else { '{0:00}:{1:00}' -f $ts.Minutes, $ts.Seconds }
            [void]$sb.Append("[$label] ")
            $paraStart = $k.s
        } else {
            [void]$sb.Append(' ')
        }
        [void]$sb.Append($k.t)
    }
    return $sb.ToString()
}


function yts {
    param(
        [Parameter(Mandatory = $true, Position = 0)][string]$Url,
        [string]$Lang = "ru",
        [string]$Dest = "$HOME\Downloads\yt-comments",
        [switch]$Open,
        [switch]$KeepVtt,
        [switch]$KeepBgutil
    )

    $id = Get-YtId $Url
    if (-not $id) { Write-Host "Не удалось извлечь ID видео из: $Url" -ForegroundColor Red; return }

    $ytdlp = "$HOME\Downloads\YTAudio\yt-dlp.exe"
    if (-not (Test-Path $ytdlp)) { Write-Host "Не найден $ytdlp" -ForegroundColor Red; return }

    $bg = Start-Bgutil
    if (-not $bg) { return }

    try {
        if (-not (Test-Path $Dest)) { New-Item -ItemType Directory -Path $Dest | Out-Null }
        $tmp = Join-Path $Dest "_subs_$id"
        if (-not (Test-Path $tmp)) { New-Item -ItemType Directory -Path $tmp | Out-Null }

        Write-Host "Видео $id — скачиваю субтитры ($Lang)..." -ForegroundColor Cyan
        & $ytdlp --no-update --skip-download --write-subs --write-auto-subs `
            --sub-langs "$Lang.*,$Lang" --sub-format vtt --write-info-json `
            --extractor-args "youtube:player_client=mweb,ios" --js-runtimes node `
            -o "$tmp\%(id)s" "https://www.youtube.com/watch?v=$id"

        $vtts = @(Get-ChildItem -LiteralPath $tmp -Filter "$id.*.vtt" -ErrorAction SilentlyContinue)
        if (-not $vtts.Count) {
            Write-Host "Субтитров на языке '$Lang' нет (или загрузка не удалась)." -ForegroundColor Red
            Write-Host "Какие есть: & `"$ytdlp`" --list-subs https://youtu.be/$id" -ForegroundColor Yellow
            Remove-Item -LiteralPath $tmp -Recurse -Force
            return
        }

        # ручные субтитры (ID.ru.vtt) приоритетнее автоматических (ID.ru-orig.vtt)
        $vtt = $vtts | Where-Object { $_.Name -eq "$id.$Lang.vtt" } | Select-Object -First 1
        if (-not $vtt) { $vtt = $vtts | Sort-Object Name | Select-Object -First 1 }

        $meta = $null
        $info = Join-Path $tmp "$id.info.json"
        if (Test-Path $info) {
            try { $meta = Get-Content -LiteralPath $info -Raw -Encoding UTF8 | ConvertFrom-Json } catch {}
        }

        $body = ConvertFrom-YtVtt -Path $vtt.FullName
        $hdr = @()
        if ($meta) {
            $hdr += "Название: $($meta.title)"
            $hdr += "Канал: $($meta.channel)"
            if ($meta.upload_date) { $hdr += "Дата: " + ($meta.upload_date -replace '^(\d{4})(\d{2})(\d{2})$', '$3.$2.$1') }
            if ($meta.duration_string) { $hdr += "Длительность: $($meta.duration_string)" }
        }
        $hdr += "Ссылка: https://youtu.be/$id"
        $hdr += "Субтитры: $($vtt.Name)"
        $text = ($hdr -join "`r`n") + "`r`n`r`n" + $body + "`r`n"

        $out = Join-Path $Dest "${id}_subtitles.txt"
        [System.IO.File]::WriteAllText($out, $text, (New-Object System.Text.UTF8Encoding($true)))

        if ($KeepVtt) { Copy-Item -LiteralPath $vtt.FullName -Destination $Dest -Force }
        Remove-Item -LiteralPath $tmp -Recurse -Force

        $words = ($body -split '\s+' | Where-Object { $_ -and $_ -notmatch '^\[\d' }).Count
        Write-Host ("`nГотово: {0}  ({1:N0} КБ, ~{2:N0} слов)" -f $out, ((Get-Item $out).Length / 1KB), $words) -ForegroundColor Green

        if ($Open) { Invoke-Item $out }
    }
    finally {
        if (-not $KeepBgutil) { Stop-Bgutil $bg }
    }
}

ytc.py

Выгрузка комментариев: до 3 попыток, диагностика ответа YouTube при неудаче, удаление дублей, ветки по лайкам, нарезка на части.

Python
# ytc.py — выгрузка комментариев YouTube в читаемый текст
# Запуск: python ytc.py <ссылка или ID> [папка-результата]
import sys, re, json, os, time
import requests
from collections import Counter
from youtube_comment_downloader import YoutubeCommentDownloader, SORT_BY_RECENT, SORT_BY_POPULAR
from youtube_comment_downloader.downloader import USER_AGENT

LIMIT = 340_000  # байт на часть

def get_vid(arg):
    m = re.search(r"(?:v=|youtu\.be/|shorts/|live/|embed/)([A-Za-z0-9_-]{11})", arg)
    if m: return m.group(1)
    if re.fullmatch(r"[A-Za-z0-9_-]{11}", arg): return arg
    sys.exit("ERROR: cannot extract video ID from: " + arg)

def votes(v):
    v = str(v or "0").replace(",", ".").replace("\xa0", "").strip().upper()
    mult = 1
    if v.endswith("K"): mult, v = 1000, v[:-1]
    try: return int(float(v) * mult)
    except: return 0

def line(r):
    tag = " ♥" if r.get("heart") else ""
    t = " ".join(r.get("text", "").split())
    return f"[{votes(r.get('votes'))}👍{tag}] {r.get('author','')}: {t}"

if len(sys.argv) < 2: sys.exit("usage: ytc.py <url or id> [output dir]")
vid = get_vid(sys.argv[1])
out_dir = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
raw_dir = os.path.join(out_dir, "raw")
os.makedirs(raw_dir, exist_ok=True)
base = os.path.join(out_dir, vid)

def probe(vid):
    """Что YouTube отдаёт на страницу видео — для диагностики, когда комментариев 0."""
    s = requests.Session()
    s.headers["User-Agent"] = USER_AGENT
    s.cookies.set("CONSENT", "YES+cb", domain=".youtube.com")
    try:
        r = s.get(f"https://www.youtube.com/watch?v={vid}", timeout=30)
    except Exception as e:
        return f"page request failed: {e}"
    h = r.text
    return (f"HTTP {r.status_code} | consent page: {'consent' in r.url} | "
            f"ytcfg: {'ytcfg.set' in h} | comments block: {'continuationItemRenderer' in h} | "
            f"bot check: {('not a bot' in h) or ('confirm you' in h.lower())}")

def download(vid, sort_by):
    seen, rows = set(), []
    for r in YoutubeCommentDownloader().get_comments(vid, sort_by=sort_by):
        if r["cid"] in seen: continue
        seen.add(r["cid"]); rows.append(r)
        if len(rows) % 50 == 0:
            print(f"\rdownloaded: {len(rows)}", end="", flush=True)
    return rows

rows = []
attempts = [(SORT_BY_RECENT, "recent"), (SORT_BY_POPULAR, "popular"), (SORT_BY_RECENT, "recent")]
for n, (sort_by, name) in enumerate(attempts, 1):
    try:
        rows = download(vid, sort_by)
    except json.JSONDecodeError:
        print(f"attempt {n} ({name}): page without data")
    except RuntimeError as e:
        print(f"attempt {n} ({name}): {e}")
    if rows:
        break
    print(f"attempt {n} ({name}): 0 comments | {probe(vid)}")
    if n < len(attempts):
        time.sleep(8)

print(f"\rdownloaded: {len(rows)}")
if not rows:
    sys.exit("ERROR: 0 comments after 3 attempts (see diagnostics above)")

with open(os.path.join(raw_dir, f"{vid}.json"), "w", encoding="utf-8") as raw:
    for r in rows:
        raw.write(json.dumps(r, ensure_ascii=False) + "\n")

tops, replies = [], {}
for r in rows:
    if r.get("reply"): replies.setdefault(r["cid"].split(".")[0], []).append(r)
    else: tops.append(r)
tops.sort(key=lambda r: votes(r.get("votes")), reverse=True)

threads = []
for i, r in enumerate(tops, 1):
    t = f"#{i} {line(r)}"
    for rep in replies.get(r["cid"], []):
        t += f"\n    ↳ {line(rep)}"
    threads.append(t)

full = "\n\n".join(threads) + "\n"
open(f"{base}_comments.txt", "w", encoding="utf-8-sig").write(full)

nparts = 0
if len(full.encode("utf-8")) > LIMIT:
    parts, cur, size = [], [], 0
    for t in threads:
        b = len(t.encode("utf-8")) + 2
        if cur and size + b > LIMIT:
            parts.append(cur); cur, size = [], 0
        cur.append(t); size += b
    if cur: parts.append(cur)
    for i, p in enumerate(parts, 1):
        open(f"{base}_part{i}.txt", "w", encoding="utf-8-sig").write("\n\n".join(p) + "\n")
    nparts = len(parts)

print(f"video: {vid} | total: {len(rows)} | top-level: {len(tops)} | replies: {sum(map(len, replies.values()))} | parts: {nparts}")
print("top authors:")
for a, n in Counter(r.get("author", "") for r in rows).most_common(5):
    print(f"  {n:4}  {a}")
print("DONE")

yt-handler.ps1

Принимает ссылку ytc:c/ID или ytc:s/ID, берёт из неё только ID и запускает ytc или yts.

PowerShell
# yt-handler.ps1 — обработчик ссылок ytc:c/ID (комментарии) и ytc:s/ID (субтитры)
# Вызывается Windows по протоколу ytc: (кнопки-закладки в браузере).
# Из полученной ссылки берётся только команда (c/s) и 11-символьный ID — всё остальное отбрасывается.

param([string]$Uri = "")

$Host.UI.RawUI.WindowTitle = "YouTube tools"

if ($Uri -notmatch '^ytc:(?://)?([cs])/([A-Za-z0-9_-]{11})/?$') {
    Write-Host "Непонятная ссылка: $Uri" -ForegroundColor Red
    Write-Host "Ожидается ytc:c/ID (комментарии) или ytc:s/ID (субтитры)."
    Read-Host "Enter — закрыть"
    exit 1
}
$mode = $Matches[1]
$id   = $Matches[2]

# функции лежат в той же папке, что и этот обработчик
. (Join-Path $PSScriptRoot "yt-functions.ps1")

$dest  = "$HOME\Downloads\yt-comments"
$start = Get-Date

if ($mode -eq 'c') {
    Write-Host "Комментарии: $id`n" -ForegroundColor Cyan
    ytc $id -Open
    $out = Join-Path $dest "${id}_comments.txt"
} else {
    Write-Host "Субтитры: $id`n" -ForegroundColor Cyan
    yts $id -Open
    $out = Join-Path $dest "${id}_subtitles.txt"
}

if ((Test-Path $out) -and (Get-Item $out).LastWriteTime -ge $start.AddSeconds(-2)) {
    Write-Host "`nОкно закроется через 5 секунд..." -ForegroundColor DarkGray
    Start-Sleep 5
} else {
    Write-Host "`nРезультат не получен — смотрите сообщения выше." -ForegroundColor Red
    Read-Host "Enter — закрыть"
}

Поиск в выводе 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.6: архив между заходами, прямые ссылки на сообщения и каталог вложений. Источник и история правок — исходный чат ChatGPT.

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

Вставить целиком в консоль браузера на веб-версии Telegram. Сохраняет все сообщения, вытаскивает дату и время публикации, восстанавливает название канала по chat_id и — с версии 7.3 — копит записи в localStorage отдельно по каждому чату, поэтому повторные заходы дополняют архив, а не начинают с нуля. В 7.5 добавились прямой адрес каждого сообщения (t.me/c/чат/номер) и имя файла-вложения, в 7.6 — сообщения-файлы без подписи, которые прошлая версия пропускала целиком; 7.6.1 чинит падение на таких сообщениях. 7.6.2 берёт текст из .message, когда у сообщения нет класса translatable-message (без этого выпадали целые посты), отсекает из текста бейдж времени и реакции и обходит только пузыри ленты — панель закрепа и правая панель несут те же data-mid и перехватывали дедупликацию. 7.6.3 не теряет ссылки, вложенные в жирный текст: разбор блоков на <strong> отдавал заголовок и внутрь не шёл — в одном посте так пропадало до 41 ссылки из 118. 7.6.4 добавляет автопрокрутку TG_EXPORT.autoscroll(): лента листается сама с паузой на отрисовку и скан, архив сохраняется каждые 20 шагов, остановка — TG_EXPORT.scrollStop(). 7.6.5 переносит архив в IndexedDB: в localStorage около 5 МБ на весь сайт, и этот запас общий для всех каналов, поэтому длинные посты упирались в потолок за пару сотен записей. Старый архив переносится сам при первом запуске. Обзор занятого места — TG_EXPORT.storage(), очистка архивов других каналов — TG_EXPORT.forgetOthers(). 7.6.6 чинит автопрокрутку: запасной селектор querySelector('.scrollable-y, .scrollable') возвращает первый подходящий элемент в документе, и им оказывалась горизонтальная полоса .scrollable-x высотой 92 px — лента не двигалась, а сбор рапортовал, что всё готово. Теперь кандидаты проверяются делом: контейнер должен содержать ленту, иметь запас прокрутки и реально сдвигаться. Список кандидатов — TG_EXPORT.scroller().

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.6',

    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.
  // ------------------------------------------------------------

  /*
    V7.6.2: служебная обвязка внутри .message —
    бейдж времени, реакции, блок ответа. В тексте
    сообщения ей не место.
  */

  const NOISE_SELECTOR =
    '.time, .message-time, .bubble-time, ' +
    '.reactions, .reply, .quote-like, .quote, ' +
    '.reply-content, .bubble-select-checkbox';

  function isNoise(el) {
    return !!el.matches?.(NOISE_SELECTOR);
  }

  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 (isNoise(el)) {
      return '';
    }

    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'
      )
    ];

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

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

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

    /*
      V7.6.2 — ЗАПАСНОЙ ПУТЬ ПО .message

      Telegram вешает .translatable-message не на каждое
      сообщение: из восьми постов ленты класс оказался
      только у трёх, а пять новых (включая всё обновление
      от 15.10) остались без него и целиком выпадали
      из сбора.

      Контейнер .message есть всегда. В нём, кроме текста,
      лежат бейдж времени и реакции — они отсекаются
      в readNode и parseBlocks.
    */

    const plain = [
      ...bubble.querySelectorAll('.message')
    ].filter(
      el =>
        !isInsideQuote(el, bubble) &&
        !el.querySelector('.translatable-message')
    );

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

    return plain.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 (isNoise(el)) {
        return;
      }

      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 }
        );

        /*
          V7.6.3: внутри жирного заголовка могут лежать
          ссылки. Раньше обход тут заканчивался, и такие
          ссылки пропадали из blocks — в одном посте
          терялось до 41 штуки из 118.
        */

        if (el.querySelector?.('a.anchor-url')) {
          for (
            const child
            of el.childNodes
          ) {
            walk(child);
          }
        }

        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;
  S.archiveStore = '';

  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;

    /*
      V7.6.5: архив лежит в IndexedDB. Чтение оттуда
      асинхронное, поэтому стартуем с localStorage (мгновенно),
      а содержимое IndexedDB подтягиваем следом и дописываем.
    */

    let raw = '';

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

    idbGet(S.archiveKey).then(
      fromIdb => {
        if (fromIdb) {
          restoreArchive(fromIdb, 'IndexedDB');

          /*
            Старый архив в localStorage больше не нужен —
            он только занимает общий пятимегабайтный запас.
          */

          if (raw) {
            try {
              localStorage.removeItem(S.archiveKey);
              console.log(
                'Старый архив из localStorage перенесён ' +
                'в IndexedDB и удалён.'
              );
            } catch (e) {}
          }

          return;
        }

        if (raw) {
          /*
            Первый запуск после обновления: переносим
            то, что было, в новое хранилище.
          */

          idbPut(S.archiveKey, raw).then(
            res => {
              if (res === null) return;

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

              S.archiveStore = 'IndexedDB';

              console.log(
                'Архив перенесён в IndexedDB — ' +
                'ограничение в 5 МБ больше не действует.'
              );
            }
          );
        } else {
          console.log(
            `Архив пуст, начинаем с нуля (${S.archiveKey})`
          );
        }
      }
    );

    if (!raw) {
      return true;
    }

    restoreArchive(raw, 'localStorage');

    return true;
  }

  /*
    Разбирает сохранённый архив и дописывает записи в память.
    Вызывается дважды: сразу из localStorage (если там что-то
    осталось) и позже из IndexedDB.
  */

  function restoreArchive(raw, where) {
    let data = null;

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

      return 0;
    }

    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.archiveStore = where;
    S._cacheToken++;

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

    return loaded;
  }

  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
    });
  }

  // ============================================================
  // V7.6.5 — ХРАНИЛИЩЕ: IndexedDB вместо localStorage
  // ============================================================

  /*
    В localStorage на весь сайт около 5 МБ, и этот запас общий
    для всех каналов: архивы прошлых сборов лежат там же и место
    не освобождают. Длинные посты с богатой разметкой упираются
    в потолок за пару сотен записей — blocks занимают больше
    половины объёма.

    IndexedDB даёт на порядки больше (обычно доля свободного
    диска), поэтому архив живёт там. localStorage остаётся
    запасным путём, если IndexedDB недоступна.
  */

  const IDB_NAME = 'tg_collector';
  const IDB_STORE = 'archives';

  let idbPromise = null;

  function openDB() {
    if (idbPromise) return idbPromise;

    idbPromise = new Promise(
      (resolve) => {
        let req;

        try {
          req = indexedDB.open(IDB_NAME, 1);
        } catch (e) {
          resolve(null);
          return;
        }

        req.onupgradeneeded = () => {
          const db = req.result;

          if (
            !db.objectStoreNames
              .contains(IDB_STORE)
          ) {
            db.createObjectStore(IDB_STORE);
          }
        };

        req.onsuccess = () => resolve(req.result);
        req.onerror = () => resolve(null);
        req.onblocked = () => resolve(null);
      }
    );

    return idbPromise;
  }

  function idbRun(mode, fn) {
    return openDB().then(
      db => {
        if (!db) return null;

        return new Promise(
          (resolve) => {
            let tx;

            try {
              tx = db.transaction(IDB_STORE, mode);
            } catch (e) {
              resolve(null);
              return;
            }

            const store =
              tx.objectStore(IDB_STORE);

            let out = null;

            try {
              out = fn(store);
            } catch (e) {
              resolve(null);
              return;
            }

            tx.oncomplete = () =>
              resolve(
                out && 'result' in out
                  ? out.result
                  : out
              );

            tx.onerror = () => resolve(null);
            tx.onabort = () => resolve(null);
          }
        );
      }
    );
  }

  const idbGet = key =>
    idbRun('readonly', st => st.get(key));

  const idbPut = (key, value) =>
    idbRun('readwrite', st => st.put(value, key));

  const idbDel = key =>
    idbRun('readwrite', st => st.delete(key));

  const idbKeys = () =>
    idbRun('readonly', st => st.getAllKeys());

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

    archiveDirty = false;

    const payload =
      serializeArchive(true);

    /*
      Основной путь — IndexedDB. Запись асинхронная, поэтому
      результат узнаём позже; размер архива там не ограничен
      пятью мегабайтами.
    */

    idbPut(S.archiveKey, payload).then(
      res => {
        if (res !== null) {
          S.archiveSaved = new Date();
          S.archiveStore = 'IndexedDB';
          return;
        }

        /*
          IndexedDB недоступна (приватный режим, запрет
          хранилища) — пробуем localStorage, как раньше.
        */

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

            S.archiveSaved = new Date();
            S.archiveStore = 'localStorage';

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

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

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

    return true;
  }

  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 и возможность
      отличить тело сообщения от блока цитаты.
    */

    /*
      V7.6.2: берём только пузыри самой ленты.

      Тот же data-mid несут панель закреплённого
      сообщения (идёт в разметке раньше ленты) и строки
      правой панели. Дедупликация по mid помечала мид
      виденным на них, и настоящий пузырь пропускался.
    */

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

    const inFeed =
      allMids.filter(
        el =>
          el.closest?.('.bubbles') &&
          !el.closest?.('.pinned-container')
      );

    const bubbles =
      inFeed.length
        ? inFeed
        : allMids;

    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.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.6',

      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.archiveStore || 'ещё не сохраняли',

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

    console.table(rows);

    return rows;
  };

  /*
    Что и сколько занято — по всем каналам сразу.
    Место общее, поэтому архив чужого канала так же мешает,
    как свой.
  */

  S.storage = async () => {
    const rows = [];

    try {
      for (
        let i = 0;
        i < localStorage.length;
        i++
      ) {
        const k = localStorage.key(i);

        if (!k || k.indexOf(ARCHIVE_PREFIX) !== 0) {
          continue;
        }

        rows.push({
          'Хранилище': 'localStorage',
          'Ключ': k,
          'Символов': (localStorage.getItem(k) || '').length,
          'Текущий': k === S.archiveKey ? 'да' : ''
        });
      }
    } catch (e) {}

    const keys = await idbKeys();

    for (const k of (keys || [])) {
      const v = await idbGet(k);

      rows.push({
        'Хранилище': 'IndexedDB',
        'Ключ': k,
        'Символов': (v || '').length,
        'Текущий': k === S.archiveKey ? 'да' : ''
      });
    }

    if (!rows.length) {
      console.log('Архивов не найдено.');
      return [];
    }

    console.table(rows);

    const total =
      rows.reduce(
        (a, r) => a + r['Символов'],
        0
      );

    const ls =
      rows
        .filter(r => r['Хранилище'] === 'localStorage')
        .reduce((a, r) => a + r['Символов'], 0);

    console.log(
      `Всего ${(total / 1024 / 1024).toFixed(2)} МБ ` +
      `в ${rows.length} архивах. ` +
      `В localStorage ${(ls / 1024 / 1024).toFixed(2)} МБ ` +
      `(потолок около 5 МБ).`
    );

    if (ls > 1024 * 1024) {
      console.log(
        'Освободить место от чужих каналов: ' +
        'TG_EXPORT.forgetOthers()'
      );
    }

    return rows;
  };

  /*
    Удаляет архивы всех каналов, кроме открытого.
  */

  S.forgetOthers = async () => {
    let freed = 0;
    let n = 0;

    try {
      const doomed = [];

      for (
        let i = 0;
        i < localStorage.length;
        i++
      ) {
        const k = localStorage.key(i);

        if (
          k &&
          k.indexOf(ARCHIVE_PREFIX) === 0 &&
          k !== S.archiveKey
        ) {
          doomed.push(k);
        }
      }

      for (const k of doomed) {
        freed += (localStorage.getItem(k) || '').length;
        localStorage.removeItem(k);
        n++;
      }
    } catch (e) {}

    const keys = await idbKeys();

    for (const k of (keys || [])) {
      if (k === S.archiveKey) continue;

      const v = await idbGet(k);

      freed += (v || '').length;

      await idbDel(k);
      n++;
    }

    console.log(
      `Удалено архивов: ${n}. ` +
      `Освобождено ${(freed / 1024 / 1024).toFixed(2)} МБ. ` +
      `Архив текущего канала не тронут.`
    );

    return { removed: n, freed };
  };

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

      return false;
    }

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

    idbDel(S.archiveKey);

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

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

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

    return true;
  };

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

  // ============================================================
  // AUTOSCROLL
  // ============================================================

  /*
    Прокручивать длинный канал руками — часы. Здесь прокрутка
    идёт сама: шаг, пауза на отрисовку и скан, проверка прогресса.

    Telegram подгружает старые сообщения, когда лента доходит
    до края, поэтому останавливаемся не по первому пустому шагу,
    а когда подряд idleLimit шагов не дали ни новых записей,
    ни сдвига полосы прокрутки.
  */

  /*
    V7.6.6: контейнер проверяется делом, а не по имени класса.

    Прошлая версия брала querySelector('.scrollable-y, .scrollable'),
    а он возвращает первый подходящий элемент В ДОКУМЕНТЕ, и в
    Telegram им оказывалась горизонтальная полоса
    .scrollable.scrollable-x высотой 92 пикселя. Прокрутка в неё
    не проходила, автопрокрутка крутилась вхолостую.

    Теперь собираем всех кандидатов, оставляем тех, кто содержит
    ленту и имеет запас прокрутки, и пробуем реально сдвинуть.
  */

  function scrollCandidates() {
    const bubbles =
      document.querySelector('.bubbles');

    const seen = new Set();
    const out = [];

    const add = el => {
      if (
        !el ||
        el === document.body ||
        seen.has(el)
      ) {
        return;
      }

      seen.add(el);
      out.push(el);
    };

    // сам контейнер ленты и его предки
    let el = bubbles;

    while (
      el &&
      el !== document.body
    ) {
      add(el);
      el = el.parentElement;
    }

    // плюс всё, что Telegram помечает как прокручиваемое
    for (
      const e
      of document.querySelectorAll(
        '.scrollable-y, .scrollable, [class*="scroll"]'
      )
    ) {
      add(e);
    }

    return out.filter(
      e => {
        // контейнер должен держать ленту
        const holdsFeed =
          (bubbles && e.contains(bubbles)) ||
          e.querySelector?.('[data-mid]');

        if (!holdsFeed) return false;

        // и иметь что прокручивать
        return (
          e.scrollHeight >
          e.clientHeight + 100
        );
      }
    );
  }

  function canScroll(el) {
    const was = el.scrollTop;

    el.scrollTop = was + 40;

    if (el.scrollTop !== was) {
      el.scrollTop = was;
      return true;
    }

    // уже у нижнего края — пробуем вверх
    el.scrollTop = Math.max(0, was - 40);

    const moved =
      el.scrollTop !== was;

    el.scrollTop = was;

    return moved;
  }

  function findScroller() {
    const list =
      scrollCandidates().sort(
        (a, b) =>
          (b.scrollHeight - b.clientHeight) -
          (a.scrollHeight - a.clientHeight)
      );

    for (const el of list) {
      if (canScroll(el)) {
        return el;
      }
    }

    return null;
  }

  /*
    Что именно коллектор считает лентой — для разбора проблем.
  */

  S.scroller = () => {
    const list = scrollCandidates();

    const rows =
      list.map(
        el => ({
          'Класс':
            String(
              typeof el.className === 'string'
                ? el.className
                : el.getAttribute?.('class') || ''
            ).slice(0, 44),

          'Высота': el.clientHeight,
          'Прокрутка': el.scrollHeight,
          'Запас': el.scrollHeight - el.clientHeight,
          'Двигается': canScroll(el) ? 'да' : 'нет'
        })
      );

    if (!rows.length) {
      console.warn(
        'Кандидатов нет. Откройте канал и попробуйте снова.'
      );

      return [];
    }

    console.table(rows);

    const picked = findScroller();

    console.log(
      picked
        ? 'Выбран контейнер с запасом ' +
          (picked.scrollHeight - picked.clientHeight) + ' px'
        : 'Ни один контейнер не двигается — ' +
          'автопрокрутка работать не сможет.'
    );

    return rows;
  };

  S.autoscroll = async (options = {}) => {
    const {
      direction = 'up',   // 'up' — к старым, 'down' — к новым
      step = 0.75,        // доля высоты экрана за шаг
      pause = 450,        // мс на отрисовку и скан
      idleLimit = 14,     // шагов без прогресса до остановки
      maxSteps = 100000
    } = options;

    const scroller =
      findScroller();

    if (!scroller) {
      console.warn(
        'Не нашёл контейнер, который реально прокручивается. ' +
        'Посмотрите кандидатов: TG_EXPORT.scroller()'
      );

      return null;
    }

    console.log(
      'Контейнер: запас прокрутки ' +
      (scroller.scrollHeight - scroller.clientHeight) + ' px'
    );

    if (S.scrolling) {
      console.warn(
        'Автопрокрутка уже идёт. Остановить: TG_EXPORT.scrollStop()'
      );

      return null;
    }

    S.scrolling = true;
    S.scrollStopFlag = false;

    const sign =
      direction === 'down' ? 1 : -1;

    const startedRecords =
      S.records.size;

    const startedLinks =
      S.links.size;

    const startedAt =
      Date.now();

    let idle = 0;
    let steps = 0;
    const startTop = scroller.scrollTop;
    let lastTop = scroller.scrollTop;
    let lastCount = S.records.size;

    console.log(
      `Автопрокрутка пошла (${direction}). ` +
      `Остановить: TG_EXPORT.scrollStop()`
    );

    const sleep =
      ms =>
        new Promise(
          r => setTimeout(r, ms)
        );

    while (
      steps < maxSteps &&
      !S.scrollStopFlag
    ) {
      scroller.scrollTop +=
        sign *
        Math.max(
          200,
          Math.round(
            scroller.clientHeight * step
          )
        );

      steps++;

      await sleep(pause);

      scan();

      const movedBy =
        Math.abs(
          scroller.scrollTop - lastTop
        );

      const gained =
        S.records.size - lastCount;

      if (
        movedBy < 5 &&
        gained === 0
      ) {
        idle++;
      } else {
        idle = 0;
      }

      lastTop = scroller.scrollTop;
      lastCount = S.records.size;

      if (steps % 20 === 0) {
        console.log(
          `  шаг ${steps} | ` +
          `+${S.records.size - startedRecords} сообщений | ` +
          `+${S.links.size - startedLinks} ссылок`
        );

        S.save?.();
      }

      /*
        Если за первые шаги полоса не сдвинулась ни разу,
        дело не в конце ленты, а в контейнере.
      */

      if (
        steps === 5 &&
        Math.abs(
          scroller.scrollTop - startTop
        ) < 5
      ) {
        console.warn(
          'Лента не двигается — похоже, выбран не тот контейнер. ' +
          'Проверьте: TG_EXPORT.scroller()'
        );

        break;
      }

      if (idle >= idleLimit) {
        break;
      }
    }

    S.scrolling = false;

    scan();
    S.save?.();

    const secs =
      Math.round(
        (Date.now() - startedAt) / 1000
      );

    const reason =
      S.scrollStopFlag
        ? 'остановлено вручную'
        : idle >= idleLimit
          ? 'лента кончилась'
          : 'достигнут предел шагов';

    console.log(
      `Автопрокрутка завершена: ${reason}. ` +
      `Шагов ${steps}, ${secs} с. ` +
      `Добавлено ${S.records.size - startedRecords} сообщений / ` +
      `${S.links.size - startedLinks} ссылок. ` +
      `Всего ${S.records.size} / ${S.links.size}.`
    );

    return {
      steps,
      seconds: secs,
      added_messages:
        S.records.size - startedRecords,
      added_links:
        S.links.size - startedLinks,
      reason
    };
  };

  S.scrollStop = () => {
    if (!S.scrolling) {
      console.log('Автопрокрутка не запущена.');
      return false;
    }

    S.scrollStopFlag = true;

    console.log('Останавливаю после текущего шага…');

    return true;
  };

  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.6 запущен',
    '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.storage()'
  );

  console.log(
    'Удалить архивы других каналов: TG_EXPORT.forgetOthers()'
  );

  console.log(
    'Автопрокрутка: TG_EXPORT.autoscroll()'
  );

  console.log(
    'Проверить контейнер прокрутки: TG_EXPORT.scroller()'
  );

  console.log(
    'Прервать прокрутку: TG_EXPORT.scrollStop()'
  );

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

Старую версию останавливать не нужно — скрипт сам снимает observer и таймеры предыдущего TG_EXPORT. После вставки прокручивайте ленту: в консоль пишется V7.6.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(), потом уже это.

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