Add CP→CA→PI platform foundation on MapMil monorepo.

Unify parsing workers, analytics API with PostgreSQL, map UI, and PI distribution into centers/ with Docker Compose.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 11:43:27 +03:00
co-authored by Cursor
commit 9dfe713668
88 changed files with 6587 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY workers/ ./workers/
COPY worker.py .
ENV PYTHONPATH=/app
CMD ["python", "worker.py"]
+6
View File
@@ -0,0 +1,6 @@
httpx==0.28.1
redis==5.2.1
telethon==1.44.0
python-socks[asyncio]==2.7.1
beautifulsoup4==4.12.3
lxml==5.3.0
+132
View File
@@ -0,0 +1,132 @@
"""CP worker: poll Redis jobs, parse Telegram, ingest to CA."""
import asyncio
import json
import logging
import os
import time
import httpx
import redis
from workers.converter import event_record_to_ingest
from workers.parsers.telegram_events import parse_event_posts
from workers.sources.telegram_client import (
TelegramAuthError,
TelegramConfigError,
fetch_channel_posts,
normalize_channel,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("cp-worker")
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
JOB_QUEUE_KEY = "cp:jobs"
CA_API_URL = os.getenv("CA_API_URL", "http://ca-api:8000")
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "dev-internal-token")
POLL_TIMEOUT = int(os.getenv("WORKER_POLL_TIMEOUT", "5"))
def get_redis() -> redis.Redis:
return redis.from_url(REDIS_URL, decode_responses=True)
async def process_telegram_job(job_id: int, source_config: dict) -> tuple[list[dict], str | None]:
channel = source_config.get("channel", "creamy_caprice")
limit = int(source_config.get("limit", 100))
try:
username = normalize_channel(channel)
posts = await fetch_channel_posts(username, limit=limit)
except (TelegramConfigError, TelegramAuthError, ValueError) as exc:
return [], str(exc)
except Exception as exc:
return [], f"Telegram: {exc}"
records = parse_event_posts(posts)
events = [event_record_to_ingest(r) for r in records]
return events, None
async def post_ingest(job_id: int, events: list[dict]) -> None:
if not events:
return
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{CA_API_URL}/internal/ingest",
json={"job_id": job_id, "events": events},
headers={"X-Internal-Token": INTERNAL_TOKEN},
)
response.raise_for_status()
logger.info("Ingested %s events for job %s: %s", len(events), job_id, response.json())
async def patch_job_status(job_id: int, status: str, error: str | None = None) -> None:
async with httpx.AsyncClient(timeout=30.0) as client:
params = {"status": status}
if error:
params["error"] = error
response = await client.patch(
f"{CA_API_URL}/internal/jobs/{job_id}",
params=params,
headers={"X-Internal-Token": INTERNAL_TOKEN},
)
response.raise_for_status()
async def handle_job(payload: dict) -> None:
job_id = payload["job_id"]
source_type = payload["source_type"]
source_config = payload.get("source_config", {})
logger.info("Processing job %s (%s)", job_id, source_type)
await patch_job_status(job_id, "running")
if source_type == "telegram":
events, error = await process_telegram_job(job_id, source_config)
else:
events, error = [], f"Unsupported source_type: {source_type}"
if error:
logger.error("Job %s failed: %s", job_id, error)
await patch_job_status(job_id, "failed", error=error)
return
try:
await post_ingest(job_id, events)
if not events:
await patch_job_status(job_id, "completed", error="No events found")
logger.info("Job %s completed with %s events", job_id, len(events))
except Exception as exc:
logger.exception("Ingest failed for job %s", job_id)
await patch_job_status(job_id, "failed", error=str(exc))
async def worker_loop() -> None:
r = get_redis()
logger.info("CP worker started, polling %s", JOB_QUEUE_KEY)
while True:
try:
item = r.blpop(JOB_QUEUE_KEY, timeout=POLL_TIMEOUT)
if not item:
continue
_, raw = item
payload = json.loads(raw)
await handle_job(payload)
except redis.RedisError as exc:
logger.error("Redis error: %s", exc)
time.sleep(3)
except Exception:
logger.exception("Unexpected worker error")
time.sleep(1)
def main() -> None:
asyncio.run(worker_loop())
if __name__ == "__main__":
main()
@@ -0,0 +1,50 @@
"""Convert EventRecord to CA ingest payload items."""
import re
from datetime import datetime, timezone
from workers.parsers.telegram_events import EventRecord
COORDS_RE = re.compile(
r"(-?\d{1,3}\.\d+)\s*,\s*(-?\d{1,3}\.\d+)",
)
def parse_event_date(date_str: str) -> datetime | None:
for fmt in ("%d.%m.%y", "%d.%m.%Y"):
try:
dt = datetime.strptime(date_str.strip(), fmt)
return dt.replace(tzinfo=timezone.utc)
except ValueError:
continue
return None
def parse_geolocation(geo: str) -> tuple[float | None, float | None]:
if not geo:
return None, None
match = COORDS_RE.search(geo)
if not match:
return None, None
return float(match.group(1)), float(match.group(2))
def event_record_to_ingest(record: EventRecord) -> dict:
lat, lng = parse_geolocation(record.geolocation)
title = record.locality or (record.event.splitlines()[0][:120] if record.event else "")
event_date = parse_event_date(record.date)
return {
"source_type": "telegram",
"source_url": record.source_url,
"raw_text": record.event,
"title": title,
"description": record.event,
"locality": record.locality,
"latitude": lat,
"longitude": lng,
"event_date": event_date.isoformat() if event_date else None,
"region": record.locality or None,
"topic": "telegram",
"tags": ["telegram"],
"metadata": {"original_date": record.date, "geolocation": record.geolocation},
}
@@ -0,0 +1,92 @@
"""Обход сайта на заданную глубину (BFS)."""
from collections import deque
from dataclasses import dataclass
from urllib.parse import urljoin, urlparse, urlunparse
import httpx
from app.fetcher import fetch_page
SKIP_EXTENSIONS = (
".pdf", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg",
".zip", ".rar", ".mp4", ".mp3", ".avi", ".doc", ".docx",
".xls", ".xlsx", ".css", ".js", ".xml", ".rss",
)
@dataclass
class CrawlResult:
pages: list[tuple[str, str]]
errors: list[str]
def _normalize_url(url: str) -> str:
parsed = urlparse(url)
path = parsed.path or "/"
if path != "/" and path.endswith("/"):
path = path.rstrip("/")
return urlunparse((parsed.scheme, parsed.netloc.lower(), path, "", parsed.query, ""))
def _same_domain(start: str, candidate: str) -> bool:
return urlparse(start).netloc.lower() == urlparse(candidate).netloc.lower()
def _is_fetchable(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
path = parsed.path.lower()
return not any(path.endswith(ext) for ext in SKIP_EXTENSIONS)
async def crawl_site(
start_url: str,
steps: int,
max_pages: int = 100,
timeout: float = 30.0,
) -> CrawlResult:
"""Обойти сайт от start_url на steps уровней (1 = только стартовая страница)."""
steps = max(1, min(steps, 10))
max_pages = max(1, min(max_pages, 200))
start = _normalize_url(start_url)
visited: set[str] = set()
queue: deque[tuple[str, int]] = deque([(start, 0)])
pages: list[tuple[str, str]] = []
errors: list[str] = []
async with httpx.AsyncClient(
follow_redirects=True,
timeout=timeout,
headers={"User-Agent": "SocialParser/1.0"},
) as client:
while queue and len(visited) < max_pages:
url, depth = queue.popleft()
if url in visited:
continue
visited.add(url)
try:
page = await fetch_page(url, client)
except Exception as exc:
errors.append(f"{url}: {exc}")
continue
pages.append((url, page.text))
if depth + 1 >= steps:
continue
for link in page.links:
normalized = _normalize_url(link)
if normalized in visited:
continue
if not _same_domain(start, normalized):
continue
if not _is_fetchable(normalized):
continue
queue.append((normalized, depth + 1))
return CrawlResult(pages=pages, errors=errors)
+106
View File
@@ -0,0 +1,106 @@
"""Загрузка содержимого из источников данных."""
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urljoin, urlparse
import httpx
from bs4 import BeautifulSoup
NOISE_TAGS = ("script", "style", "noscript", "nav", "header", "footer", "aside", "menu")
CONTENT_SELECTORS = (
"article",
"[role='main']",
"main",
".article",
".content",
".post",
".entry-content",
".news-item",
".card-full-news",
".box-card",
)
@dataclass
class PageContent:
text: str
links: list[str]
async def fetch_page(url: str, client: httpx.AsyncClient) -> PageContent:
response = await client.get(url)
response.raise_for_status()
content_type = response.headers.get("content-type", "")
if "html" not in content_type:
return PageContent(text=response.text, links=[])
return _parse_html(response.text, base_url=str(response.url))
def _parse_html(html: str, base_url: str) -> PageContent:
soup = BeautifulSoup(html, "lxml")
links = _extract_links(soup, base_url)
for tag in soup.find_all(NOISE_TAGS):
tag.decompose()
chunks: list[str] = []
for selector in CONTENT_SELECTORS:
for node in soup.select(selector):
text = node.get_text(separator=" ", strip=True)
if len(text) > 80:
chunks.append(text)
if chunks:
return PageContent(text=" ".join(chunks), links=links)
body = soup.body or soup
return PageContent(text=body.get_text(separator=" ", strip=True), links=links)
def _extract_links(soup: BeautifulSoup, base_url: str) -> list[str]:
found: list[str] = []
seen: set[str] = set()
base_host = urlparse(base_url).netloc.lower()
for tag in soup.find_all("a", href=True):
href = tag["href"].strip()
if not href or href.startswith(("#", "mailto:", "tel:", "javascript:")):
continue
absolute = urljoin(base_url, href)
parsed = urlparse(absolute)
if parsed.scheme not in ("http", "https"):
continue
if parsed.netloc.lower() != base_host:
continue
clean = absolute.split("#", 1)[0]
if clean and clean not in seen:
seen.add(clean)
found.append(clean)
return found
async def fetch_url(url: str, timeout: float = 30.0) -> str:
async with httpx.AsyncClient(
follow_redirects=True,
timeout=timeout,
headers={"User-Agent": "SocialParser/1.0"},
) as client:
page = await fetch_page(url, client)
return page.text
def read_file(path: str) -> str:
file_path = Path(path)
if not file_path.exists():
raise FileNotFoundError(f"Файл не найден: {path}")
for encoding in ("utf-8", "cp1251", "latin-1"):
try:
return file_path.read_text(encoding=encoding)
except UnicodeDecodeError:
continue
raise ValueError(f"Не удалось прочитать файл: {path}")
+13
View File
@@ -0,0 +1,13 @@
"""Общие модели данных."""
from dataclasses import dataclass
from datetime import datetime
@dataclass
class TelegramPost:
id: int
text: str
date: datetime
url: str
channel: str
+11
View File
@@ -0,0 +1,11 @@
"""Словари имён и стоп-слов."""
FIRST_NAMES = frozenset(['александр', 'александра', 'алексей', 'алина', 'алиса', 'альбина', 'анастасия', 'анатолий', 'андрей', 'анжела', 'анна', 'антон', 'антонина', 'аркадий', 'арсений', 'артем', 'артём', 'богдан', 'борис', 'вадим', 'валентин', 'валентина', 'валерий', 'валерия', 'василий', 'вера', 'вероника', 'виктор', 'виктория', 'виталий', 'владимир', 'владислав', 'вова', 'вячеслав', 'галина', 'геннадий', 'георгий', 'григорий', 'даниил', 'данила', 'дарья', 'денис', 'диана', 'дима', 'дмитрий', 'евгений', 'евгения', 'екатерина', 'елена', 'елизавета', 'жанна', 'зоя', 'иван', 'игорь', 'инесса', 'инна', 'ира', 'ирина', 'катя', 'кирилл', 'коля', 'константин', 'ксения', 'лариса', 'леонид', 'лидия', 'лилия', 'любовь', 'людмила', 'максим', 'маргарита', 'марина', 'мария', 'матвей', 'маша', 'милана', 'михаил', 'надежда', 'настя', 'наталья', 'никита', 'николай', 'нина', 'олег', 'ольга', 'оля', 'павел', 'паша', 'петр', 'полина', 'пётр', 'раиса', 'регина', 'рената', 'роман', 'руслан', 'саша', 'света', 'светлана', 'сергей', 'серёжа', 'софия', 'софья', 'станислав', 'степан', 'тамара', 'таня', 'татьяна', 'тимофей', 'тимур', 'фаина', 'федор', 'фёдор', 'эдуард', 'эльвира', 'эмма', 'юлия', 'юрий', 'яна', 'ярослав'])
FIRST_NAMES_GENITIVE = frozenset(['александра', 'александры', 'алексея', 'анатолия', 'андрея', 'анны', 'артема', 'артёма', 'бориса', 'вадима', 'валерия', 'василия', 'виктора', 'виталия', 'владимира', 'вячеслава', 'геннадия', 'георгия', 'григория', 'данила', 'дмитрия', 'екатерины', 'елены', 'ефима', 'ивана', 'игоря', 'кирилла', 'константина', 'леонида', 'максима', 'марии', 'михаила', 'натальи', 'николая', 'олега', 'ольги', 'павла', 'петра', 'романа', 'руслана', 'сергея', 'станислава', 'степана', 'татьяны', 'тимофея', 'тимура', 'федора', 'филиппа', 'фёдора', 'эдуарда', 'юрия', 'якова', 'ярослава'])
STOPWORDS = frozenset(['авто', 'архив', 'афера', 'беларуси', 'блогер', 'блогерша', 'блогеры', 'брянской', 'вакансии', 'видео', 'войти', 'время', 'все', 'вчера', 'главная', 'главное', 'год', 'город', 'далее', 'единое', 'журналист', 'журналисты', 'исследования', 'источник', 'источники', 'камчатке', 'комментарии', 'контакты', 'корреспондент', 'криминал', 'круг', 'культура', 'лента', 'лучшее', 'материалы', 'меню', 'место', 'месяц', 'мир', 'москве', 'неделя', 'новости', 'область', 'офис', 'партнеров', 'партнёров', 'подписаться', 'подробнее', 'поиск', 'полиция', 'популярное', 'преступная', 'путешествия', 'район', 'редакция', 'реклама', 'россии', 'россия', 'рубрика', 'рубрики', 'свежее', 'свобода', 'северный', 'сегодня', 'силовые', 'следствие', 'смотреть', 'спецпроекты', 'спорт', 'статьи', 'техподдержка', 'топ', 'украине', 'улица', 'фото', 'ценности', 'читать', 'шурыгина', 'экономика', 'эксклюзивы'])
PREPOSITIONS = frozenset(['без', 'в', 'для', 'до', 'за', 'из', 'к', 'на', 'над', 'о', 'об', 'от', 'по', 'под', 'при', 'про', 'с', 'у'])
ADJECTIVE_ENDINGS = ("ый", "ий", "ая", "яя", "ое", "ее", "ие", "ые", "ой", "ей")
+205
View File
@@ -0,0 +1,205 @@
"""Извлечение ФИО из текста."""
import re
from dataclasses import dataclass
from app.names import ADJECTIVE_ENDINGS, FIRST_NAMES, FIRST_NAMES_GENITIVE, PREPOSITIONS, STOPWORDS
PATRONYMIC_SUFFIXES = (
"ович", "евич", "овна", "евна", "ична", "инична", "оглы", "кызы",
)
SURNAME_SUFFIXES = (
"ов", "ев", "ёв", "ин", "ын", "ский", "ская", "цкий", "цкая",
"енко", "ук", "юк", "ко", "ич", "як", "ова", "ева", "ёва", "ина",
)
NON_NAME_ENDINGS = ("ция", "ство", "ение", "ание", "ура", "ика", "ник", "тель", "изм", "ист")
CYRILLIC_WORD = r"[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)?"
INITIAL = r"[А-ЯЁ]\."
FULL_FIO_RE = re.compile(
rf"\b({CYRILLIC_WORD})\s+({CYRILLIC_WORD})\s+({CYRILLIC_WORD})\b"
)
SHORT_FIO_RE = re.compile(
rf"\b({CYRILLIC_WORD})\s+({INITIAL})\s*({INITIAL})\b"
)
TWO_WORD_RE = re.compile(
rf"\b({CYRILLIC_WORD})\s+({CYRILLIC_WORD})\b"
)
@dataclass(frozen=True)
class Person:
surname: str
name: str
patronymic: str
source: str
raw: str
@property
def full_name(self) -> str:
parts = [self.surname, self.name]
if self.patronymic:
parts.append(self.patronymic)
return " ".join(parts)
def _norm(word: str) -> str:
return word.lower().replace("ё", "е")
def _looks_like_patronymic(word: str) -> bool:
lower = _norm(word)
return any(lower.endswith(s.replace("ё", "е")) for s in PATRONYMIC_SUFFIXES)
def _looks_like_surname(word: str) -> bool:
lower = _norm(word)
if len(lower) < 4:
return False
return any(lower.endswith(s.replace("ё", "е")) for s in SURNAME_SUFFIXES)
def _looks_like_adjective(word: str) -> bool:
if _is_known_first_name(word):
return False
lower = _norm(word)
return any(lower.endswith(e) for e in ADJECTIVE_ENDINGS)
def _is_known_first_name(word: str) -> bool:
lower = _norm(word)
return lower in FIRST_NAMES or lower in FIRST_NAMES_GENITIVE
def _is_blocked_word(word: str) -> bool:
lower = _norm(word)
if len(lower) < 2:
return True
if lower in STOPWORDS or lower in PREPOSITIONS:
return True
if _looks_like_adjective(word):
return True
return any(lower.endswith(e) for e in NON_NAME_ENDINGS)
def _is_initial(word: str) -> bool:
return len(word) <= 3 and word.rstrip(".").isalpha() and len(word.rstrip(".")) == 1
def _validate_person(surname: str, name: str, patronymic: str) -> bool:
parts = [surname, name, patronymic]
if any(_is_blocked_word(w) for w in parts if w):
return False
if patronymic:
if _is_initial(patronymic) or _is_initial(name):
return _looks_like_surname(surname) and not _is_blocked_word(surname)
if not _looks_like_patronymic(patronymic):
return False
if not _is_known_first_name(name):
return False
if not (_looks_like_surname(surname) or _is_known_first_name(surname)):
return False
return True
if _looks_like_patronymic(name):
return _looks_like_surname(surname) and not _is_blocked_word(name)
if not _is_known_first_name(name):
return False
if not _looks_like_surname(surname):
return False
return True
def _parse_full_fio(w1: str, w2: str, w3: str) -> Person | None:
if _looks_like_patronymic(w3):
person = Person(w1, w2, w3, "", f"{w1} {w2} {w3}")
elif _looks_like_patronymic(w2) and _looks_like_surname(w3):
person = Person(w3, w1, w2, "", f"{w1} {w2} {w3}")
elif _looks_like_surname(w1) and _is_known_first_name(w2) and _looks_like_patronymic(w3):
person = Person(w1, w2, w3, "", f"{w1} {w2} {w3}")
else:
return None
if _validate_person(person.surname, person.name, person.patronymic):
return person
return None
def _parse_two_words(w1: str, w2: str) -> Person | None:
if _looks_like_patronymic(w1) or _looks_like_patronymic(w2):
return None
if _looks_like_surname(w1) and _is_known_first_name(w2):
person = Person(w1, w2, "", "", f"{w1} {w2}")
elif _looks_like_surname(w2) and _is_known_first_name(w1):
person = Person(w2, w1, "", "", f"{w1} {w2}")
else:
return None
if _validate_person(person.surname, person.name, person.patronymic):
return person
return None
def extract_fio(text: str, source: str = "") -> list[Person]:
"""Найти все ФИО в тексте."""
seen: set[str] = set()
results: list[Person] = []
occupied: list[tuple[int, int]] = []
def overlaps(start: int, end: int) -> bool:
return any(not (end <= s or start >= e) for s, e in occupied)
def add(person: Person, start: int, end: int) -> None:
key = person.full_name.lower()
if key not in seen and not overlaps(start, end):
seen.add(key)
occupied.append((start, end))
results.append(
Person(
surname=person.surname,
name=person.name,
patronymic=person.patronymic,
source=source,
raw=person.raw,
)
)
for match in FULL_FIO_RE.finditer(text):
w1, w2, w3 = match.groups()
person = _parse_full_fio(w1, w2, w3)
if person:
add(person, *match.span())
for match in SHORT_FIO_RE.finditer(text):
start, end = match.span()
if overlaps(start, end):
continue
surname, i1, i2 = match.groups()
if not _looks_like_surname(surname) or _is_blocked_word(surname):
continue
person = Person(
surname=surname,
name=i1,
patronymic=i2,
source="",
raw=match.group(),
)
if _validate_person(person.surname, person.name, person.patronymic):
add(person, start, end)
for match in TWO_WORD_RE.finditer(text):
start, end = match.span()
if overlaps(start, end):
continue
w1, w2 = match.groups()
person = _parse_two_words(w1, w2)
if person:
add(person, start, end)
return results
@@ -0,0 +1 @@
@@ -0,0 +1,89 @@
"""Парсинг событий из постов Telegram-канала."""
import re
from dataclasses import dataclass
from datetime import datetime
from workers.models import TelegramPost
DATE_LINE_RE = re.compile(
r"^(\d{2}\.\d{2}\.\d{2,4})\s+(.+)$",
re.MULTILINE,
)
COORDS_RE = re.compile(
r"(-?\d{1,3}\.\d+)\s*,\s*(-?\d{1,3}\.\d+)",
)
HASHTAG_LINE_RE = re.compile(r"^#\w+", re.MULTILINE)
@dataclass
class EventRecord:
event: str
date: str
geolocation: str
locality: str
source_url: str
def _format_message_date(dt: datetime) -> str:
return dt.strftime("%d.%m.%y")
def _clean_event_text(text: str) -> str:
lines: list[str] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.lower() == "источник":
continue
if stripped.startswith("#"):
continue
if COORDS_RE.search(stripped):
continue
lines.append(stripped)
return "\n".join(lines)
def parse_event_post(post: TelegramPost) -> EventRecord:
text = post.text
date = _format_message_date(post.date)
locality = ""
geolocation = ""
event = text
first_line = text.splitlines()[0].strip() if text.splitlines() else ""
header_match = DATE_LINE_RE.match(first_line)
if header_match:
date = header_match.group(1)
locality = header_match.group(2).strip()
coords_match = COORDS_RE.search(text)
if coords_match:
geolocation = f"{coords_match.group(1)}, {coords_match.group(2)}"
if header_match:
body = text[len(first_line):].strip()
else:
body = text
if coords_match:
before, after = body.split(coords_match.group(0), 1)
event_body = before.strip()
else:
event_body = body
event = _clean_event_text(event_body) or _clean_event_text(text) or text
event = HASHTAG_LINE_RE.sub("", event).strip()
return EventRecord(
event=event,
date=date,
geolocation=geolocation,
locality=locality,
source_url=post.url,
)
def parse_event_posts(posts: list[TelegramPost]) -> list[EventRecord]:
return [parse_event_post(post) for post in posts if post.text.strip()]
@@ -0,0 +1 @@
@@ -0,0 +1,99 @@
"""Telegram Client API (Telethon) для чтения постов канала."""
import os
import re
from pathlib import Path
from telethon.errors import AuthKeyUnregisteredError, SessionPasswordNeededError
from workers.models import TelegramPost
from workers.sources.telegram_settings import create_client, get_api_credentials
DEFAULT_SESSION_PATH = "/data/telegram.session"
MAX_POSTS = 500
class TelegramConfigError(Exception):
pass
class TelegramAuthError(Exception):
pass
def _get_config() -> tuple[int, str, str]:
api_id, api_hash = get_api_credentials()
session_path = os.environ.get("TELEGRAM_SESSION_PATH", DEFAULT_SESSION_PATH)
return api_id, api_hash, session_path
def normalize_channel(raw: str) -> str:
value = raw.strip()
if not value:
raise ValueError("Укажите канал Telegram")
value = value.replace("https://", "").replace("http://", "")
value = re.sub(r"^web\.telegram\.org/k/#@", "", value)
value = re.sub(r"^t\.me/", "", value)
value = value.lstrip("@").split("/")[0].split("?")[0]
if not value:
raise ValueError("Некорректное имя канала")
return value
def _build_post_url(channel: str, message_id: int) -> str:
username = channel.lstrip("@")
return f"https://t.me/{username}/{message_id}"
async def fetch_channel_posts(channel: str, limit: int = 100) -> list[TelegramPost]:
limit = max(1, min(limit, MAX_POSTS))
try:
api_id, api_hash, session_path = _get_config()
except ValueError as exc:
raise TelegramConfigError(str(exc)) from exc
username = normalize_channel(channel)
if not Path(session_path).exists():
raise TelegramAuthError(
f"Файл сессии не найден: {session_path}. "
"Выполните: python scripts/telegram_auth.py"
)
client = create_client(session_path, api_id, api_hash)
posts: list[TelegramPost] = []
try:
await client.connect()
if not await client.is_user_authorized():
raise TelegramAuthError(
"Telegram-сессия не авторизована. Выполните: python scripts/telegram_auth.py"
)
entity = await client.get_entity(username)
async for message in client.iter_messages(entity, limit=limit):
if not message.text:
continue
posts.append(
TelegramPost(
id=message.id,
text=message.text.strip(),
date=message.date,
url=_build_post_url(username, message.id),
channel=username,
)
)
except AuthKeyUnregisteredError as exc:
raise TelegramAuthError(
"Сессия Telegram недействительна. Переавторизуйтесь: python scripts/telegram_auth.py"
) from exc
except SessionPasswordNeededError as exc:
raise TelegramAuthError(
"Для аккаунта включена двухфакторная аутентификация. "
"Авторизуйтесь через scripts/telegram_auth.py с паролем 2FA."
) from exc
finally:
await client.disconnect()
return posts
@@ -0,0 +1,69 @@
"""Общие настройки подключения Telethon."""
import os
from telethon import TelegramClient
DEFAULT_SESSION_PATH = "/data/telegram.session"
def get_session_path() -> str:
return os.environ.get("TELEGRAM_SESSION_PATH", DEFAULT_SESSION_PATH)
def get_api_credentials() -> tuple[int, str]:
api_id_raw = os.environ.get("TELEGRAM_API_ID", "")
api_hash = os.environ.get("TELEGRAM_API_HASH", "")
if not api_id_raw or not api_hash:
raise ValueError(
"Не заданы TELEGRAM_API_ID и TELEGRAM_API_HASH в .env"
)
try:
api_id = int(api_id_raw)
except ValueError as exc:
raise ValueError("TELEGRAM_API_ID должен быть числом") from exc
return api_id, api_hash
def get_proxy() -> tuple | None:
proxy_type = os.environ.get("TELEGRAM_PROXY_TYPE", "").strip().lower()
if not proxy_type or proxy_type == "none":
return None
host = os.environ.get("TELEGRAM_PROXY_HOST", "127.0.0.1").strip()
port = int(os.environ.get("TELEGRAM_PROXY_PORT", "1080"))
user = os.environ.get("TELEGRAM_PROXY_USER", "").strip()
password = os.environ.get("TELEGRAM_PROXY_PASS", "").strip()
if proxy_type not in ("socks5", "socks4", "http"):
raise ValueError(
f"Неподдерживаемый TELEGRAM_PROXY_TYPE={proxy_type!r}. "
"Используйте socks5, socks4 или http."
)
if user:
return proxy_type, host, port, True, user, password
return proxy_type, host, port
def describe_connection() -> str:
proxy = get_proxy()
if not proxy:
return "напрямую (без прокси)"
return f"через {proxy[0]}://{proxy[1]}:{proxy[2]}"
def create_client(session_path: str, api_id: int, api_hash: str) -> TelegramClient:
kwargs = {
"connection_retries": 10,
"retry_delay": 3,
"timeout": 60,
"request_retries": 5,
}
proxy = get_proxy()
if proxy:
kwargs["proxy"] = proxy
return TelegramClient(session_path, api_id, api_hash, **kwargs)