
Telegram Channel Parser
- 19 installs
- 177 repo stars
- Updated May 10, 2026
- artwist-polyakov/polyakov-claude-skills
telegram-channel-parser is a Claude skill that parses public Telegram channels via the t.me/s web preview to extract posts, metrics, digests, and competitive analysis without API keys.
About
This skill parses public Telegram channels through the t.me/s web preview, needing no API keys or MTProto. A developer uses it to pull posts with views and reactions, build multi-channel digests, rank top posts, analyze posting schedules, and compare channels. Output goes to TSV or CSV files and can render as a React digest artifact.
- Parses public Telegram channels through the t.me/s web preview with no API keys or MTProto
- Extracts posts, views, reactions, digests, posting schedules, and competitor comparisons
- Pure POSIX sh with only curl, sed, awk, and grep, plus a React digest artifact template
Telegram Channel Parser by the numbers
- 19 all-time installs (skills.sh)
- Ranked #1,308 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
telegram-channel-parser capabilities & compatibility
Free and zero-config; no tokens or API keys required, only curl/sed/awk/grep.
- Capabilities
- web scraping · data analysis
- Use cases
- web scraping · data analysis
- Pricing
- Free
What telegram-channel-parser says it does
Парсинг публичных Telegram-каналов через веб-превью (t.me/s/). Без API-ключей, без MTProto, zero config.
Чистый POSIX sh** — никаких зависимостей кроме curl, sed, awk, grep.
npx skills add https://github.com/artwist-polyakov/polyakov-claude-skills --skill telegram-channel-parserAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 19 |
|---|---|
| repo stars | ★ 177 |
| Last updated | May 10, 2026 |
| Repository | artwist-polyakov/polyakov-claude-skills ↗ |
What it does
Parse public Telegram channels to collect posts and metrics, build digests, or run competitive channel analysis.
Who is it for?
Pulling posts, metrics, and digests from public Telegram channels with zero config.
When should I use this skill?
You need to parse a Telegram channel, build a channel digest, or run analytics or competitive comparison on public channels.
What you get
Posts, metrics, digests, and comparisons are produced as compact TSV/CSV files or a React digest artifact.
By the numbers
- 9 bundled scripts for parsing and digests
- 1.5s pause between t.me requests
- stdout capped at 30 lines
Files
telegram-channel-parser
Парсинг публичных Telegram-каналов через веб-превью (t.me/s/). Без API-ключей, без MTProto, zero config.
Перед запуском
Перед любым запуском скриптов создай рабочую копию скилла в /home/claude/telegram-channel-parser и работай уже из неё:
bash scripts/prepare_runtime.sh
cd /home/claude/telegram-channel-parserprepare_runtime.sh копирует скилл и, если есть config/.env, нормализует его через sed -i: некавыченные значения с пробелами или кириллицей оборачиваются в кавычки в рабочей копии.
Config
Никаких токенов не требуется. Для дайджестов — скопировать .env.example:
cp config/.env.example config/.envБез `.env`: скилл работает, но каналы нужно передавать явно через --channel / --channels.
С `.env`: дайджест AI-каналов готов из коробки. Пользователь может добавить свои категории.
Структура `.env` (категории дайджестов):
TG_CATEGORIES=ai,crypto # реестр доступных категорий
TG_DEFAULT_CATEGORY=ai # дефолтная при запросе "дайджест"
TG_CHANNELS_AI_LABEL="AI и технологии"
TG_CHANNELS_AI=countwithsasha,evilfreelancer,...
TG_CHANNELS_CRYPTO_LABEL="Криптовалюты"
TG_CHANNELS_CRYPTO=channel1,channel2Алгоритм агента при запросе дайджеста: 1. Прочитать config/.env (если есть) 2. Распарсить TG_CATEGORIES — получить список доступных категорий 3. Для каждой категории: TG_CHANNELS_<ID> = каналы, TG_CHANNELS_<ID>_LABEL = название 4. Определить нужную категорию:
- Пользователь назвал тему → сопоставить с
_LABEL - Не уточнил → использовать
TG_DEFAULT_CATEGORY - Несколько категорий подходят → предложить выбор
5. Передать каналы нужной категории через --channels
Если `.env` нет → спросить какие каналы парсить, предложить cp config/.env.example config/.env.
Приоритет: --channels явно > категория из .env > агент спрашивает.
Подробности: config/README.md.
Philosophy
1. Always fresh — данные запрашиваются в реальном времени при каждом вызове. Никогда не пропустишь свежий пост. 2. Context window hygiene — stdout ограничен 30 строками. Полные данные в TSV/CSV. LLM работает с компактным форматом, а не с сырым HTML. 3. Rate limit — между запросами к t.me пауза 1.5с. Не жадничаем. 4. Чистый POSIX sh — никаких зависимостей кроме curl, sed, awk, grep.
Workflow
Парсинг одного канала
1. Получи посты:
bash scripts/fetch_posts.sh --channel countwithsasha --limit 50Выведет последние 50 постов в TSV (id, date, views, reactions, fwd_from, fwd_link, text, media_url).
2. Инфо о канале:
bash scripts/channel_info.sh --channel countwithsasha3. Поиск по постам:
bash scripts/search_posts.sh --channel countwithsasha --query "скилл"4. Топ постов (шер-парад):
bash scripts/top_posts.sh --channel countwithsasha --limit 50 --sort reactions5. Расписание публикаций:
bash scripts/posting_schedule.sh --channel countwithsasha --limit 1006. Экспорт:
bash scripts/export_csv.sh --channel countwithsasha --limit 100 --csv cache/export.csvДайджест по нескольким каналам
# Явный список каналов
bash scripts/digest.sh --channels "countwithsasha,evilfreelancer,aostrikov_ai_agents" --period today
# Дефолтный набор (без --channels)
bash scripts/digest.sh --period todayПериоды: today, yesterday, week, N (последние N дней).
Сравнение каналов
bash scripts/compare_channels.sh --channels "channel1,channel2,channel3" --limit 30Таблица: подписчики, средние просмотры, частота публикаций, engagement.
React-артифакт для дайджеста
При запросе дайджеста — отображай результаты как React-артифакт (лента карточек).
Алгоритм (4 шага): 1. Запусти digest_json.sh — он пишет JSON в файл (не stdout!) и возвращает путь:
bash scripts/digest_json.sh --period today
# → prints: cache/digest_today.json2. Прочитай JSON-файл по выведенному пути 3. Прочитай шаблон assets/digest-feed.tsx 4. Замени __DIGEST_DATA__ в шаблоне на содержимое JSON, отрендери как React-артифакт
Важно: скрипт пишет в файл, а не в stdout, чтобы обойти лимит буфера sandbox (~200KB). Для 30-дневного дайджеста 15 каналов JSON может быть 500KB+.
Посты автоматически сортируются по дате (новые сверху), перемешаны между каналами. Пользователь фильтрует по периоду и каналу через UI.
Scripts
Общий паттерн вызова:
bash scripts/<script>.sh --channel <username> [--limit N] [--before <post_id>] [--csv path]| Script | Description | Special params |
|---|---|---|
fetch_posts.sh | Посты канала → TSV | --limit, --before, --after-date YYYY-MM-DD |
channel_info.sh | Название, описание, подписчики | — |
search_posts.sh | Полнотекстовый поиск | --query "text" |
top_posts.sh | Ранжирование постов | `--sort views\ |
posting_schedule.sh | Анализ времени публикаций | --limit |
export_csv.sh | Экспорт в CSV | --csv path |
digest.sh | Дайджест нескольких каналов | --channels "a,b,c", `--period today\ |
digest_json.sh | Дайджест → JSON файл (для React-артифакта) | --channels "a,b,c", `--period today\ |
compare_channels.sh | Сравнительная таблица | --channels "a,b,c" |
Общие параметры
| Param | Required | Default | Description |
|---|---|---|---|
--channel | да* | — | Username канала (без @) |
--channels | нет | из .env | Несколько каналов через запятую |
--limit | нет | 20 | Сколько постов загрузить |
--before | нет | — | ID поста для пагинации |
--after-date | нет | — | Не загружать посты старше даты (YYYY-MM-DD) |
--csv | нет | — | Путь для экспорта |
*--channel для одного канала, --channels для мультиканальных команд.
Ввод канала
Скилл принимает канал в любом формате:
countwithsasha— просто username@countwithsasha— с собакойhttps://t.me/countwithsasha— прямая ссылкаhttps://t.me/s/countwithsasha— ссылка на веб-превьюt.me/countwithsasha?before=500— с параметрами
Всё автоматически нормализуется до голого username.
Ограничения
- Только публичные каналы (у которых есть t.me/s/ превью)
- Счётчик пересылок (shares) недоступен — t.me/s/ его не отдаёт, только MTProto API
- Зато парсится откуда переслан пост (fwd_from + ссылка на оригинал)
- Реакции парсятся суммарно (общее количество по всем эмодзи)
- Пагинация: ~20 постов на страницу, для 100 постов = 5 запросов
- Rate limit: 1.5с между запросами к t.me
config/.env
cache/channels/
/**
* Telegram Digest Feed — React artifact template (ultra-compact reader)
*
* Usage:
* 1. Agent runs: bash scripts/digest_json.sh --period today
* 2. Script outputs JSON: { posts: [...], channels: {...} }
* 3. Agent reads this template, replaces __DIGEST_DATA__ with the JSON
* 4. Renders as React artifact
*/
import { useState, useMemo } from "react";
// __DIGEST_DATA__ — agent replaces this line with JSON from digest_json.sh
const _data: { posts: Post[]; channels: Record<string, { title: string; subscribers: string }> } = __DIGEST_DATA__;
const POSTS_DATA = _data.posts;
const CHANNELS = _data.channels;
interface Post {
id: string;
channel: string;
date: string;
views: string;
reactions: string;
fwd_from?: string;
fwd_link?: string;
text: string;
mediaUrl?: string;
}
type Period = "24h" | "today" | "week" | "month" | "all";
const PERIOD_LABELS: Record<Period, string> = {
"24h": "24ч",
today: "Сегодня",
week: "Неделя",
month: "Месяц",
all: "Все",
};
function getChannelColor(channel: string): string {
const colors = [
"#2AABEE", "#E14E54", "#9B59B6", "#3498DB", "#E67E22",
"#1ABC9C", "#E74C3C", "#2ECC71", "#F39C12", "#8E44AD",
"#16A085", "#D35400", "#2980B9", "#C0392B", "#27AE60",
];
let hash = 0;
for (let i = 0; i < channel.length; i++) {
hash = channel.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
function timeAgo(dateStr: string): string {
const now = new Date();
const date = new Date(dateStr);
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diff < 60) return "сейчас";
if (diff < 3600) return `${Math.floor(diff / 60)}м`;
if (diff < 86400) return `${Math.floor(diff / 3600)}ч`;
if (diff < 604800) return `${Math.floor(diff / 86400)}д`;
return date.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
}
function filterByPeriod(posts: Post[], period: Period): Post[] {
if (period === "all") return posts;
const now = new Date();
const cutoff = new Date();
switch (period) {
case "24h": cutoff.setHours(now.getHours() - 24); break;
case "today": cutoff.setHours(0, 0, 0, 0); break;
case "week": cutoff.setDate(now.getDate() - 7); break;
case "month": cutoff.setMonth(now.getMonth() - 1); break;
}
return posts.filter((p) => new Date(p.date) >= cutoff);
}
function PostRow({ post }: { post: Post }) {
const [imgError, setImgError] = useState(false);
const color = getChannelColor(post.channel);
const postUrl = `https://t.me/${post.channel}/${post.id}`;
return (
<div style={{ padding: "10px 0", borderBottom: "1px solid #f0f0f0" }}>
{/* Header: initial + channel + time */}
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
<span style={{
width: 22, height: 22, borderRadius: "50%", background: color,
display: "inline-flex", alignItems: "center", justifyContent: "center",
color: "#fff", fontSize: 11, fontWeight: 700, flexShrink: 0,
}}>
{post.channel[0].toUpperCase()}
</span>
<span style={{ fontSize: 13, fontWeight: 600, color: "#1a1a1a", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
@{post.channel}
</span>
<span style={{ fontSize: 11, color: "#aaa", flexShrink: 0 }}>{timeAgo(post.date)}</span>
</div>
{/* Forwarded from */}
{post.fwd_from && (
<div style={{ fontSize: 11, color: "#8e8e93", marginBottom: 4, paddingLeft: 30 }}>
↩ {post.fwd_from}
</div>
)}
{/* Media */}
{post.mediaUrl && (
<div style={{ paddingLeft: 30, marginBottom: 6 }}>
{imgError ? (
<div style={{
width: "100%", height: 120, borderRadius: 8,
background: "#f5f5f5", display: "flex", alignItems: "center",
justifyContent: "center", fontSize: 28, color: "#ccc",
}}>🖼</div>
) : (
<img src={post.mediaUrl} alt=""
onError={() => setImgError(true)}
style={{ width: "100%", borderRadius: 8, objectFit: "cover", maxHeight: 300, display: "block" }} />
)}
</div>
)}
{/* Full post text with HTML formatting */}
<div
className="tg-post"
style={{ paddingLeft: 30, fontSize: 14, lineHeight: 1.55, color: "#333", wordBreak: "break-word" }}
dangerouslySetInnerHTML={{ __html: post.text }}
/>
{/* Metrics + open link */}
<div style={{ display: "flex", gap: 12, marginTop: 4, paddingLeft: 30, fontSize: 11, color: "#aaa", alignItems: "center" }}>
{post.views && <span>👁 {post.views}</span>}
{post.reactions && <span>❤️ {post.reactions}</span>}
<span style={{ flex: 1 }} />
<a href={postUrl} target="_blank" rel="noopener noreferrer"
style={{ color: "#2AABEE", textDecoration: "none", cursor: "pointer" }}>
Открыть →
</a>
</div>
</div>
);
}
export default function TelegramDigest() {
const [period, setPeriod] = useState<Period>("today");
const [channelFilter, setChannelFilter] = useState<string>("all");
const allChannels = useMemo(() => [...new Set(POSTS_DATA.map((p) => p.channel))], []);
const filtered = useMemo(() => {
let posts = filterByPeriod(POSTS_DATA, period);
if (channelFilter !== "all") {
posts = posts.filter((p) => p.channel === channelFilter);
}
return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [period, channelFilter]);
return (
<div style={{ maxWidth: 400, margin: "0 auto", padding: "12px 8px", fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" }}>
<style>{`
.tg-post a { color: #2AABEE; text-decoration: none; }
.tg-post a:hover { text-decoration: underline; }
.tg-post b, .tg-post strong { font-weight: 600; }
.tg-post i, .tg-post em { font-style: italic; }
.tg-post s, .tg-post del, .tg-post strike { text-decoration: line-through; color: #999; }
.tg-post code { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; font-size: 12px; }
.tg-post pre { background: #f5f5f5; padding: 8px; border-radius: 6px; overflow-x: auto; font-size: 12px; }
.tg-post blockquote { border-left: 3px solid #2AABEE; margin: 6px 0; padding: 4px 10px; color: #555; background: #f9f9f9; border-radius: 0 4px 4px 0; }
.tg-post .tg-spoiler { background: #333; color: #333; border-radius: 3px; padding: 0 3px; cursor: pointer; transition: all 0.2s; }
.tg-post .tg-spoiler:hover, .tg-post .tg-spoiler.revealed { background: transparent; color: inherit; }
.tg-post br { display: block; content: ""; margin: 4px 0; }
`}</style>
{/* Header */}
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 10 }}>
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700 }}>Telegram Digest</h2>
<span style={{ fontSize: 11, color: "#aaa" }}>
{filtered.length} постов
</span>
</div>
{/* Period tabs */}
<div style={{ display: "flex", gap: 4, marginBottom: 8 }}>
{(Object.keys(PERIOD_LABELS) as Period[]).map((p) => (
<button key={p} onClick={() => setPeriod(p)} style={{
padding: "4px 10px", borderRadius: 14, border: "none", cursor: "pointer",
fontSize: 12, fontWeight: period === p ? 600 : 400,
background: period === p ? "#2AABEE" : "#f5f5f5",
color: period === p ? "#fff" : "#666",
}}>
{PERIOD_LABELS[p]}
</button>
))}
</div>
{/* Channel chips */}
<div style={{ display: "flex", gap: 4, marginBottom: 8, flexWrap: "wrap" }}>
<button onClick={() => setChannelFilter("all")} style={{
padding: "3px 8px", borderRadius: 10, border: "none", cursor: "pointer",
fontSize: 11, background: channelFilter === "all" ? "#333" : "#f5f5f5",
color: channelFilter === "all" ? "#fff" : "#666",
}}>
Все
</button>
{allChannels.map((ch) => (
<button key={ch} onClick={() => setChannelFilter(ch)} style={{
padding: "3px 8px", borderRadius: 10, border: "none", cursor: "pointer",
fontSize: 11, background: channelFilter === ch ? getChannelColor(ch) : "#f5f5f5",
color: channelFilter === ch ? "#fff" : "#666",
}}>
@{ch}
</button>
))}
</div>
{/* Divider */}
<div style={{ height: 1, background: "#e8e8e8", marginBottom: 4 }} />
{/* Posts */}
{filtered.length === 0 ? (
<div style={{ textAlign: "center", padding: 32, color: "#aaa", fontSize: 13 }}>
Нет постов за выбранный период
</div>
) : (
filtered.map((post) => <PostRow key={`${post.channel}-${post.id}`} post={post} />)
)}
</div>
);
}
# Telegram Channel Parser — config
# Copy to .env to enable digests: cp .env.example .env
# Without .env the skill still works — just pass --channels explicitly.
# ─── Category registry ────────────────────────────────────────
# List of available digest categories.
# Agent reads this to know what to offer the user.
# Format: comma-separated category IDs (lowercase)
TG_CATEGORIES=ai
# Default category (used when user asks for "digest" without specifying)
TG_DEFAULT_CATEGORY=ai
# ─── Category: AI ─────────────────────────────────────────────
TG_CHANNELS_AI_LABEL="AI и технологии"
TG_CHANNELS_AI=countwithsasha,evilfreelancer,aostrikov_ai_agents,tips_ai,neuraldeep,oestick,max_about_ai,kdoronin_blog,nobilix,the_ai_architect,dealerAI,gleb_pro_ai,elkornacio,etechlead,NGI_ru,dashamingalieva
# ─── Add your own categories ─────────────────────────────────
# 1. Add category ID to TG_CATEGORIES: TG_CATEGORIES=ai,crypto,news
# 2. Define label and channels:
#
# TG_CHANNELS_CRYPTO_LABEL="Криптовалюты"
# TG_CHANNELS_CRYPTO=channel1,channel2,channel3
#
# TG_CHANNELS_NEWS_LABEL="Новости"
# TG_CHANNELS_NEWS=channel1,channel2,channel3
#
# TG_CHANNELS_DESIGN_LABEL="Дизайн и UX"
# TG_CHANNELS_DESIGN=channel1,channel2,channel3
Настройка telegram-channel-parser
Быстрый старт
Скилл работает без настройки — парсит любой публичный канал через --channel.
Для дайджестов скопируйте .env.example:
cp config/.env.example config/.envГотово — дайджест AI-каналов работает из коробки.
Перед запуском скриптов из установленного скилла сначала создайте рабочую копию:
bash scripts/prepare_runtime.sh
cd /home/claude/telegram-channel-parserПодготовка также чинит config/.env в рабочей копии через sed -i: значения с пробелами или кириллицей должны быть в кавычках.
Категории дайджестов
.env содержит реестр категорий — агент читает его и знает какие дайджесты доступны.
Структура
# Реестр (агент читает этот список)
TG_CATEGORIES=ai,crypto,news
TG_DEFAULT_CATEGORY=ai
# Категория: AI
TG_CHANNELS_AI_LABEL="AI и технологии"
TG_CHANNELS_AI=countwithsasha,evilfreelancer,...
# Категория: Crypto
TG_CHANNELS_CRYPTO_LABEL="Криптовалюты"
TG_CHANNELS_CRYPTO=channel1,channel2Конвенция именования
Для каждой категории <ID>:
TG_CHANNELS_<ID>— список каналов (через запятую, без @)TG_CHANNELS_<ID>_LABEL— человекочитаемое название категории
Приоритет каналов
1. --channels "a,b,c" — явный параметр (высший приоритет) 2. Категория из .env — агент подбирает по контексту 3. Без .env — агент спросит какие каналы парсить
Ограничения
- Только публичные каналы (с включённым t.me/s/ превью)
- Rate limit: 1.5с между запросами
- Пагинация: ~20 постов на страницу
#!/usr/bin/env bash
# Get channel info (title, description, subscribers)
# Usage: bash scripts/channel_info.sh --channel <username>
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
require_channel
_cache_dir=$(cache_dir_for_channel "$CHANNEL")
_info_file="$_cache_dir/info.json"
# Fetch main page
_html_file="$_cache_dir/raw/page_latest.html"
tg_fetch "${TG_BASE_URL}/${CHANNEL}" > "$_html_file"
if [ ! -s "$_html_file" ]; then
echo "Error: Could not fetch channel page for @$CHANNEL" >&2
exit 1
fi
# Parse and save
_info=$(parse_channel_info_from_html "$_html_file")
echo "$_info" > "$_info_file"
echo "Channel: @$CHANNEL"
echo "$_info" | sed 's/[{}]//g;s/,/\n/g' | sed 's/"//g;s/:/: /'
#!/usr/bin/env bash
# Common functions for Telegram Channel Parser skill
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/../config/.env"
CACHE_DIR="$SCRIPT_DIR/../cache"
TG_BASE_URL="https://t.me/s"
TG_REQUEST_DELAY="1.5"
load_config() {
if [ -f "$CONFIG_FILE" ]; then
if [ -f "$SCRIPT_DIR/sanitize_env.sh" ]; then
bash "$SCRIPT_DIR/sanitize_env.sh" "$CONFIG_FILE"
fi
. "$CONFIG_FILE"
fi
}
# --------------- Input normalization ---------------
# Accepts: username, @username, https://t.me/username, https://t.me/s/username, t.me/username
normalize_channel() {
local input="$1"
# Remove @ prefix
input="${input#@}"
# Remove https:// or http://
input="${input#https://}"
input="${input#http://}"
# Remove t.me/s/ or t.me/
input="${input#t.me/s/}"
input="${input#t.me/}"
# Remove trailing slash
input="${input%/}"
# Remove any query params
input="${input%%\?*}"
echo "$input"
}
cache_dir_for_channel() {
local dir="$CACHE_DIR/channels/$1"
mkdir -p "$dir/raw"
echo "$dir"
}
tg_fetch() {
sleep "$TG_REQUEST_DELAY"
curl -s -L \
-H "Accept-Language: ru-RU,ru;q=0.9,en;q=0.5" \
-H "Accept: text/html" \
-A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" \
"$1"
}
parse_posts_from_html() {
local html_file="$1"
local out_file="$2"
awk -f "$SCRIPT_DIR/parse_tg_posts.awk" "$html_file" > "$out_file"
}
parse_channel_info_from_html() {
local html_file="$1"
local title desc subs
# Title is inside <span dir="auto"> within header_title div
title=$(grep 'tgme_channel_info_header_title' "$html_file" 2>/dev/null | grep -oP '<span[^>]*>\K[^<]+' | head -1 || true)
# Description
desc=$(grep 'tgme_channel_info_description' "$html_file" 2>/dev/null | sed 's/<[^>]*>//g;s/^[[:space:]]*//' | head -1 || true)
# Subscribers from tgme_header_counter or counter_value
subs=$(grep 'tgme_header_counter' "$html_file" 2>/dev/null | grep -oE '[0-9][0-9.]*K?' | head -1 || true)
if [ -z "$subs" ]; then
subs=$(grep 'counter_value' "$html_file" 2>/dev/null | grep -oP 'counter_value[^>]*>\K[^<]+' | head -1 || true)
fi
title="${title//\"/\\\"}"
desc="${desc//\"/\\\"}"
printf '{"title":"%s","description":"%s","subscribers":"%s"}\n' "$title" "$desc" "$subs"
}
fetch_channel_pages() {
local channel="$1"
local limit="${2:-20}"
local before="${3:-}"
local after_date="${4:-}"
local cache_dir
cache_dir=$(cache_dir_for_channel "$channel")
local collected=0
local merged="$cache_dir/posts_merged.tsv"
: > "$merged"
while [ "$collected" -lt "$limit" ]; do
local url
if [ -n "$before" ]; then
url="${TG_BASE_URL}/${channel}?before=${before}"
else
url="${TG_BASE_URL}/${channel}"
fi
local page_file="$cache_dir/raw/page_${before:-latest}.html"
tg_fetch "$url" > "$page_file"
if [ ! -s "$page_file" ]; then
echo "Error: Empty response from $url" >&2
break
fi
local page_tsv="$cache_dir/raw/page_${before:-latest}.tsv"
parse_posts_from_html "$page_file" "$page_tsv"
local page_count
page_count=$(wc -l < "$page_tsv" | tr -d ' ')
if [ "$page_count" -eq 0 ]; then
break
fi
if [ -n "$after_date" ]; then
local filtered="$cache_dir/raw/page_${before:-latest}_filtered.tsv"
awk -F'\t' -v cutoff="$after_date" '$2 >= cutoff { print }' "$page_tsv" > "$filtered"
local filtered_count
filtered_count=$(wc -l < "$filtered" | tr -d ' ')
cat "$filtered" >> "$merged"
collected=$(( collected + filtered_count ))
if [ "$filtered_count" -lt "$page_count" ]; then
break
fi
else
cat "$page_tsv" >> "$merged"
collected=$(( collected + page_count ))
fi
before=$(tail -1 "$page_tsv" | cut -f1)
if [ -z "$before" ]; then
break
fi
done
head -n "$limit" "$merged"
}
print_tsv_head() {
local file="$1"
local n="${2:-30}"
if [ -f "$file" ]; then
head -n "$n" "$file"
local total
total=$(wc -l < "$file" | tr -d ' ')
if [ "$total" -gt "$n" ]; then
local remaining=$(( total - n ))
echo "... ${remaining} more rows, full data in: $file"
fi
fi
}
normalize_views() {
local val="$1"
case "$val" in
*[Kk]) val="${val%[Kk]}"; echo "$val" | awk '{printf "%d", $1 * 1000}' ;;
*[Mm]) val="${val%[Mm]}"; echo "$val" | awk '{printf "%d", $1 * 1000000}' ;;
*) echo "$val" | sed 's/[^0-9]//g' ;;
esac
}
parse_common_params() {
CHANNEL=""
CHANNELS=""
LIMIT="20"
BEFORE=""
AFTER_DATE=""
QUERY=""
SORT_BY="views"
CSV_OUT=""
PERIOD=""
while [ $# -gt 0 ]; do
case "$1" in
--channel) CHANNEL="$2"; shift 2 ;;
--channels) CHANNELS="$2"; shift 2 ;;
--limit) LIMIT="$2"; shift 2 ;;
--before) BEFORE="$2"; shift 2 ;;
--after-date) AFTER_DATE="$2"; shift 2 ;;
--query) QUERY="$2"; shift 2 ;;
--sort) SORT_BY="$2"; shift 2 ;;
--csv) CSV_OUT="$2"; shift 2 ;;
--period) PERIOD="$2"; shift 2 ;;
*) shift ;;
esac
done
}
require_channel() {
if [ -z "$CHANNEL" ]; then
echo "Error: --channel <username> is required." >&2
exit 1
fi
CHANNEL=$(normalize_channel "$CHANNEL")
}
require_channels() {
if [ -z "$CHANNELS" ]; then
# Try default category from .env
local _cat="${TG_DEFAULT_CATEGORY:-ai}"
_cat=$(echo "$_cat" | tr '[:lower:]' '[:upper:]')
eval "_cat_channels=\${TG_CHANNELS_${_cat}:-}"
if [ -n "$_cat_channels" ]; then
CHANNELS="$_cat_channels"
elif [ -n "$TG_CHANNELS" ]; then
CHANNELS="$TG_CHANNELS"
else
echo "Error: --channels required. Copy config/.env.example to config/.env or pass --channels \"ch1,ch2\"." >&2
exit 1
fi
fi
# Normalize each channel in the list
local normalized=""
local IFS=","
for ch in $CHANNELS; do
ch=$(normalize_channel "$ch")
if [ -n "$normalized" ]; then
normalized="${normalized},${ch}"
else
normalized="$ch"
fi
done
CHANNELS="$normalized"
}
period_to_after_date() {
case "$1" in
today) date +%Y-%m-%d ;;
yesterday) date -d "yesterday" +%Y-%m-%d 2>/dev/null || date -v-1d +%Y-%m-%d ;;
week) date -d "7 days ago" +%Y-%m-%d 2>/dev/null || date -v-7d +%Y-%m-%d ;;
[0-9]*) date -d "$1 days ago" +%Y-%m-%d 2>/dev/null || date -v-"${1}d" +%Y-%m-%d ;;
*) echo "" ;;
esac
}
#!/usr/bin/env bash
# Compare multiple channels: subscribers, avg views, posting frequency, engagement
# Usage: bash scripts/compare_channels.sh --channels "ch1,ch2,ch3" [--limit 30]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
require_channels
echo "=== Channel Comparison ==="
echo ""
printf "%-25s %12s %10s %12s %8s\n" "Channel" "Subscribers" "Avg Views" "Avg Reactions" "Posts"
printf "%-25s %12s %10s %12s %8s\n" "-------" "-----------" "---------" "-------------" "-----"
_old_ifs="$IFS"
IFS=','
for _channel in $CHANNELS; do
_channel=$(echo "$_channel" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$_channel" ] && continue
_cache_dir=$(cache_dir_for_channel "$_channel")
# Fetch info
_html_file="$_cache_dir/raw/page_latest.html"
tg_fetch "${TG_BASE_URL}/${_channel}" > "$_html_file" 2>/dev/null || true
_subs=""
if [ -s "$_html_file" ]; then
_info=$(parse_channel_info_from_html "$_html_file")
_subs=$(echo "$_info" | grep -o '"subscribers":"[^"]*"' | sed 's/.*"subscribers":"//;s/"//')
fi
# Fetch posts
_posts_file="$_cache_dir/posts.tsv"
fetch_channel_pages "$_channel" "$LIMIT" "" "" > "$_posts_file" 2>/dev/null || true
if [ ! -s "$_posts_file" ]; then
printf "%-25s %12s %10s %12s %8s\n" "@$_channel" "${_subs:-?}" "?" "?" "0"
continue
fi
# Calculate metrics
# TSV cols: $1=id $2=date $3=views $4=reactions
_metrics=$(awk -F'\t' '
{
v = $3; r = $4
gsub(/[[:space:]]/, "", v)
if (v ~ /[Kk]$/) { sub(/[Kk]$/, "", v); v = v * 1000 }
if (v ~ /[Mm]$/) { sub(/[Mm]$/, "", v); v = v * 1000000 }
if (v + 0 > 0) total_v += v
if (r + 0 > 0) total_r += r
n++
}
END {
avg_v = (n > 0) ? int(total_v / n) : 0
avg_r = (n > 0) ? int(total_r / n) : 0
printf "%d\t%d\t%d", avg_v, avg_r, n
}
' "$_posts_file")
_avg_v=$(echo "$_metrics" | cut -f1)
_avg_r=$(echo "$_metrics" | cut -f2)
_post_count=$(echo "$_metrics" | cut -f3)
printf "%-25s %12s %10s %12s %8s\n" \
"@$_channel" "${_subs:-?}" "$_avg_v" "$_avg_r" "$_post_count"
done
IFS="$_old_ifs"
echo ""
echo "(based on last $LIMIT posts per channel)"
#!/usr/bin/env bash
# Digest as JSON — ready to inject into React artifact
# Usage: bash scripts/digest_json.sh --period today [--channels "ch1,ch2"] [--out path]
# Output: writes JSON file, prints path to stdout
# JSON shape: { "posts": [...], "channels": {...} }
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
require_channels
if [ -z "$PERIOD" ]; then
PERIOD="today"
fi
_after_date=$(period_to_after_date "$PERIOD")
if [ -z "$_after_date" ]; then
echo "Error: invalid --period '$PERIOD'." >&2
exit 1
fi
# Output file — user can override with --csv (reusing the flag)
_out_dir="$CACHE_DIR"
mkdir -p "$_out_dir"
_outfile="${CSV_OUT:-$_out_dir/digest_${PERIOD}.json}"
# Temp files for streaming — avoid bash variable limits
_posts_tmp="${TMPDIR:-/tmp}/tg_posts_$$.jsonl"
_channels_tmp="${TMPDIR:-/tmp}/tg_channels_$$.jsonl"
trap 'rm -f "$_posts_tmp" "$_channels_tmp"' EXIT
: > "$_posts_tmp"
: > "$_channels_tmp"
_old_ifs="$IFS"
IFS=','
for _channel in $CHANNELS; do
_channel=$(echo "$_channel" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$_channel" ] && continue
_cache_dir=$(cache_dir_for_channel "$_channel")
_html_file="$_cache_dir/raw/page_latest.html"
# Fetch page
tg_fetch "${TG_BASE_URL}/${_channel}" > "$_html_file" 2>/dev/null || true
# Channel info → append to channels temp
if [ -s "$_html_file" ]; then
_info=$(parse_channel_info_from_html "$_html_file")
_title=$(echo "$_info" | sed 's/.*"title":"//;s/".*//')
_subs=$(echo "$_info" | sed 's/.*"subscribers":"//;s/".*//')
printf '"%s":{"title":"%s","subscribers":"%s"}\n' "$_channel" "$_title" "$_subs" >> "$_channels_tmp"
fi
# Posts → stream TSV through awk → append JSONL to posts temp
_result=$(fetch_channel_pages "$_channel" "50" "" "$_after_date" 2>/dev/null) || true
[ -z "$_result" ] && continue
echo "$_result" | awk -F'\t' -v ch="$_channel" '
{
id = $1; date = $2; views = $3; reactions = $4
fwd_from = $5; fwd_link = $6; text = $7; media = $8
gsub(/\\/, "\\\\", text)
gsub(/"/, "\\\"", text)
gsub(/\t/, " ", text)
gsub(/\r/, "", text)
gsub(/\\/, "\\\\", fwd_from)
gsub(/"/, "\\\"", fwd_from)
printf "{\"id\":\"%s\",\"channel\":\"%s\",\"date\":\"%s\",\"views\":\"%s\",\"reactions\":\"%s\"", id, ch, date, views, reactions
if (fwd_from != "") printf ",\"fwd_from\":\"%s\"", fwd_from
if (fwd_link != "") printf ",\"fwd_link\":\"%s\"", fwd_link
if (media != "") printf ",\"mediaUrl\":\"%s\"", media
printf ",\"text\":\"%s\"}\n", text
}' >> "$_posts_tmp"
echo " @$_channel: done" >&2
done
IFS="$_old_ifs"
# Assemble final JSON from temp files → write directly to output file
{
printf '{"posts":['
# Join JSONL lines with commas
awk 'NR>1{printf ","}{printf "%s",$0}' "$_posts_tmp"
printf '],"channels":{'
awk 'NR>1{printf ","}{printf "%s",$0}' "$_channels_tmp"
printf '}}\n'
} > "$_outfile"
_size=$(wc -c < "$_outfile" | tr -d ' ')
_count=$(wc -l < "$_posts_tmp" | tr -d ' ')
echo "Digest: $_count posts, ${_size} bytes → $_outfile" >&2
echo "$_outfile"
#!/usr/bin/env bash
# Digest: collect fresh posts from multiple channels for a time period
# Usage: bash scripts/digest.sh --channels "ch1,ch2,ch3" --period today
# Or: bash scripts/digest.sh --period yesterday (uses TG_CHANNELS from config)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
require_channels
# Default period
if [ -z "$PERIOD" ]; then
PERIOD="today"
fi
# Convert period to after_date
_after_date=$(period_to_after_date "$PERIOD")
if [ -z "$_after_date" ]; then
echo "Error: invalid --period '$PERIOD'. Use: today, yesterday, week, or N (days)." >&2
exit 1
fi
echo "=== Telegram Digest: $PERIOD (since $_after_date) ==="
echo ""
# Process each channel
_old_ifs="$IFS"
IFS=','
for _channel in $CHANNELS; do
# Trim whitespace
_channel=$(echo "$_channel" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -z "$_channel" ]; then
continue
fi
echo "--- @$_channel ---"
# Fetch posts with date filter
_cache_dir=$(cache_dir_for_channel "$_channel")
_digest_file="$_cache_dir/digest_${PERIOD}.tsv"
_result=$(fetch_channel_pages "$_channel" "50" "" "$_after_date" 2>/dev/null) || true
if [ -z "$_result" ]; then
echo "(no posts for this period)"
echo ""
continue
fi
echo "$_result" > "$_digest_file"
_count=$(echo "$_result" | wc -l | tr -d ' ')
echo "Posts: $_count"
echo ""
# Show each post: date | views | text preview
echo "$_result" | awk -F'\t' '{
# Extract time from ISO date
split($2, dt, "T")
time = dt[2]
sub(/\+.*/, "", time)
sub(/:00$/, "", time)
# Truncate text to 120 chars for digest
# TSV cols: $1=id $2=date $3=views $4=reactions $5=fwd_from $6=fwd_link $7=text $8=media_url
text = $7
if (length(text) > 120) text = substr(text, 1, 120) "..."
printf " %s | %s views | %s\n", time, $3, text
}'
echo ""
done
IFS="$_old_ifs"
echo "=== End of digest ==="
#!/usr/bin/env bash
# Export channel posts to CSV
# Usage: bash scripts/export_csv.sh --channel <username> [--limit 100] --csv path/to/output.csv
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
require_channel
if [ -z "$CSV_OUT" ]; then
_cache_dir=$(cache_dir_for_channel "$CHANNEL")
CSV_OUT="$_cache_dir/${CHANNEL}_export.csv"
fi
_cache_dir=$(cache_dir_for_channel "$CHANNEL")
_posts_file="$_cache_dir/posts.tsv"
# Ensure we have posts
if [ ! -s "$_posts_file" ]; then
echo "Fetching posts..." >&2
fetch_channel_pages "$CHANNEL" "$LIMIT" "" "" > "$_posts_file"
fi
# Convert TSV to CSV with proper escaping
{
echo "id,date,views,reactions,fwd_from,fwd_link,text_preview,media_url,url"
awk -F'\t' -v ch="$CHANNEL" '{
gsub(/"/, "\"\"", $7)
url = "https://t.me/" ch "/" $1
printf "%s,%s,%s,%s,%s,%s,\"%s\",%s,%s\n", $1, $2, $3, $4, $5, $6, $7, $8, url
}' "$_posts_file"
} > "$CSV_OUT"
_count=$(wc -l < "$CSV_OUT" | tr -d ' ')
echo "Exported $(( _count - 1 )) posts to: $CSV_OUT"
#!/usr/bin/env bash
# Fetch posts from a public Telegram channel
# Usage: bash scripts/fetch_posts.sh --channel <username> [--limit 50] [--before <id>] [--after-date YYYY-MM-DD] [--csv path]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
require_channel
_cache_dir=$(cache_dir_for_channel "$CHANNEL")
_posts_file="$_cache_dir/posts.tsv"
# Header for output
echo "id date views reactions fwd_from fwd_link text_preview media_url"
# Fetch with pagination
_result=$(fetch_channel_pages "$CHANNEL" "$LIMIT" "$BEFORE" "$AFTER_DATE")
if [ -z "$_result" ]; then
echo "(no posts found)" >&2
exit 0
fi
# Save to cache
echo "$_result" > "$_posts_file"
# Export to CSV if requested
if [ -n "$CSV_OUT" ]; then
{
echo "id,date,views,reactions,fwd_from,fwd_link,text_preview,media_url"
echo "$_result" | awk -F'\t' '{
gsub(/"/, "\"\"", $7)
printf "%s,%s,%s,%s,%s,%s,\"%s\",%s\n", $1, $2, $3, $4, $5, $6, $7, $8
}'
} > "$CSV_OUT"
echo "Exported to: $CSV_OUT" >&2
fi
# Output with limit
_count=$(echo "$_result" | wc -l | tr -d ' ')
if [ "$_count" -gt 30 ]; then
echo "$_result" | head -30
echo "... ($(( _count - 30 )) more rows, full data in: $_posts_file)"
else
echo "$_result"
fi
#!/usr/bin/awk -f
# parse_tg_posts.awk — extract posts from Telegram web preview HTML
# Output: id \t date \t views \t reactions \t fwd_from \t fwd_link \t text_html \t media_url
#
# Note: t.me/s/ does NOT expose share/forward COUNTS (only available via MTProto).
# fwd_from = channel name this post was forwarded from (empty if original)
# fwd_link = link to original post (empty if original)
# media_url = first image/video thumbnail URL (empty if no media)
BEGIN { OFS = "\t"; id = ""; date = ""; views = ""; text = ""; reactions = 0; fwd_from = ""; fwd_link = ""; media_url = ""; in_text = 0 }
function clean_text(t) {
gsub(/[\t\n\r]+/, " ", t)
# Convert tg_spoiler class
gsub(/class="tg_spoiler"/, "class=\"tg-spoiler\"", t)
# Convert Telegram blockquote: opening div → <blockquote>, its closing </div> → </blockquote>
# Mark quote-divs before stripping all divs
gsub(/<div[^>]*class="[^"]*quote[^"]*"[^>]*>/, "<!BQ>", t)
# Strip all div tags (open and close)
gsub(/<\/?div[^>]*>/, "", t)
# Now restore blockquotes — each <!BQ> needs a closing tag
# Simple approach: replace markers, then ensure balanced tags
gsub(/<!BQ>/, "<blockquote>", t)
# Ensure all blockquotes are closed — count and append missing closers
{
_open = 0; _close = 0
_tmp = t
while (match(_tmp, /<blockquote>/)) { _open++; _tmp = substr(_tmp, RSTART + RLENGTH) }
_tmp = t
while (match(_tmp, /<\/blockquote>/)) { _close++; _tmp = substr(_tmp, RSTART + RLENGTH) }
while (_close < _open) { t = t "</blockquote>"; _close++ }
}
# Preserve spoiler spans, remove all other spans
gsub(/<span[^>]*tg-spoiler[^>]*>/, "<!SPOILER>", t)
gsub(/<\/?span[^>]*>/, "", t)
gsub(/<!SPOILER>/, "<span class=\"tg-spoiler\">", t)
# Ensure spoiler spans are closed
{
_open = 0; _close = 0
_tmp = t
while (match(_tmp, /<span[^>]*>/)) { _open++; _tmp = substr(_tmp, RSTART + RLENGTH) }
_tmp = t
while (match(_tmp, /<\/span>/)) { _close++; _tmp = substr(_tmp, RSTART + RLENGTH) }
while (_close < _open) { t = t "</span>"; _close++ }
}
# Ensure pre tags are closed
{
_open = 0; _close = 0
_tmp = t
while (match(_tmp, /<pre[^>]*>/)) { _open++; _tmp = substr(_tmp, RSTART + RLENGTH) }
_tmp = t
while (match(_tmp, /<\/pre>/)) { _close++; _tmp = substr(_tmp, RSTART + RLENGTH) }
while (_close < _open) { t = t "</pre>"; _close++ }
}
# Clean leftover attrs except href and class
gsub(/ style="[^"]*"/, "", t)
gsub(/ dir="[^"]*"/, "", t)
# Normalize whitespace
gsub(/[[:space:]]+/, " ", t)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", t)
return t
}
/data-post=/ {
if (id != "") {
text = clean_text(text)
gsub(/[[:space:]]/, "", views)
if (reactions == 0) reactions = ""
print id, date, views, reactions, fwd_from, fwd_link, text, media_url
}
id = ""; date = ""; views = ""; text = ""; reactions = 0; fwd_from = ""; fwd_link = ""; media_url = ""; in_text = 0
tmp = $0
sub(/.*data-post="[^"]*\//, "", tmp)
sub(/".*/, "", tmp)
if (tmp ~ /^[0-9]+$/) id = tmp
}
id != "" && /datetime="/ && date == "" {
tmp = $0
sub(/.*datetime="/, "", tmp)
sub(/".*/, "", tmp)
date = tmp
}
id != "" && /tgme_widget_message_views/ {
tmp = $0
sub(/.*tgme_widget_message_views[^>]*>/, "", tmp)
sub(/<.*/, "", tmp)
gsub(/[[:space:]]/, "", tmp)
if (tmp != "" && views == "") views = tmp
}
# Forwarded from — extract source channel name and link
id != "" && /tgme_widget_message_forwarded_from_name/ && fwd_from == "" {
tmp = $0
# Extract link: href="https://t.me/channel/123"
link = tmp
if (match(link, /href="[^"]+"/)) {
link = substr(link, RSTART + 6, RLENGTH - 7)
fwd_link = link
}
# Extract name from <span dir="auto">Name</span>
sub(/.*<span[^>]*>/, "", tmp)
sub(/<\/span>.*/, "", tmp)
gsub(/[[:space:]]+/, " ", tmp)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", tmp)
if (tmp != "") fwd_from = tmp
}
# Media — extract first image URL from background-image:url('...')
id != "" && /tgme_widget_message_photo_wrap/ && media_url == "" {
tmp = $0
if (match(tmp, /background-image:url\('[^']+'\)/)) {
media_url = substr(tmp, RSTART + 22, RLENGTH - 24)
}
}
# Video thumbnail
id != "" && /tgme_widget_message_video_thumb/ && media_url == "" {
tmp = $0
if (match(tmp, /background-image:url\('[^']+'\)/)) {
media_url = substr(tmp, RSTART + 22, RLENGTH - 24)
}
}
# Multi-line text capture: start collecting when we see tgme_widget_message_text
id != "" && /tgme_widget_message_text/ && text == "" && in_text == 0 {
in_text = 1
tmp = $0
sub(/.*tgme_widget_message_text[^>]*>/, "", tmp)
# Check if closing </div> is on the same line
if (tmp ~ /<\/div>/) {
sub(/<\/div>.*/, "", tmp)
text = tmp
in_text = 0
} else {
text = tmp
}
next
}
# Continue collecting text lines until closing </div>
in_text == 1 {
tmp = $0
if (tmp ~ /<\/div>/) {
sub(/<\/div>.*/, "", tmp)
text = text " " tmp
in_text = 0
} else {
text = text " " tmp
}
next
}
id != "" && /tgme_reaction/ {
tmp = $0
while (match(tmp, /<\/i>[0-9]+/)) {
val = substr(tmp, RSTART, RLENGTH)
sub(/<\/i>/, "", val)
if (val + 0 > 0) reactions = reactions + val
tmp = substr(tmp, RSTART + RLENGTH)
}
}
END {
if (id != "") {
text = clean_text(text)
gsub(/[[:space:]]/, "", views)
if (reactions == 0) reactions = ""
print id, date, views, reactions, fwd_from, fwd_link, text, media_url
}
}
#!/usr/bin/env bash
# Analyze posting schedule: frequency, best hours, day-of-week distribution
# Usage: bash scripts/posting_schedule.sh --channel <username> [--limit 100]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
require_channel
_cache_dir=$(cache_dir_for_channel "$CHANNEL")
_posts_file="$_cache_dir/posts.tsv"
# Ensure we have posts
if [ ! -s "$_posts_file" ]; then
echo "Fetching posts..." >&2
fetch_channel_pages "$CHANNEL" "$LIMIT" "" "" > "$_posts_file"
fi
echo "=== Posting schedule for @$CHANNEL ==="
echo ""
# Extract hours and day-of-week from ISO dates
echo "--- Posts by hour (UTC) ---"
awk -F'\t' '
{
# Date format: 2026-03-28T12:40:00+00:00 or similar
split($2, dt, "T")
if (length(dt) >= 2) {
split(dt[2], tm, ":")
hour = tm[1] + 0
hours[hour]++
total++
}
}
END {
for (h = 0; h < 24; h++) {
count = (h in hours) ? hours[h] : 0
bar = ""
for (i = 0; i < count; i++) bar = bar "█"
printf "%02d:00 %3d %s\n", h, count, bar
}
printf "\nTotal posts analyzed: %d\n", total
}
' "$_posts_file"
echo ""
echo "--- Posts by day of week ---"
awk -F'\t' '
{
split($2, dt, "T")
# Use date command to get day of week
cmd = "date -d \"" dt[1] "\" +%u 2>/dev/null || date -j -f %Y-%m-%d \"" dt[1] "\" +%u 2>/dev/null"
cmd | getline dow
close(cmd)
if (dow != "") days[dow]++
}
END {
names[1] = "Mon"; names[2] = "Tue"; names[3] = "Wed"
names[4] = "Thu"; names[5] = "Fri"; names[6] = "Sat"; names[7] = "Sun"
for (d = 1; d <= 7; d++) {
count = (d in days) ? days[d] : 0
bar = ""
for (i = 0; i < count; i++) bar = bar "█"
printf "%s %3d %s\n", names[d], count, bar
}
}
' "$_posts_file"
echo ""
echo "--- Posting frequency ---"
awk -F'\t' '
{
split($2, dt, "T")
dates[dt[1]]++
n++
}
END {
# Count unique dates
for (d in dates) days_count++
if (days_count > 1) {
# Find date range
min_d = "9999-99-99"; max_d = "0000-00-00"
for (d in dates) {
if (d < min_d) min_d = d
if (d > max_d) max_d = d
}
printf "Period: %s to %s\n", min_d, max_d
printf "Posts: %d over %d unique days\n", n, days_count
printf "Avg: %.1f posts/day (on active days)\n", n / days_count
}
}
' "$_posts_file"
#!/usr/bin/env bash
# Copy the skill to a writable runtime directory before running scripts.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SKILL_NAME="$(basename "$SKILL_DIR")"
RUN_DIR="${1:-/home/claude/$SKILL_NAME}"
RUN_PARENT="$(dirname "$RUN_DIR")"
RUN_BASE="$(basename "$RUN_DIR")"
mkdir -p "$RUN_PARENT"
RUN_PARENT="$(cd "$RUN_PARENT" && pwd)"
RUN_DIR="$RUN_PARENT/$RUN_BASE"
if [ "$SKILL_DIR" != "$RUN_DIR" ]; then
mkdir -p "$RUN_DIR"
cp -R "$SKILL_DIR"/. "$RUN_DIR"/
fi
if [ -f "$RUN_DIR/config/.env" ]; then
bash "$RUN_DIR/scripts/sanitize_env.sh" "$RUN_DIR/config/.env"
fi
printf '%s\n' "$RUN_DIR"
#!/usr/bin/env bash
# Quote unquoted .env values that contain spaces before shell sourcing.
set -e
ENV_FILE="${1:-config/.env}"
if [ ! -f "$ENV_FILE" ]; then
exit 0
fi
SED_SKIP_COMMENT='/^[[:space:]]*#/b'
SED_SKIP_EMPTY='/^[[:space:]]*$/b'
SED_QUOTE_VALUE='s/^([[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=[[:space:]]*)([^"'\''#][^#]*[[:space:]][^#]*)([[:space:]]*(#.*)?)$/\1"\3"\4/'
if sed --version >/dev/null 2>&1; then
sed -i -E -e "$SED_SKIP_COMMENT" -e "$SED_SKIP_EMPTY" -e "$SED_QUOTE_VALUE" "$ENV_FILE"
else
sed -i '' -E -e "$SED_SKIP_COMMENT" -e "$SED_SKIP_EMPTY" -e "$SED_QUOTE_VALUE" "$ENV_FILE"
fi
#!/usr/bin/env bash
# Search posts by text query
# Usage: bash scripts/search_posts.sh --channel <username> --query "text" [--limit 50]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
require_channel
if [ -z "$QUERY" ]; then
echo "Error: --query \"text\" is required." >&2
exit 1
fi
_cache_dir=$(cache_dir_for_channel "$CHANNEL")
_posts_file="$_cache_dir/posts.tsv"
# Ensure we have posts cached
if [ ! -s "$_posts_file" ]; then
echo "Fetching posts first..." >&2
fetch_channel_pages "$CHANNEL" "$LIMIT" "" "" > "$_posts_file"
fi
echo "id date views reactions fwd_from fwd_link text media_url"
# Case-insensitive grep
_result=$(grep -i "$QUERY" "$_posts_file" 2>/dev/null || true)
if [ -z "$_result" ]; then
echo "(no posts matching '$QUERY')" >&2
exit 0
fi
_count=$(echo "$_result" | wc -l | tr -d ' ')
if [ "$_count" -gt 30 ]; then
echo "$_result" | head -30
echo "... ($(( _count - 30 )) more results)"
else
echo "$_result"
fi
echo "" >&2
echo "Found $_count posts matching '$QUERY' in @$CHANNEL" >&2
#!/usr/bin/env bash
set -e
TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
bash "$TEST_DIR/test_config_loader.sh"
echo "telegram-channel-parser tests passed"
#!/usr/bin/env bash
set -e
TEST_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$TEST_DIR/../.." && pwd)"
COMMON_SH="$SKILL_DIR/scripts/common.sh"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tg_config_test.XXXXXX")"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT INT TERM
cat > "$TMP_DIR/.env" <<'EOF'
TG_CATEGORIES=ai
TG_DEFAULT_CATEGORY=ai
TG_CHANNELS_AI_LABEL=AI и технологии
TG_CHANNELS_AI=countwithsasha,evilfreelancer
EOF
# shellcheck disable=SC1090
. "$COMMON_SH"
SCRIPT_DIR="$SKILL_DIR/scripts"
CONFIG_FILE="$TMP_DIR/.env"
CACHE_DIR="$TMP_DIR/cache"
load_config >/dev/null
[ "$TG_CATEGORIES" = "ai" ]
[ "$TG_DEFAULT_CATEGORY" = "ai" ]
[ "$TG_CHANNELS_AI_LABEL" = "AI и технологии" ]
[ "$TG_CHANNELS_AI" = "countwithsasha,evilfreelancer" ]
grep -q '^TG_CHANNELS_AI_LABEL="AI и технологии"$' "$TMP_DIR/.env"
#!/usr/bin/env bash
# Top posts by views, forwards, or reactions (шер-парад)
# Usage: bash scripts/top_posts.sh --channel <username> [--limit 50] [--sort views|forwards|reactions]
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/common.sh"
load_config
parse_common_params "$@"
require_channel
_cache_dir=$(cache_dir_for_channel "$CHANNEL")
_posts_file="$_cache_dir/posts.tsv"
# Ensure we have posts
if [ ! -s "$_posts_file" ]; then
echo "Fetching posts..." >&2
fetch_channel_pages "$CHANNEL" "$LIMIT" "" "" > "$_posts_file"
fi
# Determine sort column: $3=views, $4=reactions
case "$SORT_BY" in
views|view) _sort_col=3 ;;
reactions|reaction|react) _sort_col=4 ;;
*) _sort_col=3 ;;
esac
echo "=== Top posts by $SORT_BY for @$CHANNEL ==="
echo "rank id date views reactions fwd_from fwd_link text media_url"
# Normalize K/M values and sort numerically descending
awk -F'\t' -v col="$_sort_col" '
{
val = $col
gsub(/[[:space:]]/, "", val)
multiplier = 1
if (val ~ /[Kk]$/) { sub(/[Kk]$/, "", val); multiplier = 1000 }
if (val ~ /[Mm]$/) { sub(/[Mm]$/, "", val); multiplier = 1000000 }
# Handle decimal
if (val ~ /\./) {
norm = val * multiplier
} else {
norm = val * multiplier
}
if (norm == "" || norm == 0) norm = 0
printf "%d\t%s\n", norm, $0
}
' "$_posts_file" | sort -t' ' -k1 -nr | head -n "${LIMIT:-20}" | awk -F'\t' '
{
# Remove the prepended sort key, add rank
printf "%d\t", NR
for (i = 2; i <= NF; i++) {
printf "%s%s", $i, (i < NF ? "\t" : "\n")
}
}
'
# Engagement summary
# TSV cols: $1=id $2=date $3=views $4=reactions
echo "" >&2
echo "--- Engagement summary ---" >&2
awk -F'\t' '
{
gsub(/[[:space:]]/, "", $3)
gsub(/[[:space:]]/, "", $4)
v = $3; r = $4
if (v ~ /[Kk]$/) { sub(/[Kk]$/, "", v); v = v * 1000 }
if (v ~ /[Mm]$/) { sub(/[Mm]$/, "", v); v = v * 1000000 }
if (r + 0 > 0) total_r += r
total_views += v
n++
}
END {
if (n > 0) {
printf "Posts: %d | Avg views: %d | Avg reactions: %d", n, total_views/n, total_r/n
if (total_views > 0) printf " | Reaction rate: %.1f%%", (total_r/total_views)*100
printf "\n"
}
}
' "$_posts_file" >&2