Заметки и сниппеты
Личная коллекция команд и запросов
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
Поиск в выводе 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.1: архив между заходами, прямые ссылки на сообщения и каталог вложений. Источник и история правок — исходный чат ChatGPT.
Telegram Collector V7.6.1 — полный код
Вставить целиком в консоль браузера на веб-версии Telegram. Сохраняет все сообщения, вытаскивает дату и время публикации, восстанавливает название канала по chat_id и — с версии 7.3 — копит записи в localStorage отдельно по каждому чату, поэтому повторные заходы дополняют архив, а не начинают с нуля. В 7.5 добавились прямой адрес каждого сообщения (t.me/c/чат/номер) и имя файла-вложения, в 7.6 — сообщения-файлы без подписи, которые прошлая версия пропускала целиком; 7.6.1 чинит падение на таких сообщениях.
(() => {
// ============================================================
// TELEGRAM COLLECTOR V7
// ============================================================
//
// Новое относительно V6:
//
// 1. Сохраняет ВСЕ сообщения, а не только сообщения со ссылками.
// 2. Собирает дату публикации:
// published_date: "2026-08-01"
//
// 3. Собирает дату + время:
// published_at: "2026-08-01 16:46"
//
// 4. Понимает разделители:
// 1 августа
// 1 августа 2026
// Сегодня
// Вчера
//
// 5. Восстанавливает название канала по Telegram chat_id.
//
// 6. Не использует cloneNode().
// 7. Не загружает blob:-ресурсы.
// 8. Не изменяет DOM Telegram.
// 9. Работает с виртуализированным скроллингом.
//
// ============================================================
// ------------------------------------------------------------
// STOP PREVIOUS VERSION
// ------------------------------------------------------------
try {
window.TG_EXPORT?.observer?.disconnect?.();
clearInterval(window.TG_EXPORT?.safetyTimer);
clearTimeout(window.TG_EXPORT?.scanTimer);
} catch (e) {}
// ------------------------------------------------------------
// STATE
// ------------------------------------------------------------
const S = {
version: '7.6.1',
records: new Map(),
links: new Map(),
observer: null,
safetyTimer: null,
scanTimer: null,
scans: 0,
mutations: 0,
startedAt: new Date()
};
window.TG_EXPORT = S;
// ============================================================
// TEXT HELPERS
// ============================================================
function clean(text = '') {
return String(text)
.replace(/\u00A0/g, ' ')
.replace(/\r/g, '')
.replace(/[ \t]+/g, ' ')
.replace(/[ \t]*\n[ \t]*/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function oneLine(text = '') {
return clean(text)
.replace(/\n+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// ------------------------------------------------------------
// Читаем DOM напрямую.
//
// Telegram emoji:
//
// <img class="emoji" alt="🧩">
//
// превращается обратно в Unicode emoji.
// ------------------------------------------------------------
function readNode(node) {
if (!node) return '';
if (node.nodeType === Node.TEXT_NODE) {
return node.nodeValue || '';
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const el = node;
if (el.tagName === 'BR') {
return '\n';
}
if (el.tagName === 'IMG') {
return el.getAttribute('alt') || '';
}
let out = '';
for (const child of el.childNodes) {
out += readNode(child);
}
return out;
}
function textOf(el) {
return clean(readNode(el));
}
function oneLineOf(el) {
return oneLine(readNode(el));
}
// ============================================================
// CHANNEL NORMALIZATION
// ============================================================
function normalizeChannel(name = '') {
let s = oneLine(name);
if (!s) return '';
// Ошибка предыдущих версий:
//
// [SW AI] Navigation[SW AI] AIWARI
//
// →
//
// [SW AI] AIWARI
s = s.replace(
/^\[SW AI\]\s*Navigation\s*/i,
''
);
// [SW] AIWARI
// →
// [SW AI] AIWARI
s = s.replace(
/^\[SW\]\s*/i,
'[SW AI] '
);
// SW AI AIWARI
// SW AI AIWARI
// [SW AI] AIWARI
//
// →
//
// [SW AI] AIWARI
if (/^\[?\s*SW\s+AI\s*\]?/i.test(s)) {
s = s.replace(
/^\[?\s*SW\s+AI\s*\]?\s*/i,
'[SW AI] '
);
}
return oneLine(s);
}
function isNavigationLabel(text = '') {
return /^\[?\s*SW\s+AI\s*\]?\s*Navigation$/i
.test(oneLine(text));
}
function looksLikeChannel(text = '') {
const s = oneLine(text);
if (!s) return false;
if (isNavigationLabel(s)) {
return false;
}
return /^\[?\s*SW\s+AI\s*\]?/i.test(s);
}
// ------------------------------------------------------------
// V7.1 — ЧИСТОТА ИМЕНИ КАНАЛА
//
// Баг V6/V7: в channel попадал текст сообщения, приклеенный
// к имени канала без пробела:
// "[SW AI] AIWARIПоделюсь хостингами, которыми..."
//
// Отделяем ведущий тег [..] и проверяем остаток.
// ------------------------------------------------------------
function splitChannelTag(name = '') {
const s = oneLine(name);
const m = s.match(
/^(\[[^\]]{1,20}\]\s*)?([\s\S]*)$/
);
return {
tag: (m && m[1]) || '',
rest: (m && m[2]) || ''
};
}
function looksLikeCleanChannel(name = '') {
const s = oneLine(name);
if (!s || s.length > 40) return false;
const { rest } = splitChannelTag(s);
if (!rest) return false;
// знаки препинания из тела сообщения
if (/[.,!?;:…«»"/\\]/.test(rest)) return false;
// склеенный таймстамп "20:59"
if (/\d{1,2}:\d{2}/.test(rest)) return false;
// второй тег внутри имени
if (/[[\]]/.test(rest)) return false;
// "AIWARIПоделюсь" / "AIWARIhttps"
if (/[A-ZА-ЯЁ]{2,}[a-zа-яё]/.test(rest)) return false;
if (/[A-ZА-ЯЁ]{2,}[A-ZА-ЯЁ][a-zа-яё]/.test(rest)) return false;
return true;
}
// ============================================================
// TELEGRAM CHAT ID
// ============================================================
function getChatIdFromUrl(url = '') {
const m = String(url).match(
/https?:\/\/t\.me\/c\/(\d+)/
);
return m ? m[1] : '';
}
function getMessageIdFromUrl(url = '') {
const m = String(url).match(
/https?:\/\/t\.me\/c\/\d+\/(?:\d+\/)*(\d+)/
);
return m ? m[1] : '';
}
// ------------------------------------------------------------
// V7.1 — chat_id ОТКРЫТОГО ЧАТА
//
// V7 брал chat_id только из ссылок t.me/c/<id> ВНУТРИ сообщения.
// Такие ссылки есть у единиц сообщений, поэтому маппинг почти
// не работал. Читаем id прямо из адресной строки.
//
// web.telegram.org/k/#-1003760061495 -> 3760061495
// (совпадает с форматом t.me/c/3760061495)
// ------------------------------------------------------------
function getCurrentChatId() {
let h = '';
try {
h = String(
location.hash || ''
).replace(/^#/, '').trim();
} catch (e) {
return '';
}
if (!h) return '';
// приватный канал: -1003760061495
let m = h.match(/^-?100(\d{5,})$/);
if (m) return m[1];
// обычный числовой id
m = h.match(/^-?(\d{5,})$/);
if (m) return m[1];
// @username
m = h.match(/^@([A-Za-z0-9_]{3,})$/);
if (m) return '@' + m[1].toLowerCase();
return '';
}
// ------------------------------------------------------------
// V7.2 — chat_id ИЗ ПУЗЫРЯ
//
// <div class="bubble" data-peer-id="-3760061495">
// ------------------------------------------------------------
function normalizePeerId(raw = '') {
const v = String(raw).trim();
let m = v.match(/^-?100(\d{5,})$/);
if (m) return m[1];
m = v.match(/^-?(\d{5,})$/);
if (m) return m[1];
return '';
}
// ------------------------------------------------------------
// V7.2 — ТОЧНОЕ ВРЕМЯ ИЗ data-timestamp
//
// <div class="bubble" data-timestamp="1787652539">
//
// Заменяет разбор разделителей дат и span.time:
// ни промахов на длительности голосовых, ни зависимости
// от того, отрисован ли разделитель при виртуализации.
// ------------------------------------------------------------
function readBubbleTimestamp(bubble) {
if (!bubble || !bubble.getAttribute) {
return null;
}
const raw =
bubble.getAttribute(
'data-timestamp'
);
if (!raw) return null;
const n = Number(raw);
if (
!Number.isFinite(n) ||
n < 1000000000 ||
n > 4000000000
) {
return null;
}
const d = new Date(n * 1000);
return {
date: localDateISO(d),
time:
pad2(d.getHours()) + ':' +
pad2(d.getMinutes()),
epoch: n
};
}
function dominantChatId(links = []) {
const counts = new Map();
for (const link of links) {
const id = getChatIdFromUrl(link.url);
if (!id) continue;
counts.set(
id,
(counts.get(id) || 0) + 1
);
}
if (!counts.size) {
return '';
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])[0][0];
}
// ============================================================
// DATE PARSER
// ============================================================
const MONTHS_RU = {
'января': 1,
'февраля': 2,
'марта': 3,
'апреля': 4,
'мая': 5,
'июня': 6,
'июля': 7,
'августа': 8,
'сентября': 9,
'октября': 10,
'ноября': 11,
'декабря': 12
};
const MONTHS_EN = {
january: 1,
february: 2,
march: 3,
april: 4,
may: 5,
june: 6,
july: 7,
august: 8,
september: 9,
october: 10,
november: 11,
december: 12
};
function pad2(n) {
return String(n).padStart(2, '0');
}
function localDateISO(date) {
return (
`${date.getFullYear()}-` +
`${pad2(date.getMonth() + 1)}-` +
`${pad2(date.getDate())}`
);
}
function parseDateSeparator(text = '') {
let s = oneLine(text)
.toLowerCase()
.replace(',', '')
.trim();
if (!s) return null;
// ----------------------------------------------------------
// Сегодня
// ----------------------------------------------------------
if (
s === 'сегодня' ||
s === 'today'
) {
const d = new Date();
return {
iso: localDateISO(d),
source: text
};
}
// ----------------------------------------------------------
// Вчера
// ----------------------------------------------------------
if (
s === 'вчера' ||
s === 'yesterday'
) {
const d = new Date();
d.setDate(
d.getDate() - 1
);
return {
iso: localDateISO(d),
source: text
};
}
// ----------------------------------------------------------
// DD.MM.YYYY
// DD.MM.YY
// ----------------------------------------------------------
let m = s.match(
/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/
);
if (m) {
let day = Number(m[1]);
let month = Number(m[2]);
let year = Number(m[3]);
if (year < 100) {
year += 2000;
}
const d =
new Date(year, month - 1, day);
if (
d.getFullYear() === year &&
d.getMonth() === month - 1 &&
d.getDate() === day
) {
return {
iso:
`${year}-` +
`${pad2(month)}-` +
`${pad2(day)}`,
source: text
};
}
}
// ----------------------------------------------------------
// 1 августа
// 1 августа 2026
// ----------------------------------------------------------
m = s.match(
/^(\d{1,2})\s+([а-яё]+)(?:\s+(\d{4}))?$/
);
if (m) {
const day = Number(m[1]);
const month =
MONTHS_RU[m[2]];
if (month) {
let year =
m[3]
? Number(m[3])
: new Date().getFullYear();
/*
Telegram иногда не показывает год.
Если сейчас январь, а на экране декабрь,
скорее всего это прошлый год.
*/
const now = new Date();
if (
!m[3] &&
month > now.getMonth() + 1 + 6
) {
year--;
}
const d =
new Date(year, month - 1, day);
if (
d.getMonth() === month - 1 &&
d.getDate() === day
) {
return {
iso:
`${year}-` +
`${pad2(month)}-` +
`${pad2(day)}`,
source: text
};
}
}
}
// ----------------------------------------------------------
// August 1
// August 1 2026
// ----------------------------------------------------------
m = s.match(
/^([a-z]+)\s+(\d{1,2})(?:\s+(\d{4}))?$/
);
if (m) {
const month =
MONTHS_EN[m[1]];
const day =
Number(m[2]);
if (month) {
let year =
m[3]
? Number(m[3])
: new Date().getFullYear();
return {
iso:
`${year}-` +
`${pad2(month)}-` +
`${pad2(day)}`,
source: text
};
}
}
return null;
}
// ============================================================
// TIME PARSER
// ============================================================
function parseTime(text = '') {
const m = oneLine(text).match(
/\b([01]?\d|2[0-3]):([0-5]\d)\b/
);
if (!m) {
return '';
}
return (
`${pad2(Number(m[1]))}:` +
`${m[2]}`
);
}
// ============================================================
// FIND TELEGRAM MESSAGE CONTAINER
// ============================================================
function getMessageContainer(message) {
if (
!message ||
!message.nodeType
) {
return null;
}
let el = message;
for (
let level = 0;
level < 8 && el;
level++
) {
if (
el.matches?.(
'[data-mid], ' +
'[data-message-id], ' +
'.bubble, ' +
'.message'
)
) {
return el;
}
el = el.parentElement;
}
return message;
}
// ============================================================
// MESSAGE TIME
// ============================================================
function detectMessageTime(message) {
if (
!message ||
!message.nodeType
) {
return '';
}
const container =
getMessageContainer(message);
if (!container) return '';
const selectors = [
'time',
'.time',
'.message-time',
'.bubble-time',
'[class*="time"]'
];
for (const selector of selectors) {
const elements =
container.querySelectorAll?.(
selector
) || [];
for (const el of elements) {
// datetime attribute
const dt =
el.getAttribute?.('datetime');
if (dt) {
const tm = parseTime(dt);
if (tm) return tm;
}
const tm =
parseTime(
el.textContent || ''
);
if (tm) return tm;
}
}
/*
Fallback.
Берём время ближе к концу текста контейнера.
*/
const text =
container.textContent || '';
const matches = [
...text.matchAll(
/\b([01]?\d|2[0-3]):([0-5]\d)\b/g
)
];
if (matches.length) {
const last =
matches.at(-1);
return (
`${pad2(Number(last[1]))}:` +
`${last[2]}`
);
}
return '';
}
// ============================================================
// DATE SEPARATOR CANDIDATES
// ============================================================
function findDateElements() {
const result = [];
/*
Не привязываемся жёстко к одному Telegram class.
Ищем короткие видимые элементы,
текст которых похож на дату.
*/
const root =
document.querySelector(
'#column-center'
) || document.body;
const all =
root.querySelectorAll(
'div, span'
);
for (const el of all) {
const text =
oneLine(
el.textContent || ''
);
if (
!text ||
text.length > 35
) {
continue;
}
const parsed =
parseDateSeparator(text);
if (!parsed) {
continue;
}
/*
Не берём элемент, содержащий другие большие
текстовые блоки.
*/
if (
el.querySelectorAll(
'.translatable-message'
).length
) {
continue;
}
result.push({
el,
date: parsed.iso,
source: text
});
}
return result;
}
// ============================================================
// FIND DATE BEFORE MESSAGE
// ============================================================
function detectPublishedDate(
message,
dateElements
) {
if (
!message ||
!message.nodeType
) {
return { date: '', source: '' };
}
const container =
getMessageContainer(message);
if (!container) {
return { date: '', source: '' };
}
let best = null;
for (const candidate of dateElements) {
const relation =
candidate.el.compareDocumentPosition(
container
);
/*
candidate находится перед message
*/
if (
relation &
Node.DOCUMENT_POSITION_FOLLOWING
) {
best = candidate;
}
}
return best
? {
date: best.date,
source: best.source
}
: {
date: '',
source: ''
};
}
// ============================================================
// V7.5 — ССЫЛКА НА СООБЩЕНИЕ И ВЛОЖЕНИЕ
// ============================================================
//
// Telegram Web K хранит в data-mid глобальный идентификатор.
// Локальный номер, который стоит в ссылке t.me/c/<чат>/<номер>,
// это data-mid минус 2^32. Проверено: mid 4294988859
// соответствует t.me/c/3760061495/21563.
//
// Файлы шаблонов приложены документами, а не ссылками,
// поэтому забираем имя файла и строим прямой адрес
// сообщения — чтобы до вложения можно было дойти в один клик.
// ------------------------------------------------------------
const MID_BASE = 4294967296;
function localMessageId(mid) {
const n = Number(mid);
if (!Number.isFinite(n)) return 0;
return n > MID_BASE
? n - MID_BASE
: n;
}
function buildMessageLink(
chatId,
mid
) {
const local =
localMessageId(mid);
if (
!chatId ||
!local ||
!/^\d+$/.test(String(chatId))
) {
return '';
}
return (
'https://t.me/c/' +
chatId + '/' + local
);
}
const DOC_NAME_SELECTORS = [
'.document-name',
'.document-title',
'[class*="document-name"]',
'.audio-title',
'.media-caption-name'
];
const DOC_SIZE_SELECTORS = [
'.document-size',
'[class*="document-size"]',
'.audio-subtitle'
];
function detectAttachment(bubble) {
if (!bubble) return null;
let name = '';
for (
const sel
of DOC_NAME_SELECTORS
) {
const el =
bubble.querySelector(sel);
if (el) {
const t = oneLineOf(el);
if (t) { name = t; break; }
}
}
if (!name) return null;
let size = '';
for (
const sel
of DOC_SIZE_SELECTORS
) {
const el =
bubble.querySelector(sel);
if (el) {
const t = oneLineOf(el);
if (t) { size = t; break; }
}
}
/*
V7.6: в .document-size Telegram держит и прогресс
загрузки — «22 КБ · / 22 КБ». Берём первую величину.
*/
size = oneLine(
size.split(/[·/]/)[0]
);
/*
Тип берём по расширению — он полезнее,
чем класс контейнера.
*/
const m =
name.match(/\.([A-Za-z0-9]{1,6})$/);
return {
name,
size,
ext: m ? m[1].toLowerCase() : ''
};
}
// ============================================================
// V7.2 — РАЗБОР ПУЗЫРЯ
// ============================================================
//
// Разметка Telegram Web K:
//
// <div class="bubble is-reply" data-mid=".."
// data-peer-id=".." data-timestamp="..">
// <div class="bubble-content-wrapper">
// <div class="bubble-content">
// <div class="colored-name name floating-part">…</div>
// <div class="reply quote-like …">…</div> <- ЦИТАТА
// <div class="message spoilers-container">
// <span class="translatable-message">…</span> <- ТЕЛО
// <span class="time">…</span>
//
// V7 брал ЛЮБОЙ .translatable-message, включая тот, что
// внутри блока цитаты. У ответа оба элемента дают один
// data-mid, и скоринг оставлял более длинный — то есть
// цитату вместо собственного текста ответа.
// ------------------------------------------------------------
const QUOTE_SELECTOR =
'.reply, .quote-like, .quote, .reply-content';
function isInsideQuote(el, bubble) {
let cur = el;
while (
cur &&
cur !== bubble
) {
if (
cur.matches?.(QUOTE_SELECTOR)
) {
return true;
}
cur = cur.parentElement;
}
return false;
}
function findBubbleBody(bubble) {
const all = [
...bubble.querySelectorAll(
'.translatable-message'
)
];
if (!all.length) return null;
const own =
all.filter(
el =>
!isInsideQuote(
el,
bubble
)
);
const pool =
own.length
? own
: [];
if (!pool.length) {
return null;
}
/*
Обычно тело одно. Если их несколько,
берём самое содержательное.
*/
return pool.reduce(
(a, b) =>
(b.textContent || '').length >
(a.textContent || '').length
? b
: a
);
}
function findBubbleChannel(bubble) {
const content =
bubble.querySelector(
'.bubble-content'
);
if (!content) return '';
for (
const el
of content.children
) {
if (
el.matches?.(
'.colored-name, .name'
) &&
!el.matches?.(
QUOTE_SELECTOR
)
) {
const t =
oneLineOf(el);
if (t) {
return normalizeChannel(t);
}
}
}
return '';
}
// ============================================================
// LINKS
// ============================================================
function extractLinks(message) {
return [
...message.querySelectorAll(
'a.anchor-url[href]'
)
]
.map(a => ({
text:
oneLineOf(a),
url:
a.href,
chat_id:
getChatIdFromUrl(a.href),
message_id:
getMessageIdFromUrl(a.href)
}))
.filter(
x =>
x.text &&
x.url
);
}
// ============================================================
// CHANNEL DETECTION
// ============================================================
function nearbyTexts(message) {
const result = [];
let cur =
getMessageContainer(message);
for (
let level = 0;
level < 5 && cur;
level++
) {
let prev =
cur.previousElementSibling;
for (
let i = 0;
i < 5 && prev;
i++
) {
const txt =
textOf(prev);
if (txt) {
result.push(txt);
}
prev =
prev.previousElementSibling;
}
cur =
cur.parentElement;
}
return result;
}
function detectChannel(message) {
const lines =
textOf(message)
.split('\n')
.map(oneLine)
.filter(Boolean);
for (const line of lines) {
if (looksLikeChannel(line)) {
return normalizeChannel(line);
}
}
for (
const nearby
of nearbyTexts(message)
) {
const parts =
nearby
.split('\n')
.map(oneLine)
.filter(Boolean);
for (const line of parts) {
if (looksLikeChannel(line)) {
return normalizeChannel(line);
}
}
}
return '';
}
// ============================================================
// MENU UPDATE DATE
// ============================================================
function parseMenuDate(text = '') {
const m =
oneLine(text).match(
/\b(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})\b/
);
if (!m) {
return null;
}
let day = Number(m[1]);
let month = Number(m[2]);
let year = Number(m[3]);
if (year < 100) {
year += 2000;
}
const d =
new Date(
year,
month - 1,
day
);
if (
d.getFullYear() !== year ||
d.getMonth() !== month - 1 ||
d.getDate() !== day
) {
return null;
}
if (
year < 2020 ||
year > 2100
) {
return null;
}
return {
raw:
`${pad2(day)}.` +
`${pad2(month)}.` +
`${String(year).slice(-2)}`,
time:
d.getTime()
};
}
function detectMenuDate(message) {
for (
const line
of textOf(message).split('\n')
) {
const d =
parseMenuDate(line);
if (d) return d;
}
for (
const nearby
of nearbyTexts(message)
) {
for (
const line
of nearby.split('\n')
) {
const d =
parseMenuDate(line);
if (d) return d;
}
}
return null;
}
// ============================================================
// RECORD KEY
// ============================================================
function getRecordKey(
message,
links
) {
let el =
getMessageContainer(message);
for (
let level = 0;
level < 7 && el;
level++
) {
/*
V7.2: data-peer-id УБРАН.
Это id чата, он одинаков у всех сообщений
(подтверждено разметкой: <div class="bubble"
data-mid=".." data-peer-id="-3760061495">).
В ключе он схлопнул бы весь канал в одну запись.
*/
for (const attr of [
'data-mid',
'data-message-id'
]) {
const value =
el.getAttribute?.(attr);
if (value) {
return `${attr}:${value}`;
}
}
if (
el.id &&
/message|bubble/i.test(el.id)
) {
return `id:${el.id}`;
}
el =
el.parentElement;
}
if (links.length) {
return [
'links',
links[0].url,
links.at(-1).url,
links.length
].join('|');
}
const text =
textOf(message);
if (text) {
return (
'text:' +
text.slice(0, 400)
);
}
return '';
}
// ============================================================
// BLOCK PARSER
// ============================================================
function parseBlocks(
message,
channel
) {
const blocks = [];
function push(
type,
data = {}
) {
blocks.push({
type,
...data
});
}
function walk(node) {
if (
node.nodeType ===
Node.TEXT_NODE
) {
const raw =
node.nodeValue || '';
raw
.split('\n')
.forEach(
(
part,
index,
arr
) => {
const t =
oneLine(part);
if (t) {
push(
'text',
{ text: t }
);
}
if (
index <
arr.length - 1
) {
push('break');
}
}
);
return;
}
if (
node.nodeType !==
Node.ELEMENT_NODE
) {
return;
}
const el = node;
const tag = el.tagName;
if (tag === 'BR') {
push('break');
return;
}
if (tag === 'IMG') {
const alt =
el.getAttribute('alt') ||
'';
if (alt) {
push(
'emoji',
{ text: alt }
);
}
return;
}
if (
tag === 'A' &&
el.classList.contains(
'anchor-url'
)
) {
const text =
oneLineOf(el);
if (
text &&
el.href
) {
push(
'link',
{
text,
url: el.href
}
);
}
return;
}
if (
tag === 'STRONG' ||
tag === 'B'
) {
const text =
oneLineOf(el);
if (!text) return;
if (
isNavigationLabel(text)
) {
return;
}
if (
channel &&
normalizeChannel(text)
.toLowerCase() ===
normalizeChannel(channel)
.toLowerCase()
) {
return;
}
push(
'heading',
{ text }
);
return;
}
for (
const child
of el.childNodes
) {
walk(child);
}
}
for (
const child
of message.childNodes
) {
walk(child);
}
return blocks;
}
// ============================================================
// RECORD CLASSIFICATION
// ============================================================
function classifyRecord(
channel,
links
) {
if (
links.length >= 10
) {
const swLinks =
links.filter(
x =>
/^\[SW AI\]/i.test(
normalizeChannel(
x.text
)
)
).length;
if (
swLinks /
links.length >
0.65
) {
return 'navigation-index';
}
}
if (links.length) {
return 'linked-message';
}
return 'message';
}
// ============================================================
// BUILD CHAT_ID → CHANNEL MAP
// ============================================================
function buildChatChannelMap() {
const map =
new Map();
const votes =
new Map();
for (
const record
of S.records.values()
) {
const channel =
normalizeChannel(
record.channel
);
/*
V7.1: голосовать могут ТОЛЬКО чистые имена.
В V7 голосовало любое имя, начинающееся с [SW AI],
включая сломанные — каждое давало свой голос
и засоряло карту.
*/
if (
!looksLikeCleanChannel(
channel
)
) {
continue;
}
const chatId =
record.chat_id;
if (!chatId) {
continue;
}
if (
!votes.has(chatId)
) {
votes.set(
chatId,
new Map()
);
}
const inner =
votes.get(chatId);
inner.set(
channel,
(inner.get(channel) || 0)
+ 1
);
}
for (
const [chatId, choices]
of votes
) {
const winner =
[...choices.entries()]
.sort(
(a, b) =>
b[1] - a[1]
)[0]?.[0];
if (winner) {
map.set(
chatId,
winner
);
}
}
return map;
}
// ------------------------------------------------------------
// V7.1 — КОРПУС ЧИСТЫХ ИМЁН
//
// Правильное имя канала уже есть в самих данных: оно
// встречается десятки раз как отдельное сообщение-заголовок.
// Поэтому сломанное имя не угадываем регулярками, а ищем
// самый длинный чистый префикс среди уже виденных имён.
// ------------------------------------------------------------
function buildCleanChannelNames() {
const votes =
new Map();
for (
const record
of S.records.values()
) {
const c =
normalizeChannel(
record.channel
);
if (
looksLikeCleanChannel(c)
) {
votes.set(
c,
(votes.get(c) || 0) + 1
);
}
}
return [...votes.entries()]
.filter(
([, n]) => n >= 2
)
.map(
([name]) => name
)
.sort(
(a, b) =>
b.length - a.length
);
}
function matchCleanPrefix(
name,
cleanNames
) {
const s =
normalizeChannel(name)
.toLowerCase();
if (!s) return '';
for (
const c
of cleanNames
) {
if (
s.startsWith(
c.toLowerCase()
)
) {
return c;
}
}
return '';
}
/*
Кэш: resolvedChannel вызывается на каждый текстовый блок
при экспорте, а обе карты перебирают все записи.
Без кэша экспорт квадратичный по числу записей.
*/
S._cacheToken = 0;
let cacheAt = -1;
let cacheMap = null;
let cacheNames = null;
let cacheInherited = null;
function channelCaches() {
if (
cacheAt !== S._cacheToken
) {
cacheMap =
buildChatChannelMap();
cacheNames =
buildCleanChannelNames();
cacheInherited =
buildInheritedChannels(
cacheMap,
cacheNames
);
cacheAt =
S._cacheToken;
}
return {
mapping: cacheMap,
cleanNames: cacheNames,
inherited: cacheInherited
};
}
function recordMid(record) {
const m =
String(record.key || '')
.match(/(\d+)\s*$/);
return m ? Number(m[1]) : 0;
}
/*
V7.4 — НАСЛЕДОВАНИЕ КАНАЛА
Длинное меню разбивается на несколько сообщений, и в
продолжении нет шапки "[SW AI] ...". Такая запись уезжала
под случайное имя — например, 48 ссылок попали под
заголовок раздела "📌 С ЧЕГО НАЧАТЬ".
Идём по сообщениям в порядке mid и, если имя не похоже
на канал, берём последнее нормальное — ровно так это
читается глазами.
*/
function buildInheritedChannels(
mapping,
cleanNames
) {
const map =
new Map();
const ordered =
[...S.records.values()]
.sort(
(a, b) =>
recordMid(a) -
recordMid(b)
);
let last = '';
for (
const record
of ordered
) {
const base =
baseResolvedChannel(
record,
mapping,
cleanNames
);
if (
looksLikeChannel(base)
) {
last = base;
map.set(record.key, base);
continue;
}
map.set(
record.key,
last || base
);
}
return map;
}
function resolvedChannel(
record
) {
const {
mapping,
cleanNames,
inherited
} = channelCaches();
return (
inherited.get(record.key) ||
baseResolvedChannel(
record,
mapping,
cleanNames
)
);
}
function baseResolvedChannel(
record,
mapping,
cleanNames
) {
const original =
normalizeChannel(
record.channel
);
/*
V7.1, порядок разрешения:
1. имя уже чистое
2. самый длинный чистый префикс из корпуса
3. карта chat_id -> канал
4. как есть
В V7 шаг 1 был проверкой startsWith('[SW AI]'),
поэтому сломанное имя проходило его первым
и до восстановления дело не доходило.
*/
/*
V7.2: префикс проверяется ПЕРВЫМ.
В V7.1 сначала шла проверка "имя уже чистое", и мимо
проходили имена вида "[SW AI] AIWARIGIF" или
"[SW AI] AIWARI1 ссылка" — короткие, без пунктуации,
без склейки заглавной со строчной.
*/
const byPrefix =
matchCleanPrefix(
original,
cleanNames
);
if (byPrefix) {
return byPrefix;
}
if (
looksLikeCleanChannel(
original
)
) {
return original;
}
if (
record.chat_id &&
mapping.has(
record.chat_id
)
) {
return mapping.get(
record.chat_id
);
}
return (
original ||
'[SW AI] UNKNOWN'
);
}
// ============================================================
// V7.3 — АРХИВ МЕЖДУ СЕССИЯМИ
// ============================================================
//
// Причина: состояние коллектора жило только внутри одного
// запуска. Три прогона по одному каналу дали 658 / 726 / 742
// разных сообщения, и лишь их объединение покрыло канал.
// Каждый заход недобирал случайный кусок из-за виртуализации.
//
// Теперь записи копятся в localStorage отдельно по каждому
// чату, и новый проход только дополняет архив.
// ------------------------------------------------------------
const ARCHIVE_PREFIX =
'tg_collector_v73::';
/*
Блоки занимают почти половину объёма записи, поэтому
в архив они кладутся компактно: [код, текст, url].
*/
const B_NAMES = [
'text',
'break',
'emoji',
'heading',
'link'
];
const B_CODES = {
text: 0,
break: 1,
emoji: 2,
heading: 3,
link: 4
};
function packBlocks(blocks = []) {
return blocks.map(b => {
const c =
B_CODES[b.type] ?? 0;
if (c === 1) {
return [1];
}
if (c === 4) {
return [
4,
b.text || '',
b.url || ''
];
}
return [
c,
b.text || ''
];
});
}
function unpackBlocks(arr = []) {
return arr.map(a => {
const type =
B_NAMES[a[0]] || 'text';
if (type === 'break') {
return { type };
}
if (type === 'link') {
return {
type,
text: a[1] || '',
url: a[2] || ''
};
}
return {
type,
text: a[1] || ''
};
});
}
function packRecord(record) {
const out = {};
for (
const [k, v]
of Object.entries(record)
) {
if (k === 'blocks') continue;
out[k] = v;
}
out.b =
packBlocks(record.blocks);
return out;
}
function unpackRecord(packed) {
const out = {};
for (
const [k, v]
of Object.entries(packed)
) {
if (k === 'b') continue;
out[k] = v;
}
out.blocks =
unpackBlocks(packed.b || []);
return out;
}
S.archiveKey = '';
S.fromArchive = 0;
S.archiveSaved = null;
let archiveDirty = false;
let archiveTimer = null;
function ensureArchiveLoaded() {
if (S.archiveKey) return true;
let chatId =
getCurrentChatId();
if (!chatId) {
/*
Адресная строка не дала id — берём самый частый
chat_id уже собранных записей.
*/
const counts =
new Map();
for (
const r
of S.records.values()
) {
if (!r.chat_id) continue;
counts.set(
r.chat_id,
(counts.get(r.chat_id) || 0) + 1
);
}
chatId =
[...counts.entries()]
.sort(
(a, b) => b[1] - a[1]
)[0]?.[0] || '';
}
if (!chatId) return false;
S.archiveKey =
ARCHIVE_PREFIX + chatId;
let raw = '';
try {
raw =
localStorage.getItem(
S.archiveKey
) || '';
} catch (e) {
console.warn(
'localStorage недоступен — архив отключён'
);
return true;
}
if (!raw) {
console.log(
`Архив пуст, начинаем с нуля (${S.archiveKey})`
);
return true;
}
let data = null;
try {
data = JSON.parse(raw);
} catch (e) {
console.warn(
'Архив повреждён, игнорирую'
);
return true;
}
let loaded = 0;
for (
const packed
of (data.records || [])
) {
const record =
unpackRecord(packed);
if (!record.key) continue;
if (
!S.records.has(record.key)
) {
S.records.set(
record.key,
record
);
loaded++;
}
for (
const link
of (record.links || [])
) {
if (
!S.links.has(link.url)
) {
S.links.set(
link.url,
{
...link,
channel:
record.channel,
source_record_key:
record.key,
source_published_at:
record.published_at
}
);
}
}
}
S.fromArchive = loaded;
S._cacheToken++;
console.log(
`Из архива поднято ${loaded} сообщений ` +
`(сохранён ${data.saved_at || '?'})`
);
return true;
}
function serializeArchive(
withBlocks
) {
const records = [];
for (
const r
of S.records.values()
) {
if (withBlocks) {
records.push(
packRecord(r)
);
} else {
const p =
packRecord(r);
p.b = [];
records.push(p);
}
}
return JSON.stringify({
v: 3,
chat_id:
S.archiveKey.replace(
ARCHIVE_PREFIX,
''
),
saved_at:
new Date().toISOString(),
records
});
}
function saveArchive() {
if (!S.archiveKey) {
return false;
}
archiveDirty = false;
for (
const withBlocks
of [true, false]
) {
try {
localStorage.setItem(
S.archiveKey,
serializeArchive(
withBlocks
)
);
S.archiveSaved =
new Date();
if (!withBlocks) {
console.warn(
'Места не хватило — архив сохранён без блоков. ' +
'Разметка Markdown у поднятых записей будет проще. ' +
'Выгрузите JSON и вызовите TG_EXPORT.forget().'
);
}
return true;
} catch (e) {
if (withBlocks) continue;
console.warn(
'Архив НЕ сохранён: в localStorage нет места. ' +
'Выгрузите данные через TG_EXPORT.exportJSON().'
);
return false;
}
}
return false;
}
function scheduleArchiveSave() {
archiveDirty = true;
if (archiveTimer) return;
archiveTimer =
setTimeout(
() => {
archiveTimer = null;
if (archiveDirty) {
saveArchive();
}
},
4000
);
}
// ============================================================
// SAVE MESSAGE
// ============================================================
/*
V7.3 — слияние двух версий одного сообщения.
*/
const KEEP_IF_EMPTY = [
'channel',
'chat_id',
'message_link',
'local_id',
'attachment',
'menu_updated',
'menu_updated_timestamp',
'published_date',
'published_time',
'published_at',
'published_epoch',
'date_separator_source'
];
function mergeRecords(old, next) {
const out = {
...old,
...next
};
for (
const k
of KEEP_IF_EMPTY
) {
if (
!next[k] &&
old[k]
) {
out[k] = old[k];
}
}
/*
time_source: 'data-timestamp' точнее запасного пути.
*/
if (
old.time_source ===
'data-timestamp' &&
next.time_source !==
'data-timestamp'
) {
out.time_source =
old.time_source;
}
if (
(old.text || '').length >
(next.text || '').length
) {
out.text = old.text;
out.chars = old.chars;
}
if (
(old.links || []).length >
(next.links || []).length
) {
out.links = old.links;
}
if (
(old.blocks || []).length >
(next.blocks || []).length
) {
out.blocks = old.blocks;
}
return out;
}
function saveMessage(
message,
dateElements,
bubble
) {
/*
V7.6: сообщение-файл без подписи.
Файлы шаблонов выкладывают отдельными сообщениями:
подпись у первого, остальные без текста. Такие пузыри
V7.5 выбрасывал целиком, потому что в них нет
.translatable-message — так терялся второй файл у
«Шаблон №2. Сразу 2 шаблона» и 26 сообщений подряд
у «Шаблона №25».
*/
const attachment =
detectAttachment(bubble);
if (
!message &&
!attachment
) {
return false;
}
const text =
message
? textOf(message)
: '';
const links =
message
? extractLinks(message)
: [];
/*
Пустой служебный элемент
*/
if (
!text &&
!links.length &&
!attachment
) {
return false;
}
const key =
message
? getRecordKey(
message,
links
)
: 'data-mid:' +
(bubble.getAttribute?.(
'data-mid'
) || '');
if (!key) {
return false;
}
const channel =
findBubbleChannel(bubble) ||
(message
? detectChannel(message)
: '');
const menuDate =
message
? detectMenuDate(message)
: null;
/*
V7.2: сначала точный data-timestamp пузыря.
Разбор разделителей дат и span.time остаётся
запасным вариантом.
*/
const stamp =
readBubbleTimestamp(bubble);
/*
V7.6: у сообщения-файла нет .translatable-message,
поэтому запасной путь получал null и падал на
compareDocumentPosition. Опорным элементом в этом
случае служит сам пузырь.
*/
const anchorEl =
message || bubble || null;
const published =
stamp
? { date: stamp.date, source: 'data-timestamp' }
: anchorEl
? detectPublishedDate(
anchorEl,
dateElements
)
: { date: '', source: '' };
const messageTime =
stamp
? stamp.time
: anchorEl
? detectMessageTime(
anchorEl
)
: '';
const chatId =
(bubble &&
normalizePeerId(
bubble.getAttribute?.(
'data-peer-id'
) || ''
)) ||
getCurrentChatId() ||
dominantChatId(links);
const publishedDate =
published.date || '';
const publishedAt =
publishedDate &&
messageTime
? `${publishedDate} ${messageTime}`
: publishedDate;
const record = {
key,
channel,
chat_id:
chatId,
menu_updated:
menuDate?.raw || '',
menu_updated_timestamp:
menuDate?.time || null,
published_date:
publishedDate,
published_time:
messageTime,
published_at:
publishedAt,
message_link:
buildMessageLink(
chatId,
bubble &&
bubble.getAttribute?.('data-mid')
),
local_id:
localMessageId(
bubble &&
bubble.getAttribute?.('data-mid')
),
attachment:
attachment,
published_epoch:
stamp ? stamp.epoch : null,
time_source:
stamp
? 'data-timestamp'
: 'разделитель+span.time',
date_separator_source:
published.source || '',
type:
classifyRecord(
channel,
links
),
text,
links,
blocks:
message
? parseBlocks(
message,
channel
)
: [],
chars:
text.length,
collected_at:
new Date().toISOString()
};
const old =
S.records.get(key);
/*
V7.3: СЛИЯНИЕ вместо замены.
Раньше запись заменялась целиком, и при равном
скоринге более полная версия могла быть затёрта
более бедной — например, потерять уже собранную
дату. Теперь каждое поле берётся из той версии,
где оно содержательнее. Это же нужно и для записей,
поднятых из архива.
*/
const merged =
old
? mergeRecords(
old,
record
)
: record;
S.records.set(
key,
merged
);
S._cacheToken++;
scheduleArchiveSave();
for (
const link
of links
) {
const oldLink =
S.links.get(
link.url
);
if (
!oldLink ||
link.text.length >
oldLink.text.length
) {
S.links.set(
link.url,
{
...link,
channel,
source_record_key:
key,
source_published_at:
publishedAt
}
);
}
}
return !old;
}
// ============================================================
// SCAN
// ============================================================
function scan() {
ensureArchiveLoaded();
const beforeRecords =
S.records.size;
const beforeLinks =
S.links.size;
/*
Сначала собираем видимые разделители дат.
*/
const dateElements =
findDateElements();
/*
Затем сообщения.
*/
/*
V7.2: обходим ПУЗЫРИ, а не .translatable-message.
Это даёт data-timestamp, data-peer-id и возможность
отличить тело сообщения от блока цитаты.
*/
const bubbles = [
...document.querySelectorAll(
'[data-mid]'
)
];
let handled = 0;
const seenMids =
new Set();
for (
const bubble
of bubbles
) {
const mid =
bubble.getAttribute(
'data-mid'
);
if (
!mid ||
seenMids.has(mid)
) {
continue;
}
seenMids.add(mid);
const body =
findBubbleBody(bubble);
/*
V7.6: пузырь без текста, но с файлом — тоже запись.
*/
if (
!body &&
!detectAttachment(bubble)
) {
continue;
}
saveMessage(
body,
dateElements,
bubble
);
handled++;
}
/*
Запасной путь: если разметка изменилась и пузырей
не нашлось, работаем по-старому.
*/
if (!handled) {
const messages = [
...document.querySelectorAll(
'.translatable-message'
)
];
for (
const message
of messages
) {
saveMessage(
message,
dateElements,
message.closest?.(
'[data-mid]'
) || null
);
}
}
S.scans++;
const addedRecords =
S.records.size -
beforeRecords;
const addedLinks =
S.links.size -
beforeLinks;
if (
addedRecords > 0 ||
addedLinks > 0 ||
S.scans === 1
) {
console.log(
`V7.6 | +${addedRecords} сообщений | ` +
`+${addedLinks} ссылок | ` +
`всего ${S.records.size} сообщений / ` +
`${S.links.size} ссылок`
);
}
}
function scheduleScan() {
clearTimeout(
S.scanTimer
);
S.scanTimer =
setTimeout(
scan,
250
);
}
// Первый scan
scan();
// ============================================================
// MUTATION OBSERVER
// ============================================================
const observer =
new MutationObserver(
mutations => {
S.mutations +=
mutations.length;
scheduleScan();
}
);
/*
Нам нужны только изменения структуры DOM.
Никакого attributes / blob / src.
*/
observer.observe(
document.body,
{
subtree: true,
childList: true
}
);
S.observer =
observer;
/*
Страховочный скан.
*/
S.safetyTimer =
setInterval(
scan,
3000
);
// ============================================================
// FIX CHANNELS USING CHAT_ID
// ============================================================
S.resolveChannels = () => {
const mapping =
buildChatChannelMap();
let fixed = 0;
for (
const record
of S.records.values()
) {
const original =
normalizeChannel(
record.channel
);
if (
original &&
original.startsWith(
'[SW AI]'
)
) {
continue;
}
if (
record.chat_id &&
mapping.has(
record.chat_id
)
) {
record.channel_resolved =
mapping.get(
record.chat_id
);
fixed++;
}
}
console.log(
`Названия восстановлены по chat_id: ${fixed}`
);
return fixed;
};
// ============================================================
// STATUS
// ============================================================
S.status = () => {
const mapping =
buildChatChannelMap();
const resolvedNames =
new Set();
let withDate = 0;
let withTime = 0;
let withStamp = 0;
for (
const record
of S.records.values()
) {
const ch =
resolvedChannel(
record
);
if (
ch &&
ch !==
'[SW AI] UNKNOWN'
) {
resolvedNames.add(ch);
}
if (
record.published_date
) {
withDate++;
}
if (
record.published_time
) {
withTime++;
}
if (
record.published_epoch
) {
withStamp++;
}
}
console.table({
'Версия':
S.version,
'Сообщений':
S.records.size,
'Уникальных ссылок':
S.links.size,
'Каналов':
resolvedNames.size,
'chat_id → channel':
mapping.size,
'С датой публикации':
withDate,
'Со временем':
withTime,
'Из data-timestamp':
withStamp,
'DOM-событий':
S.mutations,
'Сканов':
S.scans,
'Старт':
S.startedAt
.toLocaleTimeString()
});
return {
messages:
S.records.size,
links:
S.links.size,
channels:
resolvedNames.size,
published_dates:
withDate,
published_times:
withTime
};
};
// ============================================================
// PREVIEW MESSAGES
// ============================================================
S.preview = (
n = 20
) => {
const rows =
[...S.records.values()]
.slice(-n)
.map(
record => ({
channel:
resolvedChannel(
record
),
chat_id:
record.chat_id,
published_at:
record.published_at,
menu_updated:
record.menu_updated,
links:
record.links.length,
text:
oneLine(
record.text
).slice(0, 80)
})
);
console.table(rows);
return rows;
};
// ============================================================
// PREVIEW CHANNELS
// ============================================================
S.previewChannels = () => {
const groups =
new Map();
for (
const record
of S.records.values()
) {
const channel =
resolvedChannel(
record
);
if (
!groups.has(channel)
) {
groups.set(
channel,
{
messages: 0,
links:
new Set(),
dates:
new Set(),
chatIds:
new Set()
}
);
}
const g =
groups.get(channel);
g.messages++;
for (
const link
of record.links
) {
g.links.add(
link.url
);
}
if (
record.published_date
) {
g.dates.add(
record.published_date
);
}
if (
record.chat_id
) {
g.chatIds.add(
record.chat_id
);
}
}
const rows =
[...groups.entries()]
.map(
([channel, g]) => ({
channel,
clean:
looksLikeCleanChannel(
channel
)
? 'да'
: 'НЕТ',
messages:
g.messages,
links:
g.links.size,
publication_dates:
g.dates.size,
chat_id:
[...g.chatIds]
.join(', ')
})
);
console.table(rows);
return rows;
};
// ============================================================
// DOWNLOAD HELPER
// ============================================================
function downloadText(
text,
filename,
mime
) {
const blob =
new Blob(
[text],
{
type:
`${mime};charset=utf-8`
}
);
const url =
URL.createObjectURL(
blob
);
const a =
document.createElement(
'a'
);
a.href =
url;
a.download =
filename;
document.body.appendChild(
a
);
a.click();
a.remove();
setTimeout(
() =>
URL.revokeObjectURL(
url
),
5000
);
}
// ============================================================
// RAW JSON EXPORT
// ============================================================
S.exportJSON = () => {
saveArchive();
const mapping =
buildChatChannelMap();
const records =
[...S.records.values()]
.map(
record => ({
...record,
channel_resolved:
resolvedChannel(
record
)
})
);
const output = {
collector:
'Telegram Collector V7.6.1',
exported_at:
new Date()
.toISOString(),
stats: {
records:
records.length,
links:
S.links.size,
chat_channel_mappings:
mapping.size
},
chat_channel_map:
Object.fromEntries(
mapping
),
records
};
downloadText(
JSON.stringify(
output,
null,
2
),
`telegram-v76-RAW-${S.links.size}-links.json`,
'application/json'
);
console.log(
`V7 RAW JSON сохранён: ` +
`${records.length} сообщений / ` +
`${S.links.size} ссылок`
);
};
// ============================================================
// CSV EXPORT
// ============================================================
function csvCell(value = '') {
return (
'"' +
String(value)
.replace(
/"/g,
'""'
) +
'"'
);
}
S.exportCSV = () => {
saveArchive();
const rows = [
[
'channel',
'chat_id',
'published_date',
'published_time',
'published_at',
'menu_updated',
'message_key',
'links_count',
'text'
]
];
for (
const record
of S.records.values()
) {
rows.push([
resolvedChannel(
record
),
record.chat_id,
record.published_date,
record.published_time,
record.published_at,
record.menu_updated,
record.key,
record.links.length,
oneLine(
record.text
)
]);
}
const csv =
'\uFEFF' +
rows
.map(
row =>
row
.map(csvCell)
.join(',')
)
.join('\n');
downloadText(
csv,
`telegram-v76-messages-${S.records.size}.csv`,
'text/csv'
);
console.log(
`CSV сохранён: ${S.records.size} сообщений`
);
};
// ============================================================
// MARKDOWN HELPERS
// ============================================================
/*
V7.4: URL со скобками ломает синтаксис Markdown.
Такие адреса оборачиваем в угловые скобки.
*/
function escapeUrl(url = '') {
const u =
String(url).trim();
return /[()\s]/.test(u)
? `<${u}>`
: u;
}
function escapeMd(
text = ''
) {
return oneLine(text)
.replace(
/\\/g,
'\\\\'
)
.replace(
/\[/g,
'\\['
)
.replace(
/\]/g,
'\\]'
);
}
function isSeparator(
text = ''
) {
const s =
oneLine(text);
return (
s === '•' ||
/^[⎯—\-_=─]{6,}$/.test(
s
)
);
}
function isEmojiOnly(
text = ''
) {
const s =
oneLine(text);
if (!s) return false;
if (
/[A-Za-zА-Яа-яЁё0-9]/.test(
s
)
) {
return false;
}
return [...s].some(
c =>
c.codePointAt(0) >
0x2500
);
}
function recordToMarkdownLines(
record,
seenUrls
) {
let blocks =
record.blocks || [];
/*
V7.3: страховка. Если архив пришлось сохранить без
блоков, восстанавливаем минимальную структуру из
text и links, чтобы запись не выпала из Markdown.
*/
if (
!blocks.length &&
(record.text ||
(record.links || []).length)
) {
blocks = [];
const used =
new Set();
for (
const line
of String(record.text || '')
.split('\n')
) {
const t = oneLine(line);
if (!t) continue;
const hit =
(record.links || []).find(
l =>
!used.has(l.url) &&
oneLine(l.text) === t
);
if (hit) {
used.add(hit.url);
blocks.push({
type: 'link',
text: hit.text,
url: hit.url
});
} else {
blocks.push({
type: 'text',
text: t
});
}
blocks.push({ type: 'break' });
}
for (
const l
of (record.links || [])
) {
if (used.has(l.url)) continue;
blocks.push({
type: 'link',
text: l.text,
url: l.url
});
}
}
const lines = [];
/*
V7.5: файл-вложение и прямой адрес сообщения.
*/
if (record.attachment) {
lines.push(
'**Файл:** ' +
escapeMd(record.attachment.name) +
(record.attachment.size
? ' (' + escapeMd(record.attachment.size) + ')'
: '')
);
}
if (
record.message_link &&
record.attachment
) {
lines.push(
'- [Открыть сообщение с файлом](' +
escapeUrl(record.message_link) + ')'
);
}
/*
V7.4 — СБОРКА ПО СТРОКАМ
Раньше каждый текстовый блок выводился отдельной
строкой Markdown, а блоки break игнорировались.
Одно предложение разрывалось на куски:
### 1️⃣
Заходим на
### сайт Google
Теперь части копятся в буфер и сбрасываются на break.
Жирный текст становится заголовком, только если
занимает строку целиком; иначе остаётся **жирным**.
*/
const skipText = text => (
!text ||
isSeparator(text) ||
isNavigationLabel(text) ||
normalizeChannel(text)
.toLowerCase() ===
resolvedChannel(record)
.toLowerCase() ||
!!parseMenuDate(text)
);
const atLineStart = idx => {
let j = idx - 1;
while (j >= 0) {
const p = blocks[j];
if (p.type === 'break') {
return true;
}
if (
p.type === 'emoji' ||
(p.type === 'link' &&
isEmojiOnly(
oneLine(p.text)
)) ||
(p.type === 'text' &&
skipText(
oneLine(p.text)
))
) {
j--;
continue;
}
return false;
}
return true;
};
const atLineEnd = idx => {
let j = idx + 1;
while (j < blocks.length) {
const n = blocks[j];
if (n.type === 'break') {
return true;
}
if (
n.type === 'text' &&
skipText(
oneLine(n.text)
)
) {
j++;
continue;
}
return false;
}
return true;
};
/*
isEmojiOnly не ловит клавишные эмодзи вроде 1️⃣ —
у них первый код это обычная цифра. Для решения
"заголовок или значок" достаточно проверить,
есть ли в тексте хоть одна буква.
*/
const isIconOnly = t =>
!!t &&
!/\p{L}/u.test(t);
let cur = [];
const takeIcon = () => {
const icon =
cur.join(' ')
.replace(/\s+/g, ' ')
.trim();
cur = [];
return icon ? icon + ' ' : '';
};
const flush = () => {
const line =
cur.join(' ')
.replace(/\s+/g, ' ')
.trim();
cur = [];
if (line) {
lines.push(line);
}
};
for (
let i = 0;
i < blocks.length;
i++
) {
const b = blocks[i];
if (b.type === 'break') {
flush();
continue;
}
if (b.type === 'emoji') {
const t =
oneLine(b.text);
if (t) cur.push(t);
continue;
}
if (b.type === 'text') {
const t =
oneLine(b.text);
if (skipText(t)) continue;
cur.push(t);
continue;
}
if (b.type === 'heading') {
const t =
oneLine(b.text);
if (!t) continue;
const standalone =
atLineStart(i) &&
atLineEnd(i) &&
!isIconOnly(t);
if (standalone) {
const level =
/ГЛАВНОЕ\s+МЕНЮ/i
.test(t)
? '##'
: '###';
lines.push(
`${level} ${takeIcon()}${t}`
);
} else {
cur.push(
isIconOnly(t)
? t
: `**${t}**`
);
}
continue;
}
if (b.type === 'link') {
const t =
oneLine(b.text);
const url =
b.url || '';
if (!t || !url) continue;
/*
Повторный URL больше не выбрасывается молча —
остаётся текстом, чтобы фраза не теряла кусок.
*/
if (seenUrls.has(url)) {
cur.push(escapeMd(t));
continue;
}
seenUrls.add(url);
const md =
`[${escapeMd(t)}](${escapeUrl(url)})`;
/*
Эмодзи-ссылка перед заголовком — его иконка.
*/
if (
isEmojiOnly(t) &&
blocks[i + 1] &&
blocks[i + 1].type ===
'heading'
) {
cur.push(md);
continue;
}
if (
atLineStart(i) &&
atLineEnd(i)
) {
lines.push(
`- ${takeIcon()}${md}`
);
} else {
cur.push(md);
}
continue;
}
}
flush();
return lines;
}
// ============================================================
// CLEAN MARKDOWN EXPORT
// ============================================================
S.exportMD = () => {
saveArchive();
const records =
[...S.records.values()];
const navigation =
records.filter(
r =>
r.type ===
'navigation-index'
);
const regular =
records.filter(
r =>
r.type !==
'navigation-index'
);
const groups =
new Map();
for (
const record
of regular
) {
const channel =
resolvedChannel(
record
);
const key =
channel.toLowerCase();
if (
!groups.has(key)
) {
groups.set(
key,
{
name:
channel,
records: []
}
);
}
groups
.get(key)
.records
.push(record);
}
const out = [];
// ----------------------------------------------------------
// GLOBAL NAVIGATION
// ----------------------------------------------------------
if (
navigation.length
) {
out.push(
'# [SW AI] Navigation'
);
out.push('');
const seen =
new Set();
for (
const record
of navigation
) {
for (
const link
of record.links
) {
if (
seen.has(
link.url
)
) {
continue;
}
seen.add(
link.url
);
out.push(
`- [${escapeMd(link.text)}](${link.url})`
);
}
}
out.push('');
out.push('---');
out.push('');
}
// ----------------------------------------------------------
// CHANNELS
// ----------------------------------------------------------
for (
const group
of groups.values()
) {
out.push(
`# ${group.name}`
);
const menuDates =
group.records
.map(
r => ({
text:
r.menu_updated,
time:
r.menu_updated_timestamp
})
)
.filter(
x =>
x.text &&
x.time
)
.sort(
(a, b) =>
b.time -
a.time
);
if (
menuDates.length
) {
out.push(
`Обновлено: ${menuDates[0].text}`
);
}
out.push('');
const seenUrls =
new Set();
const seenLines =
new Set();
for (
const record
of group.records
) {
const lines =
recordToMarkdownLines(
record,
seenUrls
);
for (
const line
of lines
) {
if (!line) {
continue;
}
if (
line.startsWith(
'- ['
)
) {
out.push(
line
);
continue;
}
const key =
line
.replace(
/^#{2,3}\s*/,
''
)
.toLowerCase()
.trim();
if (
seenLines.has(key)
) {
continue;
}
seenLines.add(
key
);
out.push(
line
);
}
out.push('');
}
out.push('---');
out.push('');
}
let md =
out
.join('\n')
.replace(
/\n{3,}/g,
'\n\n'
)
.trim();
downloadText(
md,
`telegram-v76-CLEAN-${S.links.size}-links.md`,
'text/markdown'
);
console.log(
`V7 CLEAN MD сохранён | ` +
`${groups.size} каналов | ` +
`${S.links.size} ссылок`
);
};
// ============================================================
// PUBLICATION TIMELINE MARKDOWN
// ============================================================
S.exportTimeline = () => {
const records =
[...S.records.values()]
.filter(
r =>
r.published_date
)
.sort(
(a, b) =>
String(
a.published_at
).localeCompare(
String(
b.published_at
)
)
);
const out = [];
let currentDate = '';
for (
const record
of records
) {
if (
record.published_date !==
currentDate
) {
currentDate =
record.published_date;
out.push('');
out.push(
`## ${currentDate}`
);
out.push('');
}
const time =
record.published_time
? `${record.published_time} — `
: '';
const preview =
oneLine(
record.text
).slice(
0,
250
);
out.push(
`- **${time}${resolvedChannel(record)}** — ${preview}`
);
}
downloadText(
out
.join('\n')
.trim(),
`telegram-v76-TIMELINE-${records.length}-messages.md`,
'text/markdown'
);
console.log(
`Timeline сохранён: ${records.length} сообщений с датой`
);
};
// ============================================================
// V7.5 — КАТАЛОГ ВЛОЖЕНИЙ
// ============================================================
S.attachments = () => {
const rows =
[...S.records.values()]
.filter(r => r.attachment)
.sort(
(a, b) =>
(a.local_id || 0) -
(b.local_id || 0)
)
.map(r => ({
id: r.local_id,
file:
r.attachment.name,
size:
r.attachment.size,
date:
r.published_at,
text:
(r.text || '')
.slice(0, 60),
link:
r.message_link
}));
console.table(rows);
console.log(
`Вложений: ${rows.length}`
);
return rows;
};
S.exportAttachmentsCSV = () => {
saveArchive();
const rows =
S.attachments();
const q = v =>
'"' +
String(v ?? '')
.replace(/"/g, '""') +
'"';
const csv = [
[
'local_id',
'file',
'size',
'published_at',
'text',
'message_link'
].join(',')
];
for (const r of rows) {
csv.push([
q(r.id), q(r.file), q(r.size),
q(r.date), q(r.text), q(r.link)
].join(','));
}
downloadText(
'\uFEFF' + csv.join('\n'),
`telegram-v76-ATTACHMENTS-${rows.length}.csv`,
'text/csv'
);
};
// ============================================================
// V7.3 — УПРАВЛЕНИЕ АРХИВОМ
// ============================================================
S.save = () => {
const ok = saveArchive();
console.log(
ok
? `Архив сохранён: ${S.records.size} сообщений`
: 'Архив сохранить не удалось'
);
return ok;
};
S.stats = () => {
let size = 0;
try {
size =
(localStorage.getItem(
S.archiveKey
) || '').length;
} catch (e) {}
const rows = {
'Ключ архива':
S.archiveKey || '(не определён)',
'Поднято из архива':
S.fromArchive,
'Добрано в этом заходе':
S.records.size -
S.fromArchive,
'Всего сообщений':
S.records.size,
'Уникальных ссылок':
S.links.size,
'Размер архива, символов':
size,
'Последнее сохранение':
S.archiveSaved
? S.archiveSaved
.toLocaleTimeString()
: 'ещё не было'
};
console.table(rows);
return rows;
};
S.forget = () => {
if (!S.archiveKey) {
console.warn(
'Архив не определён'
);
return false;
}
try {
localStorage.removeItem(
S.archiveKey
);
} catch (e) {}
S.records.clear();
S.links.clear();
S.fromArchive = 0;
S.archiveSaved = null;
S._cacheToken++;
console.log(
`Архив ${S.archiveKey} удалён, память очищена. ` +
'Прокрутите канал заново.'
);
return true;
};
// ============================================================
// RESCAN
// ============================================================
S.rescan = () => {
scan();
return S.status();
};
// ============================================================
// STOP
// ============================================================
S.stop = () => {
saveArchive();
S.observer?.disconnect?.();
clearInterval(
S.safetyTimer
);
clearTimeout(
S.scanTimer
);
console.log(
`Collector V7 остановлен | ` +
`${S.records.size} сообщений | ` +
`${S.links.size} ссылок`
);
};
// ============================================================
// READY
// ============================================================
console.log(
'%cTelegram Collector V7.6.1 запущен',
'font-size:15px;font-weight:bold'
);
console.log(
'Во время работы прокручивайте Telegram.'
);
console.log(
'Статус: TG_EXPORT.status()'
);
console.log(
'Последние сообщения: TG_EXPORT.preview(20)'
);
console.log(
'Каналы: TG_EXPORT.previewChannels()'
);
console.log(
'Восстановить названия: TG_EXPORT.resolveChannels()'
);
console.log(
'RAW JSON: TG_EXPORT.exportJSON()'
);
console.log(
'CLEAN MD: TG_EXPORT.exportMD()'
);
console.log(
'CSV сообщений: TG_EXPORT.exportCSV()'
);
console.log(
'Timeline: TG_EXPORT.exportTimeline()'
);
console.log(
'Вложения: TG_EXPORT.attachments()'
);
console.log(
'Вложения в CSV: TG_EXPORT.exportAttachmentsCSV()'
);
console.log(
'Архив: TG_EXPORT.stats()'
);
console.log(
'Сохранить архив сейчас: TG_EXPORT.save()'
);
console.log(
'Очистить архив канала: TG_EXPORT.forget()'
);
console.log(
'Остановить: TG_EXPORT.stop()'
);
})();
Старую версию останавливать не нужно — скрипт сам снимает observer и таймеры предыдущего TG_EXPORT. После вставки прокручивайте ленту: в консоль пишется V7.6 | +N сообщений | +N ссылок.
Экспорт RAW JSON
Сырые записи со всеми blocks[], датами и картой chat_id → channel — из них можно пересобрать любой формат без повторного скрапинга.
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(), потом уже это.