""" Telegram 财经快讯机器人 - 主入口 功能:自动抓取财经/科技快讯,AI 评分过滤,推送到 Telegram """ import os import time import logging import asyncio from datetime import datetime from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import ( Application, CommandHandler, CallbackQueryHandler, ContextTypes, MessageHandler, filters, ) from telegram.constants import ParseMode import sources import scorer import storage import summarizer # 日志配置 logging.basicConfig( format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", level=logging.INFO, ) logger = logging.getLogger(__name__) # 配置 BOT_TOKEN = os.environ.get("BOT_TOKEN", "") ADMIN_ID = int(os.environ.get("ADMIN_ID", "165067365")) # 分页状态缓存 _page_cache = {} # ========== 工具函数 ========== def _check_admin(user_id: int) -> bool: """检查是否为管理员""" return user_id == ADMIN_ID def _format_news_item(item: dict) -> str: """格式化单条新闻(支持 HTML 链接)""" score = item.get("score", 0) if score >= 9: emoji = "🔥" elif score >= 8: emoji = "⚡" elif score >= 7: emoji = "📌" elif score >= 6: emoji = "🔹" else: emoji = "•" src = item.get("source_name", "") ts = item.get("time_str", "") title = item.get("title", "") url = item.get("url", "") # 转义 HTML 特殊字符 import html as html_mod title_safe = html_mod.escape(title) if url: title_display = f'{title_safe}' else: title_display = title_safe return f"{emoji} {title_display}\n 📡 {src} 🕐 {ts} 评分:{score}" # ========== /start 命令 ========== async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): """欢迎消息 + 主菜单""" if not _check_admin(update.effective_user.id): await update.message.reply_text("⛔ 仅限管理员使用") return keyboard = [ [InlineKeyboardButton("📰 最新快讯", callback_data="news_0"), InlineKeyboardButton("📊 新闻总结", callback_data="summary_menu")], [InlineKeyboardButton("📡 订阅源管理", callback_data="sources_menu"), InlineKeyboardButton("🔑 关键词管理", callback_data="keywords_menu")], [InlineKeyboardButton("⚙️ 设置", callback_data="settings_menu")], ] text = ( "👋 欢迎使用财经快讯机器人\n\n" "🤖 自动抓取金十、华尔街见闻、36氪、新浪财经快讯\n" "📊 AI 评分过滤,只推送相关内容\n" "⏰ 定时总结,不错过重要新闻\n\n" "请选择功能:" ) await update.message.reply_text( text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) # ========== /news 命令 ========== async def cmd_news(update: Update, context: ContextTypes.DEFAULT_TYPE): """查看最新快讯""" if not _check_admin(update.effective_user.id): return await _show_news_page(update.message, 0) async def _show_news_page(message_or_query, page: int): """显示新闻分页""" settings = storage.get_settings() news_list = storage.get_news(limit=100) # 评分 scorer.score_and_filter(news_list, settings.get("keywords", [])) # 只显示评分 >= 4 的 news_list = [n for n in news_list if n.get("score", 0) >= 4] news_list.sort(key=lambda x: x.get("timestamp", 0), reverse=True) page_size = 10 total_pages = max(1, (len(news_list) + page_size - 1) // page_size) page = max(0, min(page, total_pages - 1)) start = page * page_size page_items = news_list[start:start + page_size] if not page_items: text = "📭 暂无快讯,等待抓取中..." else: lines = [f"📰 最新快讯 (第{page+1}/{total_pages}页)\n"] for item in page_items: lines.append(_format_news_item(item)) text = "\n".join(lines) # 分页按钮 buttons = [] if page > 0: buttons.append(InlineKeyboardButton("⬅️ 上一页", callback_data=f"news_{page-1}")) buttons.append(InlineKeyboardButton("🔄 刷新", callback_data=f"news_{page}")) if page < total_pages - 1: buttons.append(InlineKeyboardButton("➡️ 下一页", callback_data=f"news_{page+1}")) keyboard = [buttons, [InlineKeyboardButton("🏠 主菜单", callback_data="main_menu")]] if hasattr(message_or_query, "edit_message_text"): try: await message_or_query.edit_message_text( text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) except Exception: await message_or_query.edit_message_text( text, reply_markup=InlineKeyboardMarkup(keyboard), ) else: await message_or_query.reply_text( text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) # ========== /summary 命令 ========== async def cmd_summary(update: Update, context: ContextTypes.DEFAULT_TYPE): """手动触发新闻总结""" if not _check_admin(update.effective_user.id): return await _show_summary_menu(update.message) async def _show_summary_menu(message_or_query): """显示总结时间范围选择""" keyboard = [ [InlineKeyboardButton("⏱ 最近1小时", callback_data="summary_最近1小时")], [InlineKeyboardButton("🌅 上午", callback_data="summary_上午")], [InlineKeyboardButton("📅 全天", callback_data="summary_全天")], [InlineKeyboardButton("🏠 主菜单", callback_data="main_menu")], ] text = "📊 新闻总结\n\n请选择时间范围:" if hasattr(message_or_query, "edit_message_text"): await message_or_query.edit_message_text( text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) else: await message_or_query.reply_text( text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) # ========== /sources 命令 ========== async def cmd_sources(update: Update, context: ContextTypes.DEFAULT_TYPE): """查看/管理订阅源""" if not _check_admin(update.effective_user.id): return await _show_sources_menu(update.message) async def _show_sources_menu(message_or_query): """显示订阅源管理菜单""" settings = storage.get_settings() srcs = settings.get("sources", {}) source_info = { "jin10": "金十数据", "wallstreet": "华尔街见闻", "kr36": "36氪", "sina": "新浪财经", } keyboard = [] for key, name in source_info.items(): enabled = srcs.get(key, True) status = "✅" if enabled else "❌" keyboard.append([InlineKeyboardButton( f"{status} {name}", callback_data=f"toggle_src_{key}" )]) keyboard.append([InlineKeyboardButton("🏠 主菜单", callback_data="main_menu")]) text = "📡 订阅源管理\n\n点击切换开/关:" if hasattr(message_or_query, "edit_message_text"): await message_or_query.edit_message_text( text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) else: await message_or_query.reply_text( text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) # ========== /keywords 命令 ========== async def cmd_keywords(update: Update, context: ContextTypes.DEFAULT_TYPE): """管理关注关键词""" if not _check_admin(update.effective_user.id): return await _show_keywords_menu(update.message) async def _show_keywords_menu(message_or_query): """显示关键词管理菜单""" settings = storage.get_settings() kw_list = settings.get("keywords", []) # 每行显示3个关键词删除按钮 keyboard = [] row = [] for kw in kw_list[:30]: row.append(InlineKeyboardButton(f"❌ {kw}", callback_data=f"del_kw_{kw}")) if len(row) == 3: keyboard.append(row) row = [] if row: keyboard.append(row) keyboard.append([InlineKeyboardButton("➕ 添加关键词", callback_data="add_kw_prompt")]) keyboard.append([InlineKeyboardButton("🏠 主菜单", callback_data="main_menu")]) text = f"🔑 关键词管理\n\n当前 {len(kw_list)} 个关键词\n(点击关键词可删除)" if hasattr(message_or_query, "edit_message_text"): await message_or_query.edit_message_text( text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) else: await message_or_query.reply_text( text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) # ========== /settings 命令 ========== async def cmd_settings(update: Update, context: ContextTypes.DEFAULT_TYPE): """设置菜单""" if not _check_admin(update.effective_user.id): return await _show_settings_menu(update.message) async def _show_settings_menu(message_or_query): """显示设置菜单""" settings = storage.get_settings() push_on = settings.get("push_enabled", True) min_score = settings.get("min_score", 6) instant_score = settings.get("instant_score", 8) interval = settings.get("batch_interval", 30) push_txt = "✅ 推送已开" if push_on else "❌ 推送已关" keyboard = [ [InlineKeyboardButton(push_txt, callback_data="toggle_push")], [ InlineKeyboardButton(f"最低评分: {min_score} ➖", callback_data="score_min_down"), InlineKeyboardButton(f"最低评分: {min_score} ➕", callback_data="score_min_up"), ], [ InlineKeyboardButton(f"即时阈值: {instant_score} ➖", callback_data="score_instant_down"), InlineKeyboardButton(f"即时阈值: {instant_score} ➕", callback_data="score_instant_up"), ], [ InlineKeyboardButton(f"汇总间隔: {interval}min ➖", callback_data="interval_down"), InlineKeyboardButton(f"汇总间隔: {interval}min ➕", callback_data="interval_up"), ], [InlineKeyboardButton("🏠 主菜单", callback_data="main_menu")], ] text = ( f"⚙️ 设置\n\n" f"推送状态: {'开启' if push_on else '关闭'}\n" f"最低推送评分: {min_score}\n" f"即时推送阈值: {instant_score}\n" f"汇总推送间隔: {interval} 分钟" ) if hasattr(message_or_query, "edit_message_text"): await message_or_query.edit_message_text( text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) else: await message_or_query.reply_text( text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) # ========== CallbackQuery 处理器 ========== # 等待用户输入关键词的状态 _waiting_kw_add = set() async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): """统一处理所有按钮回调""" query = update.callback_query await query.answer() data = query.data user_id = query.from_user.id if not _check_admin(user_id): return # ---------- 主菜单 ---------- if data == "main_menu": keyboard = [ [InlineKeyboardButton("📰 最新快讯", callback_data="news_0"), InlineKeyboardButton("📊 新闻总结", callback_data="summary_menu")], [InlineKeyboardButton("📡 订阅源管理", callback_data="sources_menu"), InlineKeyboardButton("🔑 关键词管理", callback_data="keywords_menu")], [InlineKeyboardButton("⚙️ 设置", callback_data="settings_menu")], ] await query.edit_message_text( "🏠 主菜单\n\n请选择功能:", parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard), ) # ---------- 新闻分页 ---------- elif data.startswith("news_"): page = int(data.split("_", 1)[1]) await _show_news_page(query, page) # ---------- 总结菜单 ---------- elif data == "summary_menu": await _show_summary_menu(query) elif data.startswith("summary_"): period = data[len("summary_"):] start_ts, end_ts = summarizer.get_period_range(period) settings = storage.get_settings() news_list = storage.get_news(limit=500, since_ts=start_ts) news_list = [n for n in news_list if n.get("timestamp", 0) <= end_ts] scorer.score_and_filter(news_list, settings.get("keywords", [])) news_list = [n for n in news_list if n.get("score", 0) >= settings.get("min_score", 6)] text = summarizer.generate_summary(news_list, period) back_btn = InlineKeyboardMarkup([[ InlineKeyboardButton("🔙 返回", callback_data="summary_menu"), InlineKeyboardButton("🏠 主菜单", callback_data="main_menu"), ]]) try: await query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=back_btn) except Exception: await query.edit_message_text(text, reply_markup=back_btn) # ---------- 订阅源 ---------- elif data == "sources_menu": await _show_sources_menu(query) elif data.startswith("toggle_src_"): src = data[len("toggle_src_"):] new_state = storage.toggle_source(src) state_txt = "开启" if new_state else "关闭" await query.answer(f"已{state_txt} {sources.SOURCE_NAMES.get(src, src)}", show_alert=False) await _show_sources_menu(query) # ---------- 关键词 ---------- elif data == "keywords_menu": await _show_keywords_menu(query) elif data.startswith("del_kw_"): kw = data[len("del_kw_"):] storage.remove_keyword(kw) await query.answer(f"已删除关键词: {kw}", show_alert=False) await _show_keywords_menu(query) elif data == "add_kw_prompt": _waiting_kw_add.add(user_id) await query.edit_message_text( "🔑 添加关键词\n\n请直接发送关键词文字(可空格分隔多个):", parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[ InlineKeyboardButton("取消", callback_data="keywords_menu") ]]), ) # ---------- 设置 ---------- elif data == "settings_menu": await _show_settings_menu(query) elif data == "toggle_push": settings = storage.get_settings() storage.update_settings({"push_enabled": not settings.get("push_enabled", True)}) await _show_settings_menu(query) elif data == "score_min_up": s = storage.get_settings() storage.update_settings({"min_score": min(10, s.get("min_score", 6) + 1)}) await _show_settings_menu(query) elif data == "score_min_down": s = storage.get_settings() storage.update_settings({"min_score": max(1, s.get("min_score", 6) - 1)}) await _show_settings_menu(query) elif data == "score_instant_up": s = storage.get_settings() storage.update_settings({"instant_score": min(10, s.get("instant_score", 8) + 1)}) await _show_settings_menu(query) elif data == "score_instant_down": s = storage.get_settings() storage.update_settings({"instant_score": max(1, s.get("instant_score", 8) - 1)}) await _show_settings_menu(query) elif data == "interval_up": s = storage.get_settings() storage.update_settings({"batch_interval": min(120, s.get("batch_interval", 30) + 5)}) await _show_settings_menu(query) elif data == "interval_down": s = storage.get_settings() storage.update_settings({"batch_interval": max(5, s.get("batch_interval", 30) - 5)}) await _show_settings_menu(query) async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE): """处理普通文本消息(用于添加关键词)""" user_id = update.effective_user.id if not _check_admin(user_id): return if user_id not in _waiting_kw_add: return _waiting_kw_add.discard(user_id) text = update.message.text.strip() added = [] for kw in text.split(): if storage.add_keyword(kw): added.append(kw) if added: await update.message.reply_text( f"✅ 已添加关键词: {', '.join(added)}", reply_markup=InlineKeyboardMarkup([[ InlineKeyboardButton("🔑 关键词管理", callback_data="keywords_menu") ]]), ) else: await update.message.reply_text("⚠️ 关键词已存在或无效") # ========== 后台抓取任务 ========== # 汇总队列(评分6-7的新闻暂存) _batch_queue: list = [] _last_batch_push: float = 0.0 _pushed_in_memory: set = set() # 内存去重,防止并发重复推送 async def _fetch_and_process(app: Application): """核心抓取+处理循环""" settings = storage.get_settings() enabled = settings.get("sources", {}) push_on = settings.get("push_enabled", True) min_score = settings.get("min_score", 6) instant_score = settings.get("instant_score", 8) logger.info("开始抓取新闻...") try: all_news = await sources.fetch_all(enabled) except Exception as e: logger.error(f"fetch_all 异常: {e}") return if not all_news: return # 评分 scorer.score_and_filter(all_news, settings.get("keywords", [])) # 去重(全局标题去重) all_news = scorer.dedup_news(all_news) # 过滤已推送(内存+文件双重去重) pushed_ids = storage.get_pushed_ids() | _pushed_in_memory new_items = [n for n in all_news if n["id"] not in pushed_ids and n.get("score", 0) >= min_score] if not new_items: return # 保存到存储 storage.save_news(new_items) if not push_on: return instant_items = [n for n in new_items if n.get("score", 0) >= instant_score] batch_items = [n for n in new_items if min_score <= n.get("score", 0) < instant_score] # 即时推送 for item in instant_items: await _push_instant(app, item) # 加入汇总队列(去重) existing_ids = {n["id"] for n in _batch_queue} for item in batch_items: if item["id"] not in existing_ids: _batch_queue.append(item) existing_ids.add(item["id"]) # 标记已推送(即时+批量都标记,防止重复) pushed = [n["id"] for n in instant_items + batch_items] _pushed_in_memory.update(pushed) storage.mark_pushed(pushed) async def _push_instant(app: Application, item: dict): """立即推送单条重磅新闻""" score = item.get("score", 0) if score >= 9: emoji = "🔥🔥🔥" elif score >= 8: emoji = "⚡ 重磅" else: emoji = "📌" src = item.get("source_name", "") title = item.get("title", "") ts = item.get("time_str", "") url = item.get("url", "") import html as html_mod title_safe = html_mod.escape(title) if url: title_display = f'{title_safe}' else: title_display = f"{title_safe}" text = f"{emoji}\n\n{title_display}\n\n📡 {src} 🕐 {ts} 评分:{score}" try: await app.bot.send_message( chat_id=ADMIN_ID, text=text, parse_mode=ParseMode.HTML, ) except Exception as e: logger.error(f"即时推送失败: {e}") try: await app.bot.send_message(chat_id=ADMIN_ID, text=f"{emoji}\n\n{title}\n\n{src} {ts}") except Exception as e2: logger.error(f"即时推送备用失败: {e2}") async def _push_batch(app: Application): """汇总推送(每N分钟一次)""" global _batch_queue, _last_batch_push if not _batch_queue: return settings = storage.get_settings() interval = settings.get("batch_interval", 30) * 60 now = time.time() if now - _last_batch_push < interval: return items = _batch_queue[:] _batch_queue.clear() _last_batch_push = now # 标记已推送 storage.mark_pushed([n["id"] for n in items]) if not items: return import html as html_mod from collections import defaultdict # 按来源分组 source_map = defaultdict(list) for item in sorted(items, key=lambda x: x.get("score", 0), reverse=True): src = item.get("source_name", "未知") source_map[src].append(item) source_emoji = { "金十数据": "💰", "华尔街见闻": "📈", "36氪": "🚀", "新浪财经": "📊", "Google News": "🌐", "Finviz": "📉", "TechCrunch": "💻", } lines = [f"🟡 快讯汇总({len(items)}条)"] lines.append("") for src, src_items in source_map.items(): emoji = source_emoji.get(src, "📰") lines.append(f"{emoji} {src}({len(src_items)})") for item in src_items[:10]: title = item.get("title", "") url = item.get("url", "") title_safe = html_mod.escape(title) if url: lines.append(f" • {title_safe}") else: lines.append(f" • {title_safe}") if len(src_items) > 10: lines.append(f" ... 还有{len(src_items) - 10}条") lines.append("") lines.append(f"⏰ {datetime.now().strftime('%H:%M')}") text = "\n".join(lines) try: await app.bot.send_message( chat_id=ADMIN_ID, text=text, parse_mode=ParseMode.HTML, ) except Exception as e: logger.error(f"汇总推送失败: {e}") try: await app.bot.send_message(chat_id=ADMIN_ID, text="\n".join(lines[:15])) except Exception as e2: logger.error(f"汇总推送备用失败: {e2}") async def _send_scheduled_summary(app: Application, period: str): """发送定时总结""" settings = storage.get_settings() start_ts, end_ts = summarizer.get_period_range(period) news_list = storage.get_news(limit=500, since_ts=start_ts) news_list = [n for n in news_list if n.get("timestamp", 0) <= end_ts] scorer.score_and_filter(news_list, settings.get("keywords", [])) news_list = [n for n in news_list if n.get("score", 0) >= settings.get("min_score", 6)] text = summarizer.generate_summary(news_list, period) try: await app.bot.send_message( chat_id=ADMIN_ID, text=text, parse_mode=ParseMode.HTML, ) except Exception as e: logger.error(f"定时总结推送失败: {e}") try: await app.bot.send_message(chat_id=ADMIN_ID, text=text) except Exception: pass # ========== 调度器 ========== class Scheduler: """轻量调度器,基于 asyncio""" def __init__(self, app: Application): self.app = app self._tasks: list[asyncio.Task] = [] def start(self): self._tasks.append(asyncio.create_task(self._fast_loop())) self._tasks.append(asyncio.create_task(self._slow_loop())) self._tasks.append(asyncio.create_task(self._batch_loop())) self._tasks.append(asyncio.create_task(self._summary_loop())) logger.info("调度器已启动") async def _fast_loop(self): """金十/华尔街见闻 每2分钟抓取""" while True: try: await _fetch_and_process(self.app) except Exception as e: logger.error(f"fast_loop 异常: {e}") await asyncio.sleep(120) async def _slow_loop(self): """36氪/新浪 每5分钟额外触发(fast_loop已包含全部,这里可扩展)""" await asyncio.sleep(60) # 错开启动时间 while True: try: await _fetch_and_process(self.app) except Exception as e: logger.error(f"slow_loop 异常: {e}") await asyncio.sleep(300) async def _batch_loop(self): """每分钟检查是否需要汇总推送""" await asyncio.sleep(30) while True: try: await _push_batch(self.app) except Exception as e: logger.error(f"batch_loop 异常: {e}") await asyncio.sleep(60) async def _summary_loop(self): """定时总结 08:00 / 11:30 / 20:00""" schedule = [ (8, 0, "昨晚到今早"), (11, 30, "上午"), (20, 0, "全天"), ] triggered = set() while True: now = datetime.now() key = f"{now.date()}" for hour, minute, period in schedule: tid = f"{key}_{hour}_{minute}" if tid not in triggered: # 在目标时间 ±2 分钟内触发 target = now.replace(hour=hour, minute=minute, second=0) diff = abs((now - target).total_seconds()) if diff <= 120: triggered.add(tid) try: await _send_scheduled_summary(self.app, period) except Exception as e: logger.error(f"定时总结异常: {e}") # 每分钟检查一次 await asyncio.sleep(60) def stop(self): for t in self._tasks: t.cancel() # ========== 主函数 ========== def main(): if not BOT_TOKEN: logger.error("请设置 BOT_TOKEN 环境变量") return logger.info(f"机器人启动,ADMIN_ID={ADMIN_ID}") app = Application.builder().token(BOT_TOKEN).build() # 注册命令 app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("news", cmd_news)) app.add_handler(CommandHandler("summary", cmd_summary)) app.add_handler(CommandHandler("sources", cmd_sources)) app.add_handler(CommandHandler("keywords", cmd_keywords)) app.add_handler(CommandHandler("settings", cmd_settings)) # 注册回调 app.add_handler(CallbackQueryHandler(handle_callback)) # 注册文本消息(用于添加关键词) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text)) # 启动调度器(在 post_init 中启动,确保 event loop 已就绪) scheduler = Scheduler(app) async def post_init(application: Application): scheduler.start() logger.info("调度器已在 post_init 中启动") app.post_init = post_init logger.info("开始 polling...") app.run_polling(drop_pending_updates=True) if __name__ == "__main__": main()