Parcourir la source

refactor: 优化时间管理功能和用户体验

sansan il y a 3 mois
Parent
commit
e07ae182ff

+ 16 - 6
config/config.yaml

@@ -1,6 +1,6 @@
 # ═══════════════════════════════════════════════════════════════
 #                    TrendRadar 配置文件
-#                      Version: 1.1.0
+#                      Version: 1.2.0
 # ═══════════════════════════════════════════════════════════════
 
 
@@ -178,7 +178,7 @@ display:
     new_items: true                   # 新增热点区域(含热榜新增 + RSS 新增)
                                       # 注:热点词汇统计中的新增标记🆕不受此配置影响
 
-    rss: false                         # RSS 订阅区域
+    rss: true                         # RSS 订阅区域
                                       # 开启后将对 RSS 进行关键词分析并在通知中展示
                                       # 关闭后跳过分析,但独立展示区不受影响
 
@@ -225,13 +225,18 @@ notification:
   # 适用场景:
   #   • 只想在工作日白天接收推送(如 09:00-18:00)
   #   • 希望在晚上固定时间收到汇总(如 20:00-22:00)
+  #   • 夜间工作者可配置跨日窗口(如 22:00-02:00)
   # ⚠️ GitHub Actions 用户注意:
   #   执行时间不稳定,时间范围建议至少留足 2 小时
   # 💡 想要精准定时?建议使用 Docker 部署在个人服务器上
+  #
+  # 📌 跨日时间窗口支持:
+  #   • 正常窗口:start < end,如 09:00-21:00(当天 9 点到 21 点)
+  #   • 跨日窗口:start > end,如 22:00-02:00(当天 22 点到次日 2 点)
   push_window:
     enabled: false                    # 是否启用推送时间窗口控制
-    start: "20:00"                    # 开始时间(北京时间)
-    end: "22:00"                      # 结束时间(北京时间)
+    start: "20:00"                    # 开始时间(使用 app.timezone 配置的时区
+    end: "22:00"                      # 结束时间(支持跨日,如 end: "02:00"
     once_per_day: true                # true=窗口内只推送一次,false=窗口内每次执行都推送
 
   # 推送渠道配置
@@ -396,13 +401,18 @@ ai_analysis:
   # 适用场景:
   #   • 只在工作时间进行 AI 分析(如 09:00-18:00)
   #   • 在特定时段进行深度分析(如 20:00-22:00)
+  #   • 夜间工作者可配置跨日窗口(如 22:00-02:00)
   # ⚠️ GitHub Actions 用户注意:
   #   执行时间不稳定,时间范围建议至少留足 2 小时
   # 💡 想要精准定时?建议使用 Docker 部署在个人服务器上
+  #
+  # 📌 跨日时间窗口支持:
+  #   • 正常窗口:start < end,如 09:00-22:00(当天 9 点到 22 点)
+  #   • 跨日窗口:start > end,如 22:00-02:00(当天 22 点到次日 2 点)
   analysis_window:
     enabled: false                  # 是否启用 AI 分析时间窗口控制
-    start: "12:00"                  # 开始时间(使用 app.timezone 配置的时区)
-    end: "21:00"                    # 结束时间(使用 app.timezone 配置的时区)
+    start: "09:00"                  # 开始时间(使用 app.timezone 配置的时区)
+    end: "22:00"                    # 结束时间(支持跨日,如 end: "02:00"
     once_per_day: false             # true=窗口内只分析一次,false=窗口内每次执行都分析
 
   # 分析报告输出语言

+ 1 - 1
pyproject.toml

@@ -1,6 +1,6 @@
 [project]
 name = "trendradar"
-version = "5.5.0"
+version = "5.5.3"
 description = "TrendRadar - 热点新闻聚合与分析工具"
 requires-python = ">=3.10"
 dependencies = [

+ 1 - 1
trendradar/__init__.py

@@ -9,5 +9,5 @@ TrendRadar - 热点新闻聚合与分析工具
 
 from trendradar.context import AppContext
 
-__version__ = "5.5.2"
+__version__ = "5.5.3"
 __all__ = ["AppContext", "__version__"]

+ 158 - 5
trendradar/__main__.py

@@ -6,6 +6,7 @@ TrendRadar 主程序
 支持: python -m trendradar
 """
 
+import argparse
 import os
 import re
 import webbrowser
@@ -20,7 +21,7 @@ from trendradar.core import load_config
 from trendradar.core.analyzer import convert_keyword_stats_to_platform_stats
 from trendradar.crawler import DataFetcher
 from trendradar.storage import convert_crawl_results_to_news_data
-from trendradar.utils.time import is_within_days
+from trendradar.utils.time import DEFAULT_TIMEZONE, is_within_days, calculate_days_old
 from trendradar.ai import AIAnalyzer, AIAnalysisResult
 
 
@@ -213,7 +214,7 @@ class NewsAnalyzer:
             config = load_config()
         print(f"TrendRadar v{__version__} 配置加载完成")
         print(f"监控平台数量: {len(config['PLATFORMS'])}")
-        print(f"时区: {config.get('TIMEZONE', 'Asia/Shanghai')}")
+        print(f"时区: {config.get('TIMEZONE', DEFAULT_TIMEZONE)}")
 
         # 创建应用上下文
         self.ctx = AppContext(config)
@@ -1102,7 +1103,7 @@ class NewsAnalyzer:
             # RSS 代理:优先使用 RSS 专属代理,否则使用爬虫默认代理
             rss_proxy_url = rss_config.get("PROXY_URL", "") or self.proxy_url or ""
             # 获取配置的时区
-            timezone = self.ctx.config.get("TIMEZONE", "Asia/Shanghai")
+            timezone = self.ctx.config.get("TIMEZONE", DEFAULT_TIMEZONE)
             # 获取新鲜度过滤配置
             freshness_config = rss_config.get("FRESHNESS_FILTER", {})
             freshness_enabled = freshness_config.get("ENABLED", True)
@@ -1312,13 +1313,15 @@ class NewsAnalyzer:
         """将 RSS 条目字典转换为列表格式,并应用新鲜度过滤(用于推送)"""
         rss_items = []
         filtered_count = 0
+        filtered_details = []  # 用于 DEBUG 模式下的详细日志
 
         # 获取新鲜度过滤配置
         rss_config = self.ctx.rss_config
         freshness_config = rss_config.get("FRESHNESS_FILTER", {})
         freshness_enabled = freshness_config.get("ENABLED", True)
         default_max_age_days = freshness_config.get("MAX_AGE_DAYS", 3)
-        timezone = self.ctx.config.get("TIMEZONE", "Asia/Shanghai")
+        timezone = self.ctx.config.get("TIMEZONE", DEFAULT_TIMEZONE)
+        debug_mode = self.ctx.config.get("DEBUG", False)
 
         # 构建 feed_id -> max_age_days 的映射
         feed_max_age_map = {}
@@ -1342,6 +1345,16 @@ class NewsAnalyzer:
                 if freshness_enabled and max_days > 0:
                     if item.published_at and not is_within_days(item.published_at, max_days, timezone):
                         filtered_count += 1
+                        # 记录详细信息用于 DEBUG 模式
+                        if debug_mode:
+                            days_old = calculate_days_old(item.published_at, timezone)
+                            feed_name = id_to_name.get(feed_id, feed_id)
+                            filtered_details.append({
+                                "title": item.title[:50] + "..." if len(item.title) > 50 else item.title,
+                                "feed": feed_name,
+                                "days_old": days_old,
+                                "max_days": max_days,
+                            })
                         continue  # 跳过超过指定天数的文章
 
                 rss_items.append({
@@ -1357,6 +1370,14 @@ class NewsAnalyzer:
         # 输出过滤统计
         if filtered_count > 0:
             print(f"[RSS] 新鲜度过滤:跳过 {filtered_count} 篇超过指定天数的旧文章(仍保留在数据库中)")
+            # DEBUG 模式下显示详细信息
+            if debug_mode and filtered_details:
+                print(f"[RSS] 被过滤的文章详情(共 {len(filtered_details)} 篇):")
+                for detail in filtered_details[:10]:  # 最多显示 10 条
+                    days_str = f"{detail['days_old']:.1f}" if detail['days_old'] else "未知"
+                    print(f"  - [{days_str}天前] [{detail['feed']}] {detail['title']} (限制: {detail['max_days']}天)")
+                if len(filtered_details) > 10:
+                    print(f"  ... 还有 {len(filtered_details) - 10} 篇被过滤")
 
         return rss_items
 
@@ -1631,10 +1652,77 @@ class NewsAnalyzer:
 
 def main():
     """主程序入口"""
+    # 解析命令行参数
+    parser = argparse.ArgumentParser(
+        description="TrendRadar - 热点新闻聚合与分析工具",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+状态管理命令:
+  --show-push-status     显示推送状态(窗口配置、今日是否已推送)
+  --show-ai-status       显示 AI 分析状态
+  --reset-push-state     重置今日推送状态(允许重新推送)
+  --reset-ai-state       重置今日 AI 分析状态
+  --force-push           忽略 once_per_day 限制,强制推送
+
+示例:
+  python -m trendradar                    # 正常运行
+  python -m trendradar --show-push-status # 查看推送状态
+  python -m trendradar --reset-push-state # 重置推送状态后再运行
+  python -m trendradar --force-push       # 强制推送(忽略今日已推送限制)
+"""
+    )
+    parser.add_argument(
+        "--show-push-status",
+        action="store_true",
+        help="显示推送状态信息"
+    )
+    parser.add_argument(
+        "--show-ai-status",
+        action="store_true",
+        help="显示 AI 分析状态信息"
+    )
+    parser.add_argument(
+        "--reset-push-state",
+        action="store_true",
+        help="重置今日推送状态"
+    )
+    parser.add_argument(
+        "--reset-ai-state",
+        action="store_true",
+        help="重置今日 AI 分析状态"
+    )
+    parser.add_argument(
+        "--force-push",
+        action="store_true",
+        help="忽略 once_per_day 限制,强制推送"
+    )
+    parser.add_argument(
+        "--force-ai",
+        action="store_true",
+        help="忽略 once_per_day 限制,强制 AI 分析"
+    )
+
+    args = parser.parse_args()
+
     debug_mode = False
     try:
-        # 先加载配置以获取 version_check_url
+        # 先加载配置
         config = load_config()
+
+        # 处理状态查看/重置命令
+        if args.show_push_status or args.show_ai_status or args.reset_push_state or args.reset_ai_state:
+            _handle_status_commands(config, args)
+            return
+
+        # 设置强制推送标志
+        if args.force_push:
+            config["_FORCE_PUSH"] = True
+            print("[CLI] 已启用强制推送模式,将忽略 once_per_day 限制")
+
+        if args.force_ai:
+            config["_FORCE_AI"] = True
+            print("[CLI] 已启用强制 AI 分析模式,将忽略 once_per_day 限制")
+
         version_url = config.get("VERSION_CHECK_URL", "")
         configs_version_url = config.get("CONFIGS_VERSION_CHECK_URL", "")
 
@@ -1669,5 +1757,70 @@ def main():
             raise
 
 
+def _handle_status_commands(config: Dict, args) -> None:
+    """处理状态查看/重置命令"""
+    from trendradar.context import AppContext
+
+    ctx = AppContext(config)
+    push_manager = ctx.create_push_manager()
+
+    print("=" * 60)
+    print(f"TrendRadar v{__version__} 状态信息")
+    print("=" * 60)
+
+    # 显示推送状态
+    if args.show_push_status:
+        push_window_config = config.get("PUSH_WINDOW", {})
+        status = push_manager.get_push_status(push_window_config)
+        print("\n📤 推送状态:")
+        print(f"  当前时间: {status['current_time']} ({status['timezone']})")
+        print(f"  当前日期: {status['current_date']}")
+        print(f"  窗口控制: {'启用' if status['enabled'] else '未启用'}")
+        if status['enabled']:
+            print(f"  窗口时间: {status['window_start']} - {status['window_end']}")
+            print(f"  当前在窗口内: {'是 ✅' if status.get('in_window') else '否 ❌'}")
+            print(f"  每天只推一次: {'是' if status.get('once_per_day') else '否'}")
+            if status.get('once_per_day'):
+                executed = status.get('executed_today', False)
+                print(f"  今日已推送: {'是 ⚠️' if executed else '否 ✅'}")
+
+    # 显示 AI 分析状态
+    if args.show_ai_status:
+        ai_window_config = config.get("AI_ANALYSIS", {}).get("ANALYSIS_WINDOW", {})
+        status = push_manager.get_ai_analysis_status(ai_window_config)
+        print("\n🤖 AI 分析状态:")
+        print(f"  当前时间: {status['current_time']} ({status['timezone']})")
+        print(f"  当前日期: {status['current_date']}")
+        print(f"  窗口控制: {'启用' if status['enabled'] else '未启用'}")
+        if status['enabled']:
+            print(f"  窗口时间: {status['window_start']} - {status['window_end']}")
+            print(f"  当前在窗口内: {'是 ✅' if status.get('in_window') else '否 ❌'}")
+            print(f"  每天只分析一次: {'是' if status.get('once_per_day') else '否'}")
+            if status.get('once_per_day'):
+                executed = status.get('executed_today', False)
+                print(f"  今日已分析: {'是 ⚠️' if executed else '否 ✅'}")
+
+    # 重置推送状态
+    if args.reset_push_state:
+        print("\n🔄 正在重置推送状态...")
+        if push_manager.reset_push_state():
+            print("  ✅ 推送状态已重置")
+        else:
+            print("  ❌ 重置失败")
+
+    # 重置 AI 分析状态
+    if args.reset_ai_state:
+        print("\n🔄 正在重置 AI 分析状态...")
+        if push_manager.reset_ai_analysis_state():
+            print("  ✅ AI 分析状态已重置")
+        else:
+            print("  ❌ 重置失败")
+
+    print("=" * 60)
+
+    # 清理资源
+    ctx.cleanup()
+
+
 if __name__ == "__main__":
     main()

+ 3 - 2
trendradar/context.py

@@ -10,6 +10,7 @@ from pathlib import Path
 from typing import Any, Dict, List, Optional, Tuple
 
 from trendradar.utils.time import (
+    DEFAULT_TIMEZONE,
     get_configured_time,
     format_date_folder,
     format_time_filename,
@@ -78,7 +79,7 @@ class AppContext:
     @property
     def timezone(self) -> str:
         """获取配置的时区"""
-        return self.config.get("TIMEZONE", "Asia/Shanghai")
+        return self.config.get("TIMEZONE", DEFAULT_TIMEZONE)
 
     @property
     def rank_threshold(self) -> int:
@@ -429,7 +430,7 @@ class AppContext:
             get_time_func=self.get_time,
             rss_items=rss_items,
             rss_new_items=rss_new_items,
-            timezone=self.config.get("TIMEZONE", "Asia/Shanghai"),
+            timezone=self.config.get("TIMEZONE", DEFAULT_TIMEZONE),
             display_mode=self.display_mode,
             ai_content=ai_content,
             standalone_data=standalone_data,

+ 2 - 1
trendradar/core/analyzer.py

@@ -11,6 +11,7 @@
 from typing import Dict, List, Tuple, Optional, Callable
 
 from trendradar.core.frequency import matches_word_groups, _word_matches
+from trendradar.utils.time import DEFAULT_TIMEZONE
 
 
 def calculate_news_weight(
@@ -496,7 +497,7 @@ def count_rss_frequency(
     new_items: Optional[List[Dict]] = None,
     max_news_per_keyword: int = 0,
     sort_by_position_first: bool = False,
-    timezone: str = "Asia/Shanghai",
+    timezone: str = DEFAULT_TIMEZONE,
     rank_threshold: int = 5,
     quiet: bool = False,
 ) -> Tuple[List[Dict], int]:

+ 3 - 2
trendradar/core/loader.py

@@ -12,6 +12,7 @@ from typing import Dict, Any, Optional
 import yaml
 
 from .config import parse_multi_account_config, validate_paired_configs
+from trendradar.utils.time import DEFAULT_TIMEZONE
 
 
 def _get_env_bool(key: str, default: bool = False) -> Optional[bool]:
@@ -57,7 +58,7 @@ def _load_app_config(config_data: Dict) -> Dict:
         "VERSION_CHECK_URL": advanced.get("version_check_url", ""),
         "CONFIGS_VERSION_CHECK_URL": advanced.get("configs_version_check_url", ""),
         "SHOW_VERSION_UPDATE": app_config.get("show_version_update", True),
-        "TIMEZONE": _get_env_str("TIMEZONE") or app_config.get("timezone", "Asia/Shanghai"),
+        "TIMEZONE": _get_env_str("TIMEZONE") or app_config.get("timezone", DEFAULT_TIMEZONE),
         "DEBUG": _get_env_bool("DEBUG") if _get_env_bool("DEBUG") is not None else advanced.get("debug", False),
     }
 
@@ -225,7 +226,7 @@ def _load_ai_config(config_data: Dict) -> Dict:
 
     return {
         # LiteLLM 核心配置
-        "MODEL": _get_env_str("AI_MODEL") or ai_config.get("model", "deepseek/deepseek-chat"),
+        "MODEL": _get_env_str("AI_MODEL") or ai_config.get("model", ""),
         "API_KEY": _get_env_str("AI_API_KEY") or ai_config.get("api_key", ""),
         "API_BASE": _get_env_str("AI_API_BASE") or ai_config.get("api_base", ""),
 

+ 121 - 25
trendradar/notification/push_manager.py

@@ -7,10 +7,12 @@
 """
 
 from datetime import datetime
-from typing import Callable, Optional, Any
+from typing import Callable, Optional, Any, Tuple
 
 import pytz
 
+from trendradar.utils.time import DEFAULT_TIMEZONE, TimeWindowChecker
+
 
 class PushRecordManager:
     """
@@ -42,7 +44,7 @@ class PushRecordManager:
 
     def _default_get_time(self) -> datetime:
         """默认时间获取函数(使用 storage_backend 的时区配置)"""
-        timezone = getattr(self.storage_backend, 'timezone', 'Asia/Shanghai')
+        timezone = getattr(self.storage_backend, 'timezone', DEFAULT_TIMEZONE)
         return datetime.now(pytz.timezone(timezone))
 
     def has_pushed_today(self) -> bool:
@@ -77,34 +79,128 @@ class PushRecordManager:
         Returns:
             是否在时间范围内
         """
-        now = self.get_time()
-        current_time = now.strftime("%H:%M")
+        checker = TimeWindowChecker(
+            storage_backend=self.storage_backend,
+            get_time_func=self.get_time,
+            window_name="推送窗口",
+        )
+        return checker.is_in_time_range(start_time, end_time)
+
+    def check_push_window(self, window_config: dict) -> Tuple[bool, str]:
+        """
+        检查推送窗口控制
 
-        def normalize_time(time_str: str) -> str:
-            """将时间字符串标准化为 HH:MM 格式"""
-            try:
-                parts = time_str.strip().split(":")
-                if len(parts) != 2:
-                    raise ValueError(f"时间格式错误: {time_str}")
+        Args:
+            window_config: 推送窗口配置
 
-                hour = int(parts[0])
-                minute = int(parts[1])
+        Returns:
+            (should_push, reason) 元组
+        """
+        checker = TimeWindowChecker(
+            storage_backend=self.storage_backend,
+            get_time_func=self.get_time,
+            window_name="推送窗口",
+        )
+        return checker.check_window(
+            window_config=window_config,
+            check_once_per_day_func=self.has_pushed_today,
+        )
+
+    def check_ai_analysis_window(self, window_config: dict) -> Tuple[bool, str]:
+        """
+        检查 AI 分析窗口控制
 
-                if not (0 <= hour <= 23 and 0 <= minute <= 59):
-                    raise ValueError(f"时间范围错误: {time_str}")
+        Args:
+            window_config: AI 分析窗口配置
 
-                return f"{hour:02d}:{minute:02d}"
-            except Exception as e:
-                print(f"时间格式化错误 '{time_str}': {e}")
-                return time_str
+        Returns:
+            (should_analyze, reason) 元组
+        """
+        checker = TimeWindowChecker(
+            storage_backend=self.storage_backend,
+            get_time_func=self.get_time,
+            window_name="AI 分析窗口",
+        )
+        return checker.check_window(
+            window_config=window_config,
+            check_once_per_day_func=self.storage_backend.has_ai_analyzed_today,
+        )
+
+    def get_push_status(self, window_config: dict) -> dict:
+        """
+        获取推送状态信息
 
-        normalized_start = normalize_time(start_time)
-        normalized_end = normalize_time(end_time)
-        normalized_current = normalize_time(current_time)
+        Args:
+            window_config: 推送窗口配置
 
-        result = normalized_start <= normalized_current <= normalized_end
+        Returns:
+            状态信息字典
+        """
+        checker = TimeWindowChecker(
+            storage_backend=self.storage_backend,
+            get_time_func=self.get_time,
+            window_name="推送窗口",
+        )
+        status = checker.get_status(
+            window_config=window_config,
+            check_once_per_day_func=self.has_pushed_today,
+        )
+        status["window_type"] = "push"
+        return status
+
+    def get_ai_analysis_status(self, window_config: dict) -> dict:
+        """
+        获取 AI 分析状态信息
+
+        Args:
+            window_config: AI 分析窗口配置
+
+        Returns:
+            状态信息字典
+        """
+        checker = TimeWindowChecker(
+            storage_backend=self.storage_backend,
+            get_time_func=self.get_time,
+            window_name="AI 分析窗口",
+        )
+        status = checker.get_status(
+            window_config=window_config,
+            check_once_per_day_func=self.storage_backend.has_ai_analyzed_today,
+        )
+        status["window_type"] = "ai_analysis"
+        return status
+
+    def reset_push_state(self) -> bool:
+        """
+        重置今日推送状态
 
-        if not result:
-            print(f"时间窗口判断:当前 {normalized_current},窗口 {normalized_start}-{normalized_end}")
+        Returns:
+            是否重置成功
+        """
+        try:
+            # 通过存储后端重置推送记录
+            if hasattr(self.storage_backend, 'reset_push_state'):
+                return self.storage_backend.reset_push_state()
+            else:
+                print("[推送记录] 存储后端不支持重置推送状态")
+                return False
+        except Exception as e:
+            print(f"[推送记录] 重置推送状态失败: {e}")
+            return False
+
+    def reset_ai_analysis_state(self) -> bool:
+        """
+        重置今日 AI 分析状态
 
-        return result
+        Returns:
+            是否重置成功
+        """
+        try:
+            if hasattr(self.storage_backend, 'reset_ai_analysis_state'):
+                return self.storage_backend.reset_ai_analysis_state()
+            else:
+                print("[推送记录] 存储后端不支持重置 AI 分析状态")
+                return False
+        except Exception as e:
+            print(f"[推送记录] 重置 AI 分析状态失败: {e}")
+            return False

+ 6 - 6
trendradar/notification/splitter.py

@@ -10,7 +10,7 @@ from typing import Dict, List, Optional, Callable
 
 from trendradar.report.formatter import format_title_for_platform
 from trendradar.report.helpers import format_rank_display
-from trendradar.utils.time import format_iso_time_friendly, convert_time_for_display
+from trendradar.utils.time import DEFAULT_TIMEZONE, format_iso_time_friendly, convert_time_for_display
 
 
 # 默认批次大小配置
@@ -37,7 +37,7 @@ def split_content_into_batches(
     get_time_func: Optional[Callable[[], datetime]] = None,
     rss_items: Optional[list] = None,
     rss_new_items: Optional[list] = None,
-    timezone: str = "Asia/Shanghai",
+    timezone: str = DEFAULT_TIMEZONE,
     display_mode: str = "keyword",
     ai_content: Optional[str] = None,
     standalone_data: Optional[Dict] = None,
@@ -834,7 +834,7 @@ def _process_rss_stats_section(
     current_batch: str,
     current_batch_has_content: bool,
     batches: List[str],
-    timezone: str = "Asia/Shanghai",
+    timezone: str = DEFAULT_TIMEZONE,
     add_separator: bool = True,
 ) -> tuple:
     """处理 RSS 统计区块(按关键词分组,与热榜统计格式一致)
@@ -1057,7 +1057,7 @@ def _process_rss_new_titles_section(
     current_batch: str,
     current_batch_has_content: bool,
     batches: List[str],
-    timezone: str = "Asia/Shanghai",
+    timezone: str = DEFAULT_TIMEZONE,
     add_separator: bool = True,
 ) -> tuple:
     """处理 RSS 新增区块(按来源分组,与热榜新增格式一致)
@@ -1237,7 +1237,7 @@ def _format_rss_item_line(
     item: Dict,
     index: int,
     format_type: str,
-    timezone: str = "Asia/Shanghai",
+    timezone: str = DEFAULT_TIMEZONE,
 ) -> str:
     """格式化单条 RSS 条目
 
@@ -1297,7 +1297,7 @@ def _process_standalone_section(
     current_batch: str,
     current_batch_has_content: bool,
     batches: List[str],
-    timezone: str = "Asia/Shanghai",
+    timezone: str = DEFAULT_TIMEZONE,
     rank_threshold: int = 10,
     add_separator: bool = True,
 ) -> tuple:

+ 15 - 2
trendradar/storage/local.py

@@ -16,6 +16,7 @@ from typing import Dict, List, Optional
 from trendradar.storage.base import StorageBackend, NewsItem, NewsData, RSSItem, RSSData
 from trendradar.storage.sqlite_mixin import SQLiteStorageMixin
 from trendradar.utils.time import (
+    DEFAULT_TIMEZONE,
     get_configured_time,
     format_date_folder,
     format_time_filename,
@@ -37,7 +38,7 @@ class LocalStorageBackend(SQLiteStorageMixin, StorageBackend):
         data_dir: str = "output",
         enable_txt: bool = True,
         enable_html: bool = True,
-        timezone: str = "Asia/Shanghai",
+        timezone: str = DEFAULT_TIMEZONE,
     ):
         """
         初始化本地存储后端
@@ -46,7 +47,7 @@ class LocalStorageBackend(SQLiteStorageMixin, StorageBackend):
             data_dir: 数据目录路径
             enable_txt: 是否启用 TXT 快照
             enable_html: 是否启用 HTML 报告
-            timezone: 时区配置(默认 Asia/Shanghai)
+            timezone: 时区配置
         """
         self.data_dir = Path(data_dir)
         self.enable_txt = enable_txt
@@ -202,6 +203,18 @@ class LocalStorageBackend(SQLiteStorageMixin, StorageBackend):
             print(f"[本地存储] AI 分析记录已保存: {analysis_mode} at {now_str}")
         return success
 
+    def reset_push_state(self, date: Optional[str] = None) -> bool:
+        """重置推送状态"""
+        return self._reset_push_state_impl(date)
+
+    def reset_ai_analysis_state(self, date: Optional[str] = None) -> bool:
+        """重置 AI 分析状态"""
+        return self._reset_ai_analysis_state_impl(date)
+
+    def get_push_status(self, date: Optional[str] = None) -> dict:
+        """获取推送状态详情"""
+        return self._get_push_status_impl(date)
+
     # ========================================
     # RSS 数据存储方法
     # ========================================

+ 5 - 4
trendradar/storage/manager.py

@@ -9,6 +9,7 @@ import os
 from typing import Optional
 
 from trendradar.storage.base import StorageBackend, NewsData, RSSData
+from trendradar.utils.time import DEFAULT_TIMEZONE
 
 
 # 存储管理器单例
@@ -37,7 +38,7 @@ class StorageManager:
         remote_retention_days: int = 0,
         pull_enabled: bool = False,
         pull_days: int = 0,
-        timezone: str = "Asia/Shanghai",
+        timezone: str = DEFAULT_TIMEZONE,
     ):
         """
         初始化存储管理器
@@ -52,7 +53,7 @@ class StorageManager:
             remote_retention_days: 远程数据保留天数(0 = 无限制)
             pull_enabled: 是否启用启动时自动拉取
             pull_days: 拉取最近 N 天的数据
-            timezone: 时区配置(默认 Asia/Shanghai)
+            timezone: 时区配置
         """
         self.backend_type = backend_type
         self.data_dir = data_dir
@@ -343,7 +344,7 @@ def get_storage_manager(
     remote_retention_days: int = 0,
     pull_enabled: bool = False,
     pull_days: int = 0,
-    timezone: str = "Asia/Shanghai",
+    timezone: str = DEFAULT_TIMEZONE,
     force_new: bool = False,
 ) -> StorageManager:
     """
@@ -359,7 +360,7 @@ def get_storage_manager(
         remote_retention_days: 远程数据保留天数(0 = 无限制)
         pull_enabled: 是否启用启动时自动拉取
         pull_days: 拉取最近 N 天的数据
-        timezone: 时区配置(默认 Asia/Shanghai)
+        timezone: 时区配置
         force_new: 是否强制创建新实例
 
     Returns:

+ 55 - 2
trendradar/storage/remote.py

@@ -31,6 +31,7 @@ except ImportError:
 from trendradar.storage.base import StorageBackend, NewsItem, NewsData, RSSItem, RSSData
 from trendradar.storage.sqlite_mixin import SQLiteStorageMixin
 from trendradar.utils.time import (
+    DEFAULT_TIMEZONE,
     get_configured_time,
     format_date_folder,
     format_time_filename,
@@ -60,7 +61,7 @@ class RemoteStorageBackend(SQLiteStorageMixin, StorageBackend):
         enable_txt: bool = False,  # 远程模式默认不生成 TXT
         enable_html: bool = True,
         temp_dir: Optional[str] = None,
-        timezone: str = "Asia/Shanghai",
+        timezone: str = DEFAULT_TIMEZONE,
     ):
         """
         初始化远程存储后端
@@ -74,7 +75,7 @@ class RemoteStorageBackend(SQLiteStorageMixin, StorageBackend):
             enable_txt: 是否启用 TXT 快照(默认关闭)
             enable_html: 是否启用 HTML 报告
             temp_dir: 临时目录路径(默认使用系统临时目录)
-            timezone: 时区配置(默认 Asia/Shanghai)
+            timezone: 时区配置
         """
         if not HAS_BOTO3:
             raise ImportError("远程存储后端需要安装 boto3: pip install boto3")
@@ -437,6 +438,58 @@ class RemoteStorageBackend(SQLiteStorageMixin, StorageBackend):
 
         return False
 
+    def reset_push_state(self, date: Optional[str] = None) -> bool:
+        """
+        重置推送状态(远程存储版本)
+
+        流程:下载远程数据库 → 重置状态 → 上传回远程
+        """
+        # 确保连接已建立(会自动下载远程数据库)
+        self._get_connection(date)
+
+        # 执行重置
+        success = self._reset_push_state_impl(date)
+
+        if success:
+            # 上传到远程存储
+            if self._upload_sqlite(date):
+                print(f"[远程存储] 推送状态重置已同步到远程存储")
+                return True
+            else:
+                print(f"[远程存储] 推送状态重置同步到远程存储失败")
+                return False
+
+        return False
+
+    def reset_ai_analysis_state(self, date: Optional[str] = None) -> bool:
+        """
+        重置 AI 分析状态(远程存储版本)
+
+        流程:下载远程数据库 → 重置状态 → 上传回远程
+        """
+        # 确保连接已建立(会自动下载远程数据库)
+        self._get_connection(date)
+
+        # 执行重置
+        success = self._reset_ai_analysis_state_impl(date)
+
+        if success:
+            # 上传到远程存储
+            if self._upload_sqlite(date):
+                print(f"[远程存储] AI 分析状态重置已同步到远程存储")
+                return True
+            else:
+                print(f"[远程存储] AI 分析状态重置同步到远程存储失败")
+                return False
+
+        return False
+
+    def get_push_status(self, date: Optional[str] = None) -> dict:
+        """获取推送状态详情"""
+        # 确保连接已建立(会自动下载远程数据库)
+        self._get_connection(date)
+        return self._get_push_status_impl(date)
+
     # ========================================
     # RSS 数据存储方法
     # ========================================

+ 108 - 0
trendradar/storage/sqlite_mixin.py

@@ -818,6 +818,114 @@ class SQLiteStorageMixin:
             print(f"[存储] 记录 AI 分析失败: {e}")
             return False
 
+    def _reset_push_state_impl(self, date: Optional[str] = None) -> bool:
+        """
+        重置推送状态
+
+        Args:
+            date: 日期字符串(YYYY-MM-DD),默认为今天
+
+        Returns:
+            是否重置成功
+        """
+        try:
+            conn = self._get_connection(date)
+            cursor = conn.cursor()
+
+            target_date = self._format_date_folder(date)
+
+            cursor.execute("""
+                UPDATE push_records
+                SET pushed = 0, push_time = NULL
+                WHERE date = ?
+            """, (target_date,))
+
+            conn.commit()
+            print(f"[存储] 已重置 {target_date} 的推送状态")
+            return True
+
+        except Exception as e:
+            print(f"[存储] 重置推送状态失败: {e}")
+            return False
+
+    def _reset_ai_analysis_state_impl(self, date: Optional[str] = None) -> bool:
+        """
+        重置 AI 分析状态
+
+        Args:
+            date: 日期字符串(YYYY-MM-DD),默认为今天
+
+        Returns:
+            是否重置成功
+        """
+        try:
+            conn = self._get_connection(date)
+            cursor = conn.cursor()
+
+            target_date = self._format_date_folder(date)
+
+            cursor.execute("""
+                UPDATE push_records
+                SET ai_analyzed = 0, ai_analysis_time = NULL, ai_analysis_mode = NULL
+                WHERE date = ?
+            """, (target_date,))
+
+            conn.commit()
+            print(f"[存储] 已重置 {target_date} 的 AI 分析状态")
+            return True
+
+        except Exception as e:
+            print(f"[存储] 重置 AI 分析状态失败: {e}")
+            return False
+
+    def _get_push_status_impl(self, date: Optional[str] = None) -> dict:
+        """
+        获取推送状态详情
+
+        Args:
+            date: 日期字符串(YYYY-MM-DD),默认为今天
+
+        Returns:
+            状态详情字典
+        """
+        try:
+            conn = self._get_connection(date)
+            cursor = conn.cursor()
+
+            target_date = self._format_date_folder(date)
+
+            cursor.execute("""
+                SELECT date, pushed, push_time, report_type,
+                       ai_analyzed, ai_analysis_time, ai_analysis_mode
+                FROM push_records
+                WHERE date = ?
+            """, (target_date,))
+
+            row = cursor.fetchone()
+            if row:
+                return {
+                    "date": row[0],
+                    "pushed": bool(row[1]),
+                    "push_time": row[2],
+                    "report_type": row[3],
+                    "ai_analyzed": bool(row[4]),
+                    "ai_analysis_time": row[5],
+                    "ai_analysis_mode": row[6],
+                }
+            return {
+                "date": target_date,
+                "pushed": False,
+                "push_time": None,
+                "report_type": None,
+                "ai_analyzed": False,
+                "ai_analysis_time": None,
+                "ai_analysis_mode": None,
+            }
+
+        except Exception as e:
+            print(f"[存储] 获取推送状态失败: {e}")
+            return {}
+
     # ========================================
     # RSS 数据存储
     # ========================================

+ 211 - 3
trendradar/utils/time.py

@@ -1,14 +1,16 @@
 # coding=utf-8
 """
-时间工具模块 - 统一时间处理函数
+时间工具模块
+
+本模块提供统一的时间处理函数,所有时区相关操作都应使用 DEFAULT_TIMEZONE 常量。
 """
 
 from datetime import datetime
-from typing import Optional
+from typing import Optional, Tuple
 
 import pytz
 
-# 默认时区
+# 默认时区常量 - 仅作为 fallback,正常运行时使用 config.yaml 中的 app.timezone
 DEFAULT_TIMEZONE = "Asia/Shanghai"
 
 
@@ -235,3 +237,209 @@ def is_within_days(
     except Exception:
         # 出错时保留文章
         return True
+
+
+def calculate_days_old(iso_time: str, timezone: str = DEFAULT_TIMEZONE) -> Optional[float]:
+    """
+    计算 ISO 格式时间距今多少天
+
+    Args:
+        iso_time: ISO 格式时间字符串
+        timezone: 时区名称
+
+    Returns:
+        距今天数(浮点数),如果无法解析返回 None
+    """
+    if not iso_time:
+        return None
+
+    try:
+        dt = None
+
+        # 尝试解析带时区的格式
+        if "+" in iso_time or iso_time.endswith("Z"):
+            iso_time_normalized = iso_time.replace("Z", "+00:00")
+            try:
+                dt = datetime.fromisoformat(iso_time_normalized)
+            except ValueError:
+                pass
+
+        # 尝试解析不带时区的格式(假设为 UTC)
+        if dt is None:
+            try:
+                if "T" in iso_time:
+                    dt = datetime.fromisoformat(iso_time.replace("T", " ").split(".")[0])
+                else:
+                    dt = datetime.fromisoformat(iso_time.split(".")[0])
+                dt = pytz.UTC.localize(dt)
+            except ValueError:
+                pass
+
+        if dt is None:
+            return None
+
+        now = get_configured_time(timezone)
+        diff = now - dt
+        return diff.total_seconds() / (24 * 60 * 60)
+
+    except Exception:
+        return None
+
+
+class TimeWindowChecker:
+    """
+    时间窗口检查器
+
+    统一管理时间窗口控制逻辑,支持:
+    - 推送窗口控制 (push_window)
+    - AI 分析窗口控制 (analysis_window)
+    - once_per_day 功能
+    """
+
+    def __init__(
+        self,
+        storage_backend,
+        get_time_func=None,
+        window_name: str = "时间窗口",
+    ):
+        """
+        初始化时间窗口检查器
+
+        Args:
+            storage_backend: 存储后端实例
+            get_time_func: 获取当前时间的函数
+            window_name: 窗口名称(用于日志输出)
+        """
+        self.storage_backend = storage_backend
+        self.get_time_func = get_time_func or (lambda: get_configured_time(DEFAULT_TIMEZONE))
+        self.window_name = window_name
+
+    def is_in_time_range(self, start_time: str, end_time: str) -> bool:
+        """
+        检查当前时间是否在指定时间范围内
+
+        支持跨日时间窗口,例如:
+        - 正常窗口:09:00-21:00(当天 9 点到 21 点)
+        - 跨日窗口:22:00-02:00(当天 22 点到次日 2 点)
+
+        Args:
+            start_time: 开始时间(格式:HH:MM)
+            end_time: 结束时间(格式:HH:MM)
+
+        Returns:
+            是否在时间范围内
+        """
+        now = self.get_time_func()
+        current_time = now.strftime("%H:%M")
+
+        normalized_start = self._normalize_time(start_time)
+        normalized_end = self._normalize_time(end_time)
+        normalized_current = self._normalize_time(current_time)
+
+        # 判断是否跨日窗口(start > end 表示跨日,如 22:00-02:00)
+        if normalized_start <= normalized_end:
+            # 正常窗口:09:00-21:00
+            result = normalized_start <= normalized_current <= normalized_end
+        else:
+            # 跨日窗口:22:00-02:00
+            # 当前时间 >= 开始时间(如 23:00 >= 22:00)或 当前时间 <= 结束时间(如 01:00 <= 02:00)
+            result = normalized_current >= normalized_start or normalized_current <= normalized_end
+
+        if not result:
+            print(f"[{self.window_name}] 当前 {normalized_current},窗口 {normalized_start}-{normalized_end}")
+
+        return result
+
+    def _normalize_time(self, time_str: str) -> str:
+        """将时间字符串标准化为 HH:MM 格式"""
+        try:
+            parts = time_str.strip().split(":")
+            if len(parts) != 2:
+                raise ValueError(f"时间格式错误: {time_str}")
+
+            hour = int(parts[0])
+            minute = int(parts[1])
+
+            if not (0 <= hour <= 23 and 0 <= minute <= 59):
+                raise ValueError(f"时间范围错误: {time_str}")
+
+            return f"{hour:02d}:{minute:02d}"
+        except Exception as e:
+            print(f"[{self.window_name}] 时间格式化错误 '{time_str}': {e}")
+            return time_str
+
+    def check_window(
+        self,
+        window_config: dict,
+        check_once_per_day_func=None,
+        record_func=None,
+    ) -> Tuple[bool, str]:
+        """
+        统一的时间窗口检查逻辑
+
+        Args:
+            window_config: 窗口配置字典,包含:
+                - ENABLED: 是否启用窗口控制
+                - TIME_RANGE: {"START": "HH:MM", "END": "HH:MM"}
+                - ONCE_PER_DAY: 是否每天只执行一次
+            check_once_per_day_func: 检查今天是否已执行的函数
+            record_func: 记录执行的函数(成功后调用)
+
+        Returns:
+            (should_proceed, reason) 元组:
+            - should_proceed: 是否应该继续执行
+            - reason: 原因说明
+        """
+        if not window_config.get("ENABLED", False):
+            return True, "窗口控制未启用"
+
+        time_range = window_config.get("TIME_RANGE", {})
+        start_time = time_range.get("START", "00:00")
+        end_time = time_range.get("END", "23:59")
+
+        # 检查时间范围
+        if not self.is_in_time_range(start_time, end_time):
+            now = self.get_time_func()
+            return False, f"当前时间 {now.strftime('%H:%M')} 不在窗口 {start_time}-{end_time} 内"
+
+        # 检查 once_per_day
+        if window_config.get("ONCE_PER_DAY", False) and check_once_per_day_func:
+            if check_once_per_day_func():
+                return False, "今天已执行过"
+            else:
+                print(f"[{self.window_name}] 今天首次执行")
+
+        return True, "在窗口内"
+
+    def get_status(self, window_config: dict, check_once_per_day_func=None) -> dict:
+        """
+        获取窗口状态信息
+
+        Args:
+            window_config: 窗口配置
+            check_once_per_day_func: 检查今天是否已执行的函数
+
+        Returns:
+            状态信息字典
+        """
+        now = self.get_time_func()
+        status = {
+            "enabled": window_config.get("ENABLED", False),
+            "current_time": now.strftime("%H:%M:%S"),
+            "current_date": now.strftime("%Y-%m-%d"),
+            "timezone": str(now.tzinfo),
+        }
+
+        if status["enabled"]:
+            time_range = window_config.get("TIME_RANGE", {})
+            status["window_start"] = time_range.get("START", "00:00")
+            status["window_end"] = time_range.get("END", "23:59")
+            status["in_window"] = self.is_in_time_range(
+                status["window_start"], status["window_end"]
+            )
+            status["once_per_day"] = window_config.get("ONCE_PER_DAY", False)
+
+            if status["once_per_day"] and check_once_per_day_func:
+                status["executed_today"] = check_once_per_day_func()
+
+        return status

+ 1 - 1
version

@@ -1 +1 @@
-5.5.2
+5.5.3

+ 1 - 1
version_configs

@@ -1,4 +1,4 @@
-config.yaml=1.1.0
+config.yaml=1.2.0
 frequency_words.txt=1.1.0
 ai_analysis_prompt.txt=1.0.0
 ai_translation_prompt.txt=1.0.0