Telegram-экспорт в Markdown
Скрипт на Python (только стандартная библиотека). Читает все messages*.html из папки экспорта Telegram Desktop и собирает чистый текст: дата, автор, номер сообщения, текст со ссылками и выделением. Пустые и служебные сообщения пропускаются. Как получить экспорт, описано в приёме «Telegram-канал в Markdown».
Запуск
python3 tg_export_to_md.py <папка_экспорта> -o posts.md
python3 tg_export_to_md.py <папка_экспорта> -o posts.md --by-month
С флагом --by-month результат делится на файлы posts-ГГГГ-ММ.md: так удобнее читать большой канал по частям.
Что получится
### 09.05.2024 12:54:31 · Имя автора · #3
Текст сообщения со [ссылками](https://example.com) и **выделением**.
Код
#!/usr/bin/env python3
"""Telegram Desktop export (HTML) -> один Markdown-файл (или по месяцам).
Использование:
python3 tg_export_to_md.py <папка_экспорта> [-o posts.md] [--by-month]
Берёт все messages*.html в порядке номеров, достаёт дату, автора и текст,
сохраняет ссылки и выделение, пропускает пустые и служебные сообщения. Только stdlib.
"""
import argparse, re, sys
from html.parser import HTMLParser
from pathlib import Path
class Parser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.msgs, self.cur = [], None
self.depth = {} # что отслеживаем: 'text' / 'from' / 'date'
self.stack = []
self.href = None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
cls = a.get("class", "")
if tag == "div" and "message" in cls.split() and "service" not in cls.split() and a.get("id", "").startswith("message"):
self.cur = {"id": a["id"][7:], "date": "", "from": "", "text": []}
self.msgs.append(self.cur)
self.stack = []
if self.cur is None:
return
if tag == "div":
kind = "text" if "text" in cls.split() else "from" if "from_name" in cls.split() else \
"date" if "date" in cls.split() and "details" in cls.split() else None
if kind == "date":
self.cur["date"] = a.get("title", "")
self.stack.append(kind)
elif self.stack and "text" in self.stack:
if tag == "br": self.cur["text"].append("\n")
elif tag in ("strong", "b"): self.cur["text"].append("**")
elif tag in ("em", "i"): self.cur["text"].append("*")
elif tag == "code": self.cur["text"].append("`")
elif tag == "a": self.href = a.get("href"); self.cur["text"].append("[")
def handle_endtag(self, tag):
if self.cur is None:
return
if tag == "div":
if self.stack: self.stack.pop()
elif "text" in self.stack:
if tag in ("strong", "b"): self.cur["text"].append("**")
elif tag in ("em", "i"): self.cur["text"].append("*")
elif tag == "code": self.cur["text"].append("`")
elif tag == "a": self.cur["text"].append(f"]({self.href})" if self.href else "]"); self.href = None
def handle_data(self, data):
if self.cur is None or not self.stack:
return
if "text" in self.stack:
self.cur["text"].append(data)
elif "from" in self.stack:
self.cur["from"] += data.strip()
def page_no(p):
m = re.search(r"messages(\d*)\.html", p.name)
return int(m.group(1) or 1)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("folder")
ap.add_argument("-o", "--out", default="posts.md")
ap.add_argument("--by-month", action="store_true", help="писать файлы posts-ГГГГ-ММ.md")
args = ap.parse_args()
files = sorted(Path(args.folder).glob("messages*.html"), key=page_no)
if not files:
sys.exit("messages*.html не найдены в " + args.folder)
msgs, last_from = [], ""
for f in files:
p = Parser()
p.feed(f.read_text(encoding="utf-8", errors="ignore"))
for m in p.msgs:
text = re.sub(r"\n{3,}", "\n\n", "".join(m["text"])).strip()
text = re.sub(r"[ \t]+\n", "\n", text)
if m["from"]: last_from = m["from"] # у «склеенных» сообщений имени нет
if text:
msgs.append((m["date"], last_from, m["id"], text))
def head(d, who, i):
return f"### {d.split(' UTC')[0]} · {who}".rstrip(" ·") + f" · #{i}"
if args.by_month:
groups = {}
for d, who, i, t in msgs:
m = re.match(r"(\d\d)\.(\d\d)\.(\d{4})", d)
key = f"{m[3]}-{m[2]}" if m else "unknown"
groups.setdefault(key, []).append((d, who, i, t))
for key, items in groups.items():
out = Path(args.out).with_name(f"posts-{key}.md")
out.write_text("\n\n".join(f"{head(d, w, i)}\n\n{t}" for d, w, i, t in items) + "\n", encoding="utf-8")
print(f"Сообщений: {len(msgs)}, файлов: {len(groups)}")
else:
Path(args.out).write_text("\n\n".join(f"{head(d, w, i)}\n\n{t}" for d, w, i, t in msgs) + "\n", encoding="utf-8")
print(f"Сообщений: {len(msgs)} → {args.out}")
if __name__ == "__main__":
main()