sansan hai 7 meses
pai
achega
8914e15d71
Modificáronse 6 ficheiros con 464 adicións e 118 borrados
  1. 7 1
      docker/.env
  2. 3 0
      docker/docker-compose-build.yml
  3. 3 0
      docker/docker-compose.yml
  4. 311 108
      main.py
  5. 139 8
      readme.md
  6. 1 1
      version

+ 7 - 1
docker/.env

@@ -1,15 +1,21 @@
-# Webhook 配置
+# 推送配置
 FEISHU_WEBHOOK_URL=
 TELEGRAM_BOT_TOKEN=
 TELEGRAM_CHAT_ID=
 DINGTALK_WEBHOOK_URL=
 WEWORK_WEBHOOK_URL=
+
 EMAIL_FROM=
 EMAIL_PASSWORD=
 EMAIL_TO=
 EMAIL_SMTP_SERVER=
 EMAIL_SMTP_PORT=
 
+# ntfy 推送配置
+NTFY_SERVER_URL=https://ntfy.sh  # 默认使用公共服务,可改为自托管地址
+NTFY_TOPIC=                       # ntfy主题名称
+NTFY_TOKEN=                       # 可选:访问令牌(用于私有主题)
+
 # 运行配置
 CRON_SCHEDULE=*/30 * * * * # 定时任务表达式,每 30 分钟执行一次(比如 8点,8点半,9点,9点半这种时间规律执行)
 RUN_MODE=cron              # 运行模式:cron/once

+ 3 - 0
docker/docker-compose-build.yml

@@ -22,6 +22,9 @@ services:
       - EMAIL_TO=${EMAIL_TO:-}
       - EMAIL_SMTP_SERVER=${EMAIL_SMTP_SERVER:-}
       - EMAIL_SMTP_PORT=${EMAIL_SMTP_PORT:-}
+      - NTFY_SERVER_URL=${NTFY_SERVER_URL:-https://ntfy.sh}
+      - NTFY_TOPIC=${NTFY_TOPIC:-}
+      - NTFY_TOKEN=${NTFY_TOKEN:-}
       - CRON_SCHEDULE=${CRON_SCHEDULE:-*/5 * * * *}
       - RUN_MODE=${RUN_MODE:-cron}
       - IMMEDIATE_RUN=${IMMEDIATE_RUN:-true}

+ 3 - 0
docker/docker-compose.yml

@@ -20,6 +20,9 @@ services:
       - EMAIL_TO=${EMAIL_TO:-}
       - EMAIL_SMTP_SERVER=${EMAIL_SMTP_SERVER:-}
       - EMAIL_SMTP_PORT=${EMAIL_SMTP_PORT:-}
+      - NTFY_SERVER_URL=${NTFY_SERVER_URL:-https://ntfy.sh}
+      - NTFY_TOPIC=${NTFY_TOPIC:-}
+      - NTFY_TOKEN=${NTFY_TOKEN:-}
       - CRON_SCHEDULE=${CRON_SCHEDULE:-*/5 * * * *}
       - RUN_MODE=${RUN_MODE:-cron}
       - IMMEDIATE_RUN=${IMMEDIATE_RUN:-true}

+ 311 - 108
main.py

@@ -26,63 +26,31 @@ VERSION = "2.3.2"
 # === SMTP邮件配置 ===
 SMTP_CONFIGS = {
     # Gmail
-    'gmail.com': {
-        'server': 'smtp.gmail.com',
-        'port': 587,
-        'encryption': 'TLS'
-    },
-    
+    "gmail.com": {"server": "smtp.gmail.com", "port": 587, "encryption": "TLS"},
     # QQ邮箱
-    'qq.com': {
-        'server': 'smtp.qq.com', 
-        'port': 587,
-        'encryption': 'TLS'
-    },
-    
+    "qq.com": {"server": "smtp.qq.com", "port": 587, "encryption": "TLS"},
     # Outlook
-    'outlook.com': {
-        'server': 'smtp-mail.outlook.com',
-        'port': 587,
-        'encryption': 'TLS'
-    },
-    'hotmail.com': {
-        'server': 'smtp-mail.outlook.com',
-        'port': 587,
-        'encryption': 'TLS'
+    "outlook.com": {
+        "server": "smtp-mail.outlook.com",
+        "port": 587,
+        "encryption": "TLS",
     },
-    'live.com': {
-        'server': 'smtp-mail.outlook.com',
-        'port': 587,
-        'encryption': 'TLS'
+    "hotmail.com": {
+        "server": "smtp-mail.outlook.com",
+        "port": 587,
+        "encryption": "TLS",
     },
-    
+    "live.com": {"server": "smtp-mail.outlook.com", "port": 587, "encryption": "TLS"},
     # 网易邮箱
-    '163.com': {
-        'server': 'smtp.163.com',
-        'port': 587,
-        'encryption': 'TLS'
-    },
-    '126.com': {
-        'server': 'smtp.126.com',
-        'port': 587,
-        'encryption': 'TLS'
-    },
-    
+    "163.com": {"server": "smtp.163.com", "port": 587, "encryption": "TLS"},
+    "126.com": {"server": "smtp.126.com", "port": 587, "encryption": "TLS"},
     # 新浪邮箱
-    'sina.com': {
-        'server': 'smtp.sina.com',
-        'port': 587,
-        'encryption': 'TLS'
-    },
-    
+    "sina.com": {"server": "smtp.sina.com", "port": 587, "encryption": "TLS"},
     # 搜狐邮箱
-    'sohu.com': {
-        'server': 'smtp.sohu.com',
-        'port': 587,
-        'encryption': 'TLS'
-    }
+    "sohu.com": {"server": "smtp.sohu.com", "port": 587, "encryption": "TLS"},
 }
 
+
 # === 配置管理 ===
 def load_config():
     """加载配置文件"""
@@ -108,7 +76,9 @@ def load_config():
         "ENABLE_CRAWLER": config_data["crawler"]["enable_crawler"],
         "ENABLE_NOTIFICATION": config_data["notification"]["enable_notification"],
         "MESSAGE_BATCH_SIZE": config_data["notification"]["message_batch_size"],
-        "DINGTALK_BATCH_SIZE": config_data["notification"].get("dingtalk_batch_size", 20000),
+        "DINGTALK_BATCH_SIZE": config_data["notification"].get(
+            "dingtalk_batch_size", 20000
+        ),
         "BATCH_SEND_INTERVAL": config_data["notification"]["batch_send_interval"],
         "FEISHU_MESSAGE_SEPARATOR": config_data["notification"][
             "feishu_message_separator"
@@ -161,17 +131,17 @@ def load_config():
     config["TELEGRAM_CHAT_ID"] = os.environ.get(
         "TELEGRAM_CHAT_ID", ""
     ).strip() or webhooks.get("telegram_chat_id", "")
-    
+
     # 邮件配置
-    config["EMAIL_FROM"] = os.environ.get(
-        "EMAIL_FROM", ""
-    ).strip() or webhooks.get("email_from", "")
+    config["EMAIL_FROM"] = os.environ.get("EMAIL_FROM", "").strip() or webhooks.get(
+        "email_from", ""
+    )
     config["EMAIL_PASSWORD"] = os.environ.get(
         "EMAIL_PASSWORD", ""
     ).strip() or webhooks.get("email_password", "")
-    config["EMAIL_TO"] = os.environ.get(
-        "EMAIL_TO", ""
-    ).strip() or webhooks.get("email_to", "")
+    config["EMAIL_TO"] = os.environ.get("EMAIL_TO", "").strip() or webhooks.get(
+        "email_to", ""
+    )
     config["EMAIL_SMTP_SERVER"] = os.environ.get(
         "EMAIL_SMTP_SERVER", ""
     ).strip() or webhooks.get("email_smtp_server", "")
@@ -179,6 +149,17 @@ def load_config():
         "EMAIL_SMTP_PORT", ""
     ).strip() or webhooks.get("email_smtp_port", "")
 
+    # ntfy配置
+    config["NTFY_SERVER_URL"] = os.environ.get(
+        "NTFY_SERVER_URL", "https://ntfy.sh"
+    ).strip() or webhooks.get("ntfy_server_url", "https://ntfy.sh")
+    config["NTFY_TOPIC"] = os.environ.get("NTFY_TOPIC", "").strip() or webhooks.get(
+        "ntfy_topic", ""
+    )
+    config["NTFY_TOKEN"] = os.environ.get("NTFY_TOKEN", "").strip() or webhooks.get(
+        "ntfy_token", ""
+    )
+
     # 输出配置来源信息
     notification_sources = []
     if config["FEISHU_WEBHOOK_URL"]:
@@ -199,7 +180,11 @@ def load_config():
     if config["EMAIL_FROM"] and config["EMAIL_PASSWORD"] and config["EMAIL_TO"]:
         from_source = "环境变量" if os.environ.get("EMAIL_FROM") else "配置文件"
         notification_sources.append(f"邮件({from_source})")
-        
+
+    if config["NTFY_SERVER_URL"] and config["NTFY_TOPIC"]:
+        server_source = "环境变量" if os.environ.get("NTFY_SERVER_URL") else "配置文件"
+        notification_sources.append(f"ntfy({server_source})")
+
     if notification_sources:
         print(f"通知渠道配置来源: {', '.join(notification_sources)}")
     else:
@@ -1506,6 +1491,28 @@ def format_title_for_platform(
 
         return result
 
+    elif platform == "ntfy":
+        if link_url:
+            formatted_title = f"[{cleaned_title}]({link_url})"
+        else:
+            formatted_title = cleaned_title
+
+        title_prefix = "🆕 " if title_data.get("is_new") else ""
+
+        if show_source:
+            result = f"[{title_data['source_name']}] {title_prefix}{formatted_title}"
+        else:
+            result = f"{title_prefix}{formatted_title}"
+
+        if rank_display:
+            result += f" {rank_display}"
+        if title_data["time_display"]:
+            result += f" `- {title_data['time_display']}`"
+        if title_data["count"] > 1:
+            result += f" `({title_data['count']}次)`"
+
+        return result
+
     elif platform == "html":
         rank_display = format_rank_display(
             title_data["ranks"], title_data["rank_threshold"], "html"
@@ -2245,7 +2252,7 @@ def render_html_content(
                     <a href="https://github.com/sansan0/TrendRadar" target="_blank" class="footer-link">
                         GitHub 开源项目
                     </a>"""
-                    
+
     if update_info:
         html += f"""
                     <br>
@@ -2534,9 +2541,11 @@ def split_content_into_batches(
     if max_bytes is None:
         if format_type == "dingtalk":
             max_bytes = CONFIG.get("DINGTALK_BATCH_SIZE", 20000)
+        elif format_type == "ntfy":
+            max_bytes = 3800
         else:
             max_bytes = CONFIG.get("MESSAGE_BATCH_SIZE", 4000)
-    
+
     batches = []
 
     total_titles = sum(
@@ -2549,6 +2558,8 @@ def split_content_into_batches(
         base_header = f"**总新闻数:** {total_titles}\n\n\n\n"
     elif format_type == "telegram":
         base_header = f"总新闻数: {total_titles}\n\n"
+    elif format_type == "ntfy":
+        base_header = f"**总新闻数:** {total_titles}\n\n"
     elif format_type == "dingtalk":
         base_header = f"**总新闻数:** {total_titles}\n\n"
         base_header += f"**时间:** {now.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
@@ -2564,6 +2575,10 @@ def split_content_into_batches(
         base_footer = f"\n\n更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
         if update_info:
             base_footer += f"\nTrendRadar 发现新版本 {update_info['remote_version']},当前 {update_info['current_version']}"
+    elif format_type == "ntfy":
+        base_footer = f"\n\n> 更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
+        if update_info:
+            base_footer += f"\n> TrendRadar 发现新版本 **{update_info['remote_version']}**,当前 **{update_info['current_version']}**"
     elif format_type == "dingtalk":
         base_footer = f"\n\n> 更新时间:{now.strftime('%Y-%m-%d %H:%M:%S')}"
         if update_info:
@@ -2575,6 +2590,8 @@ def split_content_into_batches(
             stats_header = f"📊 **热点词汇统计**\n\n"
         elif format_type == "telegram":
             stats_header = f"📊 热点词汇统计\n\n"
+        elif format_type == "ntfy":
+            stats_header = f"📊 **热点词汇统计**\n\n"
         elif format_type == "dingtalk":
             stats_header = f"📊 **热点词汇统计**\n\n"
 
@@ -2641,6 +2658,17 @@ def split_content_into_batches(
                     word_header = f"📈 {sequence_display} {word} : {count} 条\n\n"
                 else:
                     word_header = f"📌 {sequence_display} {word} : {count} 条\n\n"
+            elif format_type == "ntfy":
+                if count >= 10:
+                    word_header = (
+                        f"🔥 {sequence_display} **{word}** : **{count}** 条\n\n"
+                    )
+                elif count >= 5:
+                    word_header = (
+                        f"📈 {sequence_display} **{word}** : **{count}** 条\n\n"
+                    )
+                else:
+                    word_header = f"📌 {sequence_display} **{word}** : {count} 条\n\n"
             elif format_type == "dingtalk":
                 if count >= 10:
                     word_header = (
@@ -2665,6 +2693,10 @@ def split_content_into_batches(
                     formatted_title = format_title_for_platform(
                         "telegram", first_title_data, show_source=True
                     )
+                elif format_type == "ntfy":
+                    formatted_title = format_title_for_platform(
+                        "ntfy", first_title_data, show_source=True
+                    )
                 elif format_type == "dingtalk":
                     formatted_title = format_title_for_platform(
                         "dingtalk", first_title_data, show_source=True
@@ -2706,6 +2738,10 @@ def split_content_into_batches(
                     formatted_title = format_title_for_platform(
                         "telegram", title_data, show_source=True
                     )
+                elif format_type == "ntfy":
+                    formatted_title = format_title_for_platform(
+                        "ntfy", title_data, show_source=True
+                    )
                 elif format_type == "dingtalk":
                     formatted_title = format_title_for_platform(
                         "dingtalk", title_data, show_source=True
@@ -2737,6 +2773,8 @@ def split_content_into_batches(
                     separator = f"\n\n\n\n"
                 elif format_type == "telegram":
                     separator = f"\n\n"
+                elif format_type == "ntfy":
+                    separator = f"\n\n"
                 elif format_type == "dingtalk":
                     separator = f"\n---\n\n"
 
@@ -2756,6 +2794,8 @@ def split_content_into_batches(
             new_header = (
                 f"\n\n🆕 本次新增热点新闻 (共 {report_data['total_new_count']} 条)\n\n"
             )
+        elif format_type == "ntfy":
+            new_header = f"\n\n🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\n\n"
         elif format_type == "dingtalk":
             new_header = f"\n---\n\n🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\n\n"
 
@@ -2779,6 +2819,8 @@ def split_content_into_batches(
                 source_header = f"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\n\n"
             elif format_type == "telegram":
                 source_header = f"{source_data['source_name']} ({len(source_data['titles'])} 条):\n\n"
+            elif format_type == "ntfy":
+                source_header = f"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\n\n"
             elif format_type == "dingtalk":
                 source_header = f"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\n\n"
 
@@ -2868,6 +2910,8 @@ def split_content_into_batches(
             failed_header = f"\n\n\n\n⚠️ **数据获取失败的平台:**\n\n"
         elif format_type == "telegram":
             failed_header = f"\n\n⚠️ 数据获取失败的平台:\n\n"
+        elif format_type == "ntfy":
+            failed_header = f"\n\n⚠️ **数据获取失败的平台:**\n\n"
         elif format_type == "dingtalk":
             failed_header = f"\n---\n\n⚠️ **数据获取失败的平台:**\n\n"
 
@@ -2889,7 +2933,7 @@ def split_content_into_batches(
                 failed_line = f"  • **{id_value}**\n"
             else:
                 failed_line = f"  • {id_value}\n"
-            
+
             test_content = current_batch + failed_line
             if (
                 len(test_content.encode("utf-8")) + len(base_footer.encode("utf-8"))
@@ -2928,19 +2972,21 @@ def send_to_notifications(
         push_manager = PushRecordManager()
         time_range_start = CONFIG["SILENT_PUSH"]["TIME_RANGE"]["START"]
         time_range_end = CONFIG["SILENT_PUSH"]["TIME_RANGE"]["END"]
-        
+
         if not push_manager.is_in_time_range(time_range_start, time_range_end):
             now = get_beijing_time()
-            print(f"静默模式:当前时间 {now.strftime('%H:%M')} 不在推送时间范围 {time_range_start}-{time_range_end} 内,跳过推送")
+            print(
+                f"静默模式:当前时间 {now.strftime('%H:%M')} 不在推送时间范围 {time_range_start}-{time_range_end} 内,跳过推送"
+            )
             return results
-        
+
         if CONFIG["SILENT_PUSH"]["ONCE_PER_DAY"]:
             if push_manager.has_pushed_today():
                 print(f"静默模式:今天已推送过,跳过本次推送")
                 return results
             else:
                 print(f"静默模式:今天首次推送")
-    
+
     report_data = prepare_report_data(stats, failed_ids, new_titles, id_to_name, mode)
 
     feishu_url = CONFIG["FEISHU_WEBHOOK_URL"]
@@ -2953,6 +2999,9 @@ def send_to_notifications(
     email_to = CONFIG["EMAIL_TO"]
     email_smtp_server = CONFIG.get("EMAIL_SMTP_SERVER", "")
     email_smtp_port = CONFIG.get("EMAIL_SMTP_PORT", "")
+    ntfy_server_url = CONFIG["NTFY_SERVER_URL"]
+    ntfy_topic = CONFIG["NTFY_TOPIC"]
+    ntfy_token = CONFIG.get("NTFY_TOKEN", "")
 
     update_info_to_send = update_info if CONFIG["SHOW_VERSION_UPDATE"] else None
 
@@ -2986,6 +3035,19 @@ def send_to_notifications(
             mode,
         )
 
+    # 发送到 ntfy
+    if ntfy_server_url and ntfy_topic:
+        results["ntfy"] = send_to_ntfy(
+            ntfy_server_url,
+            ntfy_topic,
+            ntfy_token,
+            report_data,
+            report_type,
+            update_info_to_send,
+            proxy_url,
+            mode,
+        )
+
     # 发送邮件
     if email_from and email_password and email_to:
         results["email"] = send_to_email(
@@ -3002,10 +3064,14 @@ def send_to_notifications(
         print("未配置任何通知渠道,跳过通知发送")
 
     # 如果成功发送了任何通知,且启用了每天只推一次,则记录推送
-    if CONFIG["SILENT_PUSH"]["ENABLED"] and CONFIG["SILENT_PUSH"]["ONCE_PER_DAY"] and any(results.values()):
+    if (
+        CONFIG["SILENT_PUSH"]["ENABLED"]
+        and CONFIG["SILENT_PUSH"]["ONCE_PER_DAY"]
+        and any(results.values())
+    ):
         push_manager = PushRecordManager()
         push_manager.record_push(report_type)
-        
+
     return results
 
 
@@ -3071,11 +3137,11 @@ def send_to_dingtalk(
 
     # 获取分批内容,使用钉钉专用的批次大小
     batches = split_content_into_batches(
-        report_data, 
-        "dingtalk", 
-        update_info, 
+        report_data,
+        "dingtalk",
+        update_info,
         max_bytes=CONFIG.get("DINGTALK_BATCH_SIZE", 20000),
-        mode=mode
+        mode=mode,
     )
 
     print(f"钉钉消息分为 {len(batches)} 批次发送 [{report_type}]")
@@ -3093,8 +3159,7 @@ def send_to_dingtalk(
             # 将批次标识插入到适当位置(在标题之后)
             if "📊 **热点词汇统计**" in batch_content:
                 batch_content = batch_content.replace(
-                    "📊 **热点词汇统计**\n\n",
-                    f"📊 **热点词汇统计** {batch_header}\n\n"
+                    "📊 **热点词汇统计**\n\n", f"📊 **热点词汇统计** {batch_header}\n\n"
                 )
             else:
                 # 如果没有统计标题,直接在开头添加
@@ -3270,6 +3335,7 @@ def send_to_telegram(
     print(f"Telegram所有 {len(batches)} 批次发送完成 [{report_type}]")
     return True
 
+
 def send_to_email(
     from_email: str,
     password: str,
@@ -3284,13 +3350,13 @@ def send_to_email(
         if not html_file_path or not Path(html_file_path).exists():
             print(f"错误:HTML文件不存在或未提供: {html_file_path}")
             return False
-            
+
         print(f"使用HTML文件: {html_file_path}")
         with open(html_file_path, "r", encoding="utf-8") as f:
             html_content = f.read()
-        
-        domain = from_email.split('@')[-1].lower()
-        
+
+        domain = from_email.split("@")[-1].lower()
+
         if custom_smtp_server and custom_smtp_port:
             # 使用自定义 SMTP 配置
             smtp_server = custom_smtp_server
@@ -3299,38 +3365,38 @@ def send_to_email(
         elif domain in SMTP_CONFIGS:
             # 使用预设配置
             config = SMTP_CONFIGS[domain]
-            smtp_server = config['server']
-            smtp_port = config['port']
-            use_tls = config['encryption'] == 'TLS'
+            smtp_server = config["server"]
+            smtp_port = config["port"]
+            use_tls = config["encryption"] == "TLS"
         else:
             print(f"未识别的邮箱服务商: {domain},使用通用 SMTP 配置")
             smtp_server = f"smtp.{domain}"
             smtp_port = 587
             use_tls = True
-        
-        msg = MIMEMultipart('alternative')
-        
+
+        msg = MIMEMultipart("alternative")
+
         # 严格按照 RFC 标准设置 From header
         sender_name = "TrendRadar"
-        msg['From'] = formataddr((sender_name, from_email))
-        
+        msg["From"] = formataddr((sender_name, from_email))
+
         # 设置收件人
-        recipients = [addr.strip() for addr in to_email.split(',')]
+        recipients = [addr.strip() for addr in to_email.split(",")]
         if len(recipients) == 1:
-            msg['To'] = recipients[0]
+            msg["To"] = recipients[0]
         else:
-            msg['To'] = ', '.join(recipients)
-        
+            msg["To"] = ", ".join(recipients)
+
         # 设置邮件主题
         now = get_beijing_time()
         subject = f"TrendRadar 热点分析报告 - {report_type} - {now.strftime('%m月%d日 %H:%M')}"
-        msg['Subject'] = Header(subject, 'utf-8')
-        
+        msg["Subject"] = Header(subject, "utf-8")
+
         # 设置其他标准 header
-        msg['MIME-Version'] = '1.0'
-        msg['Date'] = formatdate(localtime=True)
-        msg['Message-ID'] = make_msgid()
-        
+        msg["MIME-Version"] = "1.0"
+        msg["Date"] = formatdate(localtime=True)
+        msg["Message-ID"] = make_msgid()
+
         # 添加纯文本部分(作为备选)
         text_content = f"""
 TrendRadar 热点分析报告
@@ -3340,16 +3406,16 @@ TrendRadar 热点分析报告
 
 请使用支持HTML的邮件客户端查看完整报告内容。
         """
-        text_part = MIMEText(text_content, 'plain', 'utf-8')
+        text_part = MIMEText(text_content, "plain", "utf-8")
         msg.attach(text_part)
-        
-        html_part = MIMEText(html_content, 'html', 'utf-8')
+
+        html_part = MIMEText(html_content, "html", "utf-8")
         msg.attach(html_part)
-        
+
         print(f"正在发送邮件到 {to_email}...")
         print(f"SMTP 服务器: {smtp_server}:{smtp_port}")
         print(f"发件人: {from_email}")
-        
+
         try:
             if use_tls:
                 # TLS 模式
@@ -3363,21 +3429,21 @@ TrendRadar 热点分析报告
                 server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30)
                 server.set_debuglevel(0)
                 server.ehlo()
-            
+
             # 登录
             server.login(from_email, password)
-            
+
             # 发送邮件
             server.send_message(msg)
             server.quit()
-            
+
             print(f"邮件发送成功 [{report_type}] -> {to_email}")
             return True
-            
+
         except smtplib.SMTPServerDisconnected:
             print(f"邮件发送失败:服务器意外断开连接,请检查网络或稍后重试")
             return False
-            
+
     except smtplib.SMTPAuthenticationError as e:
         print(f"邮件发送失败:认证错误,请检查邮箱和密码/授权码")
         print(f"详细错误: {str(e)}")
@@ -3398,10 +3464,142 @@ TrendRadar 热点分析报告
     except Exception as e:
         print(f"邮件发送失败 [{report_type}]:{e}")
         import traceback
+
         traceback.print_exc()
         return False
 
 
+def send_to_ntfy(
+    server_url: str,
+    topic: str,
+    token: Optional[str],
+    report_data: Dict,
+    report_type: str,
+    update_info: Optional[Dict] = None,
+    proxy_url: Optional[str] = None,
+    mode: str = "daily",
+) -> bool:
+    """发送到ntfy(支持分批发送,严格遵守4KB限制)"""
+    headers = {
+        "Content-Type": "text/plain; charset=utf-8",
+        "Markdown": "yes",
+        "Title": f"TrendRadar 热点分析报告 - {report_type}",
+        "Priority": "default",
+        "Tags": "newspaper,📰",
+    }
+
+    if token:
+        headers["Authorization"] = f"Bearer {token}"
+    
+    # 构建完整URL,确保格式正确
+    base_url = server_url.rstrip("/")
+    if not base_url.startswith(("http://", "https://")):
+        base_url = f"https://{base_url}"
+    url = f"{base_url}/{topic}"
+
+    proxies = None
+    if proxy_url:
+        proxies = {"http": proxy_url, "https": proxy_url}
+
+    # 获取分批内容,使用ntfy专用的4KB限制
+    batches = split_content_into_batches(
+        report_data, "ntfy", update_info, max_bytes=3800, mode=mode
+    )
+
+    print(f"ntfy消息分为 {len(batches)} 批次发送 [{report_type}]")
+
+    # 逐批发送
+    success_count = 0
+    for i, batch_content in enumerate(batches, 1):
+        batch_size = len(batch_content.encode("utf-8"))
+        print(
+            f"发送ntfy第 {i}/{len(batches)} 批次,大小:{batch_size} 字节 [{report_type}]"
+        )
+
+        # 检查消息大小,确保不超过4KB
+        if batch_size > 4096:
+            print(f"警告:ntfy第 {i} 批次消息过大({batch_size} 字节),可能被拒绝")
+
+        # 添加批次标识
+        current_headers = headers.copy()
+        if len(batches) > 1:
+            batch_header = f"**[第 {i}/{len(batches)} 批次]**\n\n"
+            batch_content = batch_header + batch_content
+            current_headers["Title"] = (
+                f"TrendRadar 热点分析报告 - {report_type} ({i}/{len(batches)})"
+            )
+
+        try:
+            response = requests.post(
+                url,
+                headers=current_headers,
+                data=batch_content.encode("utf-8"),
+                proxies=proxies,
+                timeout=30,
+            )
+
+            if response.status_code == 200:
+                print(f"ntfy第 {i}/{len(batches)} 批次发送成功 [{report_type}]")
+                success_count += 1
+                if i < len(batches):
+                    # 公共服务器建议 2-3 秒,自托管可以更短
+                    interval = 2 if "ntfy.sh" in server_url else 1
+                    time.sleep(interval)
+            elif response.status_code == 429:
+                print(
+                    f"ntfy第 {i}/{len(batches)} 批次速率限制 [{report_type}],等待后重试"
+                )
+                time.sleep(10)  # 等待10秒后重试
+                # 重试一次
+                retry_response = requests.post(
+                    url,
+                    headers=current_headers,
+                    data=batch_content.encode("utf-8"),
+                    proxies=proxies,
+                    timeout=30,
+                )
+                if retry_response.status_code == 200:
+                    print(f"ntfy第 {i}/{len(batches)} 批次重试成功 [{report_type}]")
+                    success_count += 1
+                else:
+                    print(
+                        f"ntfy第 {i}/{len(batches)} 批次重试失败,状态码:{retry_response.status_code}"
+                    )
+            elif response.status_code == 413:
+                print(
+                    f"ntfy第 {i}/{len(batches)} 批次消息过大被拒绝 [{report_type}],消息大小:{batch_size} 字节"
+                )
+            else:
+                print(
+                    f"ntfy第 {i}/{len(batches)} 批次发送失败 [{report_type}],状态码:{response.status_code}"
+                )
+                try:
+                    error_detail = response.text[:200]  # 只显示前200字符的错误信息
+                    print(f"错误详情:{error_detail}")
+                except:
+                    pass
+
+        except requests.exceptions.ConnectTimeout:
+            print(f"ntfy第 {i}/{len(batches)} 批次连接超时 [{report_type}]")
+        except requests.exceptions.ReadTimeout:
+            print(f"ntfy第 {i}/{len(batches)} 批次读取超时 [{report_type}]")
+        except requests.exceptions.ConnectionError as e:
+            print(f"ntfy第 {i}/{len(batches)} 批次连接错误 [{report_type}]:{e}")
+        except Exception as e:
+            print(f"ntfy第 {i}/{len(batches)} 批次发送异常 [{report_type}]:{e}")
+
+    # 判断整体发送是否成功
+    if success_count == len(batches):
+        print(f"ntfy所有 {len(batches)} 批次发送完成 [{report_type}]")
+        return True
+    elif success_count > 0:
+        print(f"ntfy部分发送成功:{success_count}/{len(batches)} 批次 [{report_type}]")
+        return True  # 部分成功也视为成功
+    else:
+        print(f"ntfy发送完全失败 [{report_type}]")
+        return False
+
+
 # === 主分析器 ===
 class NewsAnalyzer:
     """新闻分析器"""
@@ -3508,7 +3706,12 @@ class NewsAnalyzer:
                 CONFIG["DINGTALK_WEBHOOK_URL"],
                 CONFIG["WEWORK_WEBHOOK_URL"],
                 (CONFIG["TELEGRAM_BOT_TOKEN"] and CONFIG["TELEGRAM_CHAT_ID"]),
-                (CONFIG["EMAIL_FROM"] and CONFIG["EMAIL_PASSWORD"] and CONFIG["EMAIL_TO"]),
+                (
+                    CONFIG["EMAIL_FROM"]
+                    and CONFIG["EMAIL_PASSWORD"]
+                    and CONFIG["EMAIL_TO"]
+                ),
+                (CONFIG["NTFY_SERVER_URL"] and CONFIG["NTFY_TOPIC"]),
             ]
         )
 
@@ -3652,7 +3855,7 @@ class NewsAnalyzer:
                 self.update_info,
                 self.proxy_url,
                 mode=mode,
-                html_file_path=html_file_path, 
+                html_file_path=html_file_path,
             )
             return True
         elif CONFIG["ENABLE_NOTIFICATION"] and not has_notification:
@@ -3705,7 +3908,7 @@ class NewsAnalyzer:
         )
 
         print(f"{summary_type}报告已生成: {html_file}")
-        
+
         # 发送通知
         self._send_notification_if_needed(
             stats,
@@ -3714,7 +3917,7 @@ class NewsAnalyzer:
             failed_ids=[],
             new_titles=new_titles,
             id_to_name=id_to_name,
-            html_file_path=html_file, 
+            html_file_path=html_file,
         )
 
         return html_file

+ 139 - 8
readme.md

@@ -11,13 +11,15 @@
 [![GitHub Stars](https://img.shields.io/github/stars/sansan0/TrendRadar?style=flat-square&logo=github&color=yellow)](https://github.com/sansan0/TrendRadar/stargazers)
 [![GitHub Forks](https://img.shields.io/github/forks/sansan0/TrendRadar?style=flat-square&logo=github&color=blue)](https://github.com/sansan0/TrendRadar/network/members)
 [![License](https://img.shields.io/badge/license-GPL--3.0-blue.svg?style=flat-square)](LICENSE)
-[![Version](https://img.shields.io/badge/version-v2.3.2-green.svg?style=flat-square)](https://github.com/sansan0/TrendRadar)
+[![Version](https://img.shields.io/badge/version-v2.4.0-green.svg?style=flat-square)](https://github.com/sansan0/TrendRadar)
 
 [![企业微信通知](https://img.shields.io/badge/企业微信-通知-00D4AA?style=flat-square)](https://work.weixin.qq.com/)
 [![Telegram通知](https://img.shields.io/badge/Telegram-通知-00D4AA?style=flat-square)](https://telegram.org/)
 [![dingtalk通知](https://img.shields.io/badge/钉钉-通知-00D4AA?style=flat-square)](#)
 [![飞书通知](https://img.shields.io/badge/飞书-通知-00D4AA?style=flat-square)](https://www.feishu.cn/)
-[![邮件通知](https://img.shields.io/badge/Email-通知-00D4AA?style=flat-square)](mailto:) 
+[![邮件通知](https://img.shields.io/badge/Email-通知-00D4AA?style=flat-square)](#) 
+[![ntfy通知](https://img.shields.io/badge/ntfy-通知-00D4AA?style=flat-square)](https://github.com/binwiederhier/ntfy)
+
 [![GitHub Actions](https://img.shields.io/badge/GitHub_Actions-自动化-2088FF?style=flat-square&logo=github-actions&logoColor=white)](https://github.com/sansan0/TrendRadar)
 [![GitHub Pages](https://img.shields.io/badge/GitHub_Pages-部署-4285F4?style=flat-square&logo=github&logoColor=white)](https://sansan0.github.io/TrendRadar)
 [![Docker](https://img.shields.io/badge/Docker-部署-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/)
@@ -34,7 +36,7 @@
 - **给予资金点赞支持** 的朋友们,你们的慷慨已化身为键盘旁的零食饮料,陪伴着项目的每一次迭代
 
 <details>
-<summary>👉 点击查看<strong>致谢名单</strong> (当前 <strong>🔥24🔥</strong> 位)</summary>
+<summary>👉 点击查看<strong>致谢名单</strong> (当前 <strong>🔥25🔥</strong> 位)</summary>
 
 ### 数据支持
 
@@ -54,6 +56,7 @@
 
 |           点赞人            |  金额  |  日期  |             备注             |
 | :-------------------------: | :----: | :----: | :-----------------------: |
+|           **培           |  5.2  | 2025.10.2  |  github-yzyf1312:开源万岁         |
 |           *椿           |  3  | 2025.9.23  |  加油,很不错         |
 |           *🍍           |  10  | 2025.9.21  |           |
 |           E*f           |  1  | 2025.9.20  |           |
@@ -421,7 +424,7 @@ weight:
 
 ### **多渠道实时推送**
 
-支持**企业微信**(+ 微信推送方案)、**飞书**、**钉钉**、**Telegram**、**邮件**,消息直达手机和邮箱
+支持**企业微信**(+ 微信推送方案)、**飞书**、**钉钉**、**Telegram**、**邮件**、**ntfy**,消息直达手机和邮箱
 
 ### **多端适配**
 - **GitHub Pages**:自动生成精美网页报告,PC/移动端适配
@@ -459,6 +462,25 @@ GitHub 一键 Fork 即可使用,无需编程基础。
 - **小版本更新**:从 v2.x 升级到 v2.y, 用本项目的 `main.py` 代码替换你 fork 仓库中的对应文件 
 - **大版本升级**:从 v1.x 升级到 v2.y, 建议删除现有 fork 后重新 fork,这样更省力且避免配置冲突
 
+### 2025/10/2 - v2.4.0
+
+**新增 ntfy 推送通知**
+
+- **核心功能**:
+  - 支持 ntfy.sh 公共服务和自托管服务器
+
+- **使用场景**:
+  - 适合追求隐私的用户(支持自托管)
+  - 跨平台推送(iOS、Android、Desktop、Web)
+  - 无需注册账号(公共服务器)
+  - 开源免费(MIT 协议)
+
+- **更新提示**:
+  - 建议使用【大版本更新】
+
+<details>
+<summary><strong>👉 历史更新</strong></summary>
+
 ### 2025/09/26 - v2.3.2
 
 - 修正了邮件通知配置检查被遗漏的问题([#88](https://github.com/sansan0/TrendRadar/issues/88))
@@ -466,9 +488,6 @@ GitHub 一键 Fork 即可使用,无需编程基础。
 **修复说明**:
 - 解决了即使正确配置邮件通知,系统仍提示"未配置任何webhook"的问题
 
-<details>
-<summary><strong>👉 历史更新</strong></summary>
-
 ### 2025/09/22 - v2.3.1
 
 - **新增邮件推送功能**,支持将热点新闻报告发送到邮箱
@@ -816,6 +835,117 @@ frequency_words.txt 文件增加了一个【必须词】功能,使用 + 号
 
    </details>
 
+   <details>
+   <summary> <strong>👉 ntfy 推送</strong>(开源免费,支持自托管)</summary>
+   <br>
+
+   **两种使用方式:**
+
+   ### 方式一:免费使用(推荐新手) 🆓
+
+   **特点**:
+   - ✅ 无需注册账号,立即使用
+   - ✅ 每天 250 条消息(足够 90% 用户)
+   - ✅ Topic 名称即"密码"(需选择不易猜测的名称)
+   - ⚠️ 消息未加密,不适合敏感信息, 但适合我们这个项目的不敏感信息
+
+   **快速开始:**
+
+   1. **下载 ntfy 应用**:
+      - Android:[Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/)
+      - iOS:[App Store](https://apps.apple.com/us/app/ntfy/id1625396347)
+      - 桌面:访问 [ntfy.sh](https://ntfy.sh)
+
+   2. **订阅主题**(选择一个难猜的名称):
+      ```
+      建议格式:trendradar-{你的名字缩写}-{随机数字}
+   
+      ✅ 好例子:trendradar-zs-8492
+      ❌ 坏例子:news、alerts(太容易被猜到)
+      ```
+
+   3. **配置 GitHub Secret**:
+      - `NTFY_TOPIC`:填写你刚才订阅的主题名称
+      - `NTFY_SERVER_URL`:留空(默认使用 ntfy.sh)
+      - `NTFY_TOKEN`:留空
+
+   4. **测试**:
+      ```bash
+      curl -d "测试消息" ntfy.sh/你的主题名称
+      ```
+
+   ---
+
+   ### 方式二:自托管(完全隐私控制) 🔒
+
+   **适合人群**:有服务器、追求完全隐私、技术能力强
+
+   **优势**:
+   - ✅ 完全开源(Apache 2.0 + GPLv2)
+   - ✅ 数据完全自主控制
+   - ✅ 无任何限制
+   - ✅ 零费用
+
+   **Docker 一键部署**:
+   ```bash
+   docker run -d \
+     --name ntfy \
+     -p 80:80 \
+     -v /var/cache/ntfy:/var/cache/ntfy \
+     binwiederhier/ntfy \
+     serve --cache-file /var/cache/ntfy/cache.db
+   ```
+
+   **配置 TrendRadar**:
+   ```yaml
+   NTFY_SERVER_URL: https://ntfy.yourdomain.com
+   NTFY_TOPIC: trendradar-alerts  # 自托管可用简单名称
+   NTFY_TOKEN: tk_your_token  # 可选:启用访问控制
+   ```
+
+   **在应用中订阅**:
+   - 点击"Use another server"
+   - 输入你的服务器地址
+   - 输入主题名称
+   - (可选)输入登录凭据
+
+   ---
+
+   **常见问题:**
+
+   <details>
+   <summary><strong>Q1: 免费版够用吗?</strong></summary>
+
+   每天 250 条消息对大多数用户足够。按 30 分钟抓取一次计算,每天约 48 次推送,完全够用。
+   </details>
+
+   <details>
+   <summary><strong>Q2: Topic 名称真的安全吗?</strong></summary>
+
+   如果你选择随机的、足够长的名称(如 `trendradar-zs-8492-news`),暴力破解几乎不可能:
+   - ntfy 有严格的速率限制(1 秒 1 次请求)
+   - 64 个字符选择(A-Z, a-z, 0-9, _, -)
+   - 10 位随机字符串有 64^10 种可能性(需要数年才能破解)
+   </details>
+
+   ---
+
+   **推荐选择:**
+
+   | 用户类型 | 推荐方案 | 理由 |
+   |---------|---------|------|
+   | 普通用户 | 方式一(免费) | 简单快速,够用 |
+   | 技术用户 | 方式二(自托管) | 完全控制,无限制 |
+   | 高频用户 | 方式三(付费) | 这个自己去官网看吧 |
+
+   **相关链接:**
+   - [ntfy 官方文档](https://docs.ntfy.sh/)
+   - [自托管教程](https://docs.ntfy.sh/install/)
+   - [GitHub 仓库](https://github.com/binwiederhier/ntfy)
+
+   </details>
+
+
 3. **配置说明:**:
 
     - **推送设置**:在 [config/config.yaml](config/config.yaml) 中配置推送模式和通知选项
@@ -1020,10 +1150,11 @@ docker exec -it trend-radar ls -la /app/config/
 
 ### 项目相关
 
-> **3 篇文章**:
+> **4 篇文章**:
 
 - [可在该文章下方留言,方便项目作者用手机答疑](https://mp.weixin.qq.com/s/KYEPfTPVzZNWFclZh4am_g)
 - [2个月破 1000 star,我的GitHub项目推广实战经验](https://mp.weixin.qq.com/s/jzn0vLiQFX408opcfpPPxQ)
+- [github fork 运行本项目的注意事项 ](https://mp.weixin.qq.com/s/C8evK-U7onG1sTTdwdW2zg)
 - [基于本项目,如何开展公众号或者新闻资讯类文章写作](https://mp.weixin.qq.com/s/8ghyfDAtQZjLrnWTQabYOQ)
 
 >**AI 开发**:

+ 1 - 1
version

@@ -1 +1 @@
-2.3.2
+2.4.0