Заметки и сниппеты
Личная коллекция команд и запросов
PostgreSQL / Supabase
Колонки таблицы одной строкой
Список колонок через запятую — для копирования в SELECT, INSERT или быстрой инспекции схемы.
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.
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;
Атомарная архивация статьи
Удаление из основной таблицы с одновременной вставкой в архив. Атомарно — без промежуточных состояний.
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 назад в основную таблицу.
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.
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-вызовов.
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>.
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.
docker exec n8n-caddy-1 caddy reload --config /etc/caddy/Caddyfile
YouTube: комментарии и субтитры
Всё работает на компьютере: комментарии — ytc, субтитры — yts. Результат — текстовые файлы в Downloads\yt-comments, сразу открываются в редакторе. Сервер больше не нужен: YouTube держит его IP в блоке.
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 КБ для загрузки в ИИ.
ytc "VIDEO_ID" -Open
Без -Open файл просто ляжет в папку. -Server — выгрузка через сервер n8n (сейчас не работает: IP в блоке YouTube).
3. Субтитры из PowerShell
Скачивает субтитры (ручные, а если их нет — автоматические), чистит от таймкодов и повторов, разбивает на абзацы по минуте с метками [мм:сс]. Сервер PO-токенов bgutil поднимается и закрывается сам.
yts "VIDEO_ID" -Open
yts "VIDEO_ID" -Lang en -Open
-KeepVtt — сохранить и исходный .vtt; -KeepBgutil — не закрывать bgutil (если качаете несколько видео подряд).
4. Какие субтитры есть у видео
Если yts пишет, что субтитров на нужном языке нет.
& "$HOME\Downloads\YTAudio\yt-dlp.exe" --list-subs "https://youtu.be/VIDEO_ID"
5. Файлы этого видео
Что уже скачано, и удаление (сначала посмотреть, потом удалить).
Get-ChildItem "$HOME\Downloads\yt-comments" -Recurse -Filter "VIDEO_ID*" | Select-Object FullName, Length
Get-ChildItem "$HOME\Downloads\yt-comments" -Recurse -Filter "VIDEO_ID*" | Remove-Item
Сырой JSON комментариев лежит в подпапке raw.
Установка (один раз)
Python-библиотека для комментариев
Ставится в Python пользователя, права администратора не нужны.
python -m pip install --user youtube-comment-downloader
Файлы в Scripts\YT
В папке C:\Users\User\Scripts\YT должны лежать три файла: yt-functions.ps1, ytc.py, yt-handler.ps1 (код — ниже). После копирования — разблокировать.
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 в профиле уже есть — не добавлять повторно.
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:
Нужен для кнопок в браузере. Только для текущего пользователя, без прав администратора.
$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, очистка субтитров.
# 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 при неудаче, удаление дублей, ветки по лайкам, нарезка на части.
# 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.
# 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 строк после маркера.
# Записать всю сессию в файл
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, вывод одновременно идёт на экран и в лог.
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().
(() => {
// ============================================================
// 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 — из них можно пересобрать любой формат без повторного скрапинга.
TG_EXPORT.exportJSON()
Файл: telegram-v76-RAW-N-links.json. Делать до остальных экспортов.
Экспорт CLEAN Markdown
Читаемый Markdown: глобальный индекс [SW AI] Navigation, затем каждый канал с датой обновления меню и дедуплицированным списком ссылок.
TG_EXPORT.exportMD()
Файл: telegram-v76-CLEAN-N-links.md.
Остановка сбора
Отключает observer и таймеры. Выполнять последним — после всех экспортов.
TG_EXPORT.stop()
Данные остаются и в памяти вкладки, и в архиве, поэтому экспорт можно повторить позже.
Проверка статуса сбора
Таблица со счётчиками: версия, сообщения, уникальные ссылки, каналы, сопоставления chat_id → channel, сколько записей получили дату и время.
TG_EXPORT.status()
Форсировать пересканирование текущего DOM и сразу получить статус: TG_EXPORT.rescan().
Последние собранные сообщения
Таблица по последним N записям: канал, chat_id, дата публикации, дата меню, число ссылок и первые 80 символов текста. Аргумент необязателен, по умолчанию 20.
TG_EXPORT.preview(20)
Сводка по каналам
По каждому каналу — сколько сообщений, уникальных ссылок, различных дат публикации и какие chat_id к нему относятся.
TG_EXPORT.previewChannels()
Несколько chat_id в одной строке — признак того, что под одним именем склеились разные каналы.
Восстановление названий по chat_id
Для записей, у которых имя канала не распозналось, подставляет название по голосованию среди сообщений с тем же chat_id.
TG_EXPORT.resolveChannels()
Возвращает число исправленных записей. Экспорты вызывают эту логику и сами — команда нужна, чтобы увидеть цифру.
Экспорт сообщений в CSV
Плоская таблица: канал, chat_id, дата, время, дата меню, ключ сообщения, число ссылок и текст одной строкой.
TG_EXPORT.exportCSV()
Пишется с BOM, поэтому Excel открывает кириллицу корректно. Файл: telegram-v76-messages-N.csv.
Каталог вложений
Таблица по сообщениям с прикреплёнными файлами: локальный номер, имя файла, размер, дата, начало текста и прямая ссылка на сообщение. С 7.6 сюда попадают и файлы, выложенные без подписи.
TG_EXPORT.attachments()
Ссылка строится из data-mid минус 232 — Telegram Web K хранит там глобальный идентификатор, а в адресе используется локальный.
Экспорт вложений в CSV
Тот же каталог файлом: local_id, имя, размер, дата публикации, текст и ссылка на сообщение.
TG_EXPORT.exportAttachmentsCSV()
Перед выгрузкой принудительно сохраняет архив. Файл: telegram-v76-ATTACHMENTS-N.csv.
Экспорт хронологии публикаций
Markdown, сгруппированный по датам: под каждым днём — время, канал и начало текста. Попадают только записи, у которых определилась дата.
TG_EXPORT.exportTimeline()
Файл: telegram-v76-TIMELINE-N-messages.md.
Состояние архива
Ключ архива, сколько записей поднято из прошлых заходов, сколько добрано сейчас, размер архива в символах и время последнего сохранения.
TG_EXPORT.stats()
Архив живёт в localStorage под ключом tg_collector_v73::CHAT_ID — свой на каждый чат.
Принудительное сохранение архива
Скрипт пишет архив сам, но перед закрытием вкладки или после долгой прокрутки надёжнее вызвать вручную.
TG_EXPORT.save()
Если места в localStorage не хватило, скрипт повторит запись без blocks[] и предупредит: разметка Markdown у поднятых записей станет проще.
Очистка архива канала
Удаляет архив текущего чата и очищает память — сбор начнётся с нуля при следующей прокрутке.
TG_EXPORT.forget()
Необратимо. Сначала TG_EXPORT.exportJSON(), потом уже это.