|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import hashlib |
4 | | -import html as html_lib |
5 | 4 | import re |
6 | | -from datetime import datetime, timedelta, timezone |
7 | 5 | from typing import Any |
8 | 6 | from urllib.parse import urljoin |
9 | 7 |
|
@@ -32,9 +30,6 @@ def load_source_documents(config: dict[str, Any]) -> list[SourceDocument]: |
32 | 30 | if config["sources"]["course_repositories"]["enabled"]: |
33 | 31 | documents.extend(load_course_repository_documents(config)) |
34 | 32 |
|
35 | | - if config["sources"].get("telegram", {}).get("enabled"): |
36 | | - documents.extend(load_telegram_documents(config)) |
37 | | - |
38 | 33 | return documents |
39 | 34 |
|
40 | 35 |
|
@@ -184,139 +179,6 @@ def load_course_repository_documents(config: dict[str, Any]) -> list[SourceDocum |
184 | 179 | return documents |
185 | 180 |
|
186 | 181 |
|
187 | | -TELEGRAM_PREVIEW_BASE = "https://t.me/s/" |
188 | | -TELEGRAM_LINK_BASE = "https://t.me/" |
189 | | -# Telegram serves an empty preview to some default user agents; use a browser-like one. |
190 | | -TELEGRAM_USER_AGENT = "Mozilla/5.0 (compatible; faq-assistant-ingest/1.0)" |
191 | | - |
192 | | - |
193 | | -def load_telegram_documents(config: dict[str, Any]) -> list[SourceDocument]: |
194 | | - """Index recent posts from each course's public Telegram broadcast channel. |
195 | | -
|
196 | | - Uses the keyless ``t.me/s/<channel>`` web preview (no bot token / API key), |
197 | | - walking backwards with ``?before=<id>`` until posts predate the lookback |
198 | | - window. Only public channels expose this preview. |
199 | | - """ |
200 | | - source_config = config["sources"]["telegram"] |
201 | | - lookback_months = int(source_config.get("lookback_months", 12)) |
202 | | - cutoff = datetime.now(timezone.utc) - timedelta(days=round(lookback_months * 30.44)) |
203 | | - |
204 | | - documents: list[SourceDocument] = [] |
205 | | - for course, course_config in config["courses"].items(): |
206 | | - channel = str(course_config.get("telegram_channel") or "").strip().lstrip("@") |
207 | | - if not channel: |
208 | | - continue |
209 | | - try: |
210 | | - posts = fetch_telegram_posts(channel, cutoff) |
211 | | - except Exception as e: |
212 | | - print(f"warning: failed to fetch telegram channel {channel}: {e}") |
213 | | - continue |
214 | | - |
215 | | - for post in posts: |
216 | | - documents.append( |
217 | | - SourceDocument( |
218 | | - source_type="telegram", |
219 | | - scope="course", |
220 | | - course=course, |
221 | | - course_name=course_config["name"], |
222 | | - section="Telegram announcements", |
223 | | - title=telegram_title(post["text"]), |
224 | | - text=post["text"], |
225 | | - url=f"{TELEGRAM_LINK_BASE}{post['id']}", |
226 | | - repo=None, |
227 | | - path=None, |
228 | | - source_id=f"telegram:{post['id']}", |
229 | | - ) |
230 | | - ) |
231 | | - |
232 | | - return documents |
233 | | - |
234 | | - |
235 | | -def fetch_telegram_posts( |
236 | | - channel: str, cutoff: datetime, max_pages: int = 200 |
237 | | -) -> list[dict[str, Any]]: |
238 | | - collected: dict[str, dict[str, Any]] = {} |
239 | | - before: int | None = None |
240 | | - |
241 | | - for _ in range(max_pages): |
242 | | - params = {"before": before} if before else {} |
243 | | - response = requests.get( |
244 | | - f"{TELEGRAM_PREVIEW_BASE}{channel}", |
245 | | - params=params, |
246 | | - headers={"User-Agent": TELEGRAM_USER_AGENT}, |
247 | | - timeout=60, |
248 | | - ) |
249 | | - response.raise_for_status() |
250 | | - |
251 | | - posts = parse_telegram_page(response.text, channel) |
252 | | - if not posts: |
253 | | - break |
254 | | - |
255 | | - reached_cutoff = False |
256 | | - for post in posts: |
257 | | - if post["datetime"] < cutoff: |
258 | | - reached_cutoff = True |
259 | | - continue |
260 | | - collected[post["id"]] = post |
261 | | - |
262 | | - if reached_cutoff: |
263 | | - break |
264 | | - before = min(post["seq"] for post in posts) |
265 | | - |
266 | | - return sorted(collected.values(), key=lambda post: post["seq"], reverse=True) |
267 | | - |
268 | | - |
269 | | -def parse_telegram_page(html_text: str, channel: str) -> list[dict[str, Any]]: |
270 | | - anchors = list(re.finditer(rf'data-post="{re.escape(channel)}/(\d+)"', html_text)) |
271 | | - posts: list[dict[str, Any]] = [] |
272 | | - |
273 | | - for index, anchor in enumerate(anchors): |
274 | | - seq = int(anchor.group(1)) |
275 | | - end = anchors[index + 1].start() if index + 1 < len(anchors) else len(html_text) |
276 | | - segment = html_text[anchor.end() : end] |
277 | | - |
278 | | - time_match = re.search(r'<time datetime="([^"]+)"', segment) |
279 | | - if not time_match: |
280 | | - continue |
281 | | - posted_at = datetime.fromisoformat(time_match.group(1)) |
282 | | - |
283 | | - text_match = re.search( |
284 | | - r'tgme_widget_message_text[^>]*>(.*?)</div>', segment, re.DOTALL |
285 | | - ) |
286 | | - text = telegram_html_to_text(text_match.group(1)) if text_match else "" |
287 | | - if not text: |
288 | | - continue # media-only / empty post |
289 | | - |
290 | | - posts.append( |
291 | | - {"id": f"{channel}/{seq}", "seq": seq, "datetime": posted_at, "text": text} |
292 | | - ) |
293 | | - |
294 | | - return posts |
295 | | - |
296 | | - |
297 | | -def telegram_html_to_text(fragment: str) -> str: |
298 | | - fragment = re.sub(r"<br\s*/?>", "\n", fragment, flags=re.IGNORECASE) |
299 | | - # Preserve links as Markdown so the answer can cite the real URL. |
300 | | - fragment = re.sub( |
301 | | - r'<a\b[^>]*\bhref="([^"]+)"[^>]*>(.*?)</a>', |
302 | | - lambda m: f"[{strip_tags(m.group(2))}]({m.group(1)})", |
303 | | - fragment, |
304 | | - flags=re.DOTALL | re.IGNORECASE, |
305 | | - ) |
306 | | - return clean_text(html_lib.unescape(strip_tags(fragment))) |
307 | | - |
308 | | - |
309 | | -def telegram_title(text: str, limit: int = 80) -> str: |
310 | | - first_line = next((line.strip() for line in text.splitlines() if line.strip()), "") |
311 | | - if len(first_line) > limit: |
312 | | - first_line = first_line[: limit - 1].rstrip() + "…" |
313 | | - return first_line or "Telegram post" |
314 | | - |
315 | | - |
316 | | -def strip_tags(value: str) -> str: |
317 | | - return re.sub(r"<[^>]+>", "", value) |
318 | | - |
319 | | - |
320 | 182 | def read_github_files(github_config: dict[str, Any], required_prefix: str | None = None): |
321 | 183 | owner, repo_name = github_config["repo"].split("/", 1) |
322 | 184 | include = list(github_config.get("include", [])) |
|
0 commit comments