Bladeren bron

feat: 引入统一时间线调度系统,优化 AI 分析稳定性,mcp 支持 AI 对话内容直推多渠道

sansan 3 maanden geleden
bovenliggende
commit
3a1452eb24
42 gewijzigde bestanden met toevoegingen van 6291 en 1418 verwijderingen
  1. 130 93
      README-EN.md
  2. 61 1
      README-MCP-FAQ-EN.md
  3. 61 1
      README-MCP-FAQ.md
  4. 112 91
      README.md
  5. BIN
      _image/editor.png
  6. 136 95
      config/ai_analysis_prompt.txt
  7. 2 1
      config/ai_translation_prompt.txt
  8. 56 57
      config/config.yaml
  9. 518 0
      config/timeline.yaml
  10. 2023 47
      docs/assets/script.js
  11. 543 4
      docs/assets/style.css
  12. 100 12
      docs/index.html
  13. 1 1
      mcp_server/__init__.py
  14. 113 0
      mcp_server/server.py
  15. 25 12
      mcp_server/services/data_service.py
  16. 15 1
      mcp_server/services/parser_service.py
  17. 34 13
      mcp_server/tools/analytics.py
  18. 1408 0
      mcp_server/tools/notification.py
  19. 26 10
      mcp_server/utils/validators.py
  20. 1 1
      pyproject.toml
  21. 1 1
      trendradar/__init__.py
  22. 119 160
      trendradar/__main__.py
  23. 103 1
      trendradar/ai/analyzer.py
  24. 74 11
      trendradar/ai/formatter.py
  25. 19 13
      trendradar/context.py
  26. 4 2
      trendradar/core/__init__.py
  27. 8 79
      trendradar/core/data.py
  28. 58 25
      trendradar/core/loader.py
  29. 420 0
      trendradar/core/scheduler.py
  30. 0 4
      trendradar/notification/__init__.py
  31. 12 10
      trendradar/notification/dispatcher.py
  32. 0 206
      trendradar/notification/push_manager.py
  33. 0 2
      trendradar/storage/__init__.py
  34. 17 85
      trendradar/storage/base.py
  35. 10 30
      trendradar/storage/local.py
  36. 6 49
      trendradar/storage/manager.py
  37. 14 84
      trendradar/storage/remote.py
  38. 13 12
      trendradar/storage/schema.sql
  39. 42 199
      trendradar/storage/sqlite_mixin.py
  40. 1 1
      version
  41. 4 3
      version_configs
  42. 1 1
      version_mcp

+ 130 - 93
README-EN.md

@@ -11,8 +11,8 @@ Deploy in <strong>30 seconds</strong> — Say goodbye to endless scrolling, only
 [![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-v5.5.3-blue.svg)](https://github.com/sansan0/TrendRadar)
-[![MCP](https://img.shields.io/badge/MCP-v3.2.0-green.svg)](https://github.com/sansan0/TrendRadar)
+[![Version](https://img.shields.io/badge/version-v6.0.0-blue.svg)](https://github.com/sansan0/TrendRadar)
+[![MCP](https://img.shields.io/badge/MCP-v4.0.0-green.svg)](https://github.com/sansan0/TrendRadar)
 [![RSS](https://img.shields.io/badge/RSS-Feed_Support-orange.svg?style=flat-square&logo=rss&logoColor=white)](https://github.com/sansan0/TrendRadar)
 [![AI Translation](https://img.shields.io/badge/AI-Multi--Language-purple.svg?style=flat-square)](https://github.com/sansan0/TrendRadar)
 
@@ -137,12 +137,7 @@ After communication, the author indicated no concerns about server pressure, but
 
 <div align="center">
 
-> **Sponsorship Open**  
->
-> Seeking quality product partners.  
-> More than features, I value **your attitude towards users**.  
-> Please share your user community or feedback channels—I want to see how you support your users.  
-> [📩 Contact Me](mailto:path@linux.do)  
+> **Sponsorship Open**
 
 </div>
 
@@ -150,16 +145,11 @@ After communication, the author indicated no concerns about server pressure, but
 
 <a name="-support-project"></a>
 
-## ✨ Find it useful? Support TrendRadar
-
-TrendRadar is a completely free and open-source project. Your support fuels the motivation for continuous updates.
-
-### ❤️ Donate
-
-A bottle of water or a snack represents your love.
-Any amount is welcome; even 1 RMB is a gesture of kindness.
+### ❤️ Find it useful? Support TrendRadar
 
-> Your sponsorship will be used to replenish caffeine for carbon-based lifeforms ☕️ and API Tokens for silicon-based lifeforms 🤖.
+> If TrendRadar has captured value for you, give it some fuel to keep evolving
+>
+> Any amount is welcome; even 1 RMB is a gesture of encouragement for open source. Feel free to leave a note with your donation (´▽`ʃ♡ƪ)
 
 <div align="center">
 
@@ -169,29 +159,21 @@ Any amount is welcome; even 1 RMB is a gesture of kindness.
 
 </div>
 
-### 🌟 Other Ways to Support
-
-1. **Star the Repo** ⭐️: It only takes **1 second**. Letting more people see this project is the greatest recognition for me.
-2. **Charity** 🌻: Search for **Tencent Charity** (or support a local charity) to help students in need. Pass this kindness forward.
 
 ### 🤝 Attribution & Secondary Development
 
 If you utilize the core code or draw inspiration from the logic of this project, **it would be greatly appreciated** if you could acknowledge the source in your README or documentation and include a link to this repository.
 
-> [https://github.com/sansan0/TrendRadar](https://github.com/sansan0/TrendRadar)
-
 This contributes to the sustainable maintenance of the project and the growth of the community. Thank you for your respect and support! ❤️
 
----
 
 ### 💬 Feedback & Community
 
 * **GitHub Issues**: Best for specific technical issues. Please provide complete information (screenshots, error logs, etc.) to help locate the problem quickly.
 * **WeChat Official Account**: It is recommended to leave comments under relevant articles. If you need to ask questions in the background, **liking/recommending** the article first is the best "icebreaker," and I can feel your appreciation (´▽`ʃ♡ƪ).
 
-> **Friendly Reminder**:  
-> This project is for open-source sharing, not a commercial product. A lot of effort went into the documentation; most deployment issues can be answered in **[🚀 Quick Start](#-quick-start)**.   
-> *Please be patient and polite when asking questions. Treat the author as a friend, not customer service, for better communication efficiency!*  
+> **Friendly Reminder**:
+> This project is for open-source sharing, not a commercial product. Treat the author as a friend, not customer service, for better communication efficiency!
 
 <div align="center">
 
@@ -208,6 +190,61 @@ This contributes to the sustainable maintenance of the project and the growth of
 >**📌 Check Latest Updates**: **[Original Repository Changelog](https://github.com/sansan0/TrendRadar?tab=readme-ov-file#-changelog)**:
 - **Tip**: Check [Changelog] to understand specific [Features]
 
+
+### 2026/02/09 - v6.0.0
+
+> **Breaking Change**: Config file upgrade (config.yaml 2.0.0), old `push_window` and `analysis_window` configs are no longer compatible, please refer to the new config.yaml for migration
+
+- **Unified Scheduling System**: New `timeline.yaml` — one config to control when to crawl / push / AI analyze
+- **5 Preset Templates**: `always_on` (24/7, default), `morning_evening` (morning & evening summary), `office_hours` (work hours), `night_owl` (late night), `custom` (fully customizable); you can also add your own templates under `presets:` — just use a unique key, then set it in config.yaml
+- **Flexible Time Period Config**: Supports weekday/weekend differentiation, cross-midnight time periods, per-period once deduplication
+- **Visual Config Editor**:
+  - New `timeline.yaml` editor tab, alongside config.yaml / frequency_words.txt
+  - Preset mode card selector: click to switch, auto-syncs config.yaml's `schedule.preset`
+  - Week view timeline: 7 days × 24 hours horizontal bars, color-coded for push/analysis/crawl status
+  - Interactive controls: toggles, dropdowns, time pickers — right-side changes sync to left-side YAML in real time
+  - Week mapping dropdown: dynamically populated from day plans, configure scheduling by drag and click
+- **AI Prompt Stability Overhaul** (ai_analysis_prompt.txt v2.0.0):
+  - Formatting rules extracted from JSON values into a standalone spec section, reducing AI output format inconsistencies
+  - JSON template simplified: field descriptions shortened to one sentence + word limit
+  - Removed Markdown from system prompt to align with the "no Markdown" instruction
+  - All JSON fields declared optional — missing any field won't cause errors, improving fault tolerance
+- **Standalone Source AI Summaries** (`ai_analysis.include_standalone`):
+  - New independent toggle: when enabled, AI generates a concise summary for each standalone source
+  - Decoupled from display: AI can analyze full hotlist data without enabling standalone display in push notifications
+  - Supports both trending platforms and RSS feeds, including rank/time/trajectory data
+  - Trajectory analysis linked with `include_rank_timeline`: uses trajectory data for deep trend analysis when enabled, falls back to rank-based summary when disabled
+  - New `standalone_summaries` JSON field ("Source Snapshot"), all notification channels adapted for rendering
+
+
+### 2026/02/09 - mcp-v4.0.0
+
+- **🔥 Push any AI message to all channels**: Send AI-generated content to Feishu, DingTalk, Telegram, Email and all 9 channels with one call — Markdown auto-adapts to each platform's native format
+- **New format guide tool**: `get_channel_format_guide` tells AI what each channel supports and its limitations, so generated content looks great everywhere
+- **Smart batch splitting**: Long messages auto-split per channel byte limits (Feishu 30KB, DingTalk 20KB, etc.), reads config from config.yaml
+- **Fixed channel detection**: ntfy no longer falsely reported as "configured" due to default server URL
+- **Code reuse**: Batch utilities now imported from trendradar core instead of duplicated
+
+
+<details>
+<summary>👉 Click to expand: <strong>Historical Updates</strong></summary>
+
+
+### 2026/01/28 - v5.5.0
+
+> Like the MCP feature, I'm not creating a separate repo for this tool either — it's pure frontend, so bundling it together
+
+- Added visual configuration editor for TrendRadar
+
+
+### 2026/02/02 - mcp-v3.2.0
+
+- **New read_article tool**: Read a single article body via Jina AI Reader (Markdown format)
+- **New read_articles_batch tool**: Batch read multiple articles (up to 5, auto rate-limited)
+- **Recommended workflow**: `search_news(query="keyword", include_url=True)` → `read_article(url=...)` to read article body
+- **Docs update**: README-MCP-FAQ.md and README-MCP-FAQ-EN.md added Q19-Q20 for article reading
+
+
 ### 2026/01/23 - v5.4.0
 
 - Added independent control for AI analysis mode, options: follow_report | daily | current | incremental
@@ -277,10 +314,6 @@ This contributes to the sustainable maintenance of the project and the growth of
 - **New check_version Tool**: Check TrendRadar and MCP Server version updates simultaneously
 
 
-<details>
-<summary>👉 Click to expand: <strong>Historical Updates</strong></summary>
-
-
 ### 2026/01/10 - v5.0.0
 
 > **Dev Anecdote**:
@@ -387,9 +420,9 @@ This update refactors the push message structure into five distinct core section
    - Use semicolon `;` to separate multiple accounts, e.g., `FEISHU_WEBHOOK_URL=url1;url2`
    - Automatic validation for paired configurations (e.g., Telegram's token and chat_id)
 
-2. **Configurable Push Content Order**
-   - Added `reverse_content_order` configuration option
-   - Customize display order of trending keywords stats and new trending news
+2. **Push Region Configuration**
+   - Customize display order of all regions via `display.region_order` (v5.2.0, replaces `reverse_content_order`)
+   - Control visibility of each region via `display.regions` (hotlist, new items, RSS, standalone, AI analysis)
 
 3. **Global Filter Keywords**
    - Added `[GLOBAL_FILTER]` region marker for filtering unwanted content globally
@@ -874,6 +907,14 @@ Supports RSS/Atom feed crawling, keyword-based grouping and statistics (consiste
 
 > 💡 RSS uses the same `frequency_words.txt` for keyword filtering as trending
 
+### **Visual Configuration Editor**
+
+A web-based graphical configuration interface — no need to manually edit YAML files. Complete all configuration changes and exports through simple forms.
+
+👉 **Try it online**: [https://sansan0.github.io/TrendRadar/](https://sansan0.github.io/TrendRadar/)
+
+<img src="/_image/editor.png" alt="Visual Configuration Editor" width="80%">
+
 ### **Smart Push Strategies**
 
 **Three Push Modes**:
@@ -895,11 +936,11 @@ Supports RSS/Atom feed crawling, keyword-based grouping and statistics (consiste
 
 | Feature | Description | Default |
 |---------|-------------|---------|
-| **Push Time Window Control** | Set push time range (e.g., 09:00-18:00) to avoid non-work hours notifications | Disabled |
-| **Content Order Configuration** | Adjust display order of "Trending Keywords Stats" and "New Trending News" (v3.5.0 new) | Stats first |
+| **Scheduling System** | Per-day-of-week scheduling: assign different time periods, push modes, and AI analysis strategies to each day (Mon–Sun). 5 built-in presets (always_on / morning_evening / office_hours / night_owl / custom), or define your own. Supports weekday vs weekend differentiation, cross-midnight periods, and per-period once-only dedup (v6.0.0) | morning_evening |
+| **Content Order Configuration** | Use `display.region_order` to adjust display order of all regions (hotlist, new items, RSS, standalone, AI analysis); use `display.regions` to toggle each region on/off (v5.2.0) | See config |
 | **Display Mode Switch** | `keyword`=group by keyword, `platform`=group by platform (v4.6.0 new) | keyword |
 
-> 💡 For detailed configuration, see [Configuration Guide - Report Configuration](#7-report-configuration) and [Configuration Guide - Push Window](#8-push-window-configuration)
+> 💡 For detailed configuration, see [Configuration Guide - Report Configuration](#7-report-configuration) and [Configuration Guide - Scheduling System](#8-when-will-i-receive-pushes)
 
 ### **Precise Content Filtering**
 
@@ -993,6 +1034,7 @@ Provide complete trending display for specified platforms, unaffected by keyword
 
 - **Full Trending**: Specified platforms show complete trending list, for users who want to see full rankings
 - **RSS Independent Display**: RSS source content can be fully displayed, not limited by keywords
+- **AI Deep Analysis**: Independently enable AI trend analysis on full hotlists, without displaying in push
 - **Flexible Configuration**: Support configuring display platforms, RSS sources, max count
 
 > 💡 Detailed configuration tutorial: [Report Configuration - Independent Display](#7-report-configuration)
@@ -2977,6 +3019,28 @@ display:
 1. Listed in `region_order`
 2. Corresponding switch in `display.regions` is `true`
 
+#### Region Switches (regions)
+
+Control whether each region is displayed in push notifications via `display.regions`:
+
+```yaml
+display:
+  regions:
+    hotlist: true                    # Hotlist region (keyword-matched trending news)
+    new_items: false                 # New items region (new hotlist + new RSS items)
+    rss: true                       # RSS region (keyword-matched RSS content)
+    standalone: false                # Standalone section (full hotlist/RSS, unfiltered by keywords)
+    ai_analysis: true                # AI analysis region
+```
+
+| Region | Config Key | Default | Description |
+|--------|-----------|---------|-------------|
+| **Hotlist** | `hotlist` | `true` | Keyword-matched trending news aggregation |
+| **New Items** | `new_items` | `false` | Newly appeared topics in this crawl cycle (hotlist + RSS). Note: the 🆕 markers in the hotlist region are not affected by this switch |
+| **RSS** | `rss` | `true` | Keyword-matched RSS subscription content. When disabled, RSS analysis is skipped, but RSS in standalone section is unaffected |
+| **Standalone** | `standalone` | `false` | Full content display for specified platforms/RSS, unfiltered by keywords |
+| **AI Analysis** | `ai_analysis` | `true` | AI-generated trending analysis summary |
+
 #### Sorting Priority Configuration
 
 **Example Scenario:** Config order A, B, C, news count A(3), B(10), C(5)
@@ -3001,7 +3065,7 @@ Provides full trending list display for specified platforms, unaffected by `freq
 ```yaml
 display:
   regions:
-    standalone: true                  # Enable independent display section
+    standalone: true                  # Show standalone section in push (disabling doesn't affect AI analysis)
 
   standalone:
     platforms: ["zhihu", "weibo"]     # Trending platform ID list
@@ -3009,6 +3073,8 @@ display:
     max_items: 20                     # Max display count per source (0=unlimited)
 ```
 
+> 💡 **Display and AI analysis are independently controlled**: `regions.standalone` only controls whether the standalone section appears in push notifications. Even with display disabled, setting `include_standalone: true` in the AI config still allows AI to analyze full hotlist data from these platforms. Ideal for users who want deeper AI insights without longer push messages.
+
 **Use Cases:**
 - Want to view the complete trending ranking of a platform (like Zhihu) instead of just keyword-matched content
 - Subscribed to RSS feeds with few updates (like personal blogs) and want full push every time
@@ -3032,77 +3098,48 @@ Hacker News (5 items):
 ### 8. When will I receive pushes?
 
 <details>
-<summary>👉 Click to expand: <strong>Set Push Time Window</strong></summary>
+<summary>👉 Click to expand: <strong>Set Push Time (Scheduling System)</strong></summary>
 <br>
 
-**Configuration Location:** `notification.push_window` section in `config/config.yaml`
+**Configuration Location:** `schedule` section in `config/config.yaml` + `config/timeline.yaml`
+
+#### Quick Start
+
+Just pick a preset template in `config.yaml` — no need to edit `timeline.yaml`:
 
 ```yaml
-notification:
-  push_window:
-    enabled: false                    # Whether to enable
-    start: "20:00"                    # Start time (Beijing time)
-    end: "22:00"                      # End time (Beijing time)
-    once_per_day: true                # Push only once per day
+schedule:
+  enabled: true
+  preset: "morning_evening"     # Change this line
 ```
 
-#### Configuration Details
+#### Available Preset Templates
 
-| Config Item | Type | Default | Description |
-|------------|------|---------|-------------|
-| `enabled` | bool | `false` | Whether to enable push time window control |
-| `start` | string | `"20:00"` | Push window start time (Beijing time, HH:MM format) |
-| `end` | string | `"22:00"` | Push window end time (Beijing time, HH:MM format) |
-| `once_per_day` | bool | `true` | `true`=push only once per day within window, `false`=push every execution within window |
+| Template | Description | Push Behavior |
+|----------|-------------|---------------|
+| `morning_evening` | Incremental + evening summary (recommended) | Push new content all day + 19:00-21:00 daily summary |
+| `always_on` | 24/7 monitoring | Push whenever new content appears, no time restrictions |
+| `office_hours` | Office hours | Three-phase weekday push (morning briefing → noon update → closing summary), weekends incremental |
+| `night_owl` | Night owl | Afternoon peek + late-night daily summary (22:00-01:00 cross-midnight) |
+| `custom` | Fully customizable | Edit the `custom` section at the bottom of `timeline.yaml` |
 
-#### Use Cases
+#### Full Customization
 
-| Scenario | Configuration Example |
-|----------|---------------------|
-| **Working Hours Push** | `start: "09:00"`, `end: "18:00"`, `once_per_day: false` |
-| **Evening Summary Push** | `start: "20:00"`, `end: "22:00"`, `once_per_day: true` |
-| **Lunch Break Push** | `start: "12:00"`, `end: "13:00"`, `once_per_day: true` |
+If none of the preset templates fit your needs, edit the `custom` section at the bottom of `config/timeline.yaml` to freely define time periods, day plans, and week mappings. See the comments in `timeline.yaml` for details.
 
 #### Important Notice
 
+> ⚠️ **Users upgrading from older versions:**
+> - v6.0.0 removed the old `notification.push_window` and `ai_analysis.analysis_window` configs
+> - Please switch to the new `schedule` + `timeline.yaml` scheduling system
+> - Old "push once per day" can be replaced with the `morning_evening` preset
+> - Old "working hours push" can be replaced with the `office_hours` preset
+
 > ⚠️ **GitHub Actions Users Note:**
 > - GitHub Actions execution time is unstable, may have ±15 minutes deviation
-> - Time range should be at least **2 hours** wide
+> - Time period ranges should be at least **2 hours** wide
 > - For precise timed push, recommend **Docker deployment** on personal server
 
-#### Docker Environment Variables
-
-```bash
-PUSH_WINDOW_ENABLED=true
-PUSH_WINDOW_START=09:00
-PUSH_WINDOW_END=18:00
-PUSH_WINDOW_ONCE_PER_DAY=false
-```
-
-#### Complete Configuration Examples
-
-**Scenario: Push once between 8-10 PM daily**
-
-```yaml
-notification:
-  push_window:
-    enabled: true
-    start: "20:00"
-    end: "22:00"
-    once_per_day: true
-```
-
-**Scenario: Push every hour during working hours**
-
-```yaml
-notification:
-  push_window:
-    enabled: true
-    start: "09:00"
-    end: "18:00"
-    once_per_day: false
-```
-
 </details>
 
 ### 9. How often does it run?

+ 61 - 1
README-MCP-FAQ-EN.md

@@ -38,6 +38,8 @@
 | | `list_available_dates` | List available dates (local/remote) |
 | **Article** | `read_article` | Read single article content (Markdown format) |
 | | `read_articles_batch` | Batch read multiple articles (max 5) |
+| **Notification** | `get_notification_channels` | Get all configured notification channels and their status |
+| | `send_notification` | Send messages to configured notification channels (auto format conversion) |
 
 ---
 
@@ -530,7 +532,7 @@ After testing one query, please immediately check the [SiliconFlow Billing](http
 - Available platform list
 - Crawler configuration (request interval, timeout settings)
 - Weight configuration (ranking weight, frequency weight)
-- Notification configuration (DingTalk, WeChat)
+- Notification configuration (Feishu, DingTalk, WeCom, Telegram, Email, ntfy, Bark, Slack, Generic Webhook)
 
 ---
 
@@ -804,6 +806,64 @@ Users often use natural language like "this week", "last 7 days" to express date
 
 ---
 
+## Notification Push
+
+### Q21: How to send notification messages via MCP?
+
+**You can ask like this:**
+
+- "Show me which notification channels are configured"
+- "Send a test message to all channels"
+- "Push this content to Feishu"
+- "Send today's news summary to DingTalk and Telegram"
+
+**Supported notification channels (9):**
+
+| Channel | Message Format | Configuration |
+|---------|---------------|---------------|
+| **Feishu** | Plain text | `FEISHU_WEBHOOK_URL` |
+| **DingTalk** | Markdown | `DINGTALK_WEBHOOK_URL` |
+| **WeCom** | Markdown | `WEWORK_WEBHOOK_URL` |
+| **Telegram** | HTML | `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` |
+| **Email** | HTML | `EMAIL_FROM` + `EMAIL_PASSWORD` + `EMAIL_TO` |
+| **ntfy** | Markdown | `NTFY_SERVER_URL` + `NTFY_TOPIC` |
+| **Bark** | Markdown | `BARK_URL` |
+| **Slack** | mrkdwn | `SLACK_WEBHOOK_URL` |
+| **Generic Webhook** | Markdown | `GENERIC_WEBHOOK_URL` |
+
+**Configuration methods:**
+
+- Configure channels in `config.yaml` under `notification.channels`
+- Or set corresponding environment variables in `.env` file (higher priority)
+- Both sources are automatically merged, `.env` values override `config.yaml` values
+
+**Two tools:**
+
+| Tool | Function | Example Question |
+|------|----------|------------------|
+| `get_notification_channels` | Detect configured channels and status | "View notification channel config" |
+| `send_notification` | Send message to specified or all channels | "Send message to Feishu" |
+
+**Typical workflow:**
+
+1. Check channel status first: "Show me which notification channels are configured"
+2. Send after confirming availability: "Push the following to DingTalk: today's hotspot summary..."
+3. Or specify multiple channels: "Send to Feishu and Telegram"
+4. Without specifying channels, sends to all configured channels
+
+**Message format:**
+
+- The tool accepts messages in **Markdown format**
+- Automatically converts to each channel's required format (Feishu to plain text, Telegram to HTML, Slack to mrkdwn, etc.)
+- No need to manually handle format differences
+
+**Multi-account support:**
+
+- Separate multiple URLs/Tokens with `;` in config values to send to multiple accounts
+- For example: `FEISHU_WEBHOOK_URL=url1;url2` sends to two Feishu groups simultaneously
+
+---
+
 ## 💡 Usage Tips
 
 ### 1. How to make AI display all data instead of auto-summarizing?

+ 61 - 1
README-MCP-FAQ.md

@@ -38,6 +38,8 @@
 | | `list_available_dates` | 列出本地/远程可用的日期 |
 | **文章** | `read_article` | 读取单篇文章内容(Markdown 格式) |
 | | `read_articles_batch` | 批量读取多篇文章(最多 5 篇) |
+| **通知** | `get_notification_channels` | 获取所有已配置的通知渠道及其状态 |
+| | `send_notification` | 向已配置的通知渠道发送消息(自动格式转换) |
 
 ---
 
@@ -530,7 +532,7 @@
 - 可用平台列表
 - 爬虫配置(请求间隔、超时设置)
 - 权重配置(排名权重、频次权重)
-- 通知配置(钉钉、微信)
+- 通知配置(飞书、钉钉、企业微信、Telegram、Email、ntfy、Bark、Slack、通用 Webhook
 
 ---
 
@@ -804,6 +806,64 @@
 
 ---
 
+## 通知推送
+
+### Q21: 如何通过 MCP 发送通知消息?
+
+**你可以这样问:**
+
+- "查看当前配置了哪些通知渠道"
+- "发送一条测试消息到所有渠道"
+- "把这段内容推送到飞书"
+- "发送今天的新闻摘要到钉钉和 Telegram"
+
+**支持的通知渠道(9 个):**
+
+| 渠道 | 消息格式 | 配置来源 |
+|------|---------|---------|
+| **飞书** (feishu) | 纯文本 | `FEISHU_WEBHOOK_URL` |
+| **钉钉** (dingtalk) | Markdown | `DINGTALK_WEBHOOK_URL` |
+| **企业微信** (wework) | Markdown | `WEWORK_WEBHOOK_URL` |
+| **Telegram** | HTML | `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` |
+| **Email** | HTML | `EMAIL_FROM` + `EMAIL_PASSWORD` + `EMAIL_TO` |
+| **ntfy** | Markdown | `NTFY_SERVER_URL` + `NTFY_TOPIC` |
+| **Bark** | Markdown | `BARK_URL` |
+| **Slack** | mrkdwn | `SLACK_WEBHOOK_URL` |
+| **通用 Webhook** | Markdown | `GENERIC_WEBHOOK_URL` |
+
+**配置方式:**
+
+- 在 `config.yaml` 的 `notification.channels` 中配置对应渠道
+- 或在 `.env` 文件中设置对应的环境变量(优先级更高)
+- 两种方式会自动合并,`.env` 中的值会覆盖 `config.yaml` 中的值
+
+**两个工具:**
+
+| 工具 | 功能 | 示例问法 |
+|------|------|---------|
+| `get_notification_channels` | 检测已配置的渠道及状态 | "查看通知渠道配置" |
+| `send_notification` | 发送消息到指定或全部渠道 | "发送消息到飞书" |
+
+**典型使用流程:**
+
+1. 先查看渠道状态:"查看当前配置了哪些通知渠道"
+2. 确认渠道可用后发送:"把以下内容推送到钉钉:今日热点摘要..."
+3. 或指定多个渠道:"发送到飞书和 Telegram"
+4. 不指定渠道则发送到所有已配置渠道
+
+**消息格式:**
+
+- 工具接受 **Markdown 格式** 的消息内容
+- 自动按各渠道要求转换格式(飞书转纯文本、Telegram 转 HTML、Slack 转 mrkdwn 等)
+- 无需手动处理格式差异
+
+**多账号支持:**
+
+- 配置值中用 `;` 分隔多个 URL/Token 即可发送到多个账号
+- 例如:`FEISHU_WEBHOOK_URL=url1;url2` 会同时发送到两个飞书群
+
+---
+
 ## 💡 使用技巧
 
 ### 1. 如何让 AI 展示全部数据而不是自动总结?

+ 112 - 91
README.md

@@ -12,8 +12,8 @@
 [![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-v5.5.3-blue.svg)](https://github.com/sansan0/TrendRadar)
-[![MCP](https://img.shields.io/badge/MCP-v3.2.0-green.svg)](https://github.com/sansan0/TrendRadar)
+[![Version](https://img.shields.io/badge/version-v6.0.0-blue.svg)](https://github.com/sansan0/TrendRadar)
+[![MCP](https://img.shields.io/badge/MCP-v4.0.0-green.svg)](https://github.com/sansan0/TrendRadar)
 [![RSS](https://img.shields.io/badge/RSS-订阅源支持-orange.svg?style=flat-square&logo=rss&logoColor=white)](https://github.com/sansan0/TrendRadar)
 [![AI翻译](https://img.shields.io/badge/AI-多语言推送-purple.svg?style=flat-square)](https://github.com/sansan0/TrendRadar)
 
@@ -186,27 +186,18 @@
 <div align="center">
 
 > **虚位以待**
->
-> 寻找靠谱的产品赞助。   
-> 比起功能,我更看重**你对待用户的态度**。  
-> 请附带你的用户群或反馈渠道,让我看到你是如何帮助用户解决问题的。  
-> [📩 点击联系](mailto:path@linux.do) 
 
 </div>
 
 <br>
 
 <a name="-支持项目"></a>
-## ✨ 觉得好用?支持一下
-
-TrendRadar 是完全开源免费的项目,持续更新需要你的动力支持。
 
-### ❤️ 随心赞赏
+### ❤️ 觉得好用?支持一下
 
-一瓶水、一包辣条都是爱。
-金额随意,1 元也是一份心意。
-
-> 你的赞助将用于补充碳基生物的咖啡因 ☕️ 和硅基生物的 API Token 消耗 🤖。
+> 若 TrendRadar 曾为你捕捉价值,不妨为它注入动力,助其持续进化
+>
+> 金额随意,1 元也是对开源的鼓励。欢迎在赞赏时备注留言 (´▽`ʃ♡ƪ)
 
 <div align="center">
 
@@ -216,29 +207,21 @@ TrendRadar 是完全开源免费的项目,持续更新需要你的动力支持
 
 </div>
 
-### 🌟 其他支持方式
-
-1. **点亮 Star** ⭐️:动动手指只需 **1 秒**,让更多人看到这个项目,这是对我最大的认可。
-2. **公益助学** 🌻:微信搜索**腾讯公益**,支持**助学**项目。将这份善意传递给需要的人。
 
 ### 🤝 二次开发与引用
 
 如果你在项目中使用或借鉴了本项目的思路、核心代码,**非常欢迎**在 README 或文档中注明来源并附上本仓库链接。
 
-> [https://github.com/sansan0/TrendRadar](https://github.com/sansan0/TrendRadar)
-
 这将有助于项目的持续维护和社区发展,感谢你的尊重与支持!❤️
 
----
 
 ### 💬 交流与反馈
 
 - **GitHub Issues**:适合具体的技术问题。提问时请提供完整信息(截图、错误日志等),有助于快速定位。
 - **公众号交流**:建议优先在相关文章下的留言区交流。若需后台提问,**先点赞/推荐**文章是最好的“敲门砖”,我在后台都能感受到这份心意哟 (´▽`ʃ♡ƪ)。
 
-> **友情提示**:   
-> 本项目为开源分享,非商业产品。文档倾注了大量心血,绝大多数部署问题都能在 [**🚀 快速开始**](#-快速开始) 中找到答案。   
-> *提问请保持耐心与礼貌,把作者当朋友而非客服,沟通效率会更高哦!*  
+> **友情提示**:        
+> 本项目为开源分享,非商业产品。把作者当朋友而非客服,沟通效率会更高哦!     
 
 <div align="center">
 
@@ -256,6 +239,45 @@ TrendRadar 是完全开源免费的项目,持续更新需要你的动力支持
 - **提示**:建议查看【历史更新】,明确具体的【功能内容】
 
 
+### 2026/02/09 - v6.0.0
+
+> **Breaking Change**:配置文件升级(config.yaml 2.0.0),旧版 `push_window` 和 `analysis_window` 配置不再兼容,请参考新版 config.yaml 迁移
+
+- **统一调度系统**:新增 `timeline.yaml`,用一套配置控制「什么时间采集 / 推送 / AI 分析」
+- **5 种预设模板**:`always_on`(全天候,默认)、`morning_evening`(早晚汇总)、`office_hours`(办公时间)、`night_owl`(夜猫子)、`custom`(自定义);也支持在 `presets:` 下新增自己的模板,只要 key 不重复,然后在 config.yaml 里填你的模板名即可
+- **灵活的时间段配置**:支持工作日/周末差异化、跨午夜时间段、per-period once 去重
+- **可视化配置编辑器**:
+  - 新增 `timeline.yaml` 编辑标签页,与 config.yaml / frequency_words.txt 并列
+  - 预设模式卡片选择:点击即切换,自动同步 config.yaml 的 `schedule.preset`
+  - 周视图时间线:7 天 × 24 小时水平条,用颜色区分推送/分析/采集状态
+  - 可交互控件:开关、下拉框、时间选择器,右侧修改实时同步到左侧 YAML
+  - 周映射下拉选择:根据日计划动态填充,拖拉点击即可完成调度配置
+- **AI 提示词稳定性优化**(ai_analysis_prompt.txt v2.0.0):
+  - 格式规范独立说明:将换行/标签/序号/禁止事项从 JSON value 中抽出,作为独立章节
+  - JSON 模板简化:字段描述缩短为一句话 + 字数限制,减少 AI 输出格式混乱
+  - 去除 system prompt 中的 Markdown 格式,与"禁止 Markdown"指令保持一致
+  - 所有 JSON 字段声明为可选,缺少任何字段不会报错,增强容错性
+- **新增独立展示区 AI 概括分析**(`ai_analysis.include_standalone`):
+  - 新增独立开关,开启后 AI 对每个 standalone 源生成核心概括
+  - AI 分析与推送展示解耦:无需开启独立展示区的推送显示,AI 也可独立分析完整热榜数据
+  - 支持热榜平台和 RSS 源,含排名/时间/轨迹数据
+  - 轨迹分析与 `include_rank_timeline` 联动:开启时利用轨迹数据做深度趋势分析,关闭时基于排名做简要判断
+  - 新增 `standalone_summaries` JSON 字段(独立源点速览),所有推送渠道均已适配渲染
+
+
+### 2026/02/09 - mcp-v4.0.0
+
+- **🔥 AI 消息直推所有渠道**:让 AI 写好的内容一键推送到飞书、钉钉、Telegram、邮件等 9 个渠道,Markdown 自动适配各平台格式,不用操心格式差异
+- **新增格式化策略指南**:新增 `get_channel_format_guide` 工具,告诉 AI 每个渠道支持什么格式、有什么限制,生成的内容排版更好看
+- **智能分批发送**:超长消息自动按各渠道字节限制拆分(飞书 30KB、钉钉 20KB 等),配置读取自 config.yaml
+- **修复渠道误检测**:ntfy 不再因为默认地址被误报为"已配置"
+- **代码复用优化**:批次处理函数直接复用 trendradar 核心模块,不重复造轮子
+
+
+<details>
+<summary>👉 点击展开:<strong>历史更新</strong></summary>
+
+
 ### 2026/01/28 - v5.5.0
 
 > 和 mcp 功能一样, 这个小工具我也不新开一个仓库维护了, 反正纯前端, 都搁一起吧
@@ -271,11 +293,6 @@ TrendRadar 是完全开源免费的项目,持续更新需要你的动力支持
 - **文档更新**:README-MCP-FAQ.md 和 README-MCP-FAQ-EN.md 新增 Q19-Q20 文章读取相关说明
 
 
-
-<details>
-<summary>👉 点击展开:<strong>历史更新</strong></summary>
-
-
 ### 2026/01/10 - mcp-v3.0.0~v3.1.5
 
 - **Breaking Change**:所有工具返回值统一为 `{success, summary, data, error}` 结构
@@ -469,9 +486,9 @@ TrendRadar 是完全开源免费的项目,持续更新需要你的动力支持
    - 使用分号 `;` 分隔多个账号,例如:`FEISHU_WEBHOOK_URL=url1;url2`
    - 自动验证配对配置(如 Telegram 的 token 和 chat_id)数量一致性
 
-2. **推送内容顺序可配置**
-   - 新增 `reverse_content_order` 配置项
-   - 支持自定义热点词汇统计与新增热点新闻的显示顺序
+2. **推送区域配置**
+   - 通过 `display.region_order` 自定义各区域的显示顺序(v5.2.0 替代原 `reverse_content_order`)
+   - 通过 `display.regions` 控制各区域是否显示(热榜、新增热点、RSS、独立展示区、AI 分析)
 
 3. **全局过滤关键词**
    - 新增 `[GLOBAL_FILTER]` 区域标记,支持全局过滤不想看到的内容
@@ -946,6 +963,14 @@ frequency_words.txt 文件增加了一个【必须词】功能,使用 + 号
 
 > 💡 RSS 使用与热榜相同的 `frequency_words.txt` 进行关键词过滤
 
+### **可视化配置编辑器**
+
+提供基于 Web 的图形化配置界面,无需手动编辑 YAML 文件,通过表单即可完成所有配置项的修改与导出。
+
+👉 **在线体验**:[https://sansan0.github.io/TrendRadar/](https://sansan0.github.io/TrendRadar/)
+
+<img src="/_image/editor.png" alt="可视化配置编辑器" width="80%">
+
 ### **智能推送策略**
 
 **三种推送模式**:
@@ -967,8 +992,8 @@ frequency_words.txt 文件增加了一个【必须词】功能,使用 + 号
 
 | 功能 | 说明 | 默认 |
 |------|------|------|
-| **推送时间窗口控制** | 设定推送时间范围(如 09:00-18:00),避免非工作时间打扰 | 关闭 |
-| **内容顺序配置** | 调整"热点词汇统计"和"新增热点新闻"的显示顺序(v3.5.0 新增) | 统计在前 |
+| **调度系统** | 按周一到周日逐日编排:为每天分配不同时间段、推送模式和 AI 分析策略。内置 5 种预设(always_on / morning_evening / office_hours / night_owl / custom),也可自定义。支持工作日/周末差异化、跨午夜时段、per-period 去重(v6.0.0) | morning_evening |
+| **内容顺序配置** | 通过 `display.region_order` 调整各区域(热榜、新增热点、RSS、独立展示区、AI 分析)的显示顺序;通过 `display.regions` 控制各区域是否显示(v5.2.0) | 见配置文件 |
 | **显示模式切换** | `keyword`=按关键词分组,`platform`=按平台分组(v4.6.0 新增) | keyword |
 
 > 💡 详细配置教程见 [推送内容怎么显示?](#7-推送内容怎么显示) 和 [什么时候给我推送?](#8-什么时候给我推送)
@@ -1064,6 +1089,7 @@ ai_translation:
 
 - **完整热榜**:指定平台的热榜完整展示,适合想看完整排名的用户
 - **RSS 独立展示**:RSS 源内容可完整展示,不受关键词限制
+- **AI 深度分析**:可独立开启 AI 对完整热榜的趋势分析,无需在推送中展示
 - **灵活配置**:支持配置展示平台、RSS 源、最大条数
 
 > 💡 详细配置教程见 [推送内容怎么显示? - 独立展示区](#7-推送内容怎么显示)
@@ -3017,6 +3043,28 @@ display:
 1. 在 `region_order` 列表中
 2. 在 `display.regions` 中对应开关为 `true`
 
+#### 区域开关(regions)
+
+通过 `display.regions` 控制各区域是否在推送中显示:
+
+```yaml
+display:
+  regions:
+    hotlist: true                    # 热榜区域(关键词匹配的热点新闻)
+    new_items: false                 # 新增热点区域(含热榜新增 + RSS 新增)
+    rss: true                       # RSS 订阅区域(关键词匹配的 RSS 内容)
+    standalone: false                # 独立展示区(完整热榜/RSS,不受关键词过滤)
+    ai_analysis: true                # AI 分析区域
+```
+
+| 区域 | 配置键 | 默认值 | 说明 |
+|------|--------|-------|------|
+| **热榜** | `hotlist` | `true` | 按关键词匹配的热点新闻聚合 |
+| **新增热点** | `new_items` | `false` | 本轮新出现的热点话题(含热榜新增 + RSS 新增)。注:热榜区域中的 🆕 标记不受此开关影响 |
+| **RSS** | `rss` | `true` | 按关键词匹配的 RSS 订阅内容。关闭后跳过 RSS 分析,但独立展示区中的 RSS 不受影响 |
+| **独立展示区** | `standalone` | `false` | 指定平台/RSS 的完整内容展示,不受关键词过滤 |
+| **AI 分析** | `ai_analysis` | `true` | AI 生成的热点分析摘要 |
+
 #### 排序优先级(sort_by_position_first)
 
 假设你配置了关键词:1.特斯拉,2.比亚迪。
@@ -3034,7 +3082,7 @@ display:
 ```yaml
 display:
   regions:
-    standalone: true                  # 开启这个“特权区域”
+    standalone: true                  # 推送中展示独立展示区(关闭不影响 AI 分析)
 
   standalone:
     platforms: ["zhihu", "weibo"]     # 这些平台的热榜给我完整显示
@@ -3042,82 +3090,55 @@ display:
     max_items: 20                     # 最多显示多少条
 ```
 
+> 💡 **推送展示与 AI 分析独立控制**:`regions.standalone` 只控制推送中是否显示独立展示区。即使关闭推送展示,只要在 AI 配置中开启 `include_standalone: true`,AI 仍会分析这些平台的完整数据。适合想让 AI 做深度分析、但不想推送消息太长的用户。
+
 </details>
 
 ### 8. 什么时候给我推送?
 
 <details>
-<summary>👉 点击展开:<strong>设置推送时间</strong></summary>
+<summary>👉 点击展开:<strong>设置推送时间(调度系统)</strong></summary>
 <br>
 
-**配置位置:** `config/config.yaml` 的 `notification.push_window` 部分
+**配置位置:** `config/config.yaml` 的 `schedule` 部分 + `config/timeline.yaml`
+
+#### 快速上手
+
+只需在 `config.yaml` 中选一个预设模板,不需要编辑 `timeline.yaml`:
 
 ```yaml
-notification:
-  push_window:
-    enabled: false                    # 是否启用
-    start: "20:00"                    # 开始时间(北京时间)
-    end: "22:00"                      # 结束时间(北京时间)
-    once_per_day: true                # 每天只推送一次
+schedule:
+  enabled: true
+  preset: "morning_evening"     # 改这里就行
 ```
 
-#### 配置项详解
+#### 可选预设模板
 
-| 配置项 | 类型 | 默认值 | 说明 |
-|-------|------|-------|------|
-| `enabled` | bool | `false` | 是否启用推送时间窗口控制 |
-| `start` | string | `"20:00"` | 推送时间窗口开始时间(北京时间,HH:MM 格式) |
-| `end` | string | `"22:00"` | 推送时间窗口结束时间(北京时间,HH:MM 格式) |
-| `once_per_day` | bool | `true` | `true`=每天在窗口内只推送一次,`false`=窗口内每次执行都推送 |
+| 模板名 | 说明 | 推送行为 |
+|-------|------|---------|
+| `morning_evening` | 全天增量 + 晚间汇总(推荐) | 全天有新增就推 + 19:00-21:00 晚间当日汇总 |
+| `always_on` | 全天候监控 | 全天有新增就推送,不划分时间段 |
+| `office_hours` | 办公时间 | 工作日三段式(到岗速览→午间热点→收工汇总),周末增量自由推 |
+| `night_owl` | 夜猫子 | 午后速览 + 深夜全天汇总(22:00-01:00 跨午夜) |
+| `custom` | 完全自定义 | 编辑 `timeline.yaml` 底部的 custom 段 |
 
-#### 使用场景
+#### 完全自定义
 
-| 场景 | 配置示例 |
-|------|---------|
-| **工作时间推送** | `start: "09:00"`, `end: "18:00"`, `once_per_day: false` |
-| **晚间汇总推送** | `start: "20:00"`, `end: "22:00"`, `once_per_day: true` |
-| **午休时间推送** | `start: "12:00"`, `end: "13:00"`, `once_per_day: true` |
+如果预设模板都不满足需求,可以编辑 `config/timeline.yaml` 底部的 `custom` 段,自由定义时间段、日计划和周映射。详见 `timeline.yaml` 文件内的注释说明。
 
 #### 重要提示
 
+> ⚠️ **从旧版本升级的用户注意:**
+> - v6.0.0 移除了旧的 `notification.push_window` 和 `ai_analysis.analysis_window` 配置
+> - 请改用新的 `schedule` + `timeline.yaml` 调度系统
+> - 旧的"每天推送一次"可用 `morning_evening` 预设替代
+> - 旧的"工作时间推送"可用 `office_hours` 预设替代
+
 > ⚠️ **GitHub Actions 用户注意:**
 > - GitHub Actions 执行时间不稳定,可能有 ±15 分钟的偏差
-> - 时间范围建议至少留足 **2 小时**
+> - 时间范围建议至少留足 **2 小时**
 > - 如果想要精准的定时推送,建议使用 **Docker 部署**在个人服务器上
 
-#### Docker 环境变量
-
-```bash
-PUSH_WINDOW_ENABLED=true
-PUSH_WINDOW_START=09:00
-PUSH_WINDOW_END=18:00
-PUSH_WINDOW_ONCE_PER_DAY=false
-```
-
-#### 完整配置示例
-
-**场景:每天晚上 8-10 点只推送一次汇总**
-
-```yaml
-notification:
-  push_window:
-    enabled: true
-    start: "20:00"
-    end: "22:00"
-    once_per_day: true
-```
-
-**场景:工作时间内每小时推送**
-
-```yaml
-notification:
-  push_window:
-    enabled: true
-    start: "09:00"
-    end: "18:00"
-    once_per_day: false
-```
-
 </details>
 
 ### 9. 多久运行一次?

BIN
_image/editor.png


+ 136 - 95
config/ai_analysis_prompt.txt

@@ -1,108 +1,147 @@
 # ═══════════════════════════════════════════════════════════════
 #                    TrendRadar AI 分析提示词配置
-#                      Version: 1.0.0
+#                      Version: 2.0.0
 # ═══════════════════════════════════════════════════════════════
 #
 # 此文件定义 AI 分析热点新闻时使用的提示词模板
 #
 # 可用变量(在分析时会被替换):
-#   {language}        - 输出语言 (由 ai_analysis.language 配置)
-#   {report_mode}     - 当前报告模式
-#   {report_type}     - 报告类型描述
-#   {current_time}    - 当前时间
-#   {news_count}      - 热榜新闻条数
-#   {rss_count}       - RSS 新闻条数
-#   {keywords}        - 匹配的关键词列表
-#   {platforms}       - 数据来源平台列表
-#   {news_content}    - 热榜新闻内容
-#   {rss_content}     - RSS 订阅内容 (需开启 ai_analysis.include_rss)
+#   {language}            - 输出语言 (由 ai_analysis.language 配置)
+#   {report_mode}         - 当前报告模式
+#   {report_type}         - 报告类型描述
+#   {current_time}        - 当前时间
+#   {news_count}          - 热榜新闻条数
+#   {rss_count}           - RSS 新闻条数
+#   {keywords}            - 匹配的关键词列表
+#   {platforms}           - 数据来源平台列表
+#   {news_content}        - 热榜新闻内容
+#   {rss_content}         - RSS 订阅内容 (需开启 ai_analysis.include_rss)
+#   {standalone_content}  - 独立展示区数据 (需开启 ai_analysis.include_standalone)
 #
 # ═══════════════════════════════════════════════════════════════
 
 [system]
-你是一名**高级情报分析师**。你的核心能力是从海量、碎片化的公开来源情报(OSINT)中提炼核心逻辑,并识别被大众忽略的**弱信号**
+你是一名高级情报分析师。你的核心能力是从海量、碎片化的公开来源情报(OSINT)中提炼核心逻辑,并识别被大众忽略的弱信号。
 
 ## 核心思维模型 (Mental Models)
 
-1. **见微知著 (Signal Detection)**:不要只盯着榜首的大新闻。要善于从"排名第50的冷门技术贴"与"排名第1的热门事件"中找到潜在的因果联系。
-2. **交叉验证 (Triangulation)**:利用"热榜"(大众情绪)与"RSS"(专家视角)的差异。当两者观点冲突时,通常隐藏着认知套利的机会。
-3. **反直觉思考 (Counter-Intuitive)**:当全网都在叫好时,寻找风险;当全网都在恐慌时,寻找机会。拒绝平庸的共识。
-4. **结构化输出 (MECE)**:确保分析维度相互独立且完全穷尽,避免逻辑混乱。
+1. 见微知著 (Signal Detection):不要只盯着榜首的大新闻。要善于从"排名第50的冷门技术贴"与"排名第1的热门事件"中找到潜在的因果联系。
+2. 交叉验证 (Triangulation):利用"热榜"(大众情绪)与"RSS"(专家视角)的差异。当两者观点冲突时,通常隐藏着认知套利的机会。
+3. 反直觉思考 (Counter-Intuitive):当全网都在叫好时,寻找风险;当全网都在恐慌时,寻找机会。拒绝平庸的共识。
+4. 结构化输出 (MECE):确保分析维度相互独立且完全穷尽,避免逻辑混乱。
 
 ## 核心原则
 
-1. **直击要害**:拒绝"综上所述"、"众所周知"等废话。直接输出结论。
-2. **逻辑闭环**:不仅描述"发生了什么",必须解释"为什么发生"以及"未来会怎样"。
-3. **去情绪化**:可以分析舆论的情绪,但你自己的分析必须冷静、客观、冷血。
-4. **辩证思维**:识别热点背后的"主要矛盾"(如技术变革vs既得利益),抓住事物发展的关键内因。
+1. 直击要害:拒绝"综上所述"、"众所周知"等废话。直接输出结论。
+2. 逻辑闭环:不仅描述"发生了什么",必须解释"为什么发生"以及"未来会怎样"。
+3. 去情绪化:可以分析舆论的情绪,但你自己的分析必须冷静、客观、冷血。
+4. 辩证思维:识别热点背后的"主要矛盾"(如技术变革vs既得利益),抓住事物发展的关键内因。
 
 ## 数据字段深度解读指南
 
-为了做出精准判断,请充分利用以下数据维度:
-
 ### 1. 基础维度
-- **来源平台**:每一行新闻开头的 `[平台名称]`(如 `[微博]`、`[知乎]`)明确指出了数据来源。**请务必注意:后续的排名和轨迹数据仅针对该特定平台的榜单**。
-- **排名**:"1"为该平台榜首,数字越小越热。"3-8"表示在该平台排名在第3到第8之间波动。
-- **出现次数**:次数越多,说明在热榜停留时间越长,热度越持久。
-- **时间范围**:如"09:30~12:45",跨度越大说明话题生命力越强。
-
-### 2. 轨迹量化分析 (重要)
-数据格式为 `排名(时间)→排名(时间)...`,例如 `1(09:30)→0(10:00)→2(10:30)`。
-
-**关键定义**:
-- **数值含义**:数字代表排名(1为榜首,数字越小越靠前)。**`0` 特指"未上榜"或"脱榜"**(即该时间点不在榜单中)。
-- **符号含义**:`→` 代表时间推移。
-
-**防幻觉警示(关键)**:
-- **高位横盘 ≠ 急升**:如果轨迹是 `2(10:00)→2(10:30)→2(11:00)`,说明热度**持续稳定**,绝对**不是**"急升"或"爆发"。只有排名数值**显著减小**(如 10→5)才是急升。请务必区分"热度高"和"热度升"。
-
-**请重点分析以下模式**:
-- **急升/爆发**:排名数值在短时间内大幅减小(如 20→3),代表热度飙升,往往意味着突发重大事件。
-- **衰退/僵尸**:排名数值持续变大且无反弹(如 10→15→20),代表热度正在自然衰退。
-- **回榜/反转**:序列中出现 `0` 后又变为高排名(如 5→0→2),代表话题曾脱榜但因新进展"复活",通常暗示有新爆料或剧情反转。
-
-### 3. 跨平台特征 (分级标准)
-- **全网霸屏**:5 个及以上平台同时上榜。真正的“国民级”话题,无死角覆盖。
-- **破圈扩散**:3-4 个平台同时上榜。话题已突破单一社区壁垒,正在向外蔓延。
-- **圈层热点**:仅在 1-2 个平台火爆。属于特定人群的狂欢。
-
-**平台调性参考 (Platform DNA)**:
-- **舆论/情绪场**:微博(情绪/吃瓜)、抖音/快手(视觉/传播快)、B站(年轻/玩梗)。
-- **理性/专业场**:知乎(深度/批判)、雪球(投资/财经)、IT之家/36氪(科技/商业)。
-- **资讯/分发场**:今日头条(社会/民生)、百度热搜(综合/搜索量)。
-*分析"平台温差"时,请结合平台调性。例如:某话题在微博火但在知乎冷,可能说明该话题"情绪价值大于逻辑价值"或"缺乏深度讨论点"。*
-
-## 分析板块说明 (5个核心板块)
-
-1. 核心热点态势 (Core Trends & Momentum)
-   - 整合:"趋势概述"、"热度走势"、"跨平台关联"。
-   - 任务:**提炼共性与定性**。不仅要识别最火话题,更要尝试寻找不同新闻背后的**底层逻辑或共性叙事**(如:多条看似无关的新闻共同指向"经济复苏乏力"或"AI应用落地"的大趋势)。
-   - 重点:判断热度性质(全网霸屏vs圈层自嗨)以及话题间的潜在关联。
-   - 写法:拒绝流水账。用"宏观主线+微观佐证"的结构,将散点信息串联成逻辑链条。
-
-2. 舆论风向争议 (Sentiment & Controversy)
-   - 任务:**绘制情绪光谱**。拒绝简单的"褒/贬"二元对立。要识别"舆论断层"(如:专家担忧风险而大众狂欢,或媒体冷处理而民间热议)。
-   - 核心:寻找**观点冲突点**。哪里有争吵,哪里就有价值。识别是"利益之争"(钱包问题)还是"认知之争"(观念问题)。
-
-3. 异动与弱信号 (Signals)
-   - 任务:捕捉**时间轴(轨迹)**和**空间轴(跨平台)**上的异常波动。拒绝平铺直叙的单点罗列。
-   - 关注:
-     - **跨平台共振**:某话题在A平台爆发后,是否迅速引发B平台关注?(对应"破圈扩散")。
-     - **平台温差**:某话题在微博霸榜但在知乎无人问津(对应"圈层热点")。
-     - **轨迹突变**:排名骤升(急升)、死而不僵(僵尸)、反转复活(回榜)。
-
-4. RSS 深度洞察 (RSS Insights)
-   - 任务:**寻找信息增量**。RSS 源通常比大众热榜更垂直、更专业。
-   - 策略:
-     - **去重**:果断忽略与热榜大众新闻高度雷同的内容。
-     - **互补**:挖掘热榜未覆盖的**硬核细节**(如技术参数、深度行研)或**长尾话题**。
-     - **前瞻**:识别可能尚未引爆但极具价值的早期行业信号。
-
-5. 研判策略建议 (Outlook & Strategy)
-   - 任务:**预测与推演**。不仅总结过去,更要预测未来。
-   - 核心:
-     - **后续推演**:预测事件的下一阶段(如:是否会反转?监管是否介入?热度是否可持续?)。
-     - **行动指南**:给出具体、有针对性的建议。**严禁使用"建议持续关注"等无意义的正确的废话**。
+- 来源平台:每一行新闻开头的 [平台名称](如 [微博]、[知乎])明确指出了数据来源。请务必注意:后续的排名和轨迹数据仅针对该特定平台的榜单。
+- 排名:"1"为该平台榜首,数字越小越热。"3-8"表示在该平台排名在第3到第8之间波动。
+- 出现次数:次数越多,说明在热榜停留时间越长,热度越持久。
+- 时间范围:如"09:30~12:45",跨度越大说明话题生命力越强。
+
+### 2. 轨迹量化分析(重要)
+数据格式为 排名(时间)→排名(时间)...,例如 1(09:30)→0(10:00)→2(10:30)。
+
+关键定义:
+- 数值含义:数字代表排名(1为榜首,数字越小越靠前)。0 特指"未上榜"或"脱榜"(即该时间点不在榜单中)。
+- 符号含义:→ 代表时间推移。
+
+防幻觉警示(关键):
+- 高位横盘 ≠ 急升:如果轨迹是 2(10:00)→2(10:30)→2(11:00),说明热度持续稳定,绝对不是"急升"或"爆发"。只有排名数值显著减小(如 10→5)才是急升。请务必区分"热度高"和"热度升"。
+
+请重点分析以下模式:
+- 急升/爆发:排名数值在短时间内大幅减小(如 20→3),代表热度飙升,往往意味着突发重大事件。
+- 衰退/僵尸:排名数值持续变大且无反弹(如 10→15→20),代表热度正在自然衰退。
+- 回榜/反转:序列中出现 0 后又变为高排名(如 5→0→2),代表话题曾脱榜但因新进展"复活",通常暗示有新爆料或剧情反转。
+
+### 3. 跨平台特征(分级标准)
+- 全网霸屏:5个及以上平台同时上榜。真正的"国民级"话题,无死角覆盖。
+- 破圈扩散:3-4个平台同时上榜。话题已突破单一社区壁垒,正在向外蔓延。
+- 圈层热点:仅在1-2个平台火爆。属于特定人群的狂欢。
+
+平台调性参考 (Platform DNA):
+- 舆论/情绪场:微博(情绪/吃瓜)、抖音/快手(视觉/传播快)、B站(年轻/玩梗)
+- 理性/专业场:知乎(深度/批判)、雪球(投资/财经)、IT之家/36氪(科技/商业)
+- 资讯/分发场:今日头条(社会/民生)、百度热搜(综合/搜索量)
+
+分析"平台温差"时,请结合平台调性。例如:某话题在微博火但在知乎冷,可能说明该话题"情绪价值大于逻辑价值"或"缺乏深度讨论点"。
+
+## 输出格式规范(严格遵守)
+
+你将以 JSON 格式输出分析结果。每个字段的值是纯文本字符串。
+
+换行规则:
+- 用 \n 表示换行(JSON 字符串内标准换行符)
+- 段落之间用 \n\n 分隔
+
+结构标签规则(【】仅用于分段):
+- 【】仅用于板块内的结构性分段标签,如【宏观主线】、【跨平台共振】
+- 标签后只跟冒号或直接换行(×【宏观主线】两大叙事交织:→ ○【宏观主线】:)
+- 标签前用 \n 与前段分隔
+- 【】内只允许固定的分段名称,禁止放入话题名、新闻标题等动态内容
+- 同一标签下仅有1条内容时不加序号,2条及以上才使用序号
+
+话题引用规则(「」用于行内引用):
+- 提及具体话题、新闻标题、事件名称时,使用「」角引号(×【黄仁勋暴论】→ ○「黄仁勋暴论」)
+- 「」是行内标记,不触发换行,不加冒号
+
+序号规则:
+- 列举时用 1. 2. 3. 数字序号
+- 每个序号独占一行(前面用 \n 换行)
+- 序号行内禁止使用【】标签
+
+绝对禁止:
+- 禁止使用 Markdown(如 **加粗**、## 标题、- 列表)
+- 禁止使用 emoji 或特殊装饰符号
+
+## 分析板块说明(6个板块)
+
+### 1. core_trends — 核心热点态势(200字以内)
+整合"趋势概述"、"热度走势"、"跨平台关联"。
+任务:提炼共性与定性。不仅要识别最火话题,更要尝试寻找不同新闻背后的底层逻辑或共性叙事(如:多条看似无关的新闻共同指向"经济复苏乏力"或"AI应用落地"的大趋势)。
+重点:判断热度性质(全网霸屏vs圈层自嗨)以及话题间的潜在关联。
+写法:拒绝流水账。用"宏观主线+微观佐证"的结构,将散点信息串联成逻辑链条。一句话开场定性(必须使用"全网霸屏"/"破圈扩散"/"圈层热点"等词汇),然后用【宏观主线】挖掘底层逻辑,【微观领域】用序号列举细分点。
+
+### 2. sentiment_controversy — 舆论风向争议(100字以内)
+任务:绘制情绪光谱。拒绝简单的"褒/贬"二元对立。要识别"舆论断层"(如:专家担忧风险而大众狂欢,或媒体冷处理而民间热议)。
+核心:寻找观点冲突点。哪里有争吵,哪里就有价值。识别是"利益之争"(钱包问题)还是"认知之争"(观念问题)。
+写法:【情绪光谱】识别"主流声音"与"潜流暗涌"的反差,【核心矛盾】用序号列举冲突点。
+
+### 3. signals — 异动与弱信号(150字以内)
+任务:捕捉时间轴(轨迹)和空间轴(跨平台)上的异常波动。拒绝平铺直叙的单点罗列。
+关注维度:
+- 跨平台共振:某话题在A平台爆发后,是否迅速引发B平台关注?(对应"破圈扩散")
+- 平台温差:某话题在微博霸榜但在知乎无人问津(对应"圈层热点")
+- 轨迹突变:排名骤升(急升)、死而不僵(僵尸)、反转复活(回榜)
+写法:必须结合跨平台特征分析,拒绝只列举单个平台的涨跌。用【标签】分段(不用序号),从【跨平台共振/温差】【轨迹突变】【弱信号捕捉】等维度至少覆盖2点。
+
+### 4. rss_insights — RSS深度洞察(100字以内)
+任务:寻找信息增量。RSS 源通常比大众热榜更垂直、更专业。
+策略:
+- 去重:果断忽略与热榜大众新闻高度雷同的内容
+- 互补:挖掘热榜未覆盖的硬核细节(如技术参数、深度行研)或长尾话题
+- 前瞻:识别可能尚未引爆但极具价值的早期行业信号
+写法:【认知纠偏】专业视角如何修正大众热搜的误区或盲目,【硬核增量】补充热榜缺失的关键技术参数、行业内幕或深度数据。无RSS数据时填"暂无RSS数据"。
+
+### 5. outlook_strategy — 研判策略建议
+任务:预测与推演。不仅总结过去,更要预测未来。
+核心:
+- 后续推演:预测事件的下一阶段(如:是否会反转?监管是否介入?热度是否可持续?)
+- 行动指南:给出具体、有针对性的建议。严禁使用"建议持续关注"等无意义的正确的废话。
+写法:格式为 1. 投资者:xxx 2. 品牌方:xxx 3. 公众:xxx,序号后直接跟角色名加冒号,不使用【】标签。
+
+### 6. standalone_summaries — 独立展示区概括(每源100字以内)
+仅当数据中包含独立展示区数据时返回。对象类型,key 为数据中每个源的 ### 标题方括号内的名称,value 为 100 字以内的概括。有几个源就写几个 key。
+核心原则:去重补盲 + 轨迹洞察。
+1. 去重:果断忽略前5板块已充分分析的话题,优先提取前5板块未覆盖的独有内容。若某话题虽在前5板块提及但在该平台有独特表现(如排名走势截然不同),可简要补充差异点。
+2. 轨迹洞察:若数据中包含轨迹信息,按上述"### 2. 轨迹量化分析"的规则解读排名走势,识别该平台的急升/衰退/回榜等趋势特征。若数据中无轨迹信息,则基于排名和出现次数做简要判断即可。
+写法:先用一句话点明该平台当前的整体趋势动向(基于轨迹数据判断),再列举前5板块未提及的重要话题(附带排名走势)。示例:"西藏感悟话题从第12急升至榜首,关注度爆发;此外白银交割战争预判(排名11稳定)、老君山45万年终奖(3→7缓降)值得留意"。禁止空泛总结。
 
 [user]
 请分析以下热点新闻数据:
@@ -122,25 +161,27 @@
 ## RSS 订阅
 {rss_content}
 
+## 独立展示区
+以下为独立展示的完整热榜/RSS 数据(不受关键词过滤),请按板块说明中 standalone_summaries 的要求处理。
+{standalone_content}
+
 ---
 
-请基于上述数据撰写分析报告,以 JSON 格式返回结果:
+请基于上述数据撰写分析报告。以 JSON 格式返回,所有字段均为可选(缺少任何字段不会报错)
 
 ```json
 {
-  "core_trends": "核心热点态势(200字以内)。**任务:提炼共性叙事而非简单罗列**。语言要像'大白话'一样通俗,但要像'手术刀'一样精准。格式:\n(一句话开场白,必须使用'全网霸屏'/'破圈扩散'/'圈层热点'等词汇对整体热度定性)\n\n【宏观主线】:\n(挖掘多条新闻背后的底层逻辑或共性,如:'AI应用落地引发的资本狂欢')\n\n【微观领域】:\n1. (细分点1):...\n2. (细分点2):...",
-  "sentiment_controversy": "舆论风向争议(100字以内)。**拒绝和稀泥,直击情绪的'温差'与'断层'**。格式:\n【情绪光谱】:\n(识别'主流声音'与'潜流暗涌'的反差,如:'媒体高歌猛进,民间担忧焦虑')\n\n【核心矛盾】:\n1. (利益/观念冲突):(如:'打工人与资本家的利益对立')\n2. (事实/认知分歧):...",
-  "signals": "异动与弱信号(150字以内)。**必须结合跨平台特征分析,拒绝只列举单个平台的涨跌**。**请勿使用 1. 2. 3. 序号,直接使用【标签】分段**。按以下维度分析(至少覆盖2点):\n【跨平台共振/温差】:(如:'某话题实现全网霸屏,但在技术社区遇冷')\n【轨迹突变】:(如:'某话题在下午16点呈现急升/爆发态势,排名从20直冲第1')\n【弱信号捕捉】:(如:'某小众话题在多平台低位隐现,有起势征兆')",
-  "rss_insights": "RSS 深度洞察(100字以内,无RSS数据时填'暂无RSS数据')。**核心任务:寻找'热榜盲区'**。格式:\n【认知纠偏】:\n(专业视角如何修正大众热搜的误区或盲目)\n\n【硬核增量】:\n(热榜缺失的关键技术参数、行业内幕或深度数据)",
-  "outlook_strategy": "研判策略建议。**拒绝'建议持续关注'等废话,基于'推演'给出行动指南**。格式:\n1. 投资者:(风口捕捉或风险预警)\n2. 品牌方:(流量借势或公关避坑)\n3. 公众:(认知纠偏或生活决策)"
+  "core_trends": "(按上述板块说明写法输出)",
+  "sentiment_controversy": "(按上述板块说明写法输出)",
+  "signals": "(按上述板块说明写法输出)",
+  "rss_insights": "(按上述板块说明写法输出)",
+  "outlook_strategy": "(按上述板块说明写法输出)",
+  "standalone_summaries": {"知乎": "100字概括,优先列前5板块未提及的话题及排名走势", "Hacker News": "100字概括..."}
 }
 ```
 
 要求:
-- 必须返回有效的 JSON 格式
-- 返回内容中不要使用 Markdown 格式(如 **加粗**),仅使用纯文本
+- 必须返回有效的 JSON,用 ```json 代码块包裹
 - 使用 {language} 输出,语言简练专业
-- 确保 5 个板块不重叠,信息不冗余
+- 6个板块内容不重叠不冗余
 - 若某板块无明显内容,可简写"暂无显著异常"
-- 使用 `【大标题】` 时,不要前面加序号
-- 使用 `1. 序号` 时,行内绝对禁止再使用 `【】` 方括号

+ 2 - 1
config/ai_translation_prompt.txt

@@ -1,6 +1,6 @@
 # ═══════════════════════════════════════════════════════════════
 #                    TrendRadar AI 翻译提示词配置
-#                      Version: 1.0.0
+#                      Version: 1.1.0
 # ═══════════════════════════════════════════════════════════════
 #
 # 此文件定义 AI 翻译内容时使用的提示词模板
@@ -19,6 +19,7 @@
 2. 保持新闻标题的吸引力,但不要做标题党。
 3. 专有名词(人名、地名、机构名)若有通用译名请使用通用译名,否则保留原文或在括号内备注。
 4. 输出格式必须严格遵循要求,不要输出任何多余的解释性文字。
+5. 若标题文本的主要语言与 {target_language} 一致,则视为无需翻译内容,必须逐字输出原始标题,不得进行改写、优化或格式调整。
 
 [user]
 请将以下内容翻译成 {target_language}:

+ 56 - 57
config/config.yaml

@@ -1,6 +1,6 @@
 # ═══════════════════════════════════════════════════════════════
 #                    TrendRadar 配置文件
-#                      Version: 1.2.0
+#                      Version: 2.0.0
 # ═══════════════════════════════════════════════════════════════
 
 
@@ -11,7 +11,7 @@
 # 1. 基础设置
 # ===============================================================
 app:
-  # 时区配置(影响所有时间显示、推送窗口判断、数据存储)
+  # 时区配置(影响所有时间显示、调度系统判断、数据存储)
   # 常用时区:
   #   - Asia/Shanghai (北京时间 UTC+8)
   #   - America/New_York (美东时间 UTC-5/-4)
@@ -21,6 +21,29 @@ app:
   show_version_update: true           # 显示版本更新提示
 
 
+# ===============================================================
+# 1.5 调度系统 —— 什么时间做什么事
+#
+# 通过 timeline.yaml 里定义的时间段来自动决定:
+#   - 什么时候推送通知
+#   - 什么时候做 AI 分析
+#   - 用什么报告模式
+#
+# 快速上手:选一个预设模板,改 preset 的值就行
+#
+#   always_on       → 全天候,有新增即推送
+#   morning_evening → 全天推送 + 晚间当日汇总(推荐)
+#   office_hours    → 工作日三段式(到岗→午间→收工),周末增量自由推
+#   night_owl       → 午后速览 + 深夜全天汇总
+#   custom          → 完全自定义,详见 timeline.yaml
+#
+# 详细时间线图请查看 config/timeline.yaml
+# ===============================================================
+schedule:
+  enabled: true                         # 是否启用调度系统
+  preset: "morning_evening"             # 预设模板名称(见上方说明)
+
+
 # ===============================================================
 # 2. 数据源 - 热榜平台
 #
@@ -133,6 +156,9 @@ rss:
 # ===============================================================
 report:
   mode: "current"                     # 可选: daily | current | incremental
+                                      # ⚠️ 开启调度系统后,此值会被当前时间段的 report_mode 覆盖
+
+
   display_mode: "keyword"             # 分组维度: keyword | platform
                                       # keyword: 按关键词分组显示(默认)
                                       # platform: 按平台/来源分组显示
@@ -175,7 +201,7 @@ display:
   # 控制各区域是否启用(配合 region_order 使用)
   regions:
     hotlist: true                     # 热榜区域(关键词匹配的热点新闻)
-    new_items: true                   # 新增热点区域(含热榜新增 + RSS 新增)
+    new_items: false                   # 新增热点区域(含热榜新增 + RSS 新增)
                                       # 注:热点词汇统计中的新增标记🆕不受此配置影响
 
     rss: true                         # RSS 订阅区域
@@ -185,14 +211,14 @@ display:
     standalone: false                 # 独立展示区(完整热榜/RSS,不受关键词过滤)
     ai_analysis: true                 # AI 分析区域
 
-  # 📋 独立展示区配置(仅在 regions.standalone: true 时生效)
-  # 用途:将指定平台的完整热榜/RSS 单独展示,不受关键词过滤影响
-  # 适用场景
-  #   - 想完整查看某个平台的热榜排名
-  #   - RSS 源内容较少,希望全部展示而非只显示关键词匹配的
-  # 注意:同一新闻可能同时出现在关键词匹配区和独立展示区
+  # 📋 独立展示区配置
+  # 用途:将指定平台的完整热榜/RSS 数据独立提取,不受关键词过滤影响
+  # 两个独立用途
+  #   - 推送展示:由 regions.standalone 开关控制,在推送中单独展示完整热榜
+  #   - AI 分析:由 ai.include_standalone 开关控制,将完整数据送入 AI 做深度分析
+  # 两者共享此处的平台/RSS 配置,但开关互相独立(可只开 AI 分析、不推送展示)
   standalone:
-    platforms: []                     # 热榜平台 ID 列表(如 ["zhihu", "weibo"])
+    platforms: ["zhihu", "wallstreetcn-hot"]     # 热榜平台 ID 列表(如 ["zhihu", "weibo"])
     rss_feeds: []                     # RSS 源 ID 列表(如 ["hacker-news"])
     max_items: 20                     # 每个源最多展示条数(0=不限制)
 
@@ -218,26 +244,10 @@ display:
 # • 邮箱已支持多收件人(逗号分隔)
 # ===============================================================
 notification:
-  enabled: true                       # 是否启用通知功能
-
-  # 🕐 推送时间窗口控制(可选功能)
-  # 用途:限制推送的时间范围,避免非工作时间打扰
-  # 适用场景:
-  #   • 只想在工作日白天接收推送(如 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"                    # 开始时间(使用 app.timezone 配置的时区)
-    end: "22:00"                      # 结束时间(支持跨日,如 end: "02:00")
-    once_per_day: true                # true=窗口内只推送一次,false=窗口内每次执行都推送
+  enabled: true                       # 是否启用通知功能(总开关)
+                                      # ⚠️ 开启调度系统后,此项仍为总开关:
+                                      #   false → 永远不推送(无论调度怎么设置)
+                                      #   true  → 由调度的 push 字段控制何时推送
 
   # 推送渠道配置
   channels:
@@ -394,26 +404,10 @@ ai:
 # 模型配置见上方 ai 配置段
 # ===============================================================
 ai_analysis:
-  enabled: true                     # 是否启用 AI 分析
-
-  # 🕐 AI 分析时间窗口控制(可选功能)
-  # 用途:限制 AI 分析的时间范围,避免非必要时段消耗 API 额度
-  # 适用场景:
-  #   • 只在工作时间进行 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: "09:00"                  # 开始时间(使用 app.timezone 配置的时区)
-    end: "22:00"                    # 结束时间(支持跨日,如 end: "02:00")
-    once_per_day: false             # true=窗口内只分析一次,false=窗口内每次执行都分析
+  enabled: true                     # 是否启用 AI 分析(总开关)
+                                    # ⚠️ 开启调度系统后,此项仍为总开关:
+                                    #   false → 永远不分析(无论调度怎么设置)
+                                    #   true  → 由调度的 analyze 字段控制何时分析
 
   # 分析报告输出语言
   # 格式:自然语言描述
@@ -437,25 +431,30 @@ ai_analysis:
   mode: "follow_report"
 
   # 分析内容配置
-  max_news_for_analysis: 60         # 参与分析的新闻数量上限(控制成本关键项)
+  max_news_for_analysis: 150        # 热榜+RSS 合计参与分析的新闻数量上限(控制成本关键项)
+                                    # 热榜优先占用配额,RSS 使用剩余配额;独立展示区不受此限制
                                     # 推送消息顶部会显示实际的 AI 分析数供参考
 
                                     # api 成本估算 (仅供参考)
                                       # 按默认模型(deepseek)
-                                      # max_news_for_analysis 为 50 条
-                                      # include_rank_timeline 为 false
+                                      # max_news_for_analysis 为 50
+                                      # include_rank_timeline 为 false
                                     # 则
                                       # GitHub Action 部署默认推送约 20 次(每小时推送一次), 约 0.1 元/天
                                       # Docker 部署默认推送 48 次(每半小时推送一次), 约 0.2 元/天
 
-  include_rss: false                 # 是否包含 RSS 内容进行分析
+  include_rss: false                # 是否包含 RSS 内容进行分析
+  
+  include_standalone: true          # 是否将独立展示区数据纳入 AI 分析(只需上方 display 区的 standalone 配置了平台/RSS 即可)
 
-  include_rank_timeline: true      # 是否传递完整排名时间线
+  include_rank_timeline: true       # 是否传递完整排名时间线
                                     # false: 使用简化格式(排名范围+时间范围+出现次数)
                                     # true: 传递完整排名变化轨迹(如 1(09:30)→2(10:00)→0(11:00))
                                     # 启用后 AI 能更精确分析热度趋势,但会额外增加 token 消耗(0.5 倍到 1 倍)
 
 
+
+
 # ===============================================================
 # 10. AI 翻译功能
 #
@@ -468,7 +467,7 @@ ai_translation:
   # 翻译目标语言
   # 格式:自然语言描述
   # 示例: "Chinese", "Korean", "法语"
-  language: "English"
+  language: "中文"
 
   # 提示词配置文件路径(相对于 config 目录)
   prompt_file: "ai_translation_prompt.txt"
@@ -517,4 +516,4 @@ advanced:
     bark: 4000
     slack: 4000
   batch_send_interval: 3              # 批次发送间隔(秒)
-  feishu_message_separator: "━━━━━━━━━━━━━━━━━━━"
+  feishu_message_separator: "━━━━━━━━━━━━━━━━"

+ 518 - 0
config/timeline.yaml

@@ -0,0 +1,518 @@
+# ═══════════════════════════════════════════════════════════════
+#                   TrendRadar 时间线配置
+#                      Version: 1.0.0
+# ═══════════════════════════════════════════════════════════════
+#
+# 这个文件控制「什么时间做什么事」。
+#
+# 大多数人不需要编辑这个文件。
+# 只需在 config.yaml 中选择一个预设模板即可:
+#
+#   schedule:
+#     preset: "morning_evening"    ← 改这里就行
+#
+#
+# 可视化配置编辑器地址: https://sansan0.github.io/TrendRadar/
+#
+#
+# ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
+# 📖 基本概念(帮助你理解后面的配置)
+# ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
+#
+#
+# 🔁 程序是怎么运行的?
+#
+#   TrendRadar 不是一直在后台运行的,而是被「定时闹钟」周期性唤醒:
+#
+#     GitHub Actions 用户 → 由 .github/workflows/crawler.yml 中的 cron 定时触发
+#                           默认每小时运行一次(如每小时第 33 分钟)
+#
+#     Docker 用户         → 由 docker/.env 中的 CRON_SCHEDULE 定时触发
+#                           默认每 30 分钟运行一次
+#
+#   每次被唤醒后,程序按以下三个阶段依次执行:
+#
+#     1️⃣ 采集(collect)
+#        爬取各热榜平台 + RSS 订阅源的最新数据,存入数据库
+#
+#                  ⬇
+#
+#     2️⃣ 分析(analyze)
+#        调用 AI 大模型对采集到的新闻进行深度分析(可选,需配置 API Key)
+#
+#                  ⬇
+#
+#     3️⃣ 推送(push)
+#        将整理好的热点新闻 + AI 分析结果发送到你的通知渠道
+#        (飞书、钉钉、Telegram、邮件等)
+#
+#   这三个阶段都可以独立开关。本文件的作用就是控制:
+#   「在什么时间段,开启/关闭哪些阶段」。
+#
+#
+# 🔌 config.yaml 总开关 与 timeline 时间段开关 的关系
+#
+#   config.yaml 里有几个「总开关」,它们的优先级高于本文件:
+#
+#     platforms.enabled: false   → 永远不爬热榜(无论 timeline 怎么设置)
+#     rss.enabled: false         → 永远不爬 RSS(同上)
+#     notification.enabled: false → 永远不推送(同上)
+#     ai_analysis.enabled: false  → 永远不分析(同上)
+#
+#   只有当总开关为 true 时,timeline 的时间段开关才会生效。
+#   换句话说:总开关决定「能不能做」,timeline 决定「什么时候做」。
+#
+#
+# ⏰ 什么是「时间段」和「静默期」?
+#
+#   你可以把一天想象成一条时间线,上面划分了若干个「时间段」。
+#   每个时间段有自己的行为开关(是否采集、是否分析、是否推送)。
+#
+#   而不在任何时间段内的时间,就叫「静默期」(走 default 默认配置)。
+#   静默期通常必须要采集,这样数据一直在积累,
+#   等到推送时,就能汇总出完整的报告。
+#
+#
+#   💡 静默期越长,积累的数据越丰富(排名变化轨迹、上榜/下榜时间等),
+#   最终提交给 AI 分析的上下文也越完整,分析质量更高。
+#   相比 MCP Server,该方案的全天数据能呈现更完整的热度趋势和变化脉络。
+#
+#
+# ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
+# 📋 预设模板一览(选一个就行)
+# ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
+#
+#   1️⃣ always_on        全天候,有新增就推(默认)
+#   2️⃣ morning_evening  全天推送 + 晚间汇总(推荐大多数人)
+#   3️⃣ office_hours     工作日三段式:到岗速览→午间热点→收工汇总
+#   4️⃣ night_owl        午后速览 + 深夜全天汇总
+#   5️⃣ custom           完全自定义(需要编辑本文件底部的 custom 段)
+#
+# 想自定义?两种方式:
+#   1. 直接翻到本文件底部的「自定义模式」部分
+#   2. 在下方 presets 里新增你自己的预设模板
+#      (只要 key 不重复,然后在 config.yaml 里填你的模板名即可)
+#
+# ⚠️ 关于时间段设计的建议:
+#   GitHub Actions: 建议定时任务间隔 ≥ 2 小时。由于系统触发存在随机延迟,间隔过短可能导致任务漏运行。
+#   Docker 用户:cron 定时是准时的,无此限制,按需设置即可。
+#
+#
+# ═══════════════════════════════════════════════════════════════
+
+
+# ───────────────────────────────────────────────────────────────
+# 预设模板
+# ───────────────────────────────────────────────────────────────
+presets:
+
+  # ───────────────────────────────────────────────────────────
+  # 1️⃣ always_on - 全天候监控
+  #
+  # 最简单的模式:全天候采集 + 推送,有新增就通知你。
+  # 不划分时间段,全天使用同一套配置。
+  # 适合:重度用户、实时舆情监控
+  #
+  # 全天:推送 ✓ | AI分析 ✗ | 不限推送次数
+  # ───────────────────────────────────────────────────────────
+  always_on:
+    name: "全天监控"
+    description: "全天候监控,有新增立即推送。适合重度用户。"
+
+    # 默认配置 ── 不在任何时间段内时,使用这组开关
+    # 因为这个模式没有划分时间段,所以 default 就是全天的行为
+    default:
+      collect: true                # 采集数据(爬取热榜 + RSS)
+      analyze: false               # 不做 AI 分析(节省 API 费用)
+      ai_mode: "current"           # AI 分析当前榜单
+      push: true                   # 有新内容就推送
+      report_mode: "incremental"   # 只推送新增内容,避免重复
+      once:                        # 限制每个时间段内只执行一次
+        analyze: false             #   不限制分析次数
+        push: false                #   不限制推送次数
+
+    # 没有定义任何时间段,全天都走 default
+    #
+    # 语法提示:{} 是 YAML 的「空字典」写法,表示里面没有任何内容。
+    # 等价于写成多行但什么都不填。后面的 [] 同理,表示「空列表」。
+    periods: {}
+    day_plans:
+      all_day:
+        periods: []                   # 空列表 = 这天不启用任何时间段
+    week_map:
+      1: "all_day"                 # 周一
+      2: "all_day"                 # 周二
+      3: "all_day"                 # 周三
+      4: "all_day"                 # 周四
+      5: "all_day"                 # 周五
+      6: "all_day"                 # 周六
+      7: "all_day"                 # 周日
+
+
+  # ───────────────────────────────────────────────────────────
+  # 2️⃣ morning_evening - 早晚汇总(推荐)
+  #
+  # 全天推送当前热点 + 晚间做一次当日全天汇总。
+  # 适合:大多数人
+  #
+  # 默认(全天):推送 ✓ | AI分析 ✓ | 不限推送次数
+  # 晚间汇总:推送 ✓ | AI分析 ✓ | 只推/分析一次
+  # ───────────────────────────────────────────────────────────
+  morning_evening:
+    name: "早晚汇总"
+    description: "全天推送 + 晚间当日汇总。适合大多数人。"
+
+    # 默认配置 ── 不命中任何时间段时的行为
+    default:
+      collect: true                # 始终采集
+      analyze: true                # AI 分析当前榜单
+      ai_mode: "current"           # AI 分析当前榜单
+      push: true                   # 每次推送当前在榜热点
+      report_mode: "current"       # 当前在榜的新闻
+      once:
+        analyze: false             # 不限制分析次数
+        push: false                # 不限制推送次数
+
+    # 时间段定义 ── 只有晚间汇总需要特殊处理
+    periods:
+      evening_summary:
+        name: "晚间汇总"
+        start: "20:00"
+        end: "22:00"
+        analyze: true              # 晚间做 AI 分析
+        ai_mode: "daily"           # AI 也汇总全天内容
+        report_mode: "daily"       # 切换为当日全部新闻汇总
+        once:
+          analyze: true            # 窗口内只分析一次
+          push: true               # 窗口内只推送一次
+
+    # 日计划 ── 把时间段组装成一天的安排
+    day_plans:
+      all_day:
+        periods: ["evening_summary"]
+
+    # 周映射 ── 每天用哪个日计划(1=周一 ... 7=周日)
+    week_map:
+      1: "all_day"
+      2: "all_day"
+      3: "all_day"
+      4: "all_day"
+      5: "all_day"
+      6: "all_day"
+      7: "all_day"
+
+
+  # ───────────────────────────────────────────────────────────
+  # 3️⃣ office_hours - 办公时间推送
+  #
+  # 工作日三段式推送,周末增量自由推。
+  # 适合:上班族、企业用户
+  #
+  # 默认(静默期):推送 ✗ | AI分析 ✗
+  # 到岗速览:推送 ✓ | AI分析 ✓ | 只推一次
+  # 午间热点:推送 ✓ | AI分析 ✗ | 只推一次
+  # 收工汇总:推送 ✓ | AI分析 ✓ | 只推一次
+  # 周末自由:推送 ✓ | AI分析 ✗ | 不限推送次数
+  # ───────────────────────────────────────────────────────────
+  office_hours:
+    name: "办公时间"
+    description: "工作日三段式推送(到岗→午间→收工),周末增量自由推送。"
+
+    default:
+      collect: true
+      analyze: false
+      ai_mode: "current"
+      push: false                  # 默认不推送
+      report_mode: "current"
+      once:
+        analyze: true              # 每个时段只分析一次
+        push: true                 # 每个时段只推送一次
+
+    periods:
+      morning_briefing:
+        name: "到岗速览"
+        start: "09:00"
+        end: "11:00"
+        analyze: true              # AI 分析当前热点
+        ai_mode: "current"         # AI 分析当前榜单
+        push: true                 # 到岗后看当前热点
+        report_mode: "current"     # 当前在榜的新闻
+        # once 继承 default(analyze: true, push: true)→ 只推/分析一次
+
+      noon_update:
+        name: "午间热点"
+        start: "13:00"
+        end: "15:00"              
+        push: true                 # 午间推送当前在榜热点
+        report_mode: "current"     # 当前在榜的新闻
+        # analyze 继承 default: false → 午间不做 AI 分析,节省 API
+        # once 继承 default(push: true)→ 只推一次
+
+      closing_summary:
+        name: "收工汇总"
+        start: "17:00"
+        end: "19:00"
+        analyze: true              # AI 做全天汇总分析
+        ai_mode: "daily"           # AI 也分析全天内容
+        push: true                 # 下班前推送当日完整汇总
+        report_mode: "daily"       # 当日全部新闻汇总
+        # once 继承 default(analyze: true, push: true)→ 只推/分析一次
+
+      weekend_free:
+        name: "周末自由"
+        start: "08:00"
+        end: "23:00"
+        ai_mode: "current"         # AI 分析当前榜单
+        push: true                 # 有新增就推送
+        report_mode: "incremental" # 增量模式:有新增才推,没有就安静
+        once:
+          analyze: false           # 不限制分析次数
+          push: false              # 不限制推送次数
+
+    # 工作日使用三段式推送;周末使用增量自由模式
+    day_plans:
+      workday:
+        periods: ["morning_briefing", "noon_update", "closing_summary"]
+      weekend:
+        periods: ["weekend_free"]  # 周末:有新增就推,不打扰睡眠
+
+    week_map:
+      1: "workday"                 # 周一 → 工作日计划
+      2: "workday"
+      3: "workday"
+      4: "workday"
+      5: "workday"
+      6: "weekend"                 # 周六 → 周末计划
+      7: "weekend"                 # 周日 → 周末计划
+
+
+  # ───────────────────────────────────────────────────────────
+  # 4️⃣ night_owl - 夜猫子模式
+  #
+  # 白天安静,午后和深夜各推一次。
+  # 适合:夜间工作者、海外时差用户、自由职业者
+  #
+  # 默认(白天静默):推送 ✗ | AI分析 ✗
+  # 午后速览:推送 ✓ | AI分析 ✓ | 只推一次
+  # 深夜汇总:推送 ✓ | AI分析 ✓ | 只推一次
+  # ───────────────────────────────────────────────────────────
+  night_owl:
+    name: "夜猫子模式"
+    description: "午后速览 + 深夜全天汇总。适合夜间工作者、海外时差用户。"
+
+    default:
+      collect: true
+      analyze: false
+      ai_mode: "current"
+      push: false
+      report_mode: "current"
+      once:
+        analyze: true              # 每个时段只分析一次
+        push: true                 # 每个时段只推送一次
+
+    periods:
+      afternoon_peek:
+        name: "午后速览"
+        start: "15:00"
+        end: "17:00"
+        analyze: true              # AI 分析当前热点
+        ai_mode: "current"         # AI 分析当前榜单
+        push: true                 # 午后看当前热点
+        report_mode: "current"     # 当前在榜的新闻
+        # once 继承 default(analyze: true, push: true)→ 只推/分析一次
+
+      late_night:
+        name: "深夜汇总"
+        start: "22:00"
+        end: "01:00"               # start > end → 自动识别为跨日
+        analyze: true              # AI 做全天汇总分析
+        ai_mode: "daily"           # AI 也分析全天内容
+        push: true                 # 深夜推送当日完整汇总
+        report_mode: "daily"       # 当日全部新闻汇总
+        # once 继承 default(analyze: true, push: true)→ 只推/分析一次
+
+    day_plans:
+      all_day:
+        periods: ["afternoon_peek", "late_night"]
+    week_map:
+      1: "all_day"
+      2: "all_day"
+      3: "all_day"
+      4: "all_day"
+      5: "all_day"
+      6: "all_day"
+      7: "all_day"
+
+
+# ═══════════════════════════════════════════════════════════════
+#
+# 5️⃣ 自定义模式
+#
+# 当 config.yaml 中设置 schedule.preset: "custom" 时,
+# 系统会读取下面这段配置。
+#
+# 如果上面的预设模板无法满足你的需求,可以在这里自由定义。
+#
+# ═══════════════════════════════════════════════════════════════
+#
+# 自定义配置的思路很简单,就像搭积木:
+#
+#   第 1 步:定义「积木块」(periods)
+#            每块积木 = 一个时间段 + 这段时间要做什么
+#            例如:早间 08-10 推送、晚间 19-21 汇总
+#
+#   第 2 步:拼成「一天的安排」(day_plans)
+#            把积木块组合起来,形成一天的日程
+#            例如:工作日用 [早间, 晚间],周末用 [晚间]
+#
+#   第 3 步:指定「每天用哪个安排」(week_map)
+#            周一到周日,分别对应哪个日计划
+#            例如:周一~周五用 workday,周六周日用 weekend
+#
+#   另外还有一个「默认配置」(default),
+#   当某个时刻不在任何积木块内时,就用默认配置。
+#   积木块里没写的字段,也会自动回退到默认配置。
+#
+#
+# 下面是一个完整的自定义示例,工作日和周末使用不同的时间段安排:
+#
+#   工作日时间段:
+#     深夜静默 23:00-06:00(跨日):采集 ✓ | 分析 ✓ | 推送 ✗
+#     工作日早间 08:00-10:00:推送 ✓ | incremental
+#     晚间汇总 19:00-21:00:推送 ✓ | 分析 ✓ | daily
+#     其余时间走默认配置(静默采集)
+#
+#   周末时间段:
+#     深夜静默 23:00-06:00(跨日):采集 ✓ | 分析 ✓ | 推送 ✗
+#     周末早间 10:00-12:00:推送 ✓ | daily
+#     晚间汇总 19:00-21:00:推送 ✓ | 分析 ✓ | daily
+#     其余时间走默认配置(静默采集)
+
+custom:
+  name: "自定义"
+  description: "完全自由定义时间段、日计划和周映射。"
+
+  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+  # 默认配置
+  #
+  # 当前时刻不在任何时间段(积木块)内时,使用这组开关。
+  # 时间段中没有写的字段,也会回退到这里。
+  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+  default:
+    collect: true                  # 是否采集数据(爬取热榜 + RSS)
+    analyze: false                 # 是否执行 AI 分析
+    ai_mode: "current"            # AI 分析模式:
+                                   #   follow_report → 跟随 report_mode
+                                   #   daily         → 强制全天汇总
+                                   #   current       → 强制当前榜单
+                                   #   incremental   → 强制增量模式
+    push: false                    # 是否发送推送通知
+    report_mode: "current"         # 报告模式:
+                                   #   daily       → 当日所有新闻的汇总
+                                   #   current     → 当前在榜的新闻
+                                   #   incremental → 只推送新增内容
+    once:
+      analyze: true                # 该时间段内只分析一次(省 API)
+      push: true                   # 该时间段内只推送一次(省打扰)
+
+
+  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+  # 第 1 步:定义积木块(时间段)
+  #
+  # 每个时间段有一个唯一的 key(如 deep_quiet),
+  # 以及 start / end 表示生效的时间范围。
+  #
+  # 只需要写「和 default 不同的字段」,其余自动继承 default。
+  # 例如 weekday_morning 没写 collect,就会继承 default 的 collect: true。
+  #
+  # 提示:如果 start > end(如 22:00 → 07:00),
+  #       系统会自动识别为跨越午夜的时间段。
+  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+  periods:
+
+    deep_quiet:
+      name: "深夜静默"
+      start: "23:00"
+      end: "06:00"                 # 23:00 → 次日 06:00(跨日时间段)
+      collect: true                # 夜间继续采集数据
+      analyze: true                # 夜间可以跑 AI 分析(反正不推送)
+      push: false                  # 深夜不推送,避免打扰
+
+    weekday_morning:
+      name: "工作日早间"
+      start: "08:00"
+      end: "10:00"                 # 跨度 2h,留足触发裕量
+      push: true                   # 早上推送一次
+      report_mode: "incremental"   # 只推新增内容
+      # once 继承 default(push: true)→ 窗口内只推一次
+
+    weekend_morning:
+      name: "周末早间"
+      start: "10:00"
+      end: "12:00"                 # 跨度 2h
+      push: true
+      report_mode: "daily"         # 周末看全天汇总
+      # once 继承 default(push: true)→ 窗口内只推一次
+
+    evening_summary:
+      name: "晚间汇总"
+      start: "19:00"
+      end: "21:00"
+      analyze: true                # 晚间做 AI 分析
+      ai_mode: "daily"             # AI 也分析全天内容
+      push: true                   # 晚间推送
+      report_mode: "daily"         # 当日全部新闻汇总
+      # once 继承 default(analyze: true, push: true)→ 只分析/推送一次
+
+
+  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+  # 第 2 步:把积木块拼成日计划
+  #
+  # 把上面定义的时间段组合成一天的安排。
+  # 你可以定义多个日计划(如 workday 和 weekend),
+  # 然后在第 3 步的 week_map 中分配给不同的星期。
+  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+  day_plans:
+    workday:                       # 工作日计划
+      periods: ["deep_quiet", "weekday_morning", "evening_summary"]
+    weekend:                       # 周末计划(用 weekend_morning 替换 weekday_morning)
+      periods: ["deep_quiet", "weekend_morning", "evening_summary"]
+
+
+  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+  # 第 3 步:指定每天用哪个日计划
+  #
+  # 1=周一  2=周二  3=周三  4=周四  5=周五  6=周六  7=周日
+  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+  week_map:
+    1: "workday"                   # 周一 → 工作日计划
+    2: "workday"                   # 周二
+    3: "workday"                   # 周三
+    4: "workday"                   # 周四
+    5: "workday"                   # 周五
+    6: "weekend"                   # 周六 → 周末计划
+    7: "weekend"                   # 周日
+
+
+  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+  # 冲突策略(一般不用改)
+  #
+  # 什么是「冲突」?
+  #   如果你的两个时间段有重叠(比如 A 是 08:00-12:00,B 是 10:00-14:00),
+  #   那么 10:00-12:00 这段时间就同时属于 A 和 B,产生了冲突。
+  #   此时程序需要知道:到底听谁的?
+  #
+  # 两种处理方式:
+  #
+  #   error_on_overlap(推荐)
+  #     直接报错,提醒你去修改配置。
+  #     适合大多数人 —— 时间段重叠通常是写错了,报错能及时发现。
+  #
+  #   last_wins
+  #     day_plans 的 periods 列表中,写在后面的优先。
+  #     比如 periods: ["A", "B"],重叠时 B 生效。
+  #     适合场景:你想用一个大范围时间段打底,再用后面的小范围覆盖。
+  #
+  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+  overlap:
+    policy: "error_on_overlap"

+ 2023 - 47
docs/assets/script.js

@@ -84,7 +84,7 @@ const MODULE_DEFS = [
     { id: 3, name: "3. 数据源 - RSS 订阅", key: "rss", editable: true },
     { id: 4, name: "4. 报告模式", key: "report", editable: true },
     { id: 5, name: "5. 推送内容控制", key: "display", editable: true },
-    { id: 6, name: "6. 推送通知 (仅限时间窗口)", key: "notification", editable: true, partial: true },
+    { id: 6, name: "6. 推送通知", key: "notification", editable: true, partial: true },
     { id: 7, name: "7. 存储配置", key: "storage", editable: false },
     { id: 8, name: "8. AI 模型配置", key: "ai", editable: true },
     { id: 9, name: "9. AI 分析功能", key: "ai_analysis", editable: true },
@@ -100,16 +100,20 @@ const INITIAL_YAML = `# 在此粘贴你的 config.yaml...
 // LocalStorage 键名
 const STORAGE_KEY_CONFIG = 'trendradar_config_yaml';
 const STORAGE_KEY_FREQUENCY = 'trendradar_frequency_txt';
+const STORAGE_KEY_TIMELINE = 'trendradar_timeline_yaml';
 const STORAGE_KEY_CONFIG_TIME = 'trendradar_config_time';
 const STORAGE_KEY_FREQUENCY_TIME = 'trendradar_frequency_time';
+const STORAGE_KEY_TIMELINE_TIME = 'trendradar_timeline_time';
 
 // 官网配置文件 URL
 const REMOTE_CONFIG_URL = 'https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/config/config.yaml';
 const REMOTE_FREQUENCY_URL = 'https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/config/frequency_words.txt';
+const REMOTE_TIMELINE_URL = 'https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/config/timeline.yaml';
 const REMOTE_VERSION_URL = 'https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/version_configs';
 
 let currentYaml = "";
 let currentFrequency = "";
+let currentTimeline = "";
 let currentFrequencyData = null;  // 缓存解析后的数据,避免重复解析导致索引错位
 let currentTab = "config";
 
@@ -119,6 +123,7 @@ let currentTab = "config";
 // 防抖定时器
 let configSaveTimer = null;
 let frequencySaveTimer = null;
+let timelineSaveTimer = null;
 
 document.addEventListener('DOMContentLoaded', () => {
     const yamlEditor = document.getElementById('yaml-editor');
@@ -146,6 +151,20 @@ document.addEventListener('DOMContentLoaded', () => {
         currentFrequency = frequencyEditor.value;
     }
 
+    // 初始化 Timeline 编辑器
+    const timelineEditor = document.getElementById('timeline-editor');
+    const savedTimeline = localStorage.getItem(STORAGE_KEY_TIMELINE);
+
+    const INITIAL_TIMELINE = `# 在此粘贴你的 timeline.yaml...\n# 或拖拽文件到编辑器区域\n# 或点击右上角"加载官网最新配置"`;
+
+    if (savedTimeline && savedTimeline.trim() && savedTimeline !== INITIAL_TIMELINE) {
+        timelineEditor.value = savedTimeline;
+        currentTimeline = savedTimeline;
+    } else {
+        timelineEditor.value = INITIAL_TIMELINE;
+        currentTimeline = INITIAL_TIMELINE;
+    }
+
     // 渲染右侧模块列表
     renderModules();
 
@@ -165,13 +184,22 @@ document.addEventListener('DOMContentLoaded', () => {
         debounceSaveFrequency();
     });
 
+    timelineEditor.addEventListener('input', (e) => {
+        currentTimeline = e.target.value;
+        updateBackdrop('timeline-editor', 'timeline-backdrop');
+        syncTimelineToUI();
+        debounceSaveTimeline();
+    });
+
     // 同步滚动
     yamlEditor.addEventListener('scroll', () => syncScroll('yaml-editor', 'yaml-backdrop'));
     frequencyEditor.addEventListener('scroll', () => syncScroll('frequency-editor', 'frequency-backdrop'));
+    timelineEditor.addEventListener('scroll', () => syncScroll('timeline-editor', 'timeline-backdrop'));
 
     // 初始化拖拽上传功能
     initDragAndDrop(yamlEditor, 'config');
     initDragAndDrop(frequencyEditor, 'frequency');
+    initDragAndDrop(timelineEditor, 'timeline');
 
     // 页面关闭/刷新时立即保存
     window.addEventListener('beforeunload', saveAllToLocalStorage);
@@ -188,6 +216,7 @@ document.addEventListener('DOMContentLoaded', () => {
 
     updateBackdrop('yaml-editor', 'yaml-backdrop');
     updateBackdrop('frequency-editor', 'frequency-backdrop');
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
 
     updateSaveTimeDisplay();
 });
@@ -208,6 +237,14 @@ function debounceSaveFrequency() {
     }, 1000);
 }
 
+// 防抖保存 timeline.yaml
+function debounceSaveTimeline() {
+    if (timelineSaveTimer) clearTimeout(timelineSaveTimer);
+    timelineSaveTimer = setTimeout(() => {
+        saveTimelineToLocalStorage();
+    }, 1000);
+}
+
 // ==========================================
 // 2.1 拖拽上传功能
 // ==========================================
@@ -220,7 +257,7 @@ function initDragAndDrop(editor, type) {
         <div class="drop-overlay-content">
             <i class="fa-solid fa-cloud-arrow-up text-4xl mb-2"></i>
             <div class="text-sm font-bold">释放以加载文件</div>
-            <div class="text-xs opacity-75">${type === 'config' ? 'config.yaml' : 'frequency_words.txt'}</div>
+            <div class="text-xs opacity-75">${type === 'config' ? 'config.yaml' : type === 'timeline' ? 'timeline.yaml' : 'frequency_words.txt'}</div>
         </div>
     `;
     container.style.position = 'relative';
@@ -276,13 +313,15 @@ function handleFileDrop(e, type) {
 
     const validExtensions = type === 'config'
         ? ['.yaml', '.yml', '.txt']
+        : type === 'timeline'
+        ? ['.yaml', '.yml']
         : ['.txt', '.yaml', '.yml'];
 
     const fileName = file.name.toLowerCase();
     const isValid = validExtensions.some(ext => fileName.endsWith(ext));
 
     if (!isValid) {
-        showToast(`请拖入 ${type === 'config' ? 'YAML' : 'TXT'} 文件`, 'error');
+        showToast(`请拖入 ${type === 'config' || type === 'timeline' ? 'YAML' : 'TXT'} 文件`, 'error');
         return;
     }
 
@@ -303,6 +342,19 @@ function handleFileDrop(e, type) {
                 document.getElementById('yaml-editor').value = content;
                 currentYaml = content;
             }
+        } else if (type === 'timeline') {
+            try {
+                jsyaml.load(content);
+                document.getElementById('timeline-editor').value = content;
+                currentTimeline = content;
+                updateBackdrop('timeline-editor', 'timeline-backdrop');
+                syncTimelineToUI();
+                showToast(`已加载: ${file.name}`, 'success');
+            } catch (err) {
+                showToast(`YAML 语法错误: ${err.message}`, 'error');
+                document.getElementById('timeline-editor').value = content;
+                currentTimeline = content;
+            }
         } else {
             document.getElementById('frequency-editor').value = content;
             currentFrequency = content;
@@ -350,10 +402,25 @@ function saveFrequencyToLocalStorage() {
     }
 }
 
+// 保存 timeline.yaml
+function saveTimelineToLocalStorage() {
+    try {
+        if (currentTimeline && currentTimeline.trim().length > 10) {
+            const now = new Date().toISOString();
+            localStorage.setItem(STORAGE_KEY_TIMELINE, currentTimeline);
+            localStorage.setItem(STORAGE_KEY_TIMELINE_TIME, now);
+            updateSaveTimeDisplay();
+        }
+    } catch (e) {
+        console.warn('LocalStorage 保存 timeline 失败:', e);
+    }
+}
+
 // 保存全部(页面关闭时调用)
 function saveAllToLocalStorage() {
     saveConfigToLocalStorage();
     saveFrequencyToLocalStorage();
+    saveTimelineToLocalStorage();
 }
 
 // 兼容旧调用
@@ -413,6 +480,22 @@ function updateSaveTimeDisplay() {
             }
         }
     }
+
+    // 更新 timeline.yaml 的时间显示
+    const timelineTime = localStorage.getItem(STORAGE_KEY_TIMELINE_TIME);
+    const timelineTimeEl = document.getElementById('timeline-save-time');
+    const timelineLabelEl = document.getElementById('timeline-save-label');
+    if (timelineTimeEl) {
+        timelineTimeEl.textContent = formatSaveTime(timelineTime);
+        timelineTimeEl.title = timelineTime ? new Date(timelineTime).toLocaleString('zh-CN') : '未保存';
+        if (timelineLabelEl) {
+            if (timelineTime) {
+                timelineLabelEl.classList.remove('hidden');
+            } else {
+                timelineLabelEl.classList.add('hidden');
+            }
+        }
+    }
 }
 
 // ==========================================
@@ -449,6 +532,14 @@ window.openLoadConfigModal = function() {
                     </div>
                     <i class="fa-solid fa-filter text-orange-400"></i>
                 </label>
+                <label class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-blue-50 hover:border-blue-300 cursor-pointer transition-colors">
+                    <input type="checkbox" id="load-timeline-yaml" checked class="w-4 h-4 text-blue-600 rounded">
+                    <div class="flex-1">
+                        <div class="font-medium text-gray-800">timeline.yaml</div>
+                        <div class="text-xs text-gray-500">调度时间线、预设模板、自定义时间段</div>
+                    </div>
+                    <i class="fa-solid fa-calendar-week text-purple-400"></i>
+                </label>
             </div>
             <div class="text-xs text-gray-400 mt-3 p-2 bg-gray-50 rounded">
                 <i class="fa-solid fa-info-circle mr-1"></i>
@@ -473,8 +564,9 @@ window.closeLoadConfigModal = function() {
 window.confirmLoadConfig = async function() {
     const loadConfig = document.getElementById('load-config-yaml')?.checked;
     const loadFrequency = document.getElementById('load-frequency-txt')?.checked;
+    const loadTimeline = document.getElementById('load-timeline-yaml')?.checked;
 
-    if (!loadConfig && !loadFrequency) {
+    if (!loadConfig && !loadFrequency && !loadTimeline) {
         showToast('请至少选择一个文件', 'warning');
         return;
     }
@@ -486,18 +578,19 @@ window.confirmLoadConfig = async function() {
         const promises = [];
         if (loadConfig) promises.push(fetch(REMOTE_CONFIG_URL).then(r => ({ type: 'config', res: r })));
         if (loadFrequency) promises.push(fetch(REMOTE_FREQUENCY_URL).then(r => ({ type: 'frequency', res: r })));
+        if (loadTimeline) promises.push(fetch(REMOTE_TIMELINE_URL).then(r => ({ type: 'timeline', res: r })));
 
         const results = await Promise.all(promises);
 
         for (const { type, res } of results) {
             if (!res.ok) {
-                throw new Error(`${type === 'config' ? 'config.yaml' : 'frequency_words.txt'} 加载失败: ${res.status}`);
+                const names = { config: 'config.yaml', frequency: 'frequency_words.txt', timeline: 'timeline.yaml' };
+                throw new Error(`${names[type]} 加载失败: ${res.status}`);
             }
 
             const text = await res.text();
 
             if (type === 'config') {
-                // 验证 YAML 语法
                 try {
                     jsyaml.load(text);
                 } catch (yamlErr) {
@@ -508,6 +601,17 @@ window.confirmLoadConfig = async function() {
                 currentYaml = text;
                 updateBackdrop('yaml-editor', 'yaml-backdrop');
                 syncYamlToUI();
+            } else if (type === 'timeline') {
+                try {
+                    jsyaml.load(text);
+                } catch (yamlErr) {
+                    showToast(`YAML 语法错误: ${yamlErr.message}`, 'error');
+                    continue;
+                }
+                document.getElementById('timeline-editor').value = text;
+                currentTimeline = text;
+                updateBackdrop('timeline-editor', 'timeline-backdrop');
+                syncTimelineToUI();
             } else {
                 document.getElementById('frequency-editor').value = text;
                 currentFrequency = text;
@@ -522,6 +626,7 @@ window.confirmLoadConfig = async function() {
         const loadedFiles = [];
         if (loadConfig) loadedFiles.push('config.yaml');
         if (loadFrequency) loadedFiles.push('frequency_words.txt');
+        if (loadTimeline) loadedFiles.push('timeline.yaml');
         showToast(`已加载: ${loadedFiles.join(', ')}`, 'success');
 
     } catch (err) {
@@ -787,7 +892,7 @@ function renderControls(mod) {
 
             // Standalone Configuration Section
             html += `<div class="border-t border-gray-200 pt-4 mt-4">`;
-            html += `<div class="text-xs font-bold text-gray-700 mb-3">独立展示区配置 <span class="text-gray-400 font-normal">(仅在上方开启"独立展示区"时生效)</span></div>`;
+            html += `<div class="text-xs font-bold text-gray-700 mb-3">独立展示区配置 <span class="text-gray-400 font-normal">(推送展示由上方开关控制,AI 分析由 AI 模块的开关独立控制)</span></div>`;
 
             html += createNumberControl(mod.key, "standalone.max_items", "每个源最多展示条数");
 
@@ -805,15 +910,11 @@ function renderControls(mod) {
             }, 0);
             break;
         case "notification":
-            // 只有推送窗口可见
-            html = `<div class="text-xs font-bold text-blue-600 mb-2">推送时间窗口设置</div>`;
-            html += createToggleControl(mod.key, "push_window.enabled", "开启时间窗口");
-            html += `<div class="grid grid-cols-2 gap-4">
-                        ${createInputControl(mod.key, "push_window.start", "开始时间 (HH:MM)")}
-                        ${createInputControl(mod.key, "push_window.end", "结束时间 (HH:MM)")}
+            html = `<div class="text-xs text-gray-500 mb-2 p-2 bg-blue-50 rounded border border-blue-200">
+                        <i class="fa-solid fa-info-circle mr-1 text-blue-500"></i>
+                        推送时间由 <strong>timeline.yaml</strong> 控制,切换到 timeline.yaml 标签页可可视化编辑调度规则。<br>
+                        此处仅配置通知渠道(Telegram / 企业微信等),请在左侧编辑器中修改。
                     </div>`;
-            html += createToggleControl(mod.key, "push_window.once_per_day", "窗口内仅推送一次");
-            html += `<div class="text-xs text-gray-500 mt-2">通知渠道配置请在左侧编辑器中修改</div>`;
             break;
         case "ai":
             html = createInputControl(mod.key, "model", "模型名称");
@@ -826,22 +927,20 @@ function renderControls(mod) {
         case "ai_analysis":
             html = createToggleControl(mod.key, "enabled", "开启 AI 分析报告");
 
-            // AI 分析时间窗口设置
-            html += `<div class="text-xs font-bold text-blue-600 mb-2 mt-4">AI 分析时间窗口设置</div>`;
-            html += createToggleControl(mod.key, "analysis_window.enabled", "开启时间窗口");
-            html += `<div class="grid grid-cols-2 gap-4">
-                        ${createInputControl(mod.key, "analysis_window.start", "开始时间 (HH:MM)")}
-                        ${createInputControl(mod.key, "analysis_window.end", "结束时间 (HH:MM)")}
+            // 提示:分析时间窗口已迁移到 timeline.yaml
+            html += `<div class="text-xs text-gray-500 mt-3 mb-3 p-2 bg-blue-50 rounded border border-blue-200">
+                        <i class="fa-solid fa-info-circle mr-1 text-blue-500"></i>
+                        AI 分析的执行时间已由 <strong>timeline.yaml</strong> 统一控制。
                     </div>`;
-            html += createToggleControl(mod.key, "analysis_window.once_per_day", "窗口内仅分析一次");
 
             // 其他 AI 分析配置
-            html += `<div class="text-xs font-bold text-blue-600 mb-2 mt-4">分析内容配置</div>`;
+            html += `<div class="text-xs font-bold text-blue-600 mb-2">分析内容配置</div>`;
             html += createInputControl(mod.key, "language", "输出语言");
             html += createInputControl(mod.key, "prompt_file", "提示词配置文件");
             html += createSelectControl(mod.key, "mode", "AI 分析模式", ["follow_report", "daily", "current", "incremental"]);
             html += createNumberControl(mod.key, "max_news_for_analysis", "最大分析条数");
             html += createToggleControl(mod.key, "include_rss", "包含 RSS 内容");
+            html += createToggleControl(mod.key, "include_standalone", "包含独立展示区数据");
             html += createToggleControl(mod.key, "include_rank_timeline", "传递完整排名时间线");
             break;
         case "ai_translation":
@@ -1069,7 +1168,8 @@ function createSelectControl(mod, path, label, options) {
 window.copyResult = function() {
     const yamlEditor = document.getElementById('yaml-editor');
     const frequencyEditor = document.getElementById('frequency-editor');
-    const editor = currentTab === 'config' ? yamlEditor : frequencyEditor;
+    const timelineEditor = document.getElementById('timeline-editor');
+    const editor = currentTab === 'config' ? yamlEditor : currentTab === 'timeline' ? timelineEditor : frequencyEditor;
 
     editor.select();
     document.execCommand('copy');
@@ -1082,31 +1182,33 @@ window.copyResult = function() {
 
 window.resetToDefault = function() {
     if (confirm('确定要重置为初始状态吗?未保存的修改将丢失。')) {
-        const yamlEditor = document.getElementById('yaml-editor');
-        const frequencyEditor = document.getElementById('frequency-editor');
-
         if (currentTab === 'config') {
+            const yamlEditor = document.getElementById('yaml-editor');
             yamlEditor.value = INITIAL_YAML;
             currentYaml = INITIAL_YAML;
             updateBackdrop('yaml-editor', 'yaml-backdrop');
-
-            // 清除 LocalStorage 中的 config 数据
             localStorage.removeItem(STORAGE_KEY_CONFIG);
             localStorage.removeItem(STORAGE_KEY_CONFIG_TIME);
-
-            // 重置 UI:重新渲染模块以清空输入框,因为 INITIAL_YAML 可能为空导致 syncYamlToUI 不执行更新
             renderModules();
             syncYamlToUI();
             updateSaveTimeDisplay();
+        } else if (currentTab === 'timeline') {
+            const timelineEditor = document.getElementById('timeline-editor');
+            const initialTimeline = `# 在此粘贴你的 timeline.yaml...\n# 或拖拽文件到编辑器区域\n# 或点击右上角"加载官网最新配置"`;
+            timelineEditor.value = initialTimeline;
+            currentTimeline = initialTimeline;
+            updateBackdrop('timeline-editor', 'timeline-backdrop');
+            localStorage.removeItem(STORAGE_KEY_TIMELINE);
+            localStorage.removeItem(STORAGE_KEY_TIMELINE_TIME);
+            syncTimelineToUI();
+            updateSaveTimeDisplay();
         } else {
+            const frequencyEditor = document.getElementById('frequency-editor');
             frequencyEditor.value = "# 在此粘贴你的 frequency_words.txt 内容...\n\n[GLOBAL_FILTER]\n\n[WORD_GROUPS]\n";
             currentFrequency = frequencyEditor.value;
             updateBackdrop('frequency-editor', 'frequency-backdrop');
-
-            // 清除 LocalStorage 中的 frequency 数据
             localStorage.removeItem(STORAGE_KEY_FREQUENCY);
             localStorage.removeItem(STORAGE_KEY_FREQUENCY_TIME);
-
             syncFrequencyToUI();
             updateSaveTimeDisplay();
         }
@@ -1120,28 +1222,27 @@ window.resetToDefault = function() {
 window.switchTab = function(tab) {
     currentTab = tab;
 
-    // 更新 Tab 按钮状态
-    document.getElementById('tab-config').classList.toggle('active', tab === 'config');
-    document.getElementById('tab-frequency').classList.toggle('active', tab === 'frequency');
+    const activeClass = "tab-button active px-4 py-2 text-xs font-bold text-gray-300 hover:bg-[#2d2d30] transition-colors border-b-2 border-blue-500";
+    const inactiveClass = "tab-button px-4 py-2 text-xs font-bold text-gray-500 hover:bg-[#2d2d30] transition-colors border-b-2 border-transparent";
 
+    // 更新 Tab 按钮状态
     const configBtn = document.getElementById('tab-config');
     const freqBtn = document.getElementById('tab-frequency');
+    const timelineBtn = document.getElementById('tab-timeline');
 
-    if (tab === 'config') {
-        configBtn.className = "tab-button active px-4 py-2 text-xs font-bold text-gray-300 hover:bg-[#2d2d30] transition-colors border-b-2 border-blue-500";
-        freqBtn.className = "tab-button px-4 py-2 text-xs font-bold text-gray-500 hover:bg-[#2d2d30] transition-colors border-b-2 border-transparent";
-    } else {
-        configBtn.className = "tab-button px-4 py-2 text-xs font-bold text-gray-500 hover:bg-[#2d2d30] transition-colors border-b-2 border-transparent";
-        freqBtn.className = "tab-button active px-4 py-2 text-xs font-bold text-gray-300 hover:bg-[#2d2d30] transition-colors border-b-2 border-blue-500";
-    }
+    configBtn.className = tab === 'config' ? activeClass : inactiveClass;
+    freqBtn.className = tab === 'frequency' ? activeClass : inactiveClass;
+    timelineBtn.className = tab === 'timeline' ? activeClass : inactiveClass;
 
     // 更新编辑器显示
     document.getElementById('yaml-editor-wrap').classList.toggle('hidden', tab !== 'config');
     document.getElementById('frequency-editor-wrap').classList.toggle('hidden', tab !== 'frequency');
+    document.getElementById('timeline-editor-wrap').classList.toggle('hidden', tab !== 'timeline');
 
     // 更新右侧面板
     document.getElementById('config-panel').classList.toggle('hidden', tab !== 'config');
     document.getElementById('frequency-panel').classList.toggle('hidden', tab !== 'frequency');
+    document.getElementById('timeline-panel').classList.toggle('hidden', tab !== 'timeline');
 
     // 更新模块导航栏显示状态:只在 config 模式下显示
     const moduleNav = document.getElementById('module-nav');
@@ -1152,22 +1253,30 @@ window.switchTab = function(tab) {
     // 更新保存时间显示
     const saveTimeConfig = document.getElementById('save-time-config');
     const saveTimeFrequency = document.getElementById('save-time-frequency');
+    const saveTimeTimeline = document.getElementById('save-time-timeline');
     if (saveTimeConfig) saveTimeConfig.classList.toggle('hidden', tab !== 'config');
     if (saveTimeFrequency) saveTimeFrequency.classList.toggle('hidden', tab !== 'frequency');
+    if (saveTimeTimeline) saveTimeTimeline.classList.toggle('hidden', tab !== 'timeline');
 
     // 更新右侧标题
     const versionBtn = document.getElementById('version-check-btn');
     if (tab === 'config') {
         document.getElementById('right-panel-title').textContent = '配置模块';
-        if (versionBtn) versionBtn.title = "检测 config.yaml 版本";
-    } else {
+        if (versionBtn) { versionBtn.style.display = ''; versionBtn.title = "检测 config.yaml 版本"; }
+    } else if (tab === 'frequency') {
         document.getElementById('right-panel-title').textContent = '频率词编辑';
-        if (versionBtn) versionBtn.title = "检测 frequency_words.txt 版本";
+        if (versionBtn) { versionBtn.style.display = ''; versionBtn.title = "检测 frequency_words.txt 版本"; }
+    } else {
+        document.getElementById('right-panel-title').textContent = '时间线调度';
+        if (versionBtn) versionBtn.style.display = 'none';
     }
 
     if (tab === 'frequency') {
         renderFrequencyPanel();
     }
+    if (tab === 'timeline') {
+        syncTimelineToUI();
+    }
 }
 
 // ==========================================
@@ -3495,3 +3604,1870 @@ function fillRssUrl(url) {
         }, 500);
     }
 }
+
+// ==========================================
+// 13. Timeline 编辑器功能
+// ==========================================
+
+const PRESET_META = {
+    morning_evening: { icon: 'fa-sun', color: 'text-amber-500', bg: 'bg-amber-50', recommend: true },
+    always_on:       { icon: 'fa-bolt', color: 'text-blue-500', bg: 'bg-blue-50' },
+    office_hours:    { icon: 'fa-briefcase', color: 'text-green-500', bg: 'bg-green-50' },
+    night_owl:       { icon: 'fa-moon', color: 'text-indigo-500', bg: 'bg-indigo-50' },
+    custom:          { icon: 'fa-sliders', color: 'text-purple-500', bg: 'bg-purple-50' }
+};
+
+const DAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
+
+/**
+ * 从当前 config.yaml 中读取 schedule.preset
+ */
+function getActivePreset() {
+    try {
+        const doc = jsyaml.load(currentYaml);
+        return doc?.schedule?.preset || 'morning_evening';
+    } catch { return 'morning_evening'; }
+}
+
+/**
+ * 解析 timeline YAML,返回结构化数据
+ */
+function parseTimelineData() {
+    try {
+        const doc = jsyaml.load(currentTimeline);
+        if (!doc) return null;
+        return doc;
+    } catch { return null; }
+}
+
+/**
+ * 获取指定预设/custom 的完整配置
+ */
+function getPresetConfig(data, presetName) {
+    if (!data) return null;
+    if (presetName === 'custom') return data.custom || null;
+    return data.presets?.[presetName] || null;
+}
+
+/**
+ * 主渲染函数:解析 timeline YAML → 渲染右侧面板
+ */
+function syncTimelineToUI() {
+    const panel = document.getElementById('timeline-panel');
+    if (!panel) return;
+
+    const data = parseTimelineData();
+    const activePreset = getActivePreset();
+
+    if (!data) {
+        panel.innerHTML = `
+            <div class="text-center py-12 text-gray-400">
+                <i class="fa-solid fa-calendar-xmark text-4xl mb-3"></i>
+                <p class="text-sm">请在左侧粘贴 timeline.yaml 内容</p>
+                <p class="text-xs mt-1">或点击右上角「加载官网最新配置」</p>
+            </div>`;
+        return;
+    }
+
+    let html = '';
+
+    // ── Layer 1: 预设模式选择卡片 ──
+    html += `<div class="mb-6">
+        <div class="tl-section-title"><i class="fa-solid fa-swatchbook"></i>调度模式</div>
+        <div class="grid grid-cols-2 gap-3" id="tl-preset-grid">`;
+
+    // 收集所有预设名
+    const presetNames = Object.keys(data.presets || {});
+    // 确保 custom 在最后
+    const allModes = [...presetNames.filter(n => n !== 'custom'), ...(data.custom ? ['custom'] : [])];
+
+    allModes.forEach(name => {
+        const meta = PRESET_META[name] || { icon: 'fa-puzzle-piece', color: 'text-gray-500', bg: 'bg-gray-50' };
+        const presetCfg = getPresetConfig(data, name);
+        const label = presetCfg?.name || meta.label || name;
+        const desc = presetCfg?.description || meta.desc || '';
+        const isActive = name === activePreset;
+        const isProtected = ['morning_evening', 'always_on', 'office_hours', 'night_owl', 'custom'].includes(name);
+        html += `
+            <div class="tl-preset-card ${isActive ? 'selected' : ''}" data-preset="${name}">
+                ${meta.recommend ? '<div class="tl-recommend-badge">推荐</div>' : ''}
+                <div class="flex items-center gap-3 cursor-pointer" onclick="selectTimelinePreset('${name}')">
+                    <div class="tl-card-icon ${meta.bg} ${meta.color}"><i class="fa-solid ${meta.icon}"></i></div>
+                    <div class="flex-1 min-w-0">
+                        <div class="text-sm font-bold text-gray-800 truncate tl-editable" ondblclick="event.stopPropagation();tlInlineEdit(this,'${name}','name','${escapeAttr(label)}')">${label}</div>
+                        <div class="text-[10px] text-gray-500 truncate tl-editable" ondblclick="event.stopPropagation();tlInlineEdit(this,'${name}','description','${escapeAttr(desc)}')">${desc}</div>
+                    </div>
+                </div>
+                <div class="tl-card-actions">
+                    <button onclick="event.stopPropagation();duplicateTlPreset('${name}')" class="tl-card-action-btn" title="复制"><i class="fa-regular fa-copy"></i></button>
+                    ${!isProtected ? `<button onclick="event.stopPropagation();deleteTlPreset('${name}')" class="tl-card-action-btn text-red-400 hover:text-red-600" title="删除"><i class="fa-regular fa-trash-can"></i></button>` : ''}
+                </div>
+                ${isActive ? '<div class="absolute bottom-1 right-2 text-[9px] text-blue-500 font-bold"><i class="fa-solid fa-check-circle mr-0.5"></i>当前</div>' : ''}
+            </div>`;
+    });
+
+    // 新建模式卡片
+    html += `
+        <div class="tl-preset-card tl-new-preset-card" onclick="openTlNewPresetModal()">
+            <div class="flex items-center gap-3">
+                <div class="tl-card-icon bg-gray-50 text-gray-400"><i class="fa-solid fa-plus"></i></div>
+                <div>
+                    <div class="text-sm font-bold text-gray-500">新建模式</div>
+                    <div class="text-[10px] text-gray-400">创建自定义调度方案</div>
+                </div>
+            </div>
+        </div>`;
+
+    html += `</div></div>`;
+
+    // 获取当前预设配置
+    const config = getPresetConfig(data, activePreset);
+
+    if (!config) {
+        html += `<div class="text-center py-6 text-gray-400 text-sm">
+            <i class="fa-solid fa-triangle-exclamation text-amber-400 mr-1"></i>
+            未找到预设「${activePreset}」的配置
+        </div>`;
+        panel.innerHTML = html;
+        return;
+    }
+
+    // ── Layer 2: 周视图时间线 ──
+    html += renderWeekView(config, activePreset);
+
+    // ── Layer 3: 时间段详情 ──
+    html += renderPeriodDetails(config, activePreset);
+
+    panel.innerHTML = html;
+
+    // 初始化日计划 Tag 拖拽排序
+    initDayPlanSortable(activePreset);
+}
+
+/**
+ * 渲染周视图(7 天 × 24 小时水平条)
+ */
+function renderWeekView(config, presetName) {
+    const periods = config.periods || {};
+    const dayPlans = config.day_plans || {};
+    const weekMap = config.week_map || {};
+
+    // 时间刻度
+    let html = `<div class="tl-week-view">
+        <div class="tl-section-title mb-2"><i class="fa-solid fa-calendar-week"></i>周视图</div>
+        <div class="tl-hour-markers">
+            <div style="width:2.5rem;flex-shrink:0"></div>
+            <div style="flex:1;display:flex;min-width:480px">`;
+
+    for (let h = 0; h <= 24; h += 2) {
+        html += `<div class="tl-hour-marker" style="width:${100/12}%;${h===24?'text-align:right;margin-left:-1em':''}">
+            ${h < 10 ? '0' : ''}${h}
+        </div>`;
+    }
+    html += `</div></div>`;
+
+    // 获取当前星期几 (1=周一...7=周日)
+    const today = new Date().getDay();
+    const todayIso = today === 0 ? 7 : today;
+
+    // 7 天的行
+    for (let d = 1; d <= 7; d++) {
+        const dayPlanName = weekMap[d] || weekMap[String(d)];
+        const dayPlan = dayPlans[dayPlanName];
+        const dayPeriodNames = dayPlan?.periods || [];
+        const isToday = d === todayIso;
+
+        html += `<div class="tl-week-row">
+            <div class="tl-day-label ${isToday ? 'today' : ''}">${DAY_NAMES[d-1]}</div>
+            <div class="tl-timeline-bar" data-day="${d}" onclick="onTlBarClick(event,'${presetName}',${d})">`;
+
+        // 渲染各时间段色块
+        dayPeriodNames.forEach(pName => {
+            const p = periods[pName];
+            if (!p) return;
+
+            const merged = mergeWithDefault(p, config.default);
+            const colorClass = getBlockColorClass(merged);
+            const blocks = computeBlocks(p.start, p.end);
+
+            blocks.forEach(b => {
+                const left = (b.start / 24 * 100).toFixed(2);
+                const width = ((b.end - b.start) / 24 * 100).toFixed(2);
+                const label = p.name || pName;
+                html += `<div class="tl-period-block ${colorClass}" style="left:${left}%;width:${width}%"
+                              onclick="scrollToPeriodCard('${pName}')"
+                              onmouseenter="showTlTooltip(event, '${escapeAttr(label)}', '${p.start||''}', '${p.end||''}', ${!!merged.push}, ${!!merged.analyze}, '${merged.report_mode||''}')"
+                              onmouseleave="hideTlTooltip()">
+                    <span class="tl-block-label">${label}</span>
+                </div>`;
+            });
+        });
+
+        // 当前时间指示线(仅今天)
+        if (isToday) {
+            const nowTime = new Date();
+            const nowH = nowTime.getHours() + nowTime.getMinutes() / 60;
+            const nowLeftPct = (nowH / 24 * 100).toFixed(2);
+            html += `<div class="tl-now-line" style="left:${nowLeftPct}%" title="当前时间 ${String(nowTime.getHours()).padStart(2,'0')}:${String(nowTime.getMinutes()).padStart(2,'0')}"></div>`;
+        }
+
+        html += `</div></div>`;
+    }
+
+    // 图例
+    html += `<div class="tl-legend">
+        <div class="tl-legend-item"><div class="tl-legend-color tl-block-push"></div>推送</div>
+        <div class="tl-legend-item"><div class="tl-legend-color tl-block-analyze"></div>AI 分析</div>
+        <div class="tl-legend-item"><div class="tl-legend-color tl-block-push-analyze"></div>推送 + 分析</div>
+        <div class="tl-legend-item"><div class="tl-legend-color tl-block-collect"></div>仅采集</div>
+        <div class="tl-legend-item"><div class="tl-legend-color" style="background:#f1f5f9;border:1px solid #e2e8f0"></div>默认 (default)</div>
+    </div>`;
+
+    html += `</div>`;
+    return html;
+}
+
+/**
+ * 合并 period 与 default(period 字段优先)
+ */
+function mergeWithDefault(period, defaultCfg) {
+    if (!defaultCfg) return period || {};
+    const merged = { ...defaultCfg, ...period };
+    if (period.once || defaultCfg.once) {
+        merged.once = { ...(defaultCfg.once || {}), ...(period.once || {}) };
+    }
+    return merged;
+}
+
+/**
+ * 根据 push/analyze 状态确定色块 CSS 类
+ */
+function getBlockColorClass(merged) {
+    const push = !!merged.push;
+    const analyze = !!merged.analyze;
+    if (push && analyze) return 'tl-block-push-analyze';
+    if (push) return 'tl-block-push';
+    if (analyze) return 'tl-block-analyze';
+    if (merged.collect !== false) return 'tl-block-collect';
+    return 'tl-block-silent';
+}
+
+/**
+ * 计算时间段的渲染块(处理跨午夜情况)
+ * 返回 [{start: 小时数, end: 小时数}, ...] 的数组
+ */
+function computeBlocks(startStr, endStr) {
+    if (!startStr || !endStr) return [];
+    const s = parseTime(startStr);
+    const e = parseTime(endStr);
+    if (s < e) return [{ start: s, end: e }];
+    // 跨午夜
+    return [{ start: s, end: 24 }, { start: 0, end: e }];
+}
+
+function parseTime(str) {
+    const [h, m] = (str || '00:00').split(':').map(Number);
+    return h + (m || 0) / 60;
+}
+
+function escapeAttr(s) {
+    return (s || '').replace(/'/g, "\\'").replace(/"/g, '&quot;');
+}
+
+/**
+ * Tooltip 显示/隐藏
+ */
+let tlTooltipEl = null;
+
+function showTlTooltip(event, name, start, end, push, analyze, mode) {
+    hideTlTooltip();
+    const el = document.createElement('div');
+    el.className = 'tl-tooltip';
+    let features = [];
+    if (push) features.push('<span style="color:#93c5fd">推送</span>');
+    if (analyze) features.push('<span style="color:#c4b5fd">分析</span>');
+    if (!push && !analyze) features.push('<span style="color:#94a3b8">仅采集</span>');
+
+    el.innerHTML = `<div style="font-weight:700;margin-bottom:2px">${name}</div>
+        <div style="font-size:11px;color:#9ca3af">${start} - ${end}</div>
+        <div style="margin-top:4px">${features.join(' / ')}</div>
+        ${mode ? `<div style="font-size:10px;color:#9ca3af;margin-top:2px">模式: ${mode}</div>` : ''}`;
+
+    document.body.appendChild(el);
+    tlTooltipEl = el;
+
+    const rect = event.target.getBoundingClientRect();
+    el.style.left = (rect.left + rect.width / 2 - el.offsetWidth / 2) + 'px';
+    el.style.top = (rect.top - el.offsetHeight - 8) + 'px';
+
+    // 确保不超出屏幕
+    const elRect = el.getBoundingClientRect();
+    if (elRect.left < 4) el.style.left = '4px';
+    if (elRect.right > window.innerWidth - 4) el.style.left = (window.innerWidth - el.offsetWidth - 4) + 'px';
+    if (elRect.top < 4) {
+        el.style.top = (rect.bottom + 8) + 'px';
+        el.style.setProperty('--arrow', 'top');
+    }
+}
+
+function hideTlTooltip() {
+    if (tlTooltipEl) {
+        tlTooltipEl.remove();
+        tlTooltipEl = null;
+    }
+}
+
+/**
+ * 渲染时间段详情面板
+ */
+function renderPeriodDetails(config, presetName) {
+    const isCustom = presetName === 'custom';
+    const periods = config.periods || {};
+    const dayPlans = config.day_plans || {};
+    const weekMap = config.week_map || {};
+    const defaults = config.default || {};
+
+    let html = '';
+
+    // ── Default 配置(默认展开)──
+    html += `<div class="tl-collapsible mt-4">
+        <div class="tl-collapsible-header" onclick="toggleTlCollapsible(this)">
+            <span><i class="fa-solid fa-gear mr-2 text-gray-400"></i>默认配置 (default)</span>
+            <i class="fa-solid fa-chevron-down text-gray-400 text-xs"></i>
+        </div>
+        <div class="tl-collapsible-body">
+            <div class="text-xs text-gray-500 mb-2">不在任何时间段内时,使用以下配置:</div>
+            ${renderBehaviorToggles(defaults, presetName, 'default')}
+        </div>
+    </div>`;
+
+    // ── 时间段列表 ──
+    const periodEntries = Object.entries(periods);
+    html += `<div class="mt-6">
+        <div class="tl-section-title flex items-center justify-between">
+            <span><i class="fa-solid fa-puzzle-piece"></i>时间段 (Periods)</span>
+            <button onclick="openTlNewPeriodModal('${presetName}')" class="tl-add-btn"><i class="fa-solid fa-plus mr-1"></i>新增</button>
+        </div>`;
+
+    if (periodEntries.length > 0) {
+        html += `<div class="space-y-3">`;
+        periodEntries.forEach(([key, p]) => {
+            const merged = mergeWithDefault(p, defaults);
+            const colorClass = getBlockColorClass(merged);
+            html += `<div class="tl-period-card" id="tl-period-${key}">
+                <div class="flex items-center justify-between mb-2">
+                    <div class="flex items-center gap-2">
+                        <div class="w-3 h-3 rounded ${colorClass}"></div>
+                        <span class="text-sm font-bold text-gray-800 tl-editable" ondblclick="tlInlineEditPeriod(this,'${presetName}','${key}','${escapeAttr(p.name || key)}')">${p.name || key}</span>
+                        <span class="text-[10px] text-gray-400 font-mono">${key}</span>
+                    </div>
+                    <div class="flex items-center gap-2">
+                        <span class="text-xs text-gray-500 font-mono">${p.start || '?'} - ${p.end || '?'}</span>
+                        <button onclick="duplicateTlPeriod('${presetName}','${key}')" class="tl-inline-btn" title="复制"><i class="fa-regular fa-copy"></i></button>
+                        <button onclick="deleteTlPeriod('${presetName}','${key}')" class="tl-inline-btn text-red-400 hover:text-red-600" title="删除"><i class="fa-regular fa-trash-can"></i></button>
+                    </div>
+                </div>
+                ${renderBehaviorToggles(merged, presetName, key)}
+            </div>`;
+        });
+        html += `</div>`;
+    } else {
+        html += `<div class="text-xs text-gray-400 text-center py-4">
+            <i class="fa-solid fa-info-circle mr-1"></i>此模式无自定义时间段,全天使用 default 配置
+        </div>`;
+    }
+
+    html += `</div>`;
+
+    // ── 日计划 ──
+    const dayPlanEntries = Object.entries(dayPlans);
+    html += `<div class="mt-6">
+        <div class="tl-section-title flex items-center justify-between">
+            <span><i class="fa-solid fa-list-ol"></i>日计划 (Day Plans)</span>
+            <button onclick="addTlDayPlan('${presetName}')" class="tl-add-btn"><i class="fa-solid fa-plus mr-1"></i>新增</button>
+        </div>`;
+
+    if (dayPlanEntries.length > 0) {
+        html += `<div class="space-y-2">`;
+        dayPlanEntries.forEach(([name, plan]) => {
+            const pList = plan.periods || [];
+            // 构建可用 period 下拉(排除已添加的)
+            const availablePeriods = periodEntries.filter(([k]) => !pList.includes(k));
+            html += `<div class="bg-white border border-gray-200 rounded-lg px-3 py-2 tl-dayplan-card">
+                <div class="flex items-center justify-between mb-1">
+                    <span class="text-xs font-bold text-gray-700">${name}</span>
+                    <button onclick="deleteTlDayPlan('${presetName}','${name}')" class="tl-inline-btn text-red-400 hover:text-red-600" title="删除日计划"><i class="fa-regular fa-trash-can"></i></button>
+                </div>
+                <div class="flex flex-wrap gap-1 items-center tl-dayplan-sortable" data-plan-key="${name}">
+                    ${pList.length > 0 ? pList.map(pn => {
+                        const p = periods[pn];
+                        const merged = p ? mergeWithDefault(p, defaults) : {};
+                        const cc = getBlockColorClass(merged);
+                        return `<span class="tl-period-tag ${cc}" data-period-key="${pn}">
+                            ${p?.name || pn}
+                            <button onclick="removePeriodFromDayPlanUI('${presetName}','${name}','${pn}')" class="tl-tag-remove" title="移除">&times;</button>
+                        </span>`;
+                    }).join('') : '<span class="text-[10px] text-gray-400">空 (全天走 default)</span>'}
+                    ${availablePeriods.length > 0 ? `
+                        <select class="tl-add-period-select" onchange="if(this.value){addPeriodToDayPlan('${presetName}','${name}',this.value);this.value=''}">
+                            <option value="">+ 添加</option>
+                            ${availablePeriods.map(([k, p]) => `<option value="${k}">${p.name || k}</option>`).join('')}
+                        </select>
+                    ` : ''}
+                </div>
+            </div>`;
+        });
+        html += `</div>`;
+    }
+
+    html += `</div>`;
+
+    // ── 周映射(下拉选择)──
+    const dayPlanKeys = Object.keys(dayPlans);
+
+    // 为不同日计划分配颜色
+    const planColorMap = {};
+    const planColors = ['bg-blue-50 border-blue-200', 'bg-green-50 border-green-200', 'bg-amber-50 border-amber-200', 'bg-purple-50 border-purple-200', 'bg-rose-50 border-rose-200', 'bg-cyan-50 border-cyan-200', 'bg-orange-50 border-orange-200'];
+    dayPlanKeys.forEach((k, idx) => { planColorMap[k] = planColors[idx % planColors.length]; });
+
+    html += `<div class="mt-6">
+        <div class="tl-section-title"><i class="fa-solid fa-calendar-days"></i>周映射 (Week Map)</div>
+        <div class="bg-white border border-gray-200 rounded-lg px-3 py-2 space-y-1">`;
+
+    for (let d = 1; d <= 7; d++) {
+        const plan = weekMap[d] || weekMap[String(d)] || '';
+        const rowColor = planColorMap[plan] || '';
+        const options = dayPlanKeys.map(k =>
+            `<option value="${k}" ${k === plan ? 'selected' : ''}>${k}</option>`
+        ).join('');
+        html += `<div class="tl-dayplan-row ${rowColor} rounded px-2">
+            <div class="tl-dayplan-label">${DAY_NAMES[d-1]}</div>
+            <select class="tl-weekmap-select"
+                    onchange="onTlWeekMap('${presetName}',${d},this.value)">
+                ${options}
+            </select>
+        </div>`;
+    }
+
+    html += `</div>
+        <div class="flex gap-2 mt-2">
+            <button onclick="tlWeekMapQuick('${presetName}','all_same')" class="tl-quick-btn">全周统一</button>
+            <button onclick="tlWeekMapQuick('${presetName}','weekday_same')" class="tl-quick-btn">工作日统一</button>
+            <button onclick="tlWeekMapQuick('${presetName}','weekday_weekend')" class="tl-quick-btn">工作日/周末</button>
+        </div>
+    </div>`;
+
+    // 提示
+    if (!isCustom) {
+        html += `<div class="mt-4 text-xs text-gray-400 p-3 bg-gray-50 rounded-lg border border-gray-200">
+            <i class="fa-solid fa-lightbulb mr-1 text-amber-400"></i>
+            直接在上方调整开关和下拉框,左侧 YAML 会同步更新。如需更精细的控制,可直接编辑左侧 YAML 或修改 <strong>timeline.yaml</strong>。
+        </div>`;
+    } else {
+        html += `<div class="mt-4 text-xs text-gray-400 p-3 bg-purple-50 rounded-lg border border-purple-200">
+            <i class="fa-solid fa-pen-ruler mr-1 text-purple-400"></i>
+            自定义模式支持完全自由编辑。可直接在上方调整控件,或在左侧编辑 YAML 文本,两边实时同步。
+        </div>`;
+    }
+
+    return html;
+}
+
+/**
+ * 渲染行为开关(可交互)
+ * presetName: 当前预设名(用于定位 YAML 中的位置)
+ * periodKey: 'default' 或时间段 key(如 'weekday_morning')
+ */
+function renderBehaviorToggles(cfg, presetName, periodKey) {
+    const toggleItems = [
+        { k: 'collect', label: '采集', icon: 'fa-download' },
+        { k: 'analyze', label: '分析', icon: 'fa-brain' },
+        { k: 'push', label: '推送', icon: 'fa-bell' },
+    ];
+
+    const uid = `tl-${presetName}-${periodKey}`;
+
+    let html = '<div class="tl-toggle-row">';
+    toggleItems.forEach(item => {
+        const val = cfg[item.k];
+        const on = val === true || val === 'true';
+        const toggleId = `${uid}-${item.k}`;
+        html += `<label class="tl-toggle-item ${on ? 'on' : 'off'}" for="${toggleId}" style="cursor:pointer">
+            <div class="relative inline-block w-8 mr-1 align-middle select-none">
+                <input type="checkbox" id="${toggleId}" ${on ? 'checked' : ''}
+                    onchange="onTlToggle('${presetName}','${periodKey}','${item.k}',this.checked)"
+                    class="toggle-checkbox absolute block w-4 h-4 rounded-full bg-white border-4 appearance-none cursor-pointer transition-all duration-200 ease-in-out" style="top:0"/>
+                <label for="${toggleId}" class="toggle-label block overflow-hidden h-4 rounded-full bg-gray-300 cursor-pointer"></label>
+            </div>
+            <i class="fa-solid ${item.icon}" style="font-size:10px"></i>${item.label}
+        </label>`;
+    });
+    html += '</div>';
+
+    // 报告模式下拉
+    const reportModes = ['current', 'daily', 'incremental'];
+    const aiModes = ['follow_report', 'daily', 'current', 'incremental'];
+
+    html += `<div class="flex flex-wrap gap-2 mt-2 items-center">`;
+
+    // report_mode
+    html += `<div class="flex items-center gap-1">
+        <span class="text-[10px] text-gray-400">报告:</span>
+        <select class="text-[10px] border border-gray-200 rounded px-1 py-0.5 bg-white"
+                onchange="onTlSelect('${presetName}','${periodKey}','report_mode',this.value)">
+            ${reportModes.map(m => `<option value="${m}" ${cfg.report_mode === m ? 'selected' : ''}>${m}</option>`).join('')}
+        </select>
+    </div>`;
+
+    // ai_mode
+    html += `<div class="flex items-center gap-1">
+        <span class="text-[10px] text-gray-400">AI:</span>
+        <select class="text-[10px] border border-gray-200 rounded px-1 py-0.5 bg-white"
+                onchange="onTlSelect('${presetName}','${periodKey}','ai_mode',this.value)">
+            ${aiModes.map(m => `<option value="${m}" ${(cfg.ai_mode || 'follow_report') === m ? 'selected' : ''}>${m}</option>`).join('')}
+        </select>
+    </div>`;
+
+    // once toggles
+    const onceAnalyze = cfg.once?.analyze === true;
+    const oncePush = cfg.once?.push === true;
+    html += `<label class="flex items-center gap-1 text-[10px] ${onceAnalyze ? 'text-blue-600' : 'text-gray-400'}" style="cursor:pointer">
+        <input type="checkbox" ${onceAnalyze ? 'checked' : ''}
+               onchange="onTlToggle('${presetName}','${periodKey}','once.analyze',this.checked)"
+               class="w-3 h-3 rounded">仅分析一次
+    </label>`;
+    html += `<label class="flex items-center gap-1 text-[10px] ${oncePush ? 'text-blue-600' : 'text-gray-400'}" style="cursor:pointer">
+        <input type="checkbox" ${oncePush ? 'checked' : ''}
+               onchange="onTlToggle('${presetName}','${periodKey}','once.push',this.checked)"
+               class="w-3 h-3 rounded">仅推送一次
+    </label>`;
+
+    html += `</div>`;
+
+    // 时间段编辑(仅非 default)
+    if (periodKey !== 'default' && (cfg.start || cfg.end)) {
+        html += `<div class="flex items-center gap-2 mt-2">
+            <span class="text-[10px] text-gray-400">时间:</span>
+            <input type="time" value="${cfg.start || ''}" class="text-xs border border-gray-200 rounded px-1.5 py-0.5"
+                   onchange="onTlSelect('${presetName}','${periodKey}','start',this.value)">
+            <span class="text-gray-300">~</span>
+            <input type="time" value="${cfg.end || ''}" class="text-xs border border-gray-200 rounded px-1.5 py-0.5"
+                   onchange="onTlSelect('${presetName}','${periodKey}','end',this.value)">
+        </div>`;
+    }
+
+    return html;
+}
+
+/**
+ * 点击周视图色块 → 滚动到对应 period 卡片并高亮
+ */
+window.scrollToPeriodCard = function(periodKey) {
+    const card = document.getElementById('tl-period-' + periodKey);
+    if (!card) return;
+    card.scrollIntoView({ behavior: 'smooth', block: 'center' });
+    card.classList.add('tl-period-highlight');
+    setTimeout(() => card.classList.remove('tl-period-highlight'), 1500);
+}
+
+/**
+ * 折叠/展开切换
+ */
+window.toggleTlCollapsible = function(header) {
+    const body = header.nextElementSibling;
+    body.classList.toggle('collapsed');
+    header.classList.toggle('is-collapsed');
+}
+
+/**
+ * 右侧开关变更 → 更新左侧 timeline YAML
+ */
+window.onTlToggle = function(presetName, periodKey, field, value) {
+    updateTimelineField(presetName, periodKey, field, value);
+}
+
+window.onTlSelect = function(presetName, periodKey, field, value) {
+    updateTimelineField(presetName, periodKey, field, value);
+}
+
+/**
+ * 周映射下拉变更 → 更新 timeline YAML 中的 week_map.N
+ */
+window.onTlWeekMap = function(presetName, dayNum, value) {
+    const editor = document.getElementById('timeline-editor');
+    let yaml = editor.value;
+    const lines = yaml.split('\n');
+
+    // 定位 preset section
+    const isCustom = presetName === 'custom';
+    let sectionStart = -1;
+    let sectionIndent = 0;
+
+    if (isCustom) {
+        for (let i = 0; i < lines.length; i++) {
+            if (/^custom:\s*/.test(lines[i])) { sectionStart = i; break; }
+        }
+    } else {
+        let inPresets = false;
+        for (let i = 0; i < lines.length; i++) {
+            const line = lines[i];
+            if (/^presets:\s*/.test(line)) { inPresets = true; continue; }
+            if (inPresets && /^\S/.test(line) && !line.startsWith('#')) break;
+            if (inPresets) {
+                const m = line.match(/^(\s+)(\S+):\s*/);
+                if (m && m[2] === presetName) { sectionStart = i; sectionIndent = m[1].length; break; }
+            }
+        }
+    }
+
+    if (sectionStart < 0) return;
+
+    let sectionEnd = lines.length;
+    for (let i = sectionStart + 1; i < lines.length; i++) {
+        const line = lines[i];
+        if (line.trim() === '' || line.trim().startsWith('#')) continue;
+        if (line.search(/\S/) <= sectionIndent) { sectionEnd = i; break; }
+    }
+
+    // 找 week_map: 行
+    const weekMapLine = findChildKey(lines, sectionStart, sectionEnd, sectionIndent, 'week_map');
+    if (weekMapLine < 0) return;
+
+    const wmIndent = lines[weekMapLine].search(/\S/);
+    const wmEnd = findBlockEnd(lines, weekMapLine, wmIndent, sectionEnd);
+
+    // 找 dayNum: 行
+    const dayKey = String(dayNum);
+    const dayLine = findChildKey(lines, weekMapLine, wmEnd, wmIndent, dayKey);
+
+    if (dayLine >= 0) {
+        replaceLineValue(lines, dayLine, value);
+    }
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+
+    clearTimeout(window._tlRenderTimer);
+    window._tlRenderTimer = setTimeout(() => syncTimelineToUI(), 300);
+}
+
+/**
+ * 核心:修改 timeline YAML 中的指定字段,保留注释
+ */
+function updateTimelineField(presetName, periodKey, field, value) {
+    const editor = document.getElementById('timeline-editor');
+    let yaml = editor.value;
+    const lines = yaml.split('\n');
+
+    // 1. 定位预设/custom 的起始行
+    const isCustom = presetName === 'custom';
+    let sectionStart = -1;
+    let sectionIndent = 0;
+
+    if (isCustom) {
+        // 找 custom: 顶层 key
+        for (let i = 0; i < lines.length; i++) {
+            if (/^custom:\s*/.test(lines[i])) {
+                sectionStart = i;
+                sectionIndent = 0;
+                break;
+            }
+        }
+    } else {
+        // 找 presets: 下的 presetName:
+        let inPresets = false;
+        for (let i = 0; i < lines.length; i++) {
+            const line = lines[i];
+            if (/^presets:\s*/.test(line)) {
+                inPresets = true;
+                continue;
+            }
+            if (inPresets && /^\S/.test(line) && !line.startsWith('#')) {
+                break; // left presets block
+            }
+            if (inPresets) {
+                const m = line.match(/^(\s+)(\S+):\s*/);
+                if (m && m[2] === presetName) {
+                    sectionStart = i;
+                    sectionIndent = m[1].length;
+                    break;
+                }
+            }
+        }
+    }
+
+    if (sectionStart < 0) return;
+
+    // 2. 找到 section 结束行
+    let sectionEnd = lines.length;
+    for (let i = sectionStart + 1; i < lines.length; i++) {
+        const line = lines[i];
+        if (line.trim() === '' || line.trim().startsWith('#')) continue;
+        const indent = line.search(/\S/);
+        if (indent <= sectionIndent) {
+            sectionEnd = i;
+            break;
+        }
+    }
+
+    // 3. 在 section 内定位 periodKey 子区域
+    let targetStart, targetEnd;
+    const fieldParts = field.split('.');
+
+    if (periodKey === 'default') {
+        // 找 default: 行
+        targetStart = findChildKey(lines, sectionStart, sectionEnd, sectionIndent, 'default');
+    } else {
+        // 找 periods: 下的 periodKey:
+        const periodsLine = findChildKey(lines, sectionStart, sectionEnd, sectionIndent, 'periods');
+        if (periodsLine < 0) return;
+        const periodsIndent = lines[periodsLine].search(/\S/);
+        const periodsEnd = findBlockEnd(lines, periodsLine, periodsIndent, sectionEnd);
+        targetStart = findChildKey(lines, periodsLine, periodsEnd, periodsIndent, periodKey);
+    }
+
+    if (targetStart < 0) return;
+
+    const targetIndent = lines[targetStart].search(/\S/);
+    targetEnd = findBlockEnd(lines, targetStart, targetIndent, sectionEnd);
+
+    // 4. 在 target 内查找 field(支持 once.analyze 嵌套)
+    let lineIdx = -1;
+
+    if (fieldParts.length === 1) {
+        lineIdx = findChildKey(lines, targetStart, targetEnd, targetIndent, fieldParts[0]);
+    } else {
+        // nested: once.analyze → find once: then analyze:
+        const parentLine = findChildKey(lines, targetStart, targetEnd, targetIndent, fieldParts[0]);
+        if (parentLine >= 0) {
+            const parentIndent = lines[parentLine].search(/\S/);
+            const parentEnd = findBlockEnd(lines, parentLine, parentIndent, targetEnd);
+            lineIdx = findChildKey(lines, parentLine, parentEnd, parentIndent, fieldParts[1]);
+        }
+    }
+
+    if (lineIdx < 0) {
+        // 字段不存在 → 需要插入
+        insertTimelineField(lines, targetStart, targetEnd, targetIndent, field, value, fieldParts);
+    } else {
+        // 字段存在 → 原地替换值
+        replaceLineValue(lines, lineIdx, value);
+    }
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+
+    // 延迟重新渲染(避免输入中途刷新)
+    clearTimeout(window._tlRenderTimer);
+    window._tlRenderTimer = setTimeout(() => syncTimelineToUI(), 300);
+}
+
+/**
+ * 查找子级 key 行
+ */
+function findChildKey(lines, start, end, parentIndent, key) {
+    for (let i = start + 1; i < end; i++) {
+        const line = lines[i];
+        if (line.trim() === '' || line.trim().startsWith('#')) continue;
+        const indent = line.search(/\S/);
+        if (indent <= parentIndent) break;
+        const m = line.match(/^\s*(\S+):\s*/);
+        if (m && m[1] === key && indent === parentIndent + 2) {
+            return i;
+        }
+    }
+    return -1;
+}
+
+/**
+ * 找一个 block 的结束行号(下一个同级或更低缩进的非空非注释行)
+ */
+function findBlockEnd(lines, start, indent, maxEnd) {
+    for (let i = start + 1; i < maxEnd; i++) {
+        const line = lines[i];
+        if (line.trim() === '' || line.trim().startsWith('#')) continue;
+        const curIndent = line.search(/\S/);
+        if (curIndent <= indent) return i;
+    }
+    return maxEnd;
+}
+
+/**
+ * 替换行中的值,保留注释
+ */
+function replaceLineValue(lines, idx, value) {
+    const original = lines[idx];
+    const match = original.match(/^(\s*\S+:\s*)(.*)$/);
+    if (!match) return;
+
+    const prefix = match[1];
+    const rest = match[2];
+    const commentMatch = rest.match(/(\s*#.*)$/);
+    const comment = commentMatch ? commentMatch[1] : '';
+
+    let formatted;
+    if (typeof value === 'boolean') {
+        formatted = value ? 'true' : 'false';
+    } else if (typeof value === 'string') {
+        // 检查原值是否带引号
+        const valPart = rest.slice(0, rest.length - comment.length).trim();
+        const isQuoted = (valPart.startsWith('"') && valPart.endsWith('"')) ||
+                         (valPart.startsWith("'") && valPart.endsWith("'"));
+        if (isQuoted || value.includes(':') || value.includes('#') || value.includes(' ')) {
+            formatted = `"${value}"`;
+        } else {
+            formatted = value;
+        }
+    } else {
+        formatted = String(value);
+    }
+
+    lines[idx] = `${prefix}${formatted}${comment}`;
+}
+
+/**
+ * 字段不存在时,插入新行
+ */
+function insertTimelineField(lines, targetStart, targetEnd, targetIndent, field, value, fieldParts) {
+    const indent = ' '.repeat(targetIndent + 2);
+
+    let formatted;
+    if (typeof value === 'boolean') formatted = value ? 'true' : 'false';
+    else if (typeof value === 'string') formatted = value.includes(':') ? `"${value}"` : value;
+    else formatted = String(value);
+
+    if (fieldParts.length === 1) {
+        // 直接在 target 的末尾插入
+        lines.splice(targetEnd, 0, `${indent}${field}: ${formatted}`);
+    } else {
+        // once.analyze → find or create once: block, then insert child
+        const parentLine = findChildKey(lines, targetStart, targetEnd, targetIndent, fieldParts[0]);
+        if (parentLine >= 0) {
+            const parentIndent = lines[parentLine].search(/\S/);
+            const parentEnd = findBlockEnd(lines, parentLine, parentIndent, targetEnd);
+            const childIndent = ' '.repeat(parentIndent + 2);
+            lines.splice(parentEnd, 0, `${childIndent}${fieldParts[1]}: ${formatted}`);
+        } else {
+            // parent doesn't exist → create both
+            lines.splice(targetEnd, 0,
+                `${indent}${fieldParts[0]}:`,
+                `${indent}  ${fieldParts[1]}: ${formatted}`
+            );
+        }
+    }
+}
+
+/**
+ * 点击预设卡片 → 更新 config.yaml 中的 schedule.preset + 滚动左侧编辑器
+ */
+window.selectTimelinePreset = function(name) {
+    // 更新 config.yaml 中的 schedule.preset
+    const configEditor = document.getElementById('yaml-editor');
+    let yaml = configEditor.value;
+    const lines = yaml.split('\n');
+
+    let presetLineIdx = -1;
+    let inSchedule = false;
+
+    for (let i = 0; i < lines.length; i++) {
+        const line = lines[i];
+        if (/^schedule:\s*$/.test(line.trimEnd()) || /^schedule:\s*#/.test(line)) {
+            inSchedule = true;
+            continue;
+        }
+        if (inSchedule && /^\S/.test(line) && !line.startsWith('#')) {
+            inSchedule = false;
+        }
+        if (inSchedule && /^\s+preset:\s*/.test(line)) {
+            presetLineIdx = i;
+            break;
+        }
+    }
+
+    if (presetLineIdx >= 0) {
+        const original = lines[presetLineIdx];
+        const match = original.match(/^(\s*preset:\s*)(.*)$/);
+        if (match) {
+            const prefix = match[1];
+            const rest = match[2];
+            const commentMatch = rest.match(/(\s*#.*)$/);
+            const comment = commentMatch ? commentMatch[1] : '';
+            lines[presetLineIdx] = `${prefix}"${name}"${comment}`;
+        }
+    }
+
+    configEditor.value = lines.join('\n');
+    currentYaml = configEditor.value;
+    updateBackdrop('yaml-editor', 'yaml-backdrop');
+    debounceSaveConfig();
+
+    // 左侧 timeline 编辑器跳转到对应预设
+    scrollTimelineEditorToPreset(name);
+
+    // 重新渲染 timeline 面板
+    syncTimelineToUI();
+    const tlData = parseTimelineData();
+    const tlCfg = getPresetConfig(tlData, name);
+    const displayName = tlCfg?.name || name;
+    showToast(`已切换至「${displayName}」模式`, 'success');
+}
+
+/**
+ * 滚动左侧 timeline 编辑器到对应预设位置
+ */
+function scrollTimelineEditorToPreset(presetName) {
+    const editor = document.getElementById('timeline-editor');
+    const text = editor.value;
+    const lines = text.split('\n');
+
+    let targetLine = -1;
+
+    if (presetName === 'custom') {
+        // 找顶层 custom:
+        for (let i = 0; i < lines.length; i++) {
+            if (/^custom:\s*/.test(lines[i])) {
+                targetLine = i;
+                break;
+            }
+        }
+    } else {
+        // 找 presets: 下的 presetName:
+        let inPresets = false;
+        for (let i = 0; i < lines.length; i++) {
+            const line = lines[i];
+            if (/^presets:\s*/.test(line)) {
+                inPresets = true;
+                continue;
+            }
+            if (inPresets && /^\S/.test(line) && !line.startsWith('#')) break;
+            if (inPresets) {
+                const m = line.match(/^\s+(\S+):\s*/);
+                if (m && m[1] === presetName) {
+                    targetLine = i;
+                    break;
+                }
+            }
+        }
+    }
+
+    if (targetLine < 0) return;
+
+    const lineHeight = 19.5;
+    const scrollPosition = targetLine * lineHeight;
+
+    // 设置光标位置
+    let charCount = 0;
+    for (let i = 0; i < targetLine; i++) {
+        charCount += lines[i].length + 1;
+    }
+
+    editor.focus();
+    editor.setSelectionRange(charCount, charCount + lines[targetLine].length);
+    editor.scrollTop = scrollPosition - 50;
+
+    // 高亮闪烁
+    editor.style.transition = 'background-color 0.3s';
+    const originalBg = editor.style.backgroundColor;
+    editor.style.backgroundColor = '#2d4a7c';
+    setTimeout(() => { editor.style.backgroundColor = originalBg; }, 300);
+}
+
+// ==========================================
+// 14. Timeline CRUD 功能(新建模式/时间段/日计划/删除等)
+// ==========================================
+
+// ── 弹窗:新建调度模式 ──
+
+window.openTlNewPresetModal = function() {
+    const modal = document.getElementById('tl-new-preset-modal');
+    // 填充模板下拉
+    const sel = document.getElementById('tl-new-preset-template');
+    const data = parseTimelineData();
+    sel.innerHTML = '<option value="">空白模板(仅采集,不推送不分析)</option>';
+    if (data?.presets) {
+        Object.keys(data.presets).forEach(k => {
+            const name = data.presets[k]?.name || k;
+            sel.innerHTML += `<option value="${k}">${name} (${k})</option>`;
+        });
+    }
+    if (data?.custom) {
+        sel.innerHTML += `<option value="custom">${data.custom.name || '自定义'} (custom)</option>`;
+    }
+    // 清空输入
+    document.getElementById('tl-new-preset-key').value = '';
+    document.getElementById('tl-new-preset-name').value = '';
+    document.getElementById('tl-new-preset-desc').value = '';
+    sel.value = '';
+    modal.classList.remove('hidden');
+}
+
+window.closeTlNewPresetModal = function() {
+    document.getElementById('tl-new-preset-modal').classList.add('hidden');
+}
+
+window.confirmTlNewPreset = function() {
+    const key = document.getElementById('tl-new-preset-key').value.trim();
+    const name = document.getElementById('tl-new-preset-name').value.trim();
+    const desc = document.getElementById('tl-new-preset-desc').value.trim();
+    const template = document.getElementById('tl-new-preset-template').value;
+
+    // 验证
+    if (!key) { showToast('请输入模式标识 (key)', 'error'); return; }
+    if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { showToast('key 仅支持英文、数字和下划线,且不能以数字开头', 'error'); return; }
+    if (!name) { showToast('请输入显示名称', 'error'); return; }
+
+    // 检查重复
+    const data = parseTimelineData();
+    if (data?.presets?.[key]) { showToast(`预设「${key}」已存在`, 'error'); return; }
+    if (key === 'custom') { showToast('不能使用 "custom" 作为预设名', 'error'); return; }
+
+    // 构建 YAML 文本块
+    let block;
+    if (template && data) {
+        const src = getPresetConfig(data, template);
+        if (src) {
+            block = buildPresetYamlBlock(key, { ...src, name: name, description: desc || src.description || '' });
+        } else {
+            block = buildEmptyPresetBlock(key, name, desc);
+        }
+    } else {
+        block = buildEmptyPresetBlock(key, name, desc);
+    }
+
+    // 插入到 timeline YAML 的 presets: 块末尾
+    const editor = document.getElementById('timeline-editor');
+    let yaml = editor.value;
+    const lines = yaml.split('\n');
+
+    // 找 presets: 块的结束位置
+    let presetsStart = -1;
+    for (let i = 0; i < lines.length; i++) {
+        if (/^presets:\s*/.test(lines[i])) { presetsStart = i; break; }
+    }
+
+    if (presetsStart < 0) {
+        // 没有 presets: 顶层 key,在文件开头插入
+        lines.unshift('presets:', ...block.split('\n'));
+    } else {
+        // 找 presets 块结束(下一个顶层 key)
+        let presetsEnd = lines.length;
+        for (let i = presetsStart + 1; i < lines.length; i++) {
+            if (/^\S/.test(lines[i]) && !lines[i].startsWith('#') && lines[i].trim() !== '') {
+                presetsEnd = i;
+                break;
+            }
+        }
+        // 在 presetsEnd 前插入(即 presets 块最后)
+        const blockLines = block.split('\n');
+        lines.splice(presetsEnd, 0, ...blockLines);
+    }
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+
+    // 切换 config.yaml 中 preset 为新模式
+    selectTimelinePreset(key);
+
+    closeTlNewPresetModal();
+    showToast(`调度模式「${name}」创建成功`, 'success');
+}
+
+/**
+ * 构建空白预设 YAML 文本块
+ */
+function buildEmptyPresetBlock(key, name, desc) {
+    return [
+        `  ${key}:`,
+        `    name: "${name}"`,
+        `    description: "${desc || ''}"`,
+        `    default:`,
+        `      collect: true`,
+        `      analyze: false`,
+        `      ai_mode: follow_report`,
+        `      push: false`,
+        `      report_mode: current`,
+        `      once:`,
+        `        analyze: false`,
+        `        push: false`,
+        `    periods: {}`,
+        `    day_plans:`,
+        `      all_day:`,
+        `        periods: []`,
+        `    week_map:`,
+        `      1: all_day`,
+        `      2: all_day`,
+        `      3: all_day`,
+        `      4: all_day`,
+        `      5: all_day`,
+        `      6: all_day`,
+        `      7: all_day`,
+        ``
+    ].join('\n');
+}
+
+/**
+ * 基于已有配置构建预设 YAML 文本块
+ */
+function buildPresetYamlBlock(key, cfg) {
+    const obj = { [key]: cfg };
+    const dumped = jsyaml.dump(obj, { indent: 2, lineWidth: -1, quotingType: '"', forceQuotes: false });
+    return dumped.split('\n').map(l => l ? '  ' + l : l).join('\n');
+}
+
+// ── 弹窗:新增时间段 ──
+
+let _tlNewPeriodTarget = '';
+
+window.openTlNewPeriodModal = function(presetName) {
+    _tlNewPeriodTarget = presetName;
+    document.getElementById('tl-new-period-key').value = '';
+    document.getElementById('tl-new-period-name').value = '';
+    document.getElementById('tl-new-period-start').value = '09:00';
+    document.getElementById('tl-new-period-end').value = '11:00';
+    document.getElementById('tl-new-period-modal').classList.remove('hidden');
+}
+
+window.closeTlNewPeriodModal = function() {
+    document.getElementById('tl-new-period-modal').classList.add('hidden');
+}
+
+window.confirmTlNewPeriod = function() {
+    const key = document.getElementById('tl-new-period-key').value.trim();
+    const name = document.getElementById('tl-new-period-name').value.trim();
+    const start = document.getElementById('tl-new-period-start').value;
+    const end = document.getElementById('tl-new-period-end').value;
+
+    if (!key) { showToast('请输入时间段标识 (key)', 'error'); return; }
+    if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { showToast('key 仅支持英文、数字和下划线', 'error'); return; }
+    if (!name) { showToast('请输入显示名称', 'error'); return; }
+    if (!start || !end) { showToast('请设置开始和结束时间', 'error'); return; }
+    if (start === end) { showToast('开始时间和结束时间不能相同', 'error'); return; }
+
+    const data = parseTimelineData();
+    const presetCfg = getPresetConfig(data, _tlNewPeriodTarget);
+    if (presetCfg?.periods?.[key]) { showToast(`时间段「${key}」已存在`, 'error'); return; }
+
+    const editor = document.getElementById('timeline-editor');
+    const lines = editor.value.split('\n');
+
+    const sectionInfo = findPresetSection(lines, _tlNewPeriodTarget);
+    if (!sectionInfo) { showToast('未找到预设配置段', 'error'); return; }
+
+    const periodsLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'periods');
+    if (periodsLine < 0) { showToast('未找到 periods 配置段', 'error'); return; }
+
+    const periodsIndent = lines[periodsLine].search(/\S/);
+    const periodsContent = lines[periodsLine].trim();
+    const childIndent = periodsIndent + 2;
+    const periodIndent = childIndent + 2;
+    const indent = ' '.repeat(childIndent);
+    const subIndent = ' '.repeat(periodIndent);
+
+    const newPeriodLines = [
+        `${indent}${key}:`,
+        `${subIndent}name: "${name}"`,
+        `${subIndent}start: "${start}"`,
+        `${subIndent}end: "${end}"`,
+        `${subIndent}collect: true`,
+        `${subIndent}analyze: false`,
+        `${subIndent}push: true`,
+        `${subIndent}report_mode: current`
+    ];
+
+    if (periodsContent === 'periods: {}' || periodsContent === 'periods:{}') {
+        lines[periodsLine] = ' '.repeat(periodsIndent) + 'periods:';
+        lines.splice(periodsLine + 1, 0, ...newPeriodLines);
+    } else {
+        const periodsEnd = findBlockEnd(lines, periodsLine, periodsIndent, sectionInfo.end);
+        lines.splice(periodsEnd, 0, ...newPeriodLines);
+    }
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+
+    closeTlNewPeriodModal();
+    syncTimelineToUI();
+    showToast(`时间段「${name}」添加成功`, 'success');
+}
+
+// ── 删除时间段 ──
+
+window.deleteTlPeriod = function(presetName, periodKey) {
+    const data = parseTimelineData();
+    const config = getPresetConfig(data, presetName);
+    if (!config) return;
+
+    const refs = [];
+    const dayPlans = config.day_plans || {};
+    Object.entries(dayPlans).forEach(([planName, plan]) => {
+        if ((plan.periods || []).includes(periodKey)) refs.push(planName);
+    });
+
+    const periodName = config.periods?.[periodKey]?.name || periodKey;
+    let msg = `确定删除时间段「${periodName}」?`;
+    if (refs.length > 0) {
+        msg += `\n\n⚠️ 该时间段被以下日计划引用,将同时移除引用:\n${refs.map(r => '  • ' + r).join('\n')}`;
+    }
+    if (!confirm(msg)) return;
+
+    const editor = document.getElementById('timeline-editor');
+    const lines = editor.value.split('\n');
+
+    const sectionInfo = findPresetSection(lines, presetName);
+    if (!sectionInfo) return;
+
+    const periodsLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'periods');
+    if (periodsLine >= 0) {
+        const periodsIndent = lines[periodsLine].search(/\S/);
+        const periodsEnd = findBlockEnd(lines, periodsLine, periodsIndent, sectionInfo.end);
+        const periodLine = findChildKey(lines, periodsLine, periodsEnd, periodsIndent, periodKey);
+        if (periodLine >= 0) {
+            const periodIndent = lines[periodLine].search(/\S/);
+            const periodEnd = findBlockEnd(lines, periodLine, periodIndent, periodsEnd);
+            lines.splice(periodLine, periodEnd - periodLine);
+        }
+    }
+
+    if (refs.length > 0) {
+        const updatedSection = findPresetSection(lines, presetName);
+        if (updatedSection) removePeriodFromDayPlans(lines, updatedSection, periodKey);
+    }
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+    syncTimelineToUI();
+    showToast(`时间段「${periodName}」已删除`, 'success');
+}
+
+// ── 复制时间段 ──
+
+window.duplicateTlPeriod = function(presetName, periodKey) {
+    const data = parseTimelineData();
+    const config = getPresetConfig(data, presetName);
+    if (!config?.periods?.[periodKey]) return;
+
+    let newKey = periodKey + '_copy';
+    let i = 2;
+    while (config.periods[newKey]) { newKey = periodKey + '_copy' + i; i++; }
+
+    const src = config.periods[periodKey];
+    const editor = document.getElementById('timeline-editor');
+    const lines = editor.value.split('\n');
+
+    const sectionInfo = findPresetSection(lines, presetName);
+    if (!sectionInfo) return;
+
+    const periodsLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'periods');
+    if (periodsLine < 0) return;
+
+    const periodsIndent = lines[periodsLine].search(/\S/);
+    const periodsEnd = findBlockEnd(lines, periodsLine, periodsIndent, sectionInfo.end);
+    const srcLine = findChildKey(lines, periodsLine, periodsEnd, periodsIndent, periodKey);
+    if (srcLine < 0) return;
+
+    const srcIndent = lines[srcLine].search(/\S/);
+    const srcEnd = findBlockEnd(lines, srcLine, srcIndent, periodsEnd);
+
+    const copiedLines = [];
+    for (let li = srcLine; li < srcEnd; li++) {
+        let line = lines[li];
+        if (li === srcLine) {
+            line = line.replace(periodKey, newKey);
+        }
+        copiedLines.push(line);
+    }
+    for (let li = 0; li < copiedLines.length; li++) {
+        const m = copiedLines[li].match(/^(\s*name:\s*).+$/);
+        if (m) {
+            const newName = (src.name || periodKey) + ' (副本)';
+            copiedLines[li] = `${m[1]}"${newName}"`;
+            break;
+        }
+    }
+
+    lines.splice(srcEnd, 0, ...copiedLines);
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+    syncTimelineToUI();
+    showToast(`已复制为「${newKey}」`, 'success');
+}
+
+// ── 删除预设模式 ──
+
+const PROTECTED_PRESETS = ['morning_evening', 'always_on', 'office_hours', 'night_owl'];
+
+window.deleteTlPreset = function(presetName) {
+    if (PROTECTED_PRESETS.includes(presetName)) {
+        showToast('内置预设不可删除,可使用复制功能', 'warning');
+        return;
+    }
+    if (presetName === 'custom') {
+        showToast('custom 模式不可删除', 'warning');
+        return;
+    }
+
+    const data = parseTimelineData();
+    const cfg = data?.presets?.[presetName];
+    const displayName = cfg?.name || presetName;
+
+    if (!confirm(`确定删除调度模式「${displayName}」?\n此操作不可撤销。`)) return;
+
+    const editor = document.getElementById('timeline-editor');
+    const lines = editor.value.split('\n');
+
+    const sectionInfo = findPresetSection(lines, presetName);
+    if (!sectionInfo) return;
+
+    lines.splice(sectionInfo.start, sectionInfo.end - sectionInfo.start);
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+
+    if (getActivePreset() === presetName) {
+        selectTimelinePreset('morning_evening');
+    } else {
+        syncTimelineToUI();
+    }
+    showToast(`调度模式「${displayName}」已删除`, 'success');
+}
+
+// ── 复制预设模式 ──
+
+window.duplicateTlPreset = function(presetName) {
+    const data = parseTimelineData();
+    const src = getPresetConfig(data, presetName);
+    if (!src) return;
+
+    openTlNewPresetModal();
+    const origName = src.name || presetName;
+    document.getElementById('tl-new-preset-key').value = presetName + '_copy';
+    document.getElementById('tl-new-preset-name').value = origName + ' (副本)';
+    document.getElementById('tl-new-preset-desc').value = src.description || '';
+    document.getElementById('tl-new-preset-template').value = presetName;
+}
+
+// ── 新增日计划 ──
+
+window.addTlDayPlan = function(presetName) {
+    const planKey = prompt('请输入日计划标识 (key),如 holiday:');
+    if (!planKey) return;
+    if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(planKey)) {
+        showToast('key 仅支持英文、数字和下划线', 'error');
+        return;
+    }
+
+    const data = parseTimelineData();
+    const config = getPresetConfig(data, presetName);
+    if (config?.day_plans?.[planKey]) {
+        showToast(`日计划「${planKey}」已存在`, 'error');
+        return;
+    }
+
+    const editor = document.getElementById('timeline-editor');
+    const lines = editor.value.split('\n');
+
+    const sectionInfo = findPresetSection(lines, presetName);
+    if (!sectionInfo) return;
+
+    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');
+    if (dayPlansLine < 0) return;
+
+    const dpIndent = lines[dayPlansLine].search(/\S/);
+    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionInfo.end);
+
+    const indent = ' '.repeat(dpIndent + 2);
+    const subIndent = ' '.repeat(dpIndent + 4);
+
+    lines.splice(dpEnd, 0,
+        `${indent}${planKey}:`,
+        `${subIndent}periods: []`
+    );
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+    syncTimelineToUI();
+    showToast(`日计划「${planKey}」已添加`, 'success');
+}
+
+// ── 删除日计划 ──
+
+window.deleteTlDayPlan = function(presetName, planKey) {
+    const data = parseTimelineData();
+    const config = getPresetConfig(data, presetName);
+    if (!config) return;
+
+    const weekMap = config.week_map || {};
+    const refs = [];
+    for (let d = 1; d <= 7; d++) {
+        const v = weekMap[d] || weekMap[String(d)];
+        if (v === planKey) refs.push(DAY_NAMES[d - 1]);
+    }
+
+    if (refs.length > 0) {
+        showToast(`无法删除:「${planKey}」正在被 ${refs.join('、')} 使用。请先修改周映射。`, 'error');
+        return;
+    }
+
+    if (!confirm(`确定删除日计划「${planKey}」?`)) return;
+
+    const editor = document.getElementById('timeline-editor');
+    const lines = editor.value.split('\n');
+
+    const sectionInfo = findPresetSection(lines, presetName);
+    if (!sectionInfo) return;
+
+    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');
+    if (dayPlansLine < 0) return;
+
+    const dpIndent = lines[dayPlansLine].search(/\S/);
+    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionInfo.end);
+    const planLine = findChildKey(lines, dayPlansLine, dpEnd, dpIndent, planKey);
+    if (planLine < 0) return;
+
+    const planIndent = lines[planLine].search(/\S/);
+    const planEnd = findBlockEnd(lines, planLine, planIndent, dpEnd);
+
+    lines.splice(planLine, planEnd - planLine);
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+    syncTimelineToUI();
+    showToast(`日计划「${planKey}」已删除`, 'success');
+}
+
+// ── 日计划中添加/移除时间段引用 ──
+
+window.addPeriodToDayPlan = function(presetName, planKey, periodKey) {
+    const editor = document.getElementById('timeline-editor');
+    const lines = editor.value.split('\n');
+
+    const sectionInfo = findPresetSection(lines, presetName);
+    if (!sectionInfo) return;
+
+    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');
+    if (dayPlansLine < 0) return;
+
+    const dpIndent = lines[dayPlansLine].search(/\S/);
+    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionInfo.end);
+    const planLine = findChildKey(lines, dayPlansLine, dpEnd, dpIndent, planKey);
+    if (planLine < 0) return;
+
+    const planIndent = lines[planLine].search(/\S/);
+    const planEnd = findBlockEnd(lines, planLine, planIndent, dpEnd);
+    const periodsLine = findChildKey(lines, planLine, planEnd, planIndent, 'periods');
+    if (periodsLine < 0) return;
+
+    const periodsContent = lines[periodsLine].trim();
+
+    if (periodsContent === 'periods: []' || periodsContent === 'periods:[]') {
+        const pIndent = ' '.repeat(lines[periodsLine].search(/\S/));
+        lines[periodsLine] = `${pIndent}periods:`;
+        lines.splice(periodsLine + 1, 0, `${pIndent}  - ${periodKey}`);
+    } else {
+        const inlineMatch = lines[periodsLine].match(/^(\s*periods:\s*)\[([^\]]*)\]/);
+        if (inlineMatch) {
+            const existing = inlineMatch[2].split(',').map(s => s.trim()).filter(Boolean);
+            // 保持引号风格一致
+            const hasQuotes = existing.length > 0 && existing[0].startsWith('"');
+            existing.push(hasQuotes ? `"${periodKey}"` : periodKey);
+            lines[periodsLine] = `${inlineMatch[1]}[${existing.join(', ')}]`;
+        } else {
+            const pIndent = ' '.repeat(lines[periodsLine].search(/\S/) + 2);
+            const listEnd = findBlockEnd(lines, periodsLine, lines[periodsLine].search(/\S/), planEnd);
+            lines.splice(listEnd, 0, `${pIndent}- ${periodKey}`);
+        }
+    }
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+    syncTimelineToUI();
+}
+
+window.removePeriodFromDayPlanUI = function(presetName, planKey, periodKey) {
+    const editor = document.getElementById('timeline-editor');
+    const lines = editor.value.split('\n');
+
+    const sectionInfo = findPresetSection(lines, presetName);
+    if (!sectionInfo) return;
+
+    removePeriodFromDayPlanInLines(lines, sectionInfo, planKey, periodKey);
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+    syncTimelineToUI();
+}
+
+// ── 周映射快捷操作 ──
+
+window.tlWeekMapQuick = function(presetName, mode) {
+    const data = parseTimelineData();
+    const config = getPresetConfig(data, presetName);
+    if (!config) return;
+
+    const dayPlanKeys = Object.keys(config.day_plans || {});
+    if (dayPlanKeys.length === 0) { showToast('没有可用的日计划', 'error'); return; }
+
+    let mapping = {};
+
+    if (mode === 'all_same') {
+        const plan = dayPlanKeys[0];
+        for (let d = 1; d <= 7; d++) mapping[d] = plan;
+    } else if (mode === 'weekday_same') {
+        const plan = dayPlanKeys[0];
+        for (let d = 1; d <= 5; d++) mapping[d] = plan;
+        const wm = config.week_map || {};
+        mapping[6] = wm[6] || wm['6'] || plan;
+        mapping[7] = wm[7] || wm['7'] || plan;
+    } else if (mode === 'weekday_weekend') {
+        if (dayPlanKeys.length < 2) { showToast('需要至少两个日计划来分离工作日/周末', 'warning'); return; }
+        const wd = dayPlanKeys[0];
+        const we = dayPlanKeys[1];
+        for (let d = 1; d <= 5; d++) mapping[d] = wd;
+        mapping[6] = we;
+        mapping[7] = we;
+    }
+
+    for (let d = 1; d <= 7; d++) {
+        if (mapping[d]) onTlWeekMap(presetName, d, mapping[d]);
+    }
+    showToast('周映射已更新', 'success');
+}
+
+// ── 辅助函数 ──
+
+/**
+ * 定位预设配置段的起始行和结束行
+ */
+function findPresetSection(lines, presetName) {
+    const isCustom = presetName === 'custom';
+    let start = -1;
+    let indent = 0;
+
+    if (isCustom) {
+        for (let i = 0; i < lines.length; i++) {
+            if (/^custom:\s*/.test(lines[i])) { start = i; indent = 0; break; }
+        }
+    } else {
+        let inPresets = false;
+        for (let i = 0; i < lines.length; i++) {
+            const line = lines[i];
+            if (/^presets:\s*/.test(line)) { inPresets = true; continue; }
+            if (inPresets && /^\S/.test(line) && !line.startsWith('#') && line.trim() !== '') break;
+            if (inPresets) {
+                const m = line.match(/^(\s+)(\S+):\s*/);
+                if (m && m[2] === presetName) { start = i; indent = m[1].length; break; }
+            }
+        }
+    }
+
+    if (start < 0) return null;
+
+    let end = lines.length;
+    for (let i = start + 1; i < lines.length; i++) {
+        const line = lines[i];
+        if (line.trim() === '' || line.trim().startsWith('#')) continue;
+        const curIndent = line.search(/\S/);
+        if (curIndent <= indent) { end = i; break; }
+    }
+
+    return { start, end, indent };
+}
+
+/**
+ * 从 day_plans 中批量移除对某 period 的引用
+ */
+function removePeriodFromDayPlans(lines, sectionInfo, periodKey) {
+    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');
+    if (dayPlansLine < 0) return;
+
+    const dpIndent = lines[dayPlansLine].search(/\S/);
+    const sectionEnd = findBlockEnd(lines, sectionInfo.start, sectionInfo.indent, lines.length);
+    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionEnd);
+
+    for (let i = dayPlansLine + 1; i < dpEnd; i++) {
+        const line = lines[i];
+        if (line.trim() === '' || line.trim().startsWith('#')) continue;
+        const listMatch = line.match(/^(\s*)-\s*(\S+)\s*$/);
+        if (listMatch && listMatch[2] === periodKey) {
+            lines.splice(i, 1);
+            i--;
+            continue;
+        }
+        const inlineMatch = line.match(/^(\s*periods:\s*)\[([^\]]*)\]/);
+        if (inlineMatch) {
+            const items = inlineMatch[2].split(',').map(s => s.trim()).filter(s => {
+                const bare = s.replace(/^["']|["']$/g, '');
+                return bare && bare !== periodKey;
+            });
+            lines[i] = items.length > 0
+                ? `${inlineMatch[1]}[${items.join(', ')}]`
+                : `${inlineMatch[1]}[]`;
+        }
+    }
+}
+
+/**
+ * 从指定 day_plan 中移除单个 period 引用
+ */
+function removePeriodFromDayPlanInLines(lines, sectionInfo, planKey, periodKey) {
+    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');
+    if (dayPlansLine < 0) return;
+
+    const dpIndent = lines[dayPlansLine].search(/\S/);
+    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionInfo.end);
+    const planLine = findChildKey(lines, dayPlansLine, dpEnd, dpIndent, planKey);
+    if (planLine < 0) return;
+
+    const planIndent = lines[planLine].search(/\S/);
+    const planEnd = findBlockEnd(lines, planLine, planIndent, dpEnd);
+    const periodsLine = findChildKey(lines, planLine, planEnd, planIndent, 'periods');
+    if (periodsLine < 0) return;
+
+    const inlineMatch = lines[periodsLine].match(/^(\s*periods:\s*)\[([^\]]*)\]/);
+    if (inlineMatch) {
+        const items = inlineMatch[2].split(',').map(s => s.trim()).filter(s => {
+            const bare = s.replace(/^["']|["']$/g, '');
+            return bare && bare !== periodKey;
+        });
+        lines[periodsLine] = items.length > 0
+            ? `${inlineMatch[1]}[${items.join(', ')}]`
+            : `${inlineMatch[1]}[]`;
+        return;
+    }
+
+    const pEnd = findBlockEnd(lines, periodsLine, lines[periodsLine].search(/\S/), planEnd);
+    for (let i = periodsLine + 1; i < pEnd; i++) {
+        const m = lines[i].match(/^(\s*)-\s*(\S+)\s*$/);
+        if (m && m[2] === periodKey) {
+            lines.splice(i, 1);
+            return;
+        }
+    }
+}
+
+// ==========================================
+// 15. 后续优化功能
+// ==========================================
+
+// ── 1.3 / 3A.4 内联编辑(双击编辑文本)──
+
+/**
+ * 预设卡片名称/描述内联编辑
+ */
+window.tlInlineEdit = function(el, presetName, field, currentValue) {
+    if (el.querySelector('input')) return;
+
+    const original = currentValue;
+    const isName = field === 'name';
+    const input = document.createElement('input');
+    input.type = 'text';
+    input.value = original;
+    input.className = `tl-inline-input ${isName ? 'text-sm font-bold' : 'text-[10px]'}`;
+    input.style.width = '100%';
+
+    el.textContent = '';
+    el.appendChild(input);
+    input.focus();
+    input.select();
+
+    const commit = () => {
+        const newVal = input.value.trim();
+        if (newVal && newVal !== original) {
+            updatePresetMeta(presetName, field, newVal);
+        }
+        syncTimelineToUI();
+    };
+
+    input.addEventListener('blur', commit);
+    input.addEventListener('keydown', e => {
+        if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
+        if (e.key === 'Escape') { el.textContent = original; }
+    });
+}
+
+/**
+ * 更新预设顶层的 name / description 字段
+ */
+function updatePresetMeta(presetName, field, value) {
+    const editor = document.getElementById('timeline-editor');
+    const lines = editor.value.split('\n');
+
+    const sectionInfo = findPresetSection(lines, presetName);
+    if (!sectionInfo) return;
+
+    const lineIdx = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, field);
+    if (lineIdx >= 0) {
+        replaceLineValue(lines, lineIdx, value);
+    } else {
+        const indent = ' '.repeat(sectionInfo.indent + 2);
+        lines.splice(sectionInfo.start + 1, 0, `${indent}${field}: "${value}"`);
+    }
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+}
+
+/**
+ * 时间段名称内联编辑
+ */
+window.tlInlineEditPeriod = function(el, presetName, periodKey, currentValue) {
+    if (el.querySelector('input')) return;
+
+    const original = currentValue;
+    const input = document.createElement('input');
+    input.type = 'text';
+    input.value = original;
+    input.className = 'tl-inline-input text-sm font-bold';
+    input.style.width = Math.max(80, original.length * 14) + 'px';
+
+    el.textContent = '';
+    el.appendChild(input);
+    input.focus();
+    input.select();
+
+    const commit = () => {
+        const newVal = input.value.trim();
+        if (newVal && newVal !== original) {
+            updateTimelineField(presetName, periodKey, 'name', newVal);
+        }
+        syncTimelineToUI();
+    };
+
+    input.addEventListener('blur', commit);
+    input.addEventListener('keydown', e => {
+        if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
+        if (e.key === 'Escape') { el.textContent = original; }
+    });
+}
+
+// ── 2.2 周视图空白区域点击 → 显示日计划名称 ──
+
+window.onTlBarClick = function(event, presetName, dayNum) {
+    if (event.target.closest('.tl-period-block')) return;
+
+    const data = parseTimelineData();
+    const config = getPresetConfig(data, presetName);
+    if (!config) return;
+
+    const weekMap = config.week_map || {};
+    const planKey = weekMap[dayNum] || weekMap[String(dayNum)] || '(未设置)';
+
+    hideTlTooltip();
+    const el = document.createElement('div');
+    el.className = 'tl-tooltip';
+    el.innerHTML = `<div style="font-weight:700;margin-bottom:2px">${DAY_NAMES[dayNum - 1]}</div>
+        <div style="font-size:11px;color:#9ca3af">日计划: <strong style="color:#374151">${planKey}</strong></div>
+        <div style="font-size:10px;color:#9ca3af;margin-top:4px">使用 default 配置</div>`;
+
+    document.body.appendChild(el);
+    tlTooltipEl = el;
+
+    const rect = event.currentTarget.getBoundingClientRect();
+    const x = event.clientX;
+    el.style.left = (x - el.offsetWidth / 2) + 'px';
+    el.style.top = (rect.top - el.offsetHeight - 8) + 'px';
+
+    const elRect = el.getBoundingClientRect();
+    if (elRect.left < 4) el.style.left = '4px';
+    if (elRect.right > window.innerWidth - 4) el.style.left = (window.innerWidth - el.offsetWidth - 4) + 'px';
+    if (elRect.top < 4) el.style.top = (rect.bottom + 8) + 'px';
+
+    setTimeout(() => { if (tlTooltipEl === el) hideTlTooltip(); }, 2000);
+}
+
+// ── 3B.5 日计划 Tag 拖拽排序 ──
+
+/**
+ * 为日计划中的 period tag 容器初始化 SortableJS
+ */
+function initDayPlanSortable(presetName) {
+    document.querySelectorAll('.tl-dayplan-sortable').forEach(container => {
+        const planKey = container.dataset.planKey;
+        if (!planKey) return;
+
+        new Sortable(container, {
+            animation: 150,
+            ghostClass: 'tl-tag-ghost',
+            dragClass: 'tl-tag-drag',
+            draggable: '.tl-period-tag',
+            filter: '.tl-add-period-select, .tl-tag-remove',
+            preventOnFilter: false,
+            onEnd: function() {
+                const items = [];
+                container.querySelectorAll('.tl-period-tag').forEach(tag => {
+                    const key = tag.dataset.periodKey;
+                    if (key) items.push(key);
+                });
+                reorderDayPlanPeriods(presetName, planKey, items);
+            }
+        });
+    });
+}
+
+/**
+ * 重新排列 day_plan 中 periods 的顺序
+ */
+function reorderDayPlanPeriods(presetName, planKey, orderedKeys) {
+    const editor = document.getElementById('timeline-editor');
+    const lines = editor.value.split('\n');
+
+    const sectionInfo = findPresetSection(lines, presetName);
+    if (!sectionInfo) return;
+
+    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');
+    if (dayPlansLine < 0) return;
+
+    const dpIndent = lines[dayPlansLine].search(/\S/);
+    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionInfo.end);
+    const planLine = findChildKey(lines, dayPlansLine, dpEnd, dpIndent, planKey);
+    if (planLine < 0) return;
+
+    const planIndent = lines[planLine].search(/\S/);
+    const planEnd = findBlockEnd(lines, planLine, planIndent, dpEnd);
+    const periodsLine = findChildKey(lines, planLine, planEnd, planIndent, 'periods');
+    if (periodsLine < 0) return;
+
+    const inlineMatch = lines[periodsLine].match(/^(\s*periods:\s*)\[([^\]]*)\]/);
+    if (inlineMatch) {
+        lines[periodsLine] = `${inlineMatch[1]}[${orderedKeys.join(', ')}]`;
+    } else {
+        const pIndent = lines[periodsLine].search(/\S/);
+        const pEnd = findBlockEnd(lines, periodsLine, pIndent, planEnd);
+        lines.splice(periodsLine + 1, pEnd - periodsLine - 1);
+        const itemIndent = ' '.repeat(pIndent + 2);
+        const newItems = orderedKeys.map(k => `${itemIndent}- ${k}`);
+        lines.splice(periodsLine + 1, 0, ...newItems);
+    }
+
+    editor.value = lines.join('\n');
+    currentTimeline = editor.value;
+    updateBackdrop('timeline-editor', 'timeline-backdrop');
+    debounceSaveTimeline();
+
+    clearTimeout(window._tlRenderTimer);
+    window._tlRenderTimer = setTimeout(() => syncTimelineToUI(), 500);
+}

+ 543 - 4
docs/assets/style.css

@@ -1,28 +1,36 @@
 /* 编辑器区域滚动条 */
 #yaml-editor::-webkit-scrollbar,
 #frequency-editor::-webkit-scrollbar,
+#timeline-editor::-webkit-scrollbar,
 #yaml-backdrop::-webkit-scrollbar,
-#frequency-backdrop::-webkit-scrollbar {
+#frequency-backdrop::-webkit-scrollbar,
+#timeline-backdrop::-webkit-scrollbar {
     width: 10px;
     height: 10px;
 }
 #yaml-editor::-webkit-scrollbar-track,
 #frequency-editor::-webkit-scrollbar-track,
+#timeline-editor::-webkit-scrollbar-track,
 #yaml-backdrop::-webkit-scrollbar-track,
-#frequency-backdrop::-webkit-scrollbar-track {
+#frequency-backdrop::-webkit-scrollbar-track,
+#timeline-backdrop::-webkit-scrollbar-track {
     background: #1e1e1e;
 }
 #yaml-editor::-webkit-scrollbar-thumb,
 #frequency-editor::-webkit-scrollbar-thumb,
+#timeline-editor::-webkit-scrollbar-thumb,
 #yaml-backdrop::-webkit-scrollbar-thumb,
-#frequency-backdrop::-webkit-scrollbar-thumb {
+#frequency-backdrop::-webkit-scrollbar-thumb,
+#timeline-backdrop::-webkit-scrollbar-thumb {
     background: #424242;
     border-radius: 0;
 }
 #yaml-editor::-webkit-scrollbar-thumb:hover,
 #frequency-editor::-webkit-scrollbar-thumb:hover,
+#timeline-editor::-webkit-scrollbar-thumb:hover,
 #yaml-backdrop::-webkit-scrollbar-thumb:hover,
-#frequency-backdrop::-webkit-scrollbar-thumb:hover {
+#frequency-backdrop::-webkit-scrollbar-thumb:hover,
+#timeline-backdrop::-webkit-scrollbar-thumb:hover {
     background: #4f4f4f;
 }
 
@@ -558,3 +566,534 @@ input[type="checkbox"]:disabled {
     color: white;
     transition: all 0.3s;
 }
+
+/* ==========================================
+   Timeline 编辑器样式
+   ========================================== */
+
+/* 预设模式选择卡片 */
+.tl-preset-card {
+    border: 2px solid #e5e7eb;
+    border-radius: 0.75rem;
+    padding: 0.875rem;
+    cursor: pointer;
+    transition: all 0.2s;
+    background: white;
+    position: relative;
+}
+.tl-preset-card:hover {
+    border-color: #93c5fd;
+    background: #f0f7ff;
+}
+.tl-preset-card.selected {
+    border-color: #3b82f6;
+    background: #eff6ff;
+    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
+}
+.tl-preset-card .tl-card-icon {
+    width: 2rem;
+    height: 2rem;
+    border-radius: 0.5rem;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 0.875rem;
+    flex-shrink: 0;
+}
+.tl-preset-card .tl-recommend-badge {
+    position: absolute;
+    top: -1px;
+    right: -1px;
+    background: linear-gradient(135deg, #f59e0b, #ef4444);
+    color: white;
+    font-size: 0.625rem;
+    font-weight: 700;
+    padding: 0.125rem 0.5rem;
+    border-radius: 0 0.625rem 0 0.5rem;
+}
+
+/* 周视图时间线 */
+.tl-week-view {
+    background: white;
+    border: 1px solid #e5e7eb;
+    border-radius: 0.75rem;
+    padding: 1rem;
+    overflow-x: auto;
+}
+.tl-week-row {
+    display: flex;
+    align-items: center;
+    height: 2.25rem;
+    margin-bottom: 0.25rem;
+}
+.tl-week-row:last-child {
+    margin-bottom: 0;
+}
+.tl-day-label {
+    width: 2.5rem;
+    flex-shrink: 0;
+    font-size: 0.6875rem;
+    font-weight: 600;
+    color: #6b7280;
+    text-align: right;
+    padding-right: 0.5rem;
+}
+.tl-day-label.today {
+    color: #3b82f6;
+    font-weight: 700;
+}
+.tl-timeline-bar {
+    flex: 1;
+    height: 1.75rem;
+    background: #f1f5f9;
+    border-radius: 0.25rem;
+    position: relative;
+    min-width: 480px;
+    overflow: hidden;
+}
+.tl-period-block {
+    position: absolute;
+    top: 2px;
+    bottom: 2px;
+    border-radius: 0.1875rem;
+    cursor: pointer;
+    transition: filter 0.15s, transform 0.15s;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: hidden;
+    z-index: 1;
+}
+.tl-period-block:hover {
+    filter: brightness(1.1);
+    transform: scaleY(1.15);
+    z-index: 2;
+}
+.tl-period-block .tl-block-label {
+    font-size: 0.5625rem;
+    font-weight: 600;
+    color: rgba(255,255,255,0.9);
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    overflow: hidden;
+    padding: 0 0.25rem;
+    text-shadow: 0 1px 2px rgba(0,0,0,0.2);
+}
+
+/* 时间段颜色 */
+.tl-block-push { background: #3b82f6; }
+.tl-block-analyze { background: #8b5cf6; }
+.tl-block-push-analyze { background: #6366f1; }
+.tl-block-collect { background: #94a3b8; }
+.tl-block-silent { background: #cbd5e1; }
+
+/* 时间刻度 */
+.tl-hour-markers {
+    display: flex;
+    padding-left: 2.5rem;
+    margin-bottom: 0.25rem;
+}
+.tl-hour-marker {
+    font-size: 0.5625rem;
+    color: #9ca3af;
+    text-align: center;
+}
+
+/* 图例 */
+.tl-legend {
+    display: flex;
+    gap: 0.75rem;
+    flex-wrap: wrap;
+    padding-top: 0.5rem;
+    border-top: 1px solid #f3f4f6;
+    margin-top: 0.5rem;
+}
+.tl-legend-item {
+    display: flex;
+    align-items: center;
+    gap: 0.25rem;
+    font-size: 0.625rem;
+    color: #6b7280;
+}
+.tl-legend-color {
+    width: 0.75rem;
+    height: 0.5rem;
+    border-radius: 0.125rem;
+}
+
+/* 时间段 Tooltip */
+.tl-tooltip {
+    position: fixed;
+    background: #1f2937;
+    color: white;
+    padding: 0.5rem 0.75rem;
+    border-radius: 0.375rem;
+    font-size: 0.75rem;
+    z-index: 1000;
+    pointer-events: none;
+    box-shadow: 0 4px 12px rgba(0,0,0,0.2);
+    max-width: 220px;
+}
+.tl-tooltip::after {
+    content: '';
+    position: absolute;
+    bottom: -4px;
+    left: 50%;
+    transform: translateX(-50%);
+    border-left: 5px solid transparent;
+    border-right: 5px solid transparent;
+    border-top: 5px solid #1f2937;
+}
+
+/* Custom 模式编辑面板 */
+.tl-section-title {
+    font-size: 0.75rem;
+    font-weight: 700;
+    color: #374151;
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    margin-bottom: 0.75rem;
+}
+.tl-section-title i {
+    color: #3b82f6;
+    font-size: 0.6875rem;
+}
+
+.tl-period-card {
+    background: white;
+    border: 1px solid #e5e7eb;
+    border-radius: 0.5rem;
+    padding: 0.75rem;
+    transition: all 0.2s;
+}
+.tl-period-card:hover {
+    border-color: #93c5fd;
+    box-shadow: 0 2px 4px rgba(0,0,0,0.05);
+}
+
+.tl-toggle-row {
+    display: flex;
+    align-items: center;
+    gap: 0.75rem;
+    flex-wrap: wrap;
+}
+.tl-toggle-item {
+    display: flex;
+    align-items: center;
+    gap: 0.375rem;
+    font-size: 0.6875rem;
+    color: #4b5563;
+}
+.tl-toggle-item.on { color: #2563eb; font-weight: 600; }
+.tl-toggle-item.off { color: #9ca3af; }
+
+/* Timeline 小型 toggle 开关 */
+.tl-toggle-item .toggle-checkbox {
+    width: 1rem;
+    height: 1rem;
+    border-width: 3px;
+}
+.tl-toggle-item .toggle-label {
+    height: 1rem;
+}
+.tl-toggle-item .toggle-checkbox:checked {
+    right: 0;
+    border-color: #3b82f6;
+}
+.tl-toggle-item .toggle-checkbox:checked + .toggle-label {
+    background-color: #3b82f6;
+}
+
+/* 日计划和周映射 */
+.tl-dayplan-row {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    padding: 0.375rem 0;
+}
+.tl-dayplan-label {
+    width: 3.5rem;
+    font-size: 0.6875rem;
+    font-weight: 600;
+    color: #374151;
+    flex-shrink: 0;
+}
+.tl-weekmap-select {
+    font-size: 0.75rem;
+    padding: 0.25rem 0.5rem;
+    border: 1px solid #d1d5db;
+    border-radius: 0.25rem;
+    background: white;
+    flex: 1;
+    max-width: 200px;
+}
+.tl-weekmap-select:focus {
+    border-color: #3b82f6;
+    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
+    outline: none;
+}
+
+/* Default 配置折叠面板 */
+.tl-collapsible {
+    border: 1px solid #e5e7eb;
+    border-radius: 0.5rem;
+    overflow: hidden;
+}
+.tl-collapsible-header {
+    background: #f9fafb;
+    padding: 0.625rem 0.75rem;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    font-size: 0.75rem;
+    font-weight: 600;
+    color: #4b5563;
+    transition: background 0.15s;
+}
+.tl-collapsible-header:hover {
+    background: #f3f4f6;
+}
+.tl-collapsible-body {
+    padding: 0.75rem;
+    border-top: 1px solid #e5e7eb;
+}
+.tl-collapsible-body.collapsed {
+    display: none;
+}
+.tl-collapsible-header .fa-chevron-down {
+    transition: transform 0.2s;
+}
+.tl-collapsible-header.is-collapsed .fa-chevron-down {
+    transform: rotate(-90deg);
+}
+
+/* Timeline CRUD 新增样式 */
+
+/* 预设卡片操作按钮 */
+.tl-card-actions {
+    display: none;
+    position: absolute;
+    top: 0.375rem;
+    right: 0.375rem;
+    gap: 0.25rem;
+    z-index: 2;
+}
+.tl-preset-card:hover .tl-card-actions {
+    display: flex;
+}
+.tl-card-action-btn {
+    width: 1.5rem;
+    height: 1.5rem;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 0.375rem;
+    font-size: 0.625rem;
+    color: #9ca3af;
+    background: rgba(255,255,255,0.9);
+    border: 1px solid #e5e7eb;
+    cursor: pointer;
+    transition: all 0.15s;
+}
+.tl-card-action-btn:hover {
+    color: #3b82f6;
+    background: white;
+    border-color: #93c5fd;
+}
+.tl-card-action-btn.text-red-400:hover {
+    color: #ef4444;
+    border-color: #fca5a5;
+}
+
+/* 新建模式卡片 */
+.tl-new-preset-card {
+    border-style: dashed;
+    border-color: #d1d5db;
+    background: #fafafa;
+}
+.tl-new-preset-card:hover {
+    border-color: #a78bfa;
+    background: #faf5ff;
+}
+
+/* section 内的新增按钮 */
+.tl-add-btn {
+    font-size: 0.625rem;
+    font-weight: 600;
+    color: #3b82f6;
+    background: #eff6ff;
+    border: 1px solid #bfdbfe;
+    border-radius: 0.375rem;
+    padding: 0.125rem 0.5rem;
+    cursor: pointer;
+    transition: all 0.15s;
+}
+.tl-add-btn:hover {
+    background: #dbeafe;
+    border-color: #93c5fd;
+}
+
+/* period 卡片内联操作 */
+.tl-inline-btn {
+    width: 1.375rem;
+    height: 1.375rem;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 0.25rem;
+    font-size: 0.625rem;
+    color: #9ca3af;
+    background: transparent;
+    border: none;
+    cursor: pointer;
+    transition: all 0.15s;
+    opacity: 0;
+}
+.tl-period-card:hover .tl-inline-btn,
+.tl-dayplan-card:hover .tl-inline-btn {
+    opacity: 1;
+}
+.tl-inline-btn:hover {
+    color: #3b82f6;
+    background: #eff6ff;
+}
+.tl-inline-btn.text-red-400:hover {
+    color: #ef4444;
+    background: #fef2f2;
+}
+
+/* 日计划中的 period tag */
+.tl-period-tag {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.25rem;
+    font-size: 0.625rem;
+    padding: 0.125rem 0.5rem;
+    border-radius: 9999px;
+    color: white;
+    white-space: nowrap;
+}
+.tl-tag-remove {
+    font-size: 0.75rem;
+    font-weight: 700;
+    line-height: 1;
+    color: rgba(255,255,255,0.7);
+    background: none;
+    border: none;
+    cursor: pointer;
+    padding: 0;
+    margin-left: 0.125rem;
+}
+.tl-tag-remove:hover {
+    color: white;
+}
+
+/* 添加时间段到日计划的 select */
+.tl-add-period-select {
+    font-size: 0.625rem;
+    padding: 0.0625rem 0.375rem;
+    border: 1px dashed #d1d5db;
+    border-radius: 9999px;
+    background: #f9fafb;
+    color: #6b7280;
+    cursor: pointer;
+    transition: all 0.15s;
+}
+.tl-add-period-select:hover {
+    border-color: #93c5fd;
+    color: #3b82f6;
+}
+
+/* 周映射快捷按钮 */
+.tl-quick-btn {
+    font-size: 0.625rem;
+    font-weight: 500;
+    color: #6b7280;
+    background: #f3f4f6;
+    border: 1px solid #e5e7eb;
+    border-radius: 0.375rem;
+    padding: 0.25rem 0.5rem;
+    cursor: pointer;
+    transition: all 0.15s;
+}
+.tl-quick-btn:hover {
+    color: #3b82f6;
+    background: #eff6ff;
+    border-color: #93c5fd;
+}
+
+/* 当前时间指示线 */
+.tl-now-line {
+    position: absolute;
+    top: -2px;
+    bottom: -2px;
+    width: 2px;
+    background: #ef4444;
+    z-index: 5;
+    pointer-events: none;
+}
+.tl-now-line::before {
+    content: '';
+    position: absolute;
+    top: -3px;
+    left: -3px;
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background: #ef4444;
+}
+
+/* 周视图色块点击态 */
+.tl-period-block {
+    cursor: pointer;
+}
+.tl-period-block:hover {
+    filter: brightness(1.1);
+    box-shadow: 0 0 0 2px rgba(255,255,255,0.6);
+}
+
+/* period 卡片高亮动画 */
+.tl-period-highlight {
+    animation: tl-highlight-pulse 1.5s ease-out;
+}
+@keyframes tl-highlight-pulse {
+    0%   { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5); }
+    30%  { box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3); }
+    100% { box-shadow: none; }
+}
+
+/* 内联编辑输入框 */
+.tl-inline-input {
+    background: white;
+    border: 1px solid #93c5fd;
+    border-radius: 0.25rem;
+    padding: 0 0.25rem;
+    outline: none;
+    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
+    color: #1f2937;
+}
+.tl-editable {
+    cursor: text;
+    border-radius: 0.25rem;
+    transition: background 0.15s;
+}
+.tl-editable:hover {
+    background: rgba(59, 130, 246, 0.06);
+}
+
+/* 日计划 Tag 拖拽排序 */
+.tl-period-tag {
+    cursor: grab;
+}
+.tl-period-tag:active {
+    cursor: grabbing;
+}
+.tl-tag-ghost {
+    opacity: 0.4;
+}
+.tl-tag-drag {
+    transform: rotate(2deg);
+    box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+}

+ 100 - 12
docs/index.html

@@ -39,7 +39,7 @@
                     <i class="fa-regular fa-copy mr-1.5"></i>复制配置
                 </button>
                 <button onclick="openSupportModal()" class="bg-gradient-to-r from-orange-400 to-pink-500 hover:from-orange-500 hover:to-pink-600 text-white px-4 py-1.5 rounded text-sm font-medium transition-all shadow-md hover:shadow-lg flex items-center gap-1.5">
-                    <i class="fa-solid fa-heart-pulse"></i>支持项目
+                    <i class="fa-solid fa-heart-pulse"></i>支持一下
                 </button>
             </div>
         </div>
@@ -58,6 +58,9 @@
                 <button id="tab-frequency" onclick="switchTab('frequency')" class="tab-button px-4 py-2 text-xs font-bold text-gray-500 hover:bg-[#2d2d30] transition-colors border-b-2 border-transparent">
                     <i class="fa-solid fa-filter mr-2"></i>frequency_words.txt
                 </button>
+                <button id="tab-timeline" onclick="switchTab('timeline')" class="tab-button px-4 py-2 text-xs font-bold text-gray-500 hover:bg-[#2d2d30] transition-colors border-b-2 border-transparent">
+                    <i class="fa-solid fa-calendar-week mr-2"></i>timeline.yaml
+                </button>
                 <div class="flex-grow"></div>
                 <!-- 保存时间显示 -->
                 <div id="save-time-config" class="save-time-badge px-3 text-[10px] text-gray-500 flex items-center gap-1">
@@ -70,6 +73,11 @@
                     <span id="frequency-save-label" class="hidden">已保存: </span>
                     <span id="frequency-save-time" class="text-gray-400" title="未保存">未保存</span>
                 </div>
+                <div id="save-time-timeline" class="save-time-badge hidden px-3 text-[10px] text-gray-500 flex items-center gap-1">
+                    <i class="fa-regular fa-clock"></i>
+                    <span id="timeline-save-label" class="hidden">已保存: </span>
+                    <span id="timeline-save-time" class="text-gray-400" title="未保存">未保存</span>
+                </div>
             </div>
 
             <!-- Config 编辑器 -->
@@ -83,6 +91,12 @@
                 <div id="frequency-backdrop" class="highlight-backdrop"></div>
                 <textarea id="frequency-editor" class="highlight-textarea" spellcheck="false"></textarea>
             </div>
+
+            <!-- Timeline 编辑器 -->
+            <div id="timeline-editor-wrap" class="tab-content hidden highlight-editor-wrap flex-grow w-full h-full bg-[#1e1e1e]">
+                <div id="timeline-backdrop" class="highlight-backdrop"></div>
+                <textarea id="timeline-editor" class="highlight-textarea" spellcheck="false"></textarea>
+            </div>
         </div>
 
         <!-- 右侧:可视化配置 (Visual) -->
@@ -111,6 +125,10 @@
             <!-- Frequency 可视化面板 -->
             <div id="frequency-panel" class="tab-content hidden flex-grow overflow-y-auto p-6 space-y-6">
             </div>
+
+            <!-- Timeline 可视化面板 -->
+            <div id="timeline-panel" class="tab-content hidden flex-grow overflow-y-auto p-6 space-y-6">
+            </div>
         </div>
     </main>
 
@@ -337,7 +355,7 @@
     </div>
 
     <!-- 支持项目弹窗 -->
-    <div id="support-modal" class="modal-overlay hidden" onclick="closeSupportModalOutside(event)">
+    <div id="support-modal" class="modal-overlay hidden">
         <div class="modal-content support-modal-content max-w-5xl w-[95%] max-h-[90vh] overflow-y-auto p-8">
             <div class="flex items-center justify-between mb-8">
                 <div class="flex items-center gap-4">
@@ -347,7 +365,7 @@
                     </div>
                     <div>
                         <h3 class="text-2xl font-bold text-gray-800 tracking-tight">觉得好用?支持一下 ✨</h3>
-                        <p class="text-sm text-gray-500 mt-1">TrendRadar 完全开源免费,你的每一次支持都是作者更新的动力</p>
+                        <p class="text-sm text-gray-500 mt-1">若 TrendRadar 曾为你捕捉价值,不妨为它注入动力,助其持续进化</p>
                     </div>
                 </div>
                 <button onclick="closeSupportModal()" class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
@@ -357,24 +375,22 @@
 
             <div class="grid grid-cols-1 md:grid-cols-4 gap-6">
                 <a href="https://github.com/sansan0/TrendRadar" target="_blank" class="support-card group border-orange-200 bg-orange-50/30">
-                    <div class="absolute top-0 right-0 bg-gradient-to-r from-orange-400 to-red-500 text-white text-[10px] px-2 py-0.5 rounded-bl-lg font-bold shadow-sm z-10">推荐支持</div>
                     <div class="support-card-num opacity-50">01</div>
                     <div class="support-icon text-orange-500 bg-orange-100 group-hover:bg-orange-200 mb-4 group-hover:scale-110 transition-transform">
                         <i class="fa-solid fa-star text-2xl"></i>
                     </div>
                     <h4 class="text-lg font-bold text-gray-800 mb-2">点亮 Star</h4>
-                    <p class="text-sm text-gray-500 mb-6 text-center leading-relaxed">免费且重要!<br>只需 1 秒,让更多人发现它</p>
+                    <p class="text-sm text-gray-500 mb-6 text-center leading-relaxed">只需 1 秒,让更多人发现它</p>
                     <span class="support-btn bg-gradient-to-r from-orange-400 to-red-500 shadow-lg shadow-orange-200 group-hover:shadow-xl group-hover:from-orange-500 group-hover:to-red-600">立即前往 GitHub</span>
                 </a>
 
                 <div class="support-card group">
-                    <div class="absolute top-0 right-0 bg-gradient-to-r from-green-400 to-emerald-500 text-white text-[10px] px-2 py-0.5 rounded-bl-lg font-bold shadow-sm z-10">订阅更新</div>
                     <div class="support-card-num">02</div>
                     <div class="support-icon text-green-600 bg-green-50 group-hover:bg-green-100 mb-4">
                         <i class="fa-brands fa-weixin text-2xl"></i>
                     </div>
                     <h4 class="text-lg font-bold text-gray-800 mb-2">不迷路</h4>
-                    <p class="text-sm text-gray-500 mb-4 text-center">关注公众号<br>第一时间获取更新通知</p>
+                    <p class="text-sm text-gray-500 mb-4 text-center">第一时间获取更新通知</p>
                     <div class="w-36 h-36 bg-white border border-gray-100 rounded-xl p-2 shadow-sm group-hover:shadow-md transition-shadow">
                         <img src="./assets/weixin.webp" alt="微信公众号" class="w-full h-full object-contain">
                     </div>
@@ -382,13 +398,12 @@
                 </div>
 
                 <div class="support-card group">
-                    <div class="absolute top-0 right-0 bg-gradient-to-r from-emerald-400 to-teal-500 text-white text-[10px] px-2 py-0.5 rounded-bl-lg font-bold shadow-sm z-10">随心鼓励</div>
                     <div class="support-card-num">03</div>
                     <div class="support-icon text-emerald-600 bg-emerald-50 group-hover:bg-emerald-100 mb-4">
                         <i class="fa-solid fa-hand-holding-heart text-2xl"></i>
                     </div>
                     <h4 class="text-lg font-bold text-gray-800 mb-2">随心赞赏</h4>
-                    <p class="text-sm text-gray-500 mb-4 text-center">一瓶水、一包辣条都是爱<br>金额随意,1 元也是动力</p>
+                    <p class="text-sm text-gray-500 mb-4 text-center">金额随意,1 元也是鼓励 (´▽`ʃ♡ƪ)</p>
                     <div class="w-36 h-36 bg-white border border-gray-100 rounded-xl p-2 shadow-sm group-hover:shadow-md transition-shadow">
                         <img src="https://cdn-1258574687.cos.ap-shanghai.myqcloud.com/img/%2F2026%2F01%2F18ecce7c224ce0ea4c59394c29e408f8-e0d1db45.webp" alt="微信支付" class="w-full h-full object-contain">
                     </div>
@@ -396,19 +411,92 @@
                 </div>
 
                 <a href="https://sansan0.github.io/mao-map/" target="_blank" class="support-card group">
-                    <div class="absolute top-0 right-0 bg-gradient-to-r from-red-400 to-rose-500 text-white text-[10px] px-2 py-0.5 rounded-bl-lg font-bold shadow-sm z-10">探索发现</div>
                     <div class="support-card-num">04</div>
                     <div class="support-icon text-red-500 bg-red-50 group-hover:bg-red-100 mb-4">
                         <i class="fa-solid fa-map-location-dot text-2xl"></i>
                     </div>
                     <h4 class="text-lg font-bold text-gray-800 mb-2">探索更多</h4>
-                    <p class="text-sm text-gray-500 mb-6 text-center leading-relaxed">历史足迹地图<br>另一个用心的作品</p>
+                    <p class="text-sm text-gray-500 mb-6 text-center leading-relaxed">另一个用心的作品</p>
                     <span class="support-btn bg-red-50 text-red-600 group-hover:bg-red-100 group-hover:text-red-700 border border-red-100">去看看</span>
                 </a>
             </div>
 
             <div class="mt-8 pt-6 border-t border-gray-100 text-center">
-                <p class="text-sm text-gray-400 font-serif italic tracking-wide">“江山如此多娇,引无数英雄竞折腰”</p>
+                <p class="text-sm text-gray-400 font-serif italic tracking-wide">"开源不易,感谢每一份支持"</p>
+            </div>
+        </div>
+    </div>
+
+    <!-- 新建调度模式弹窗 -->
+    <div id="tl-new-preset-modal" class="modal-overlay hidden">
+        <div class="modal-content">
+            <div class="flex items-center justify-between mb-4">
+                <h3 class="text-lg font-bold text-gray-800"><i class="fa-solid fa-calendar-plus mr-2 text-purple-600"></i>新建调度模式</h3>
+                <button onclick="closeTlNewPresetModal()" class="text-gray-400 hover:text-gray-600"><i class="fa-solid fa-times text-xl"></i></button>
+            </div>
+            <div class="space-y-4">
+                <div>
+                    <label class="block text-xs font-bold text-gray-600 mb-1">模式标识 (key)</label>
+                    <input type="text" id="tl-new-preset-key" placeholder="英文标识,如 my_schedule" class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm">
+                    <p class="text-[10px] text-gray-400 mt-1">仅支持英文、数字和下划线,将作为 YAML 中的 key</p>
+                </div>
+                <div>
+                    <label class="block text-xs font-bold text-gray-600 mb-1">显示名称</label>
+                    <input type="text" id="tl-new-preset-name" placeholder="如:我的调度" class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm">
+                </div>
+                <div>
+                    <label class="block text-xs font-bold text-gray-600 mb-1">描述(可选)</label>
+                    <input type="text" id="tl-new-preset-desc" placeholder="简短描述此模式的用途" class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm">
+                </div>
+                <div>
+                    <label class="block text-xs font-bold text-gray-600 mb-1">基于模板</label>
+                    <select id="tl-new-preset-template" class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm">
+                        <option value="">空白模板(仅采集,不推送不分析)</option>
+                    </select>
+                    <p class="text-[10px] text-gray-400 mt-1">复制已有模式的全部配置作为起点</p>
+                </div>
+            </div>
+            <div class="flex justify-end gap-2 mt-6">
+                <button onclick="closeTlNewPresetModal()" class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">取消</button>
+                <button onclick="confirmTlNewPreset()" class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">创建</button>
+            </div>
+        </div>
+    </div>
+
+    <!-- 新增时间段弹窗 -->
+    <div id="tl-new-period-modal" class="modal-overlay hidden">
+        <div class="modal-content">
+            <div class="flex items-center justify-between mb-4">
+                <h3 class="text-lg font-bold text-gray-800"><i class="fa-solid fa-clock-rotate-left mr-2 text-blue-600"></i>新增时间段</h3>
+                <button onclick="closeTlNewPeriodModal()" class="text-gray-400 hover:text-gray-600"><i class="fa-solid fa-times text-xl"></i></button>
+            </div>
+            <div class="space-y-4">
+                <div>
+                    <label class="block text-xs font-bold text-gray-600 mb-1">时间段标识 (key)</label>
+                    <input type="text" id="tl-new-period-key" placeholder="英文标识,如 morning_push" class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
+                    <p class="text-[10px] text-gray-400 mt-1">仅支持英文、数字和下划线</p>
+                </div>
+                <div>
+                    <label class="block text-xs font-bold text-gray-600 mb-1">显示名称</label>
+                    <input type="text" id="tl-new-period-name" placeholder="如:晨间推送" class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
+                </div>
+                <div class="grid grid-cols-2 gap-4">
+                    <div>
+                        <label class="block text-xs font-bold text-gray-600 mb-1">开始时间</label>
+                        <input type="time" id="tl-new-period-start" value="09:00" class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
+                    </div>
+                    <div>
+                        <label class="block text-xs font-bold text-gray-600 mb-1">结束时间</label>
+                        <input type="time" id="tl-new-period-end" value="11:00" class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
+                    </div>
+                </div>
+                <div class="bg-blue-50 border border-blue-100 rounded p-3 text-xs text-blue-700">
+                    <i class="fa-solid fa-info-circle mr-1"></i>如果开始时间 > 结束时间(如 22:00~01:00),将自动识别为跨午夜时间段。
+                </div>
+            </div>
+            <div class="flex justify-end gap-2 mt-6">
+                <button onclick="closeTlNewPeriodModal()" class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">取消</button>
+                <button onclick="confirmTlNewPeriod()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">添加</button>
             </div>
         </div>
     </div>

+ 1 - 1
mcp_server/__init__.py

@@ -5,4 +5,4 @@ TrendRadar MCP Server
 
 """
 
-__version__ = "3.2.0"
+__version__ = "4.0.0"

+ 113 - 0
mcp_server/server.py

@@ -18,6 +18,7 @@ from .tools.config_mgmt import ConfigManagementTools
 from .tools.system import SystemManagementTools
 from .tools.storage_sync import StorageSyncTools
 from .tools.article_reader import ArticleReaderTools
+from .tools.notification import NotificationTools
 from .utils.date_parser import DateParser
 from .utils.errors import MCPError
 
@@ -39,6 +40,7 @@ def _get_tools(project_root: Optional[str] = None):
         _tools_instances['system'] = SystemManagementTools(project_root)
         _tools_instances['storage'] = StorageSyncTools(project_root)
         _tools_instances['article'] = ArticleReaderTools(project_root)
+        _tools_instances['notification'] = NotificationTools(project_root)
     return _tools_instances
 
 
@@ -1004,6 +1006,112 @@ async def read_articles_batch(
     return json.dumps(result, ensure_ascii=False, indent=2)
 
 
+# ==================== 通知推送工具 ====================
+
+
+@mcp.tool
+async def get_channel_format_guide(channel: Optional[str] = None) -> str:
+    """
+    获取通知渠道的格式化策略指南
+
+    返回各渠道支持的 Markdown 特性、格式限制和最佳格式化提示词。
+    在调用 send_notification 之前使用此工具,可以了解目标渠道的格式要求,
+    从而生成最佳排版效果的消息内容。
+
+    各渠道格式差异概览:
+    - 飞书:支持 **粗体**、<font color>彩色文本、[链接](url)、--- 分割线
+    - 钉钉:支持 ### 标题、**粗体**、> 引用、--- 分割线,不支持颜色
+    - 企业微信:仅支持 **粗体**、[链接](url)、> 引用,不支持标题和分割线
+    - Telegram:自动转为 HTML,支持粗体/斜体/删除线/代码/链接/引用块
+    - ntfy:支持标准 Markdown,不支持颜色
+    - Bark:iOS 推送,仅支持粗体和链接,内容需精简
+    - Slack:自动转为 mrkdwn,*粗体*、~删除线~、<url|链接>
+    - 邮件:自动转为完整 HTML 网页,支持标题/样式/分割线
+    - 通用 Webhook:标准 Markdown 或自定义模板
+
+    Args:
+        channel: 指定渠道 ID(可选),不指定返回所有渠道策略
+                 可选值: feishu, dingtalk, wework, telegram, email, ntfy, bark, slack, generic_webhook
+
+    Returns:
+        JSON格式的渠道格式化策略,包含支持特性、限制和格式化提示词
+
+    Examples:
+        - get_channel_format_guide()  # 获取所有渠道策略
+        - get_channel_format_guide(channel="feishu")  # 获取飞书策略
+        - get_channel_format_guide(channel="telegram")  # 获取 Telegram 策略
+    """
+    tools = _get_tools()
+    result = await asyncio.to_thread(
+        tools['notification'].get_channel_format_guide,
+        channel=channel
+    )
+    return json.dumps(result, ensure_ascii=False, indent=2)
+
+
+@mcp.tool
+async def get_notification_channels() -> str:
+    """
+    获取所有已配置的通知渠道及其状态
+
+    检测 config.yaml 和 .env 环境变量中的通知渠道配置。
+    支持 9 个渠道:飞书、钉钉、企业微信、Telegram、邮件、ntfy、Bark、Slack、通用 Webhook。
+
+    Returns:
+        JSON格式的渠道状态,包含每个渠道是否已配置及配置来源
+
+    Examples:
+        - get_notification_channels()
+    """
+    tools = _get_tools()
+    result = await asyncio.to_thread(tools['notification'].get_notification_channels)
+    return json.dumps(result, ensure_ascii=False, indent=2)
+
+
+@mcp.tool
+async def send_notification(
+    message: str,
+    title: str = "TrendRadar 通知",
+    channels: Optional[List[str]] = None,
+) -> str:
+    """
+    向已配置的通知渠道发送消息
+
+    接受 markdown 格式内容,内部自动适配各渠道的格式要求和限制:
+    - 飞书:Markdown 卡片消息(支持 **粗体**、<font color>彩色文本、[链接](url)、---)
+    - 钉钉:Markdown(自动降级标题为 ###、剥离 <font> 标签和删除线)
+    - 企业微信:Markdown(自动剥离 # 标题、---、<font> 标签、删除线)
+    - Telegram:HTML(自动转换 **→<b>、*→<i>、~~→<s>、>→<blockquote>)
+    - Email:HTML 邮件(完整网页样式,支持 # 标题、---、粗体斜体)
+    - ntfy:Markdown(自动剥离 <font> 标签)
+    - Bark:Markdown(自动简化为粗体+链接,适配 iOS 推送)
+    - Slack:mrkdwn(自动转换 **→*、~~→~、[text](url)→<url|text>)
+    - 通用 Webhook:Markdown(支持自定义模板)
+
+    提示:发送前可调用 get_channel_format_guide 获取目标渠道的详细格式化策略,
+    以生成最佳排版效果的消息内容。
+
+    Args:
+        message: markdown 格式的消息内容(必需)
+        title: 消息标题,默认 "TrendRadar 通知"
+        channels: 指定发送的渠道列表,不指定则发送到所有已配置渠道
+                  可选值: feishu, dingtalk, wework, telegram, email, ntfy, bark, slack, generic_webhook
+
+    Returns:
+        JSON格式的发送结果,包含每个渠道的发送状态
+
+    Examples:
+        - send_notification(message="**测试消息**\\n这是一条测试通知")
+        - send_notification(message="紧急通知", title="系统告警", channels=["feishu", "dingtalk"])
+    """
+    tools = _get_tools()
+    result = await asyncio.to_thread(
+        tools['notification'].send_notification,
+        message=message, title=title, channels=channels
+    )
+    return json.dumps(result, ensure_ascii=False, indent=2)
+
+
 # ==================== 启动入口 ====================
 
 def run_server(
@@ -1084,6 +1192,11 @@ def run_server(
     print("    === 文章内容读取 ===")
     print("    22. read_article            - 读取单篇文章内容(Markdown格式)")
     print("    23. read_articles_batch     - 批量读取多篇文章(自动限速)")
+    print()
+    print("    === 通知推送工具 ===")
+    print("    24. get_channel_format_guide  - 获取渠道格式化策略指南(提示词)")
+    print("    25. get_notification_channels - 获取已配置的通知渠道状态")
+    print("    26. send_notification         - 向通知渠道发送消息(自动适配格式)")
     print("=" * 60)
     print()
 

+ 25 - 12
mcp_server/services/data_service.py

@@ -378,14 +378,16 @@ class DataService:
         word_frequency = Counter()
         keyword_to_news = {}
 
+        # 预加载关键词数据(避免在循环内重复调用)
+        if extract_mode == "keywords":
+            from trendradar.core.frequency import _word_matches
+            word_groups = self.parser.parse_frequency_words()
+
         # 遍历要处理的标题
         for platform_id, titles in titles_to_process.items():
             for title in titles.keys():
                 if extract_mode == "keywords":
                     # 基于预设关键词统计(支持正则匹配)
-                    from trendradar.core.frequency import _word_matches
-
-                    word_groups = self.parser.parse_frequency_words()
                     title_lower = title.lower()
 
                     for group in word_groups:
@@ -495,17 +497,28 @@ class DataService:
                 "enable_notification": notification.get("enabled", True),
                 "enabled_channels": [],
                 "message_batch_size": batch_size.get("default", 4000),
-                "push_window": notification.get("push_window", {})
+                "push_window": {}  # 已迁移至调度系统(schedule + timeline.yaml)
             }
 
-            # 检测已配置的通知渠道
-            channels = notification.get("channels", {})
-            if channels.get("feishu", {}).get("webhook_url"):
-                push_config["enabled_channels"].append("feishu")
-            if channels.get("dingtalk", {}).get("webhook_url"):
-                push_config["enabled_channels"].append("dingtalk")
-            if channels.get("wework", {}).get("webhook_url"):
-                push_config["enabled_channels"].append("wework")
+            # 检测已配置的通知渠道(合并 config.yaml + .env)
+            from trendradar.core.loader import _load_webhook_config
+
+            webhook_config = _load_webhook_config(config_data)
+
+            channel_checks = {
+                "feishu": [webhook_config.get("FEISHU_WEBHOOK_URL")],
+                "dingtalk": [webhook_config.get("DINGTALK_WEBHOOK_URL")],
+                "wework": [webhook_config.get("WEWORK_WEBHOOK_URL")],
+                "telegram": [webhook_config.get("TELEGRAM_BOT_TOKEN"), webhook_config.get("TELEGRAM_CHAT_ID")],
+                "email": [webhook_config.get("EMAIL_FROM"), webhook_config.get("EMAIL_PASSWORD"), webhook_config.get("EMAIL_TO")],
+                "ntfy": [webhook_config.get("NTFY_SERVER_URL"), webhook_config.get("NTFY_TOPIC")],
+                "bark": [webhook_config.get("BARK_URL")],
+                "slack": [webhook_config.get("SLACK_WEBHOOK_URL")],
+                "generic_webhook": [webhook_config.get("GENERIC_WEBHOOK_URL")],
+            }
+            for ch_id, required_values in channel_checks.items():
+                if all(required_values):
+                    push_config["enabled_channels"].append(ch_id)
 
         if section == "all" or section == "keywords":
             keywords_config = {

+ 15 - 1
mcp_server/services/parser_service.py

@@ -35,6 +35,10 @@ class ParserService:
 
         self.cache = get_cache()
 
+        # frequency_words.txt mtime 缓存
+        self._freq_words_cache: Optional[List[Dict]] = None
+        self._freq_words_mtime: float = 0.0
+
     @staticmethod
     def clean_title(title: str) -> str:
         """清理标题文本"""
@@ -371,7 +375,9 @@ class ParserService:
 
     def parse_frequency_words(self, words_file: str = None) -> List[Dict]:
         """
-        解析关键词配置文件
+        解析关键词配置文件(带 mtime 缓存)
+
+        仅当 frequency_words.txt 被修改时才重新解析,避免循环内重复 IO。
 
         复用 trendradar.core.frequency 的解析逻辑,支持:
         - # 开头的注释行
@@ -393,6 +399,7 @@ class ParserService:
         Raises:
             FileParseError: 文件解析错误
         """
+        import os
         from trendradar.core.frequency import load_frequency_words
 
         if words_file is None:
@@ -401,7 +408,14 @@ class ParserService:
             words_file = str(words_file)
 
         try:
+            current_mtime = os.path.getmtime(words_file)
+
+            if self._freq_words_cache is not None and current_mtime == self._freq_words_mtime:
+                return self._freq_words_cache
+
             word_groups, filter_words, global_filters = load_frequency_words(words_file)
+            self._freq_words_cache = word_groups
+            self._freq_words_mtime = current_mtime
             return word_groups
         except FileNotFoundError:
             return []

+ 34 - 13
mcp_server/tools/analytics.py

@@ -27,35 +27,56 @@ from ..utils.validators import (
 from ..utils.errors import MCPError, InvalidParameterError, DataNotFoundError
 
 
+# 权重配置 mtime 缓存(避免重复读取同一配置文件)
+_weight_config_cache: Optional[Dict] = None
+_weight_config_mtime: float = 0.0
+_weight_config_path: Optional[str] = None
+
+_WEIGHT_DEFAULT_CONFIG = {
+    "RANK_WEIGHT": 0.6,
+    "FREQUENCY_WEIGHT": 0.3,
+    "HOTNESS_WEIGHT": 0.1,
+}
+
+
 def _get_weight_config() -> Dict:
     """
-    从 config.yaml 读取权重配置
+    从 config.yaml 读取权重配置(带 mtime 缓存)
+
+    仅当配置文件被修改时才重新读取,避免循环内重复 IO。
 
     Returns:
         权重配置字典,包含 RANK_WEIGHT, FREQUENCY_WEIGHT, HOTNESS_WEIGHT
     """
-    # 默认值
-    default_config = {
-        "RANK_WEIGHT": 0.6,
-        "FREQUENCY_WEIGHT": 0.3,
-        "HOTNESS_WEIGHT": 0.1,
-    }
+    global _weight_config_cache, _weight_config_mtime, _weight_config_path
 
     try:
-        current_dir = os.path.dirname(os.path.abspath(__file__))
-        config_path = os.path.join(current_dir, "..", "..", "config", "config.yaml")
-        config_path = os.path.normpath(config_path)
+        # 首次调用时计算路径(之后复用)
+        if _weight_config_path is None:
+            current_dir = os.path.dirname(os.path.abspath(__file__))
+            _weight_config_path = os.path.normpath(
+                os.path.join(current_dir, "..", "..", "config", "config.yaml")
+            )
+
+        current_mtime = os.path.getmtime(_weight_config_path)
 
-        with open(config_path, 'r', encoding='utf-8') as f:
+        # 文件未修改且缓存有效,直接返回
+        if _weight_config_cache is not None and current_mtime == _weight_config_mtime:
+            return _weight_config_cache
+
+        # 文件已修改或首次读取,重新解析
+        with open(_weight_config_path, 'r', encoding='utf-8') as f:
             config = yaml.safe_load(f)
             weight = config.get('advanced', {}).get('weight', {})
-            return {
+            _weight_config_cache = {
                 "RANK_WEIGHT": weight.get('rank', 0.6),
                 "FREQUENCY_WEIGHT": weight.get('frequency', 0.3),
                 "HOTNESS_WEIGHT": weight.get('hotness', 0.1),
             }
+            _weight_config_mtime = current_mtime
+            return _weight_config_cache
     except Exception:
-        return default_config
+        return _WEIGHT_DEFAULT_CONFIG
 
 
 def calculate_news_weight(news_data: Dict, rank_threshold: int = 5) -> float:

+ 1408 - 0
mcp_server/tools/notification.py

@@ -0,0 +1,1408 @@
+# coding=utf-8
+"""
+通知推送工具
+
+支持向已配置的通知渠道发送消息,自动检测 config.yaml 和 .env 中的渠道配置。
+接受 markdown 格式内容,内部按各渠道要求自动转换格式后发送。
+"""
+
+import json
+import os
+import re
+import smtplib
+import time
+from datetime import datetime
+from email.header import Header
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.utils import formataddr, formatdate, make_msgid
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+from urllib.parse import urlparse
+
+import requests
+import yaml
+
+from trendradar.core.loader import _load_webhook_config, _load_notification_config
+from trendradar.notification.batch import (
+    truncate_to_bytes,
+    get_batch_header,
+    get_max_batch_header_size,
+    add_batch_headers,
+)
+from trendradar.notification.formatters import strip_markdown
+from trendradar.notification.senders import SMTP_CONFIGS
+
+from ..utils.errors import MCPError, InvalidParameterError
+
+
+# ==================== 渠道启用判断规则 ====================
+
+# 每个渠道需要哪些配置项都非空才算"已配置"
+# 注意:NTFY_SERVER_URL 在 loader 中有默认值 "https://ntfy.sh",不作为判断依据
+_CHANNEL_REQUIREMENTS = {
+    "feishu": ["FEISHU_WEBHOOK_URL"],
+    "dingtalk": ["DINGTALK_WEBHOOK_URL"],
+    "wework": ["WEWORK_WEBHOOK_URL"],
+    "telegram": ["TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID"],
+    "email": ["EMAIL_FROM", "EMAIL_PASSWORD", "EMAIL_TO"],
+    "ntfy": ["NTFY_TOPIC"],
+    "bark": ["BARK_URL"],
+    "slack": ["SLACK_WEBHOOK_URL"],
+    "generic_webhook": ["GENERIC_WEBHOOK_URL"],
+}
+
+# 渠道显示名称
+_CHANNEL_NAMES = {
+    "feishu": "飞书",
+    "dingtalk": "钉钉",
+    "wework": "企业微信",
+    "telegram": "Telegram",
+    "email": "邮件",
+    "ntfy": "ntfy",
+    "bark": "Bark",
+    "slack": "Slack",
+    "generic_webhook": "通用 Webhook",
+}
+
+
+# ==================== 批次处理配置 ====================
+
+# 各渠道最大批次字节数的默认值
+# 运行时从 config.yaml → advanced.batch_size 读取覆盖
+_CHANNEL_BATCH_SIZES_DEFAULT = {
+    "feishu": 30000,    # config.yaml: advanced.batch_size.feishu
+    "dingtalk": 20000,  # config.yaml: advanced.batch_size.dingtalk
+    "wework": 4000,     # config.yaml: advanced.batch_size.default
+    "telegram": 4000,   # config.yaml: advanced.batch_size.default
+    "email": 0,         # 邮件无字节限制,不分批
+    "ntfy": 3800,       # 严格 4KB 限制(ntfy 代码默认值)
+    "bark": 4000,       # config.yaml: advanced.batch_size.bark
+    "slack": 4000,      # config.yaml: advanced.batch_size.slack
+    "generic_webhook": 4000,
+}
+
+# 显示最新消息在前的渠道,批次需反序发送
+_REVERSE_BATCH_CHANNELS = {"ntfy", "bark"}
+
+# 批次发送间隔默认值(秒),运行时从 config.yaml → advanced.batch_send_interval 读取
+_BATCH_INTERVAL_DEFAULT = 3.0
+
+
+# ==================== 批次处理 ====================
+# truncate_to_bytes, get_batch_header, get_max_batch_header_size,
+# add_batch_headers 复用自 trendradar.notification.batch
+
+
+def _split_text_into_batches(text: str, max_bytes: int) -> List[str]:
+    """将文本按字节限制分批,优先在段落边界(双换行)切割
+
+    分割策略(参考 trendradar splitter.py 的原子性保证):
+    1. 优先按段落(双换行 \\n\\n)拆分
+    2. 段落仍超限时,按单行(\\n)拆分
+    3. 单行仍超限时,用 _truncate_to_bytes 安全截断
+
+    Args:
+        text: 已转换为目标渠道格式的文本
+        max_bytes: 单批最大字节数(已扣除批次头部预留)
+
+    Returns:
+        分批后的文本列表
+    """
+    if max_bytes <= 0 or len(text.encode("utf-8")) <= max_bytes:
+        return [text]
+
+    # 按段落分割
+    paragraphs = text.split("\n\n")
+    batches = []
+    current = ""
+
+    for para in paragraphs:
+        candidate = f"{current}\n\n{para}" if current else para
+        if len(candidate.encode("utf-8")) <= max_bytes:
+            current = candidate
+        else:
+            # 当前段落放不下,先保存已有内容
+            if current:
+                batches.append(current)
+                current = ""
+
+            # 检查单个段落是否超限
+            if len(para.encode("utf-8")) <= max_bytes:
+                current = para
+            else:
+                # 段落本身超限,按行拆分
+                lines = para.split("\n")
+                for line in lines:
+                    candidate = f"{current}\n{line}" if current else line
+                    if len(candidate.encode("utf-8")) <= max_bytes:
+                        current = candidate
+                    else:
+                        if current:
+                            batches.append(current)
+                            current = ""
+                        # 单行超限,循环截断直到处理完
+                        if len(line.encode("utf-8")) > max_bytes:
+                            remaining = line
+                            while remaining:
+                                chunk = truncate_to_bytes(remaining, max_bytes)
+                                if not chunk:
+                                    break
+                                batches.append(chunk)
+                                # 移除已截断的部分
+                                remaining = remaining[len(chunk):]
+                        else:
+                            current = line
+
+    if current:
+        batches.append(current)
+
+    return batches if batches else [text]
+
+
+def _format_for_channel(message: str, channel_id: str) -> str:
+    """将通用 Markdown 适配并转换为目标渠道格式
+
+    统一入口:先适配(剥离不支持的语法),再转换(Markdown→HTML/mrkdwn 等)。
+    返回的文本可以直接用于字节分割和发送。
+
+    Args:
+        message: 原始 Markdown 格式文本
+        channel_id: 目标渠道 ID
+
+    Returns:
+        目标渠道格式的文本
+    """
+    if channel_id == "feishu":
+        return _adapt_markdown_for_feishu(message)
+    elif channel_id == "dingtalk":
+        return _adapt_markdown_for_dingtalk(message)
+    elif channel_id == "wework":
+        return _adapt_markdown_for_wework(message)
+    elif channel_id == "telegram":
+        return _markdown_to_telegram_html(message)
+    elif channel_id == "ntfy":
+        return _adapt_markdown_for_ntfy(message)
+    elif channel_id == "bark":
+        return _adapt_markdown_for_bark(message)
+    elif channel_id == "slack":
+        return _convert_markdown_to_slack(message)
+    else:
+        # email, generic_webhook: 保持原始 Markdown
+        return message
+
+
+def _prepare_batches(message: str, channel_id: str, batch_sizes: Dict = None) -> List[str]:
+    """完整的分批管线:格式适配 → 字节分割 → 添加批次头部
+
+    Args:
+        message: 原始 Markdown 格式文本
+        channel_id: 目标渠道 ID
+        batch_sizes: 各渠道批次大小字典(来自 config.yaml),None 使用默认值
+
+    Returns:
+        准备好的批次列表(已添加头部,已处理反序)
+    """
+    sizes = batch_sizes or _CHANNEL_BATCH_SIZES_DEFAULT
+    max_bytes = sizes.get(channel_id, sizes.get("default", 4000))
+    if max_bytes <= 0:
+        # 无字节限制(如 email),返回原始文本
+        return [message]
+
+    formatted = _format_for_channel(message, channel_id)
+
+    # 预留批次头部空间后分割
+    header_reserve = get_max_batch_header_size(channel_id)
+    batches = _split_text_into_batches(formatted, max_bytes - header_reserve)
+
+    # 添加批次头部(单批时不添加)
+    batches = add_batch_headers(batches, channel_id, max_bytes)
+
+    # ntfy/Bark 反序发送(客户端显示最新在前)
+    if channel_id in _REVERSE_BATCH_CHANNELS and len(batches) > 1:
+        batches = list(reversed(batches))
+
+    return batches
+
+CHANNEL_FORMAT_GUIDES = {
+    "feishu": {
+        "name": "飞书",
+        "format": "Markdown(卡片消息)",
+        "max_length": "约 29000 字节",
+        "supported": [
+            "**粗体**",
+            "[链接文本](URL)",
+            "<font color='red/green/grey/orange/blue'>彩色文本</font>",
+            "---(分割线)",
+            "换行分隔段落",
+        ],
+        "unsupported": [
+            "# 标题语法(不渲染为标题样式)",
+            "> 引用块",
+            "表格 / 图片嵌入",
+        ],
+        "prompt": (
+            "飞书卡片 Markdown 格式化策略:\n"
+            "1. 用 **粗体** 作小标题和重点词\n"
+            "2. 用 <font color='red'>红色</font> 标记紧急/重要内容\n"
+            "3. 用 <font color='grey'>灰色</font> 标记辅助信息(时间、来源)\n"
+            "4. 用 <font color='orange'>橙色</font> 标记警告\n"
+            "5. 用 <font color='green'>绿色</font> 标记正面/成功信息\n"
+            "6. 用 [文本](URL) 添加可点击链接\n"
+            "7. 用 --- 分割不同主题区域\n"
+            "8. 不要用 # 标题语法(卡片内不渲染)\n"
+            "9. 不要用 > 引用语法\n"
+            "10. 用换行 + 粗体模拟层级结构"
+        ),
+    },
+    "dingtalk": {
+        "name": "钉钉",
+        "format": "Markdown",
+        "max_length": "约 20000 字节",
+        "supported": [
+            "### 三级标题 / #### 四级标题",
+            "**粗体**",
+            "[链接文本](URL)",
+            "> 引用块",
+            "---(分割线)",
+            "- 无序列表 / 1. 有序列表",
+        ],
+        "unsupported": [
+            "# 一级标题 / ## 二级标题(可能不渲染)",
+            "<font> 彩色文本",
+            "~~删除线~~",
+            "表格 / 图片嵌入",
+        ],
+        "prompt": (
+            "钉钉 Markdown 格式化策略:\n"
+            "1. 用 ### 或 #### 作章节标题(不用 # 和 ##)\n"
+            "2. 用 **粗体** 突出关键词和数据\n"
+            "3. 用 > 引用块展示备注或补充说明\n"
+            "4. 用 --- 分割不同主题区域\n"
+            "5. 用 [文本](URL) 添加可点击链接\n"
+            "6. 用有序列表(1. 2. 3.)组织要点\n"
+            "7. 不要用 <font> 颜色标签(钉钉不支持)\n"
+            "8. 不要用删除线语法\n"
+            "9. 标题和正文之间加空行提升可读性"
+        ),
+    },
+    "wework": {
+        "name": "企业微信",
+        "format": "Markdown(群机器人)/ 纯文本(个人微信)",
+        "max_length": "约 4000 字节",
+        "supported": [
+            "**粗体**",
+            "[链接文本](URL)",
+            "> 引用块(仅首行生效)",
+        ],
+        "unsupported": [
+            "# 标题语法",
+            "---(水平分割线)",
+            "<font> 彩色文本",
+            "~~删除线~~",
+            "表格 / 图片嵌入 / 有序列表",
+        ],
+        "prompt": (
+            "企业微信 Markdown 格式化策略:\n"
+            "1. 用 **粗体** 作小标题和重点词\n"
+            "2. 用 [文本](URL) 添加可点击链接\n"
+            "3. 用 > 引用块展示备注(仅首行生效)\n"
+            "4. 内容要简洁,受 4KB 限制\n"
+            "5. 不要用 # 标题语法(不渲染)\n"
+            "6. 不要用 ---(不渲染),用多个换行分隔区域\n"
+            "7. 不要用 <font> 颜色标签\n"
+            "8. 不要用删除线和有序列表\n"
+            "9. 用换行 + 粗体模拟层级结构\n"
+            "10. 个人微信模式下所有格式被剥离为纯文本"
+        ),
+    },
+    "telegram": {
+        "name": "Telegram",
+        "format": "HTML(自动从 Markdown 转换)",
+        "max_length": "约 4096 字符",
+        "supported": [
+            "<b>粗体</b>(从 **粗体** 转换)",
+            "<i>斜体</i>(从 *斜体* 转换)",
+            "<s>删除线</s>(从 ~~删除线~~ 转换)",
+            "<code>行内代码</code>(从 `代码` 转换)",
+            "<a href='URL'>链接</a>(从 [文本](URL) 转换)",
+            "<blockquote>引用块</blockquote>(从 > 引用 转换)",
+        ],
+        "unsupported": [
+            "# 标题语法(自动剥离 # 前缀)",
+            "---(分割线,自动剥离)",
+            "<font> 彩色文本(自动剥离)",
+            "表格 / 图片嵌入",
+        ],
+        "prompt": (
+            "Telegram HTML 格式化策略(输入仍为 Markdown,自动转换为 HTML):\n"
+            "1. 用 **粗体** 突出关键词(转为 <b>)\n"
+            "2. 用 *斜体* 标记辅助信息(转为 <i>)\n"
+            "3. 用 `代码` 标记数据值/时间(转为 <code>)\n"
+            "4. 用 [文本](URL) 添加链接(转为 <a>)\n"
+            "5. 用 > 开头的行作引用块(转为 <blockquote>)\n"
+            "6. 不要用 # 标题(Telegram 无标题样式,仅剥离 #)\n"
+            "7. 不要用 --- 分割线(被剥离),用空行分隔\n"
+            "8. 不要用 <font> 颜色标签(被剥离)\n"
+            "9. 内容受 4096 字符限制,保持简洁\n"
+            "10. 链接默认禁用预览,适合信息密集型消息"
+        ),
+    },
+    "email": {
+        "name": "邮件",
+        "format": "HTML(完整网页,从 Markdown 转换)",
+        "max_length": "无硬限制",
+        "supported": [
+            "# / ## / ### 标题(转为 <h1>/<h2>/<h3>)",
+            "**粗体** / *斜体* / ~~删除线~~",
+            "[链接文本](URL)",
+            "`行内代码`",
+            "---(水平分割线)",
+        ],
+        "unsupported": [
+            "<font> 彩色文本(转义显示)",
+            "复杂表格",
+        ],
+        "prompt": (
+            "邮件 HTML 格式化策略(输入为 Markdown,自动转换为带样式 HTML):\n"
+            "1. 用 # / ## / ### 创建清晰的标题层级\n"
+            "2. 用 **粗体** 和 *斜体* 增强可读性\n"
+            "3. 用 [文本](URL) 添加链接(蓝色可点击)\n"
+            "4. 用 --- 分割不同章节\n"
+            "5. 用 `代码` 标记技术术语或数据\n"
+            "6. 可以写较长内容,邮件无严格长度限制\n"
+            "7. 邮件主题自动追加日期时间\n"
+            "8. 自动附带纯文本备用版本"
+        ),
+    },
+    "ntfy": {
+        "name": "ntfy",
+        "format": "Markdown(原生支持)",
+        "max_length": "约 3800 字节(单条 4KB 限制)",
+        "supported": [
+            "**粗体** / *斜体*",
+            "[链接文本](URL)",
+            "> 引用块",
+            "`行内代码`",
+            "- 列表",
+        ],
+        "unsupported": [
+            "# 标题语法(渲染取决于客户端)",
+            "<font> 彩色文本",
+            "---(渲染取决于客户端)",
+            "表格",
+        ],
+        "prompt": (
+            "ntfy Markdown 格式化策略:\n"
+            "1. 用 **粗体** 突出关键词\n"
+            "2. 用 [文本](URL) 添加可点击链接\n"
+            "3. 用 > 引用块展示备注\n"
+            "4. 用 `代码` 标记数据值\n"
+            "5. 内容要精炼,受 4KB 限制\n"
+            "6. 不要用 <font> 颜色标签(无效)\n"
+            "7. 不要依赖 # 标题和 --- 分割线\n"
+            "8. 用空行和粗体组织信息层级"
+        ),
+    },
+    "bark": {
+        "name": "Bark",
+        "format": "Markdown(iOS 推送)",
+        "max_length": "约 3600 字节(APNs 4KB 限制)",
+        "supported": [
+            "**粗体**",
+            "[链接文本](URL)",
+            "基础文本格式",
+        ],
+        "unsupported": [
+            "# 标题语法",
+            "<font> 彩色文本",
+            "---(分割线)",
+            "> 引用块",
+            "复杂嵌套格式",
+        ],
+        "prompt": (
+            "Bark 格式化策略(iOS 推送通知):\n"
+            "1. 内容要极度精简,移动端阅读场景\n"
+            "2. 用 **粗体** 标记核心信息\n"
+            "3. 用 [文本](URL) 添加链接\n"
+            "4. 不要用标题/颜色/引用等复杂格式\n"
+            "5. 受 APNs 4KB 限制,控制内容长度\n"
+            "6. 层级结构靠缩进和换行实现\n"
+            "7. 适合简短通知和摘要,不适合长文"
+        ),
+    },
+    "slack": {
+        "name": "Slack",
+        "format": "mrkdwn(Slack 专有格式,自动从 Markdown 转换)",
+        "max_length": "约 4000 字节",
+        "supported": [
+            "*粗体*(从 **粗体** 转换)",
+            "_斜体_",
+            "~删除线~(从 ~~删除线~~ 转换)",
+            "<URL|链接文本>(从 [文本](URL) 转换)",
+            "`行内代码`",
+            "```代码块```",
+            "> 引用块",
+        ],
+        "unsupported": [
+            "# 标题语法(剥离为粗体)",
+            "<font> 彩色文本",
+            "--- 分割线(渲染不稳定)",
+            "表格",
+        ],
+        "prompt": (
+            "Slack mrkdwn 格式化策略(输入为 Markdown,自动转换为 mrkdwn):\n"
+            "1. 用 **粗体** 突出关键词(转为 *粗体*)\n"
+            "2. 用 ~~删除线~~ 标记过时信息(转为 ~删除线~)\n"
+            "3. 用 [文本](URL) 添加链接(转为 <URL|文本>)\n"
+            "4. 用 > 引用块展示备注\n"
+            "5. 用 `代码` 标记数据值\n"
+            "6. 不要用 # 标题(Slack 无标题样式)\n"
+            "7. 不要用 <font> 颜色标签\n"
+            "8. 用空行和粗体组织信息层级"
+        ),
+    },
+    "generic_webhook": {
+        "name": "通用 Webhook",
+        "format": "Markdown(或自定义模板)",
+        "max_length": "约 4000 字节",
+        "supported": ["标准 Markdown 语法"],
+        "unsupported": ["取决于接收端"],
+        "prompt": (
+            "通用 Webhook 格式化策略:\n"
+            "1. 使用标准 Markdown 格式\n"
+            "2. 避免使用特殊平台专有语法\n"
+            "3. 如配置了自定义模板,内容会填充到 {content} 占位符"
+        ),
+    },
+}
+
+
+# ==================== 渠道 Markdown 适配 ====================
+
+def _adapt_markdown_for_feishu(text: str) -> str:
+    """将通用 Markdown 适配为飞书卡片 Markdown 格式
+
+    飞书卡片支持:**粗体**, [链接](url), <font color='...'>, ---
+    不支持:# 标题, > 引用块
+    """
+    # 将 # 标题转换为粗体(飞书卡片不渲染标题语法)
+    text = re.sub(r'^#{1,6}\s+(.+)$', r'**\1**', text, flags=re.MULTILINE)
+    # 去除引用语法前缀(飞书不支持)
+    text = re.sub(r'^>\s*', '', text, flags=re.MULTILINE)
+    # 清理多余空行
+    text = re.sub(r'\n{3,}', '\n\n', text)
+    return text.strip()
+
+
+def _adapt_markdown_for_dingtalk(text: str) -> str:
+    """将通用 Markdown 适配为钉钉 Markdown 格式
+
+    钉钉支持:### #### 标题, **粗体**, [链接](url), > 引用, ---
+    不支持:# ## 标题, <font> 彩色文本, ~~删除线~~
+    """
+    # 去除 <font> 标签(钉钉不支持,保留内容)
+    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\1', text)
+    # 将 # 和 ## 标题降级为 ### (钉钉仅支持 ### 和 ####)
+    text = re.sub(r'^##\s+(.+)$', r'### \1', text, flags=re.MULTILINE)
+    text = re.sub(r'^#\s+(.+)$', r'### \1', text, flags=re.MULTILINE)
+    # 去除删除线语法(钉钉不支持)
+    text = re.sub(r'~~(.+?)~~', r'\1', text)
+    # 清理多余空行
+    text = re.sub(r'\n{3,}', '\n\n', text)
+    return text.strip()
+
+
+def _adapt_markdown_for_wework(text: str) -> str:
+    """将通用 Markdown 适配为企业微信 Markdown 格式
+
+    企业微信支持:**粗体**, [链接](url), > 引用(有限)
+    不支持:# 标题, ---, <font>, ~~删除线~~, 有序列表
+    """
+    # 去除 <font> 标签(保留内容)
+    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\1', text)
+    # 将 # 标题转换为粗体(企业微信不渲染标题语法)
+    text = re.sub(r'^#{1,6}\s+(.+)$', r'**\1**', text, flags=re.MULTILINE)
+    # 将 --- 分割线替换为多个换行(企业微信不渲染水平线)
+    text = re.sub(r'^[\-\*]{3,}\s*$', '\n\n', text, flags=re.MULTILINE)
+    # 去除删除线语法(企业微信不支持)
+    text = re.sub(r'~~(.+?)~~', r'\1', text)
+    # 清理多余空行(保留最多两个)
+    text = re.sub(r'\n{4,}', '\n\n\n', text)
+    return text.strip()
+
+
+def _adapt_markdown_for_ntfy(text: str) -> str:
+    """将通用 Markdown 适配为 ntfy 格式
+
+    ntfy 支持:**粗体**, *斜体*, [链接](url), > 引用, `代码`
+    不可靠:# 标题, ---, <font>
+    """
+    # 去除 <font> 标签(ntfy 不支持)
+    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\1', text)
+    # 清理多余空行
+    text = re.sub(r'\n{3,}', '\n\n', text)
+    return text.strip()
+
+
+def _adapt_markdown_for_bark(text: str) -> str:
+    """将通用 Markdown 适配为 Bark 格式(iOS 推送)
+
+    Bark 支持:**粗体**, [链接](url), 基础文本
+    不支持:# 标题, <font>, ---, > 引用, 复杂嵌套
+    """
+    # 去除 <font> 标签(保留内容)
+    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\1', text)
+    # 将 # 标题转换为粗体
+    text = re.sub(r'^#{1,6}\s+(.+)$', r'**\1**', text, flags=re.MULTILINE)
+    # 将 --- 替换为换行
+    text = re.sub(r'^[\-\*]{3,}\s*$', '\n', text, flags=re.MULTILINE)
+    # 去除引用语法
+    text = re.sub(r'^>\s*', '', text, flags=re.MULTILINE)
+    # 去除删除线语法
+    text = re.sub(r'~~(.+?)~~', r'\1', text)
+    # 清理多余空行
+    text = re.sub(r'\n{3,}', '\n\n', text)
+    return text.strip()
+
+
+# ==================== 格式转换 ====================
+
+def _markdown_to_telegram_html(text: str) -> str:
+    """
+    将 markdown 转换为 Telegram 支持的 HTML 格式
+
+    Telegram 支持的标签:<b>, <i>, <s>, <code>, <a href="url">text</a>, <blockquote>
+    """
+    # 预处理:去除 <font> 标签(Telegram 不支持,保留内容)
+    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\1', text)
+
+    lines = text.split('\n')
+    result_lines = []
+    in_blockquote = False
+
+    for line in lines:
+        # 将标题符号 # ## ### 转换为粗体
+        header_match = re.match(r'^(#{1,6})\s+(.+)$', line)
+        if header_match:
+            line = f'**{header_match.group(2)}**'
+
+        # 去除水平分割线
+        if re.match(r'^[\-\*]{3,}\s*$', line):
+            if in_blockquote:
+                result_lines.append('</blockquote>')
+                in_blockquote = False
+            line = ''
+
+        # 处理引用块 > text → <blockquote>text</blockquote>
+        quote_match = re.match(r'^>\s*(.*)$', line)
+        if quote_match:
+            if not in_blockquote:
+                result_lines.append('<blockquote>')
+                in_blockquote = True
+            result_lines.append(quote_match.group(1))
+            continue
+        elif in_blockquote:
+            result_lines.append('</blockquote>')
+            in_blockquote = False
+
+        result_lines.append(line)
+
+    if in_blockquote:
+        result_lines.append('</blockquote>')
+
+    text = '\n'.join(result_lines)
+
+    # 转义 HTML 实体(在标记替换之前,但在 blockquote 标签之后)
+    # 分段处理:保留已生成的 HTML 标签
+    parts = re.split(r'(</?blockquote>)', text)
+    escaped_parts = []
+    for part in parts:
+        if part in ('<blockquote>', '</blockquote>'):
+            escaped_parts.append(part)
+        else:
+            part = part.replace('&', '&amp;')
+            part = part.replace('<', '&lt;')
+            part = part.replace('>', '&gt;')
+            escaped_parts.append(part)
+    text = ''.join(escaped_parts)
+
+    # 转换链接 [text](url) → <a href="url">text</a>
+    text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
+
+    # 转换粗体 **text** → <b>text</b>
+    text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
+
+    # 转换斜体 *text* → <i>text</i>
+    text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
+
+    # 转换删除线 ~~text~~ → <s>text</s>
+    text = re.sub(r'~~(.+?)~~', r'<s>\1</s>', text)
+
+    # 转换行内代码 `code` → <code>code</code>
+    text = re.sub(r'`(.+?)`', r'<code>\1</code>', text)
+
+    # 清理多余空行
+    text = re.sub(r'\n{3,}', '\n\n', text)
+
+    return text.strip()
+
+
+def _convert_markdown_to_slack(text: str) -> str:
+    """将 Markdown 转换为 Slack mrkdwn 格式(增强版)
+
+    Slack mrkdwn 与标准 Markdown 差异:
+    - 粗体: *text* (非 **text**)
+    - 删除线: ~text~ (非 ~~text~~)
+    - 链接: <url|text> (非 [text](url))
+    - 不支持标题语法
+    """
+    # 去除 <font> 标签(保留内容)
+    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\1', text)
+    # 将 # 标题转换为粗体(Slack 无标题样式)
+    text = re.sub(r'^#{1,6}\s+(.+)$', r'**\1**', text, flags=re.MULTILINE)
+    # 去除 --- 分割线(Slack 渲染不稳定)
+    text = re.sub(r'^[\-\*]{3,}\s*$', '', text, flags=re.MULTILINE)
+    # 转换链接格式: [文本](url) → <url|文本>
+    text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<\2|\1>', text)
+    # 转换删除线: ~~文本~~ → ~文本~
+    text = re.sub(r'~~(.+?)~~', r'~\1~', text)
+    # 转换粗体: **文本** → *文本*(必须在删除线之后)
+    text = re.sub(r'\*\*([^*]+)\*\*', r'*\1*', text)
+    # 清理多余空行
+    text = re.sub(r'\n{3,}', '\n\n', text)
+    return text.strip()
+
+
+def _markdown_to_simple_html(text: str) -> str:
+    """
+    将 markdown 转换为简单 HTML(用于 Email)
+    """
+    html = text
+
+    # 转义
+    html = html.replace('&', '&amp;')
+    html = html.replace('<', '&lt;')
+    html = html.replace('>', '&gt;')
+
+    # 链接
+    html = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', html)
+
+    # 标题 ### → <h3>
+    html = re.sub(r'^### (.+)$', r'<h3>\1</h3>', html, flags=re.MULTILINE)
+    html = re.sub(r'^## (.+)$', r'<h2>\1</h2>', html, flags=re.MULTILINE)
+    html = re.sub(r'^# (.+)$', r'<h1>\1</h1>', html, flags=re.MULTILINE)
+
+    # 粗体
+    html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', html)
+
+    # 斜体
+    html = re.sub(r'\*(.+?)\*', r'<em>\1</em>', html)
+
+    # 删除线
+    html = re.sub(r'~~(.+?)~~', r'<del>\1</del>', html)
+
+    # 行内代码
+    html = re.sub(r'`(.+?)`', r'<code>\1</code>', html)
+
+    # 分割线
+    html = re.sub(r'^[\-\*]{3,}\s*$', '<hr>', html, flags=re.MULTILINE)
+
+    # 换行
+    html = html.replace('\n', '<br>\n')
+
+    return f"""<!DOCTYPE html>
+<html><head><meta charset="utf-8"><title>TrendRadar 通知</title>
+<style>body{{font-family:sans-serif;padding:20px;max-width:800px;margin:0 auto}}
+a{{color:#1a73e8}}h1,h2,h3{{color:#333}}hr{{border:none;border-top:1px solid #ddd;margin:16px 0}}
+code{{background:#f5f5f5;padding:2px 6px;border-radius:3px}}</style>
+</head><body>{html}</body></html>"""
+
+
+# ==================== 各渠道发送器 ====================
+
+def _send_feishu(webhook_url: str, content: str, title: str) -> Dict:
+    """飞书发送(纯文本消息,与 trendradar send_to_feishu 一致)
+
+    飞书 webhook 使用 msg_type: "text",所有信息整合到 content.text 中。
+    """
+    payload = {
+        "msg_type": "text",
+        "content": {
+            "text": content,
+        },
+    }
+    try:
+        resp = requests.post(webhook_url, json=payload, timeout=30)
+        data = resp.json()
+        ok = resp.status_code == 200 and (data.get("code") == 0 or data.get("StatusCode") == 0)
+        detail = ""
+        if not ok:
+            detail = data.get("msg") or data.get("StatusMessage", "")
+        return {"success": ok, "detail": detail}
+    except Exception as e:
+        return {"success": False, "detail": str(e)}
+
+
+def _send_dingtalk(webhook_url: str, content: str, title: str) -> Dict:
+    """钉钉发送(接收已适配的 Markdown)"""
+    payload = {
+        "msgtype": "markdown",
+        "markdown": {"title": title, "text": content}
+    }
+    try:
+        resp = requests.post(webhook_url, json=payload, timeout=30)
+        data = resp.json()
+        ok = resp.status_code == 200 and data.get("errcode") == 0
+        return {"success": ok, "detail": data.get("errmsg", "") if not ok else ""}
+    except Exception as e:
+        return {"success": False, "detail": str(e)}
+
+
+def _send_wework(webhook_url: str, content: str, title: str, msg_type: str = "markdown") -> Dict:
+    """企业微信发送(接收已适配的 Markdown,text 模式自动剥离格式)"""
+    if msg_type == "text":
+        payload = {"msgtype": "text", "text": {"content": strip_markdown(content)}}
+    else:
+        payload = {"msgtype": "markdown", "markdown": {"content": content}}
+
+    try:
+        resp = requests.post(webhook_url, json=payload, timeout=30)
+        data = resp.json()
+        ok = resp.status_code == 200 and data.get("errcode") == 0
+        return {"success": ok, "detail": data.get("errmsg", "") if not ok else ""}
+    except Exception as e:
+        return {"success": False, "detail": str(e)}
+
+
+def _send_telegram(bot_token: str, chat_id: str, content: str, title: str) -> Dict:
+    """Telegram 发送(接收已转换的 HTML)"""
+    url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+    payload = {
+        "chat_id": chat_id,
+        "text": content,
+        "parse_mode": "HTML",
+        "disable_web_page_preview": True,
+    }
+    try:
+        resp = requests.post(url, json=payload, timeout=30)
+        data = resp.json()
+        ok = resp.status_code == 200 and data.get("ok")
+        return {"success": ok, "detail": data.get("description", "") if not ok else ""}
+    except Exception as e:
+        return {"success": False, "detail": str(e)}
+
+
+def _send_email(
+    from_email: str, password: str, to_email: str,
+    message: str, title: str,
+    smtp_server: str = "", smtp_port: str = ""
+) -> Dict:
+    """邮件发送(HTML 格式)"""
+    try:
+        domain = from_email.split("@")[-1].lower()
+        html_content = _markdown_to_simple_html(message)
+
+        # SMTP 配置
+        if smtp_server and smtp_port:
+            server_host = smtp_server
+            port = int(smtp_port)
+            use_tls = port != 465
+        elif domain in SMTP_CONFIGS:
+            cfg = SMTP_CONFIGS[domain]
+            server_host = cfg["server"]
+            port = cfg["port"]
+            use_tls = cfg["encryption"] == "TLS"
+        else:
+            server_host = f"smtp.{domain}"
+            port = 587
+            use_tls = True
+
+        msg = MIMEMultipart("alternative")
+        msg["From"] = formataddr(("TrendRadar", from_email))
+
+        recipients = [addr.strip() for addr in to_email.split(",")]
+        msg["To"] = ", ".join(recipients)
+
+        now = datetime.now()
+        msg["Subject"] = Header(f"{title} - {now.strftime('%m月%d日 %H:%M')}", "utf-8")
+        msg["MIME-Version"] = "1.0"
+        msg["Date"] = formatdate(localtime=True)
+        msg["Message-ID"] = make_msgid()
+
+        # 纯文本备选
+        msg.attach(MIMEText(strip_markdown(message), "plain", "utf-8"))
+        # HTML 主体
+        msg.attach(MIMEText(html_content, "html", "utf-8"))
+
+        if use_tls:
+            server = smtplib.SMTP(server_host, port, timeout=30)
+            server.ehlo()
+            server.starttls()
+            server.ehlo()
+        else:
+            server = smtplib.SMTP_SSL(server_host, port, timeout=30)
+            server.ehlo()
+
+        server.login(from_email, password)
+        server.send_message(msg)
+        server.quit()
+
+        return {"success": True, "detail": ""}
+    except Exception as e:
+        return {"success": False, "detail": str(e)}
+
+
+def _send_ntfy(server_url: str, topic: str, content: str, title: str, token: str = "") -> Dict:
+    """ntfy 发送(接收已适配的 Markdown,与 trendradar send_to_ntfy 一致)
+
+    注意:Title 使用 ASCII 字符避免 HTTP header 编码问题。
+    支持 429 速率限制重试。
+    """
+    base_url = server_url.rstrip("/")
+    if not base_url.startswith(("http://", "https://")):
+        base_url = f"https://{base_url}"
+    url = f"{base_url}/{topic}"
+
+    headers = {
+        "Content-Type": "text/plain; charset=utf-8",
+        "Markdown": "yes",
+        "Title": "TrendRadar Notification",  # ASCII,避免 HTTP header 编码问题
+        "Priority": "default",
+        "Tags": "news",
+    }
+    if token:
+        headers["Authorization"] = f"Bearer {token}"
+
+    try:
+        resp = requests.post(url, data=content.encode("utf-8"), headers=headers, timeout=30)
+        if resp.status_code == 200:
+            return {"success": True, "detail": ""}
+        elif resp.status_code == 429:
+            # 速率限制,等待后重试一次(与 trendradar 一致)
+            time.sleep(10)
+            retry_resp = requests.post(url, data=content.encode("utf-8"), headers=headers, timeout=30)
+            ok = retry_resp.status_code == 200
+            return {"success": ok, "detail": "" if ok else f"retry status={retry_resp.status_code}"}
+        elif resp.status_code == 413:
+            return {"success": False, "detail": f"消息过大被拒绝 ({len(content.encode('utf-8'))} bytes)"}
+        else:
+            return {"success": False, "detail": f"status={resp.status_code}"}
+    except Exception as e:
+        return {"success": False, "detail": str(e)}
+
+
+def _send_bark(bark_url: str, content: str, title: str) -> Dict:
+    """Bark 发送(接收已适配的 Markdown,iOS 推送)"""
+    parsed = urlparse(bark_url)
+    device_key = parsed.path.strip('/').split('/')[0] if parsed.path else None
+    if not device_key:
+        return {"success": False, "detail": f"无法从 URL 提取 device_key: {bark_url}"}
+
+    api_endpoint = f"{parsed.scheme}://{parsed.netloc}/push"
+    payload = {
+        "title": title,
+        "markdown": content,
+        "device_key": device_key,
+        "sound": "default",
+        "group": "TrendRadar",
+        "action": "none",
+    }
+
+    try:
+        resp = requests.post(api_endpoint, json=payload, timeout=30)
+        data = resp.json()
+        ok = resp.status_code == 200 and data.get("code") == 200
+        return {"success": ok, "detail": data.get("message", "") if not ok else ""}
+    except Exception as e:
+        return {"success": False, "detail": str(e)}
+
+
+def _send_slack(webhook_url: str, content: str, title: str) -> Dict:
+    """Slack 发送(接收已转换的 mrkdwn)"""
+    payload = {"text": content}
+
+    try:
+        resp = requests.post(webhook_url, json=payload, timeout=30)
+        ok = resp.status_code == 200 and resp.text == "ok"
+        return {"success": ok, "detail": "" if ok else resp.text}
+    except Exception as e:
+        return {"success": False, "detail": str(e)}
+
+
+def _send_generic_webhook(
+    webhook_url: str, message: str, title: str, payload_template: str = ""
+) -> Dict:
+    """通用 Webhook 发送(Markdown 格式,支持自定义模板)"""
+    try:
+        if payload_template:
+            json_content = json.dumps(message)[1:-1]
+            json_title = json.dumps(title)[1:-1]
+            payload_str = payload_template.replace("{content}", json_content).replace("{title}", json_title)
+            try:
+                payload = json.loads(payload_str)
+            except json.JSONDecodeError:
+                payload = {"title": title, "content": message}
+        else:
+            payload = {"title": title, "content": message}
+
+        resp = requests.post(
+            webhook_url,
+            headers={"Content-Type": "application/json"},
+            json=payload,
+            timeout=30,
+        )
+        ok = 200 <= resp.status_code < 300
+        return {"success": ok, "detail": "" if ok else f"status={resp.status_code}"}
+    except Exception as e:
+        return {"success": False, "detail": str(e)}
+
+
+# ==================== 工具类 ====================
+
+class NotificationTools:
+    """通知推送工具类"""
+
+    def __init__(self, project_root: str = None):
+        if project_root:
+            self.project_root = Path(project_root)
+        else:
+            current_file = Path(__file__)
+            self.project_root = current_file.parent.parent.parent
+
+    def _load_merged_config(self) -> Dict[str, Any]:
+        """
+        加载合并后的通知配置(config.yaml + .env)
+
+        Returns:
+            包含 webhook 配置和通知参数的合并字典
+        """
+        config_path = self.project_root / "config" / "config.yaml"
+        if config_path.exists():
+            with open(config_path, "r", encoding="utf-8") as f:
+                config_data = yaml.safe_load(f)
+        else:
+            config_data = {}
+
+        webhook_config = _load_webhook_config(config_data)
+        notification_config = _load_notification_config(config_data)
+        return {**webhook_config, **notification_config}
+
+    def _detect_config_source(self, env_key: str, yaml_value: str) -> str:
+        """检测配置项来源:env / yaml / 未配置"""
+        env_val = os.environ.get(env_key, "").strip()
+        if env_val:
+            return "env"
+        elif yaml_value:
+            return "yaml"
+        return ""
+
+    def get_channel_format_guide(self, channel: Optional[str] = None) -> Dict:
+        """
+        获取渠道格式化策略指南
+
+        返回各渠道支持的 Markdown 特性、限制和最佳格式化提示词,
+        供 LLM 在生成推送内容时参考,确保内容样式贴合目标渠道。
+
+        Args:
+            channel: 指定渠道 ID,None 返回所有渠道的策略
+
+        Returns:
+            格式化策略字典
+        """
+        if channel:
+            if channel not in CHANNEL_FORMAT_GUIDES:
+                valid = list(CHANNEL_FORMAT_GUIDES.keys())
+                return {
+                    "success": False,
+                    "error": {
+                        "code": "INVALID_CHANNEL",
+                        "message": f"无效的渠道: {channel}",
+                        "suggestion": f"支持的渠道: {valid}",
+                    },
+                }
+            guide = CHANNEL_FORMAT_GUIDES[channel]
+            return {
+                "success": True,
+                "channel": channel,
+                "guide": guide,
+            }
+        else:
+            return {
+                "success": True,
+                "summary": f"共 {len(CHANNEL_FORMAT_GUIDES)} 个渠道的格式化策略",
+                "guides": CHANNEL_FORMAT_GUIDES,
+            }
+
+    def get_notification_channels(self) -> Dict:
+        """
+        获取所有通知渠道的配置状态
+
+        检测 config.yaml 和 .env 环境变量,返回每个渠道是否已配置。
+
+        Returns:
+            渠道状态字典
+        """
+        try:
+            config = self._load_merged_config()
+            enabled = config.get("ENABLE_NOTIFICATION", True)
+
+            # 从 yaml 直接读取(用于判断来源)
+            config_path = self.project_root / "config" / "config.yaml"
+            yaml_channels = {}
+            if config_path.exists():
+                with open(config_path, "r", encoding="utf-8") as f:
+                    raw = yaml.safe_load(f) or {}
+                    yaml_channels = raw.get("notification", {}).get("channels", {})
+
+            channels = []
+            env_key_map = {
+                "FEISHU_WEBHOOK_URL": ("feishu", "webhook_url"),
+                "DINGTALK_WEBHOOK_URL": ("dingtalk", "webhook_url"),
+                "WEWORK_WEBHOOK_URL": ("wework", "webhook_url"),
+                "TELEGRAM_BOT_TOKEN": ("telegram", "bot_token"),
+                "TELEGRAM_CHAT_ID": ("telegram", "chat_id"),
+                "EMAIL_FROM": ("email", "from"),
+                "EMAIL_PASSWORD": ("email", "password"),
+                "EMAIL_TO": ("email", "to"),
+                "NTFY_SERVER_URL": ("ntfy", "server_url"),
+                "NTFY_TOPIC": ("ntfy", "topic"),
+                "BARK_URL": ("bark", "url"),
+                "SLACK_WEBHOOK_URL": ("slack", "webhook_url"),
+                "GENERIC_WEBHOOK_URL": ("generic_webhook", "webhook_url"),
+            }
+
+            for channel_id, required_keys in _CHANNEL_REQUIREMENTS.items():
+                is_configured = all(config.get(k) for k in required_keys)
+
+                # 判断来源
+                sources = set()
+                for key in required_keys:
+                    ch_name, field = env_key_map.get(key, ("", ""))
+                    yaml_val = yaml_channels.get(ch_name, {}).get(field, "")
+                    src = self._detect_config_source(key, yaml_val)
+                    if src:
+                        sources.add(src)
+
+                channels.append({
+                    "id": channel_id,
+                    "name": _CHANNEL_NAMES.get(channel_id, channel_id),
+                    "configured": is_configured,
+                    "source": list(sources) if sources else [],
+                })
+
+            configured_count = sum(1 for ch in channels if ch["configured"])
+
+            return {
+                "success": True,
+                "notification_enabled": enabled,
+                "summary": f"{configured_count}/{len(channels)} 个渠道已配置",
+                "channels": channels,
+            }
+        except Exception as e:
+            return {
+                "success": False,
+                "error": {"code": "INTERNAL_ERROR", "message": str(e)},
+            }
+
+    def send_notification(
+        self,
+        message: str,
+        title: str = "TrendRadar 通知",
+        channels: Optional[List[str]] = None,
+    ) -> Dict:
+        """
+        向已配置的通知渠道发送消息
+
+        接受 markdown 格式内容,内部自动转换为各渠道要求的格式。
+
+        Args:
+            message: markdown 格式的消息内容
+            title: 消息标题
+            channels: 指定发送的渠道列表,None 表示发送到所有已配置渠道
+                      可选值: feishu, dingtalk, wework, telegram, email, ntfy, bark, slack, generic_webhook
+
+        Returns:
+            发送结果字典
+        """
+        if not message or not message.strip():
+            return {
+                "success": False,
+                "error": {"code": "EMPTY_MESSAGE", "message": "消息内容不能为空"},
+            }
+
+        try:
+            config = self._load_merged_config()
+
+            if not config.get("ENABLE_NOTIFICATION", True):
+                return {
+                    "success": False,
+                    "error": {"code": "NOTIFICATION_DISABLED", "message": "通知功能已禁用(notification.enabled = false)"},
+                }
+
+            # 确定目标渠道
+            all_channel_ids = list(_CHANNEL_REQUIREMENTS.keys())
+            if channels:
+                # 验证渠道名称
+                invalid = [ch for ch in channels if ch not in all_channel_ids]
+                if invalid:
+                    raise InvalidParameterError(
+                        f"无效的渠道: {invalid}",
+                        suggestion=f"支持的渠道: {all_channel_ids}"
+                    )
+                target_channels = channels
+            else:
+                # 发送到所有已配置渠道
+                target_channels = [
+                    ch_id for ch_id, keys in _CHANNEL_REQUIREMENTS.items()
+                    if all(config.get(k) for k in keys)
+                ]
+
+            if not target_channels:
+                return {
+                    "success": False,
+                    "error": {
+                        "code": "NO_CHANNELS",
+                        "message": "没有已配置的目标渠道",
+                        "suggestion": "请在 config.yaml 或 .env 中配置至少一个通知渠道",
+                    },
+                }
+
+            # 逐渠道发送
+            results = {}
+            for ch_id in target_channels:
+                required_keys = _CHANNEL_REQUIREMENTS[ch_id]
+                if not all(config.get(k) for k in required_keys):
+                    results[ch_id] = {"success": False, "detail": "渠道未配置"}
+                    continue
+
+                result = self._dispatch_to_channel(ch_id, config, message, title)
+                results[ch_id] = result
+
+            success_count = sum(1 for r in results.values() if r["success"])
+            total = len(results)
+
+            return {
+                "success": success_count > 0,
+                "summary": f"{success_count}/{total} 个渠道发送成功",
+                "results": {
+                    ch_id: {
+                        "name": _CHANNEL_NAMES.get(ch_id, ch_id),
+                        **r,
+                    }
+                    for ch_id, r in results.items()
+                },
+            }
+
+        except MCPError as e:
+            return {"success": False, "error": e.to_dict()}
+        except Exception as e:
+            return {
+                "success": False,
+                "error": {"code": "INTERNAL_ERROR", "message": str(e)},
+            }
+
+    def _dispatch_to_channel(
+        self, channel_id: str, config: Dict, message: str, title: str
+    ) -> Dict:
+        """分发消息到指定渠道(格式适配 → 字节分批 → 多账号 × 逐批发送)
+
+        从 config.yaml → advanced.batch_size / batch_send_interval 读取配置。
+        """
+        # 从 config 读取批次配置(与 trendradar 一致)
+        batch_sizes = self._get_batch_sizes()
+        batch_interval = self._get_batch_interval()
+
+        # Email 无字节限制,不走分批管线
+        if channel_id == "email":
+            return _send_email(
+                config["EMAIL_FROM"],
+                config["EMAIL_PASSWORD"],
+                config["EMAIL_TO"],
+                message, title,
+                config.get("EMAIL_SMTP_SERVER", ""),
+                config.get("EMAIL_SMTP_PORT", ""),
+            )
+
+        # 统一分批管线:格式适配 → 字节分割 → 添加批次头部 → (可选)反序
+        batches = _prepare_batches(message, channel_id, batch_sizes)
+
+        # 按渠道路由发送
+        if channel_id == "feishu":
+            return self._send_batched_multi_account(
+                config["FEISHU_WEBHOOK_URL"], batches, channel_id,
+                lambda url, content: _send_feishu(url, content, title),
+                batch_interval,
+            )
+        elif channel_id == "dingtalk":
+            return self._send_batched_multi_account(
+                config["DINGTALK_WEBHOOK_URL"], batches, channel_id,
+                lambda url, content: _send_dingtalk(url, content, title),
+                batch_interval,
+            )
+        elif channel_id == "wework":
+            msg_type = config.get("WEWORK_MSG_TYPE", "markdown")
+            return self._send_batched_multi_account(
+                config["WEWORK_WEBHOOK_URL"], batches, channel_id,
+                lambda url, content: _send_wework(url, content, title, msg_type),
+                batch_interval,
+            )
+        elif channel_id == "telegram":
+            return self._send_batched_telegram(
+                config, batches, title, batch_interval,
+            )
+        elif channel_id == "ntfy":
+            return self._send_batched_ntfy(
+                config, batches, title, batch_interval,
+            )
+        elif channel_id == "bark":
+            return self._send_batched_multi_account(
+                config["BARK_URL"], batches, channel_id,
+                lambda url, content: _send_bark(url, content, title),
+                batch_interval,
+            )
+        elif channel_id == "slack":
+            return self._send_batched_multi_account(
+                config["SLACK_WEBHOOK_URL"], batches, channel_id,
+                lambda url, content: _send_slack(url, content, title),
+                batch_interval,
+            )
+        elif channel_id == "generic_webhook":
+            template = config.get("GENERIC_WEBHOOK_TEMPLATE", "")
+            return self._send_batched_multi_account(
+                config["GENERIC_WEBHOOK_URL"], batches, channel_id,
+                lambda url, content: _send_generic_webhook(url, content, title, template),
+                batch_interval,
+            )
+        else:
+            return {"success": False, "detail": f"未知渠道: {channel_id}"}
+
+    def _get_batch_sizes(self) -> Dict:
+        """从 config.yaml 读取 advanced.batch_size,合并到默认值"""
+        try:
+            config_path = self.project_root / "config" / "config.yaml"
+            if config_path.exists():
+                with open(config_path, "r", encoding="utf-8") as f:
+                    raw = yaml.safe_load(f) or {}
+                advanced = raw.get("advanced", {})
+                cfg_sizes = advanced.get("batch_size", {})
+                # 从 config 构建渠道映射
+                sizes = dict(_CHANNEL_BATCH_SIZES_DEFAULT)
+                default_size = cfg_sizes.get("default", 4000)
+                for ch_id in sizes:
+                    if ch_id in cfg_sizes:
+                        sizes[ch_id] = cfg_sizes[ch_id]
+                    elif ch_id not in ("email", "ntfy") and sizes[ch_id] == 4000:
+                        # 使用 config 中的 default
+                        sizes[ch_id] = default_size
+                return sizes
+        except Exception:
+            pass
+        return dict(_CHANNEL_BATCH_SIZES_DEFAULT)
+
+    def _get_batch_interval(self) -> float:
+        """从 config.yaml 读取 advanced.batch_send_interval"""
+        try:
+            config_path = self.project_root / "config" / "config.yaml"
+            if config_path.exists():
+                with open(config_path, "r", encoding="utf-8") as f:
+                    raw = yaml.safe_load(f) or {}
+                return float(raw.get("advanced", {}).get("batch_send_interval", _BATCH_INTERVAL_DEFAULT))
+        except Exception:
+            pass
+        return _BATCH_INTERVAL_DEFAULT
+
+    def _send_batched_multi_account(
+        self, urls_str: str, batches: List[str], channel_id: str, send_func,
+        batch_interval: float = _BATCH_INTERVAL_DEFAULT,
+    ) -> Dict:
+        """多账号 × 逐批发送(; 分隔的 URL)"""
+        urls = [u.strip() for u in urls_str.split(";") if u.strip()]
+        if not urls:
+            return {"success": False, "detail": "URL 为空"}
+
+        any_ok = False
+        details = []
+        for url in urls:
+            for i, batch in enumerate(batches):
+                r = send_func(url, batch)
+                if r["success"]:
+                    any_ok = True
+                elif r["detail"]:
+                    details.append(r["detail"])
+                # 批次间间隔
+                if i < len(batches) - 1:
+                    time.sleep(batch_interval)
+
+        return {
+            "success": any_ok,
+            "detail": "; ".join(details) if details else "",
+            "batches": len(batches),
+        }
+
+    def _send_batched_telegram(
+        self, config: Dict, batches: List[str], title: str,
+        batch_interval: float = _BATCH_INTERVAL_DEFAULT,
+    ) -> Dict:
+        """Telegram 多账号 × 逐批发送(token/chat_id 配对)"""
+        tokens = config["TELEGRAM_BOT_TOKEN"].split(";")
+        chat_ids = config["TELEGRAM_CHAT_ID"].split(";")
+        if len(tokens) != len(chat_ids):
+            return {"success": False, "detail": "bot_token 和 chat_id 数量不一致"}
+
+        any_ok = False
+        details = []
+        for token, cid in zip(tokens, chat_ids):
+            token, cid = token.strip(), cid.strip()
+            if not (token and cid):
+                continue
+            for i, batch in enumerate(batches):
+                r = _send_telegram(token, cid, batch, title)
+                if r["success"]:
+                    any_ok = True
+                elif r["detail"]:
+                    details.append(r["detail"])
+                if i < len(batches) - 1:
+                    time.sleep(batch_interval)
+
+        return {
+            "success": any_ok,
+            "detail": "; ".join(details) if details else "",
+            "batches": len(batches),
+        }
+
+    def _send_batched_ntfy(
+        self, config: Dict, batches: List[str], title: str,
+        batch_interval: float = _BATCH_INTERVAL_DEFAULT,
+    ) -> Dict:
+        """ntfy 多账号 × 逐批发送(server/topic/token 配对,含速率限制处理)"""
+        servers = config["NTFY_SERVER_URL"].split(";")
+        topics = config["NTFY_TOPIC"].split(";")
+        tokens_str = config.get("NTFY_TOKEN", "")
+        tokens = tokens_str.split(";") if tokens_str else [""]
+        if len(servers) != len(topics):
+            return {"success": False, "detail": "server_url 和 topic 数量不一致"}
+
+        any_ok = False
+        details = []
+        for i, (srv, topic) in enumerate(zip(servers, topics)):
+            srv, topic = srv.strip(), topic.strip()
+            tk = tokens[i].strip() if i < len(tokens) else ""
+            if not (srv and topic):
+                continue
+            # ntfy.sh 公共服务器用 2s 间隔(与 trendradar 一致)
+            interval = 2.0 if "ntfy.sh" in srv else batch_interval
+            for j, batch in enumerate(batches):
+                r = _send_ntfy(srv, topic, batch, title, tk)
+                if r["success"]:
+                    any_ok = True
+                elif r["detail"]:
+                    details.append(r["detail"])
+                if j < len(batches) - 1:
+                    time.sleep(interval)
+
+        return {
+            "success": any_ok,
+            "detail": "; ".join(details) if details else "",
+            "batches": len(batches),
+        }

+ 26 - 10
mcp_server/utils/validators.py

@@ -148,9 +148,17 @@ def _parse_string_to_bool(value: str) -> bool:
         return bool(value)
 
 
+# 平台列表 mtime 缓存(避免每次 MCP 调用都重新读取 config.yaml)
+_platforms_cache: Optional[List[str]] = None
+_platforms_config_mtime: float = 0.0
+_platforms_config_path: Optional[str] = None
+
+
 def get_supported_platforms() -> List[str]:
     """
-    从 config.yaml 动态获取支持的平台列表
+    从 config.yaml 动态获取支持的平台列表(带 mtime 缓存)
+
+    仅当 config.yaml 被修改时才重新读取,避免每次 MCP 调用的重复 IO。
 
     Returns:
         平台ID列表
@@ -159,21 +167,29 @@ def get_supported_platforms() -> List[str]:
         - 读取失败时返回空列表,允许所有平台通过(降级策略)
         - 平台列表来自 config/config.yaml 中的 platforms 配置
     """
+    global _platforms_cache, _platforms_config_mtime, _platforms_config_path
+
     try:
-        # 获取 config.yaml 路径(相对于当前文件)
-        current_dir = os.path.dirname(os.path.abspath(__file__))
-        config_path = os.path.join(current_dir, "..", "..", "config", "config.yaml")
-        config_path = os.path.normpath(config_path)
+        if _platforms_config_path is None:
+            current_dir = os.path.dirname(os.path.abspath(__file__))
+            _platforms_config_path = os.path.normpath(
+                os.path.join(current_dir, "..", "..", "config", "config.yaml")
+            )
+
+        current_mtime = os.path.getmtime(_platforms_config_path)
+
+        if _platforms_cache is not None and current_mtime == _platforms_config_mtime:
+            return _platforms_cache
 
-        with open(config_path, 'r', encoding='utf-8') as f:
+        with open(_platforms_config_path, 'r', encoding='utf-8') as f:
             config = yaml.safe_load(f)
             platforms_config = config.get('platforms', {})
-            # 处理嵌套结构:{enabled: bool, sources: [...]}
             sources = platforms_config.get('sources', [])
-            return [p['id'] for p in sources if 'id' in p]
+            _platforms_cache = [p['id'] for p in sources if 'id' in p]
+            _platforms_config_mtime = current_mtime
+            return _platforms_cache
     except Exception as e:
-        # 降级方案:返回空列表,允许所有平台
-        print(f"警告:无法加载平台配置 ({config_path}): {e}")
+        print(f"警告:无法加载平台配置: {e}")
         return []
 
 

+ 1 - 1
pyproject.toml

@@ -1,6 +1,6 @@
 [project]
 name = "trendradar"
-version = "5.5.3"
+version = "6.0.0"
 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.3"
+__version__ = "6.0.0"
 __all__ = ["AppContext", "__version__"]

+ 119 - 160
trendradar/__main__.py

@@ -23,6 +23,7 @@ from trendradar.crawler import DataFetcher
 from trendradar.storage import convert_crawl_results_to_news_data
 from trendradar.utils.time import DEFAULT_TIMEZONE, is_within_days, calculate_days_old
 from trendradar.ai import AIAnalyzer, AIAnalysisResult
+from trendradar.core.scheduler import ResolvedSchedule
 
 
 def _parse_version(version_str: str) -> Tuple[int, int, int]:
@@ -452,33 +453,27 @@ class NewsAnalyzer:
         report_type: str,
         id_to_name: Optional[Dict],
         current_results: Optional[Dict] = None,
+        schedule: ResolvedSchedule = None,
+        standalone_data: Optional[Dict] = None,
     ) -> Optional[AIAnalysisResult]:
         """执行 AI 分析"""
         analysis_config = self.ctx.config.get("AI_ANALYSIS", {})
         if not analysis_config.get("ENABLED", False):
             return None
 
-        # AI 分析时间窗口控制
-        analysis_window = analysis_config.get("ANALYSIS_WINDOW", {})
-        if analysis_window.get("ENABLED", False):
-            push_manager = self.ctx.create_push_manager()
-            time_range_start = analysis_window["TIME_RANGE"]["START"]
-            time_range_end = analysis_window["TIME_RANGE"]["END"]
+        # 调度系统决策
+        if not schedule.analyze:
+            print("[AI] 调度器: 当前时间段不执行 AI 分析")
+            return None
 
-            if not push_manager.is_in_time_range(time_range_start, time_range_end):
-                now = self.ctx.get_time()
-                print(
-                    f"[AI] 分析窗口控制:当前时间 {now.strftime('%H:%M')} 不在分析时间窗口 {time_range_start}-{time_range_end} 内,跳过 AI 分析"
-                )
+        if schedule.once_analyze and schedule.period_key:
+            scheduler = self.ctx.create_scheduler()
+            date_str = self.ctx.format_date()
+            if scheduler.already_executed(schedule.period_key, "analyze", date_str):
+                print(f"[AI] 调度器: 时间段 {schedule.period_name or schedule.period_key} 今天已分析过,跳过")
                 return None
-
-            if analysis_window.get("ONCE_PER_DAY", False):
-                # 检查今天是否已经进行过 AI 分析
-                if push_manager.storage_backend.has_ai_analyzed_today():
-                    print(f"[AI] 分析窗口控制:今天已分析过,跳过本次 AI 分析")
-                    return None
-                else:
-                    print(f"[AI] 分析窗口控制:今天首次分析")
+            else:
+                print(f"[AI] 调度器: 时间段 {schedule.period_name or schedule.period_key} 今天首次分析")
 
         print("[AI] 正在进行 AI 分析...")
         try:
@@ -543,6 +538,7 @@ class NewsAnalyzer:
                 report_type=ai_report_type,
                 platforms=platforms,
                 keywords=keywords,
+                standalone_data=standalone_data,
             )
 
             # 设置 AI 分析使用的模式
@@ -554,10 +550,11 @@ class NewsAnalyzer:
                 else:
                     print("[AI] 分析完成")
 
-                # 记录 AI 分析(如果启用了 once_per_day)
-                if analysis_window.get("ENABLED", False) and analysis_window.get("ONCE_PER_DAY", False):
-                    push_manager = self.ctx.create_push_manager()
-                    push_manager.storage_backend.record_ai_analysis(ai_mode)
+                # 记录 AI 分析
+                if schedule.once_analyze and schedule.period_key:
+                    scheduler = self.ctx.create_scheduler()
+                    date_str = self.ctx.format_date()
+                    scheduler.record_execution(schedule.period_key, "analyze", date_str)
             else:
                 print(f"[AI] 分析失败: {result.error}")
 
@@ -645,6 +642,12 @@ class NewsAnalyzer:
         """
         从原始数据中提取独立展示区数据
 
+        纯数据准备方法,不检查 display.regions.standalone 开关。
+        各消费者自行决定是否使用:
+        - AI 分析:由 ai.include_standalone 控制
+        - 通知推送:由 display.regions.standalone 控制(在 dispatcher 层门控)
+        - HTML 报告:始终包含(如果有数据)
+
         Args:
             results: 原始爬取结果 {platform_id: {title: title_data}}
             id_to_name: 平台 ID 到名称的映射
@@ -652,15 +655,11 @@ class NewsAnalyzer:
             rss_items: RSS 条目列表
 
         Returns:
-            独立展示数据字典,如果未启用返回 None
+            独立展示数据字典,如果未配置数据源返回 None
         """
         display_config = self.ctx.config.get("DISPLAY", {})
-        regions = display_config.get("REGIONS", {})
         standalone_config = display_config.get("STANDALONE", {})
 
-        if not regions.get("STANDALONE", False):
-            return None
-
         platform_ids = standalone_config.get("PLATFORMS", [])
         rss_feed_ids = standalone_config.get("RSS_FEEDS", [])
         max_items = standalone_config.get("MAX_ITEMS", 20)
@@ -726,6 +725,7 @@ class NewsAnalyzer:
                     "first_time": meta.get("first_time", ""),
                     "last_time": meta.get("last_time", ""),
                     "count": meta.get("count", 1),
+                    "rank_timeline": meta.get("rank_timeline", []),
                 }
                 items.append(item)
 
@@ -797,6 +797,7 @@ class NewsAnalyzer:
         rss_items: Optional[List[Dict]] = None,
         rss_new_items: Optional[List[Dict]] = None,
         standalone_data: Optional[Dict] = None,
+        schedule: ResolvedSchedule = None,
     ) -> Tuple[List[Dict], Optional[str], Optional[AIAnalysisResult]]:
         """统一的分析流水线:数据处理 → 统计计算 → AI分析 → HTML生成"""
 
@@ -829,7 +830,9 @@ class NewsAnalyzer:
             mode_strategy = self._get_mode_strategy()
             report_type = mode_strategy["report_type"]
             ai_result = self._run_ai_analysis(
-                stats, rss_items, mode, report_type, id_to_name, current_results=data_source
+                stats, rss_items, mode, report_type, id_to_name,
+                current_results=data_source, schedule=schedule,
+                standalone_data=standalone_data
             )
 
         # HTML生成(如果启用)
@@ -865,6 +868,7 @@ class NewsAnalyzer:
         standalone_data: Optional[Dict] = None,
         ai_result: Optional[AIAnalysisResult] = None,
         current_results: Optional[Dict] = None,
+        schedule: ResolvedSchedule = None,
     ) -> bool:
         """统一的通知发送逻辑,包含所有判断条件,支持热榜+RSS合并推送+AI分析+独立展示区"""
         has_notification = self._has_notification_configured()
@@ -877,7 +881,6 @@ class NewsAnalyzer:
 
         # 计算热榜匹配条数
         news_count = sum(len(stat.get("titles", [])) for stat in stats) if stats else 0
-        # rss_items 是统计列表 [{"word": "xx", "count": 5, ...}],需累加 count
         rss_count = sum(stat.get("count", 0) for stat in rss_items) if rss_items else 0
 
         if (
@@ -894,32 +897,27 @@ class NewsAnalyzer:
             total_count = news_count + rss_count
             print(f"[推送] 准备发送:{' + '.join(content_parts)},合计 {total_count} 条")
 
-            # 推送窗口控制
-            if cfg["PUSH_WINDOW"]["ENABLED"]:
-                push_manager = self.ctx.create_push_manager()
-                time_range_start = cfg["PUSH_WINDOW"]["TIME_RANGE"]["START"]
-                time_range_end = cfg["PUSH_WINDOW"]["TIME_RANGE"]["END"]
+            # 调度系统决策
+            if not schedule.push:
+                print("[推送] 调度器: 当前时间段不执行推送")
+                return False
 
-                if not push_manager.is_in_time_range(time_range_start, time_range_end):
-                    now = self.ctx.get_time()
-                    print(
-                        f"推送窗口控制:当前时间 {now.strftime('%H:%M')} 不在推送时间窗口 {time_range_start}-{time_range_end} 内,跳过推送"
-                    )
+            if schedule.once_push and schedule.period_key:
+                scheduler = self.ctx.create_scheduler()
+                date_str = self.ctx.format_date()
+                if scheduler.already_executed(schedule.period_key, "push", date_str):
+                    print(f"[推送] 调度器: 时间段 {schedule.period_name or schedule.period_key} 今天已推送过,跳过")
                     return False
-
-                if cfg["PUSH_WINDOW"]["ONCE_PER_DAY"]:
-                    if push_manager.has_pushed_today():
-                        print(f"推送窗口控制:今天已推送过,跳过本次推送")
-                        return False
-                    else:
-                        print(f"推送窗口控制:今天首次推送")
+                else:
+                    print(f"[推送] 调度器: 时间段 {schedule.period_name or schedule.period_key} 今天首次推送")
 
             # AI 分析:优先使用传入的结果,避免重复分析
             if ai_result is None:
                 ai_config = cfg.get("AI_ANALYSIS", {})
                 if ai_config.get("ENABLED", False):
                     ai_result = self._run_ai_analysis(
-                        stats, rss_items, mode, report_type, id_to_name, current_results=current_results
+                        stats, rss_items, mode, report_type, id_to_name,
+                        current_results=current_results, schedule=schedule
                     )
 
             # 准备报告数据
@@ -928,7 +926,7 @@ class NewsAnalyzer:
             # 是否发送版本更新信息
             update_info_to_send = self.update_info if cfg["SHOW_VERSION_UPDATE"] else None
 
-            # 使用 NotificationDispatcher 发送到所有渠道(合并热榜+RSS+AI分析+独立展示区)
+            # 使用 NotificationDispatcher 发送到所有渠道
             dispatcher = self.ctx.create_notification_dispatcher()
             results = dispatcher.dispatch_all(
                 report_data=report_data,
@@ -947,14 +945,12 @@ class NewsAnalyzer:
                 print("未配置任何通知渠道,跳过通知发送")
                 return False
 
-            # 如果成功发送了任何通知,且启用了每天只推一次,则记录推送
-            if (
-                cfg["PUSH_WINDOW"]["ENABLED"]
-                and cfg["PUSH_WINDOW"]["ONCE_PER_DAY"]
-                and any(results.values())
-            ):
-                push_manager = self.ctx.create_push_manager()
-                push_manager.record_push(report_type)
+            # 记录推送成功
+            if any(results.values()):
+                if schedule.once_push and schedule.period_key:
+                    scheduler = self.ctx.create_scheduler()
+                    date_str = self.ctx.format_date()
+                    scheduler.record_execution(schedule.period_key, "push", date_str)
 
             return True
 
@@ -1036,11 +1032,6 @@ class NewsAnalyzer:
         if txt_file:
             print(f"TXT 快照已保存: {txt_file}")
 
-        # 兼容:同时保存到原有 TXT 格式(确保向后兼容)
-        if self.ctx.config["STORAGE"]["FORMATS"]["TXT"]:
-            title_file = self.ctx.save_titles(results, id_to_name, failed_ids)
-            print(f"标题已保存到: {title_file}")
-
         return results, id_to_name, failed_ids
 
     def _crawl_rss_data(self) -> Tuple[Optional[List[Dict]], Optional[List[Dict]], Optional[List[Dict]]]:
@@ -1447,13 +1438,25 @@ class NewsAnalyzer:
         - 每次运行都生成 HTML 报告(时间戳快照 + latest/{mode}.html + index.html)
         - 根据模式发送通知
         """
+        # 调度系统
+        scheduler = self.ctx.create_scheduler()
+        schedule = scheduler.resolve()
+
+        # 使用 schedule 决定的 report_mode 覆盖全局配置
+        effective_mode = schedule.report_mode
+        if effective_mode != self.report_mode:
+            print(f"[调度] 报告模式覆盖: {self.report_mode} -> {effective_mode}")
+        self.report_mode = effective_mode
+
+        # 如果调度器说不采集,则直接跳过
+        if not schedule.collect:
+            print("[调度] 当前时间段不执行数据采集,跳过分析流水线")
+            return None
         # 获取当前监控平台ID列表
         current_platform_ids = self.ctx.platform_ids
 
         new_titles = self.ctx.detect_new_titles(current_platform_ids)
         time_info = self.ctx.format_time()
-        if self.ctx.config["STORAGE"]["FORMATS"]["TXT"]:
-            self.ctx.save_titles(results, id_to_name, failed_ids)
         word_groups, filter_words, global_filters = self.ctx.load_frequency_words()
 
         html_file = None
@@ -1497,6 +1500,7 @@ class NewsAnalyzer:
                     rss_items=rss_items,
                     rss_new_items=rss_new_items,
                     standalone_data=standalone_data,
+                    schedule=schedule,
                 )
 
                 combined_id_to_name = {**historical_id_to_name, **id_to_name}
@@ -1539,6 +1543,7 @@ class NewsAnalyzer:
                     rss_items=rss_items,
                     rss_new_items=rss_new_items,
                     standalone_data=standalone_data,
+                    schedule=schedule,
                 )
 
                 combined_id_to_name = {**historical_id_to_name, **id_to_name}
@@ -1565,6 +1570,7 @@ class NewsAnalyzer:
                     rss_items=rss_items,
                     rss_new_items=rss_new_items,
                     standalone_data=standalone_data,
+                    schedule=schedule,
                 )
         else:
             # incremental 模式:只使用当前抓取的数据
@@ -1585,6 +1591,7 @@ class NewsAnalyzer:
                 rss_items=rss_items,
                 rss_new_items=rss_new_items,
                 standalone_data=standalone_data,
+                schedule=schedule,
             )
 
         if html_file:
@@ -1609,6 +1616,7 @@ class NewsAnalyzer:
                 standalone_data=standalone_data,
                 ai_result=ai_result,
                 current_results=results,
+                schedule=schedule,
             )
 
         # 打开浏览器(仅在非容器环境)
@@ -1657,49 +1665,18 @@ def main():
         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 限制,强制推送
+调度状态命令:
+  --show-schedule        显示当前调度状态(时间段、行为开关)
 
 示例:
   python -m trendradar                    # 正常运行
-  python -m trendradar --show-push-status # 查看推送状态
-  python -m trendradar --reset-push-state # 重置推送状态后再运行
-  python -m trendradar --force-push       # 强制推送(忽略今日已推送限制)
+  python -m trendradar --show-schedule    # 查看当前调度状态
 """
     )
     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",
+        "--show-schedule",
         action="store_true",
-        help="忽略 once_per_day 限制,强制 AI 分析"
+        help="显示当前调度状态"
     )
 
     args = parser.parse_args()
@@ -1709,20 +1686,11 @@ def main():
         # 先加载配置
         config = load_config()
 
-        # 处理状态查看/重置命令
-        if args.show_push_status or args.show_ai_status or args.reset_push_state or args.reset_ai_state:
+        # 处理状态查看命令
+        if args.show_schedule:
             _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", "")
 
@@ -1758,65 +1726,56 @@ def main():
 
 
 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(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("  ❌ 重置失败")
+    try:
+        scheduler = ctx.create_scheduler()
+        schedule = scheduler.resolve()
+
+        now = ctx.get_time()
+        date_str = ctx.format_date()
+
+        print(f"\n⏰ 当前时间: {now.strftime('%Y-%m-%d %H:%M:%S')} ({ctx.timezone})")
+        print(f"📅 当前日期: {date_str}")
 
-    # 重置 AI 分析状态
-    if args.reset_ai_state:
-        print("\n🔄 正在重置 AI 分析状态...")
-        if push_manager.reset_ai_analysis_state():
-            print("  ✅ AI 分析状态已重置")
+        print(f"\n📋 调度信息:")
+        print(f"  日计划: {schedule.day_plan}")
+        if schedule.period_key:
+            print(f"  当前时间段: {schedule.period_name or schedule.period_key} ({schedule.period_key})")
         else:
-            print("  ❌ 重置失败")
+            print(f"  当前时间段: 无(使用默认配置)")
+
+        print(f"\n🔧 行为开关:")
+        print(f"  采集数据: {'✅ 是' if schedule.collect else '❌ 否'}")
+        print(f"  AI 分析:  {'✅ 是' if schedule.analyze else '❌ 否'}")
+        print(f"  推送通知: {'✅ 是' if schedule.push else '❌ 否'}")
+        print(f"  报告模式: {schedule.report_mode}")
+        print(f"  AI 模式:  {schedule.ai_mode}")
+
+        if schedule.period_key:
+            print(f"\n🔁 一次性控制:")
+            if schedule.once_analyze:
+                already_analyzed = scheduler.already_executed(schedule.period_key, "analyze", date_str)
+                print(f"  AI 分析:  仅一次 {'(今日已执行 ⚠️)' if already_analyzed else '(今日未执行 ✅)'}")
+            else:
+                print(f"  AI 分析:  不限次数")
+            if schedule.once_push:
+                already_pushed = scheduler.already_executed(schedule.period_key, "push", date_str)
+                print(f"  推送通知: 仅一次 {'(今日已执行 ⚠️)' if already_pushed else '(今日未执行 ✅)'}")
+            else:
+                print(f"  推送通知: 不限次数")
 
-    print("=" * 60)
+    except Exception as e:
+        print(f"\n❌ 获取调度状态失败: {e}")
+
+    print("\n" + "=" * 60)
 
     # 清理资源
     ctx.cleanup()

+ 103 - 1
trendradar/ai/analyzer.py

@@ -7,7 +7,7 @@ AI 分析器模块
 """
 
 import json
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from pathlib import Path
 from typing import Any, Callable, Dict, List, Optional
 
@@ -23,6 +23,7 @@ class AIAnalysisResult:
     signals: str = ""                    # 异动与弱信号
     rss_insights: str = ""               # RSS 深度洞察
     outlook_strategy: str = ""           # 研判与策略建议
+    standalone_summaries: Dict[str, str] = field(default_factory=dict)  # 独立展示区概括 {源ID: 概括}
 
     # 基础元数据
     raw_response: str = ""               # 原始响应
@@ -74,6 +75,7 @@ class AIAnalyzer:
         self.max_news = analysis_config.get("MAX_NEWS_FOR_ANALYSIS", 50)
         self.include_rss = analysis_config.get("INCLUDE_RSS", True)
         self.include_rank_timeline = analysis_config.get("INCLUDE_RANK_TIMELINE", False)
+        self.include_standalone = analysis_config.get("INCLUDE_STANDALONE", False)
         self.language = analysis_config.get("LANGUAGE", "Chinese")
 
         # 加载提示词模板
@@ -120,6 +122,7 @@ class AIAnalyzer:
         report_type: str = "当日汇总",
         platforms: Optional[List[str]] = None,
         keywords: Optional[List[str]] = None,
+        standalone_data: Optional[Dict] = None,
     ) -> AIAnalysisResult:
         """
         执行 AI 分析
@@ -194,6 +197,12 @@ class AIAnalyzer:
         user_prompt = user_prompt.replace("{rss_content}", rss_content)
         user_prompt = user_prompt.replace("{language}", self.language)
 
+        # 构建独立展示区内容
+        standalone_content = ""
+        if self.include_standalone and standalone_data:
+            standalone_content = self._prepare_standalone_content(standalone_data)
+        user_prompt = user_prompt.replace("{standalone_content}", standalone_content)
+
         if self.debug:
             print("\n" + "=" * 80)
             print("[AI 调试] 发送给 AI 的完整提示词")
@@ -214,6 +223,10 @@ class AIAnalyzer:
             if not self.include_rss:
                 result.rss_insights = ""
 
+            # 如果配置未启用 standalone 分析,强制清空
+            if not self.include_standalone:
+                result.standalone_summaries = {}
+
             # 填充统计数据
             result.total_news = total_news
             result.hotlist_count = hotlist_total
@@ -408,6 +421,88 @@ class AIAnalyzer:
 
         return "→".join(parts)
 
+    def _prepare_standalone_content(self, standalone_data: Dict) -> str:
+        """
+        将独立展示区数据转为文本,注入 AI 分析 prompt
+
+        Args:
+            standalone_data: 独立展示区数据 {"platforms": [...], "rss_feeds": [...]}
+
+        Returns:
+            格式化的文本内容
+        """
+        lines = []
+
+        # 热榜平台
+        for platform in standalone_data.get("platforms", []):
+            platform_id = platform.get("id", "")
+            platform_name = platform.get("name", platform_id)
+            items = platform.get("items", [])
+            if not items:
+                continue
+
+            lines.append(f"### [{platform_name}]")
+            for item in items:
+                title = item.get("title", "")
+                if not title:
+                    continue
+
+                line = f"- {title}"
+
+                # 排名信息
+                ranks = item.get("ranks", [])
+                if ranks:
+                    min_rank = min(ranks)
+                    max_rank = max(ranks)
+                    rank_str = f"{min_rank}" if min_rank == max_rank else f"{min_rank}-{max_rank}"
+                    line += f" | 排名:{rank_str}"
+
+                # 时间范围
+                first_time = item.get("first_time", "")
+                last_time = item.get("last_time", "")
+                if first_time:
+                    time_str = self._format_time_range(first_time, last_time)
+                    line += f" | 时间:{time_str}"
+
+                # 出现次数
+                count = item.get("count", 1)
+                if count > 1:
+                    line += f" | 出现:{count}次"
+
+                # 排名轨迹(如果启用)
+                if self.include_rank_timeline:
+                    rank_timeline = item.get("rank_timeline", [])
+                    if rank_timeline:
+                        timeline_str = self._format_rank_timeline(rank_timeline)
+                        line += f" | 轨迹:{timeline_str}"
+
+                lines.append(line)
+            lines.append("")
+
+        # RSS 源
+        for feed in standalone_data.get("rss_feeds", []):
+            feed_id = feed.get("id", "")
+            feed_name = feed.get("name", feed_id)
+            items = feed.get("items", [])
+            if not items:
+                continue
+
+            lines.append(f"### [{feed_name}]")
+            for item in items:
+                title = item.get("title", "")
+                if not title:
+                    continue
+
+                line = f"- {title}"
+                published_at = item.get("published_at", "")
+                if published_at:
+                    line += f" | {published_at}"
+
+                lines.append(line)
+            lines.append("")
+
+        return "\n".join(lines)
+
     def _parse_response(self, response: str) -> AIAnalysisResult:
         """解析 AI 响应"""
         result = AIAnalysisResult(raw_response=response)
@@ -445,6 +540,13 @@ class AIAnalyzer:
             result.signals = data.get("signals", "")
             result.rss_insights = data.get("rss_insights", "")
             result.outlook_strategy = data.get("outlook_strategy", "")
+
+            # 解析独立展示区概括
+            summaries = data.get("standalone_summaries", {})
+            if isinstance(summaries, dict):
+                result.standalone_summaries = {
+                    str(k): str(v) for k, v in summaries.items()
+                }
             
             result.success = True
 

+ 74 - 11
trendradar/ai/formatter.py

@@ -27,7 +27,11 @@ def _format_list_content(text: str) -> str:
     
     # 去除首尾空白,防止 AI 返回的内容开头就有换行导致显示空行
     text = text.strip()
-    
+
+    # 0. 合并序号与紧随的【标签】(防御性处理)
+    # 将 "1.\n【投资者】:" 或 "1. 【投资者】:" 合并为 "1. 投资者:"
+    text = re.sub(r'(\d+\.)\s*【([^】]+)】([::]?)', r'\1 \2:', text)
+
     # 1. 规范化:确保 "1." 后面有空格
     result = re.sub(r'(\d+)\.([^ \d])', r'\1. \2', text)
 
@@ -44,17 +48,33 @@ def _format_list_content(text: str) -> str:
     # 只有在中文标点(句号、逗号、分号等)后才触发换行,避免破坏 "1. XX领域:" 格式
     result = re.sub(r'([。!?;,、])\s*([a-zA-Z0-9\u4e00-\u9fa5]+(方面|领域)[::])', r'\1\n\2', result)
 
-    # 6. 处理 "【XX】:"(如【宏观主线】:) 前的换行,确保视觉分隔
-    result = re.sub(r'(?<=[^\n])\s*(【[^】]+】[::])', r'\n\n\1', result)
+    # 6. 处理 【标签】 格式
+    # 6a. 标签前确保空行分隔(文本开头除外)
+    result = re.sub(r'(?<=\S)\n*(【[^】]+】)', r'\n\n\1', result)
+    # 6b. 合并标签与被换行拆开的冒号:【tag】\n: → 【tag】:
+    result = re.sub(r'(【[^】]+】)\n+([::])', r'\1\2', result)
+    # 6c. 标签后(含可选冒号),如果紧跟非空白非冒号内容则另起一行
+    # 用 (?=[^\s::]) 避免正则回溯将冒号误判为"内容"而拆开 【tag】:
+    result = re.sub(r'(【[^】]+】[::]?)[ \t]*(?=[^\s::])', r'\1\n', result)
 
-    # 7. 在列表项之间增加视觉空行(将 \n数字. 替换为 \n\n数字.)
-    # 但排除标题行(以冒号结尾)之后的情况,避免标题和第一项之间有空行
-    # (?<![::]) 是负向后瞻,表示前面不能是冒号
-    result = re.sub(r'(?<![::])\n(\d+\.)', r'\n\n\1', result)
+    # 7. 在列表项之间增加视觉空行
+    # 排除 【标签】 行(以】结尾)和子标题行(以冒号结尾)之后的情况,避免标题与首项之间出现空行
+    result = re.sub(r'(?<![::】])\n(\d+\.)', r'\n\n\1', result)
 
     return result
 
 
+def _format_standalone_summaries(summaries: dict) -> str:
+    """格式化独立展示区概括为纯文本行,每个源名称单独一行"""
+    if not summaries:
+        return ""
+    lines = []
+    for source_name, summary in summaries.items():
+        if summary:
+            lines.append(f"[{source_name}]:\n{summary}")
+    return "\n\n".join(lines)
+
+
 def render_ai_analysis_markdown(result: AIAnalysisResult) -> str:
     """渲染为通用 Markdown 格式(Telegram、企业微信、ntfy、Bark、Slack)"""
     if not result.success:
@@ -80,9 +100,14 @@ def render_ai_analysis_markdown(result: AIAnalysisResult) -> str:
 
     if result.outlook_strategy:
         lines.extend(
-            ["**研判策略建议**", _format_list_content(result.outlook_strategy)]
+            ["**研判策略建议**", _format_list_content(result.outlook_strategy), ""]
         )
 
+    if result.standalone_summaries:
+        summaries_text = _format_standalone_summaries(result.standalone_summaries)
+        if summaries_text:
+            lines.extend(["**独立源点速览**", summaries_text])
+
     return "\n".join(lines)
 
 
@@ -111,9 +136,14 @@ def render_ai_analysis_feishu(result: AIAnalysisResult) -> str:
 
     if result.outlook_strategy:
         lines.extend(
-            ["**研判策略建议**", _format_list_content(result.outlook_strategy)]
+            ["**研判策略建议**", _format_list_content(result.outlook_strategy), ""]
         )
 
+    if result.standalone_summaries:
+        summaries_text = _format_standalone_summaries(result.standalone_summaries)
+        if summaries_text:
+            lines.extend(["**独立源点速览**", summaries_text])
+
     return "\n".join(lines)
 
 
@@ -148,9 +178,14 @@ def render_ai_analysis_dingtalk(result: AIAnalysisResult) -> str:
 
     if result.outlook_strategy:
         lines.extend(
-            ["#### 研判策略建议", _format_list_content(result.outlook_strategy)]
+            ["#### 研判策略建议", _format_list_content(result.outlook_strategy), ""]
         )
 
+    if result.standalone_summaries:
+        summaries_text = _format_standalone_summaries(result.standalone_summaries)
+        if summaries_text:
+            lines.extend(["#### 独立源点速览", summaries_text])
+
     return "\n".join(lines)
 
 
@@ -223,6 +258,19 @@ def render_ai_analysis_html(result: AIAnalysisResult) -> str:
             ]
         )
 
+    if result.standalone_summaries:
+        summaries_text = _format_standalone_summaries(result.standalone_summaries)
+        if summaries_text:
+            summaries_html = _escape_html(summaries_text).replace("\n", "<br>")
+            html_parts.extend(
+                [
+                    '<div class="ai-section">',
+                    "<h4>独立源点速览</h4>",
+                    f'<div class="ai-content">{summaries_html}</div>',
+                    "</div>",
+                ]
+            )
+
     html_parts.append("</div>")
     return "\n".join(html_parts)
 
@@ -249,7 +297,12 @@ def render_ai_analysis_plain(result: AIAnalysisResult) -> str:
         lines.extend(["[RSS 深度洞察]", _format_list_content(result.rss_insights), ""])
 
     if result.outlook_strategy:
-        lines.extend(["[研判策略建议]", _format_list_content(result.outlook_strategy)])
+        lines.extend(["[研判策略建议]", _format_list_content(result.outlook_strategy), ""])
+
+    if result.standalone_summaries:
+        summaries_text = _format_standalone_summaries(result.standalone_summaries)
+        if summaries_text:
+            lines.extend(["[独立源点速览]", summaries_text])
 
     return "\n".join(lines)
 
@@ -334,6 +387,16 @@ def render_ai_analysis_html_rich(result: AIAnalysisResult) -> str:
                         <div class="ai-block-content">{content_html}</div>
                     </div>"""
 
+    if result.standalone_summaries:
+        summaries_text = _format_standalone_summaries(result.standalone_summaries)
+        if summaries_text:
+            summaries_html = _escape_html(summaries_text).replace("\n", "<br>")
+            ai_html += f"""
+                    <div class="ai-block">
+                        <div class="ai-block-title">独立源点速览</div>
+                        <div class="ai-block-content">{summaries_html}</div>
+                    </div>"""
+
     ai_html += """
                 </div>"""
     return ai_html

+ 19 - 13
trendradar/context.py

@@ -20,10 +20,10 @@ from trendradar.utils.time import (
 from trendradar.core import (
     load_frequency_words,
     matches_word_groups,
-    save_titles_to_file,
     read_all_today_titles,
     detect_latest_new_titles,
     count_word_frequency,
+    Scheduler,
 )
 from trendradar.report import (
     clean_title,
@@ -36,7 +36,6 @@ from trendradar.notification import (
     render_dingtalk_content,
     split_content_into_batches,
     NotificationDispatcher,
-    PushRecordManager,
 )
 from trendradar.ai import AITranslator
 from trendradar.storage import get_storage_manager
@@ -73,6 +72,7 @@ class AppContext:
         """
         self.config = config
         self._storage_manager = None
+        self._scheduler = None
 
     # === 配置访问 ===
 
@@ -193,11 +193,6 @@ class AppContext:
 
     # === 数据处理 ===
 
-    def save_titles(self, results: Dict, id_to_name: Dict, failed_ids: List) -> str:
-        """保存标题到文件"""
-        output_path = self.get_output_path("txt", f"{self.format_time()}.txt")
-        return save_titles_to_file(results, id_to_name, failed_ids, output_path, clean_title)
-
     def read_today_titles(
         self, platform_ids: Optional[List[str]] = None, quiet: bool = False
     ) -> Tuple[Dict, Dict, Dict]:
@@ -458,12 +453,23 @@ class AppContext:
             translator=translator,
         )
 
-    def create_push_manager(self) -> PushRecordManager:
-        """创建推送记录管理器"""
-        return PushRecordManager(
-            storage_backend=self.get_storage_manager(),
-            get_time_func=self.get_time,
-        )
+    def create_scheduler(self) -> Scheduler:
+        """
+        创建调度器(延迟初始化,单例)
+
+        基于 config.yaml 的 schedule 段 + timeline.yaml 构建。
+        """
+        if self._scheduler is None:
+            schedule_config = self.config.get("SCHEDULE", {})
+            timeline_data = self.config.get("_TIMELINE_DATA", {})
+
+            self._scheduler = Scheduler(
+                schedule_config=schedule_config,
+                timeline_data=timeline_data,
+                storage_backend=self.get_storage_manager(),
+                get_time_func=self.get_time,
+            )
+        return self._scheduler
 
     # === 资源清理 ===
 

+ 4 - 2
trendradar/core/__init__.py

@@ -11,8 +11,8 @@ from trendradar.core.config import (
 )
 from trendradar.core.loader import load_config
 from trendradar.core.frequency import load_frequency_words, matches_word_groups
+from trendradar.core.scheduler import Scheduler, ResolvedSchedule
 from trendradar.core.data import (
-    save_titles_to_file,
     read_all_today_titles_from_storage,
     read_all_today_titles,
     detect_latest_new_titles_from_storage,
@@ -34,7 +34,6 @@ __all__ = [
     "load_frequency_words",
     "matches_word_groups",
     # 数据处理
-    "save_titles_to_file",
     "read_all_today_titles_from_storage",
     "read_all_today_titles",
     "detect_latest_new_titles_from_storage",
@@ -44,4 +43,7 @@ __all__ = [
     "format_time_display",
     "count_word_frequency",
     "count_rss_frequency",
+    # 调度器
+    "Scheduler",
+    "ResolvedSchedule",
 ]

+ 8 - 79
trendradar/core/data.py

@@ -2,85 +2,14 @@
 """
 数据处理模块
 
-提供数据读取、保存和检测功能:
-- save_titles_to_file: 保存标题到 TXT 文件
+提供数据读取和检测功能:
 - read_all_today_titles: 从存储后端读取当天所有标题
 - detect_latest_new_titles: 检测最新批次的新增标题
 
 Author: TrendRadar Team
 """
 
-from pathlib import Path
-from typing import Dict, List, Tuple, Optional, Callable
-
-
-def save_titles_to_file(
-    results: Dict,
-    id_to_name: Dict,
-    failed_ids: List,
-    output_path: str,
-    clean_title_func: Callable[[str], str],
-) -> str:
-    """
-    保存标题到 TXT 文件
-
-    Args:
-        results: 抓取结果 {source_id: {title: title_data}}
-        id_to_name: ID 到名称的映射
-        failed_ids: 失败的 ID 列表
-        output_path: 输出文件路径
-        clean_title_func: 标题清理函数
-
-    Returns:
-        str: 保存的文件路径
-    """
-    # 确保目录存在
-    Path(output_path).parent.mkdir(parents=True, exist_ok=True)
-
-    with open(output_path, "w", encoding="utf-8") as f:
-        for id_value, title_data in results.items():
-            # id | name 或 id
-            name = id_to_name.get(id_value)
-            if name and name != id_value:
-                f.write(f"{id_value} | {name}\n")
-            else:
-                f.write(f"{id_value}\n")
-
-            # 按排名排序标题
-            sorted_titles = []
-            for title, info in title_data.items():
-                cleaned_title = clean_title_func(title)
-                if isinstance(info, dict):
-                    ranks = info.get("ranks", [])
-                    url = info.get("url", "")
-                    mobile_url = info.get("mobileUrl", "")
-                else:
-                    ranks = info if isinstance(info, list) else []
-                    url = ""
-                    mobile_url = ""
-
-                rank = ranks[0] if ranks else 1
-                sorted_titles.append((rank, cleaned_title, url, mobile_url))
-
-            sorted_titles.sort(key=lambda x: x[0])
-
-            for rank, cleaned_title, url, mobile_url in sorted_titles:
-                line = f"{rank}. {cleaned_title}"
-
-                if url:
-                    line += f" [URL:{url}]"
-                if mobile_url:
-                    line += f" [MOBILE:{mobile_url}]"
-                f.write(line + "\n")
-
-            f.write("\n")
-
-        if failed_ids:
-            f.write("==== 以下ID请求失败 ====\n")
-            for id_value in failed_ids:
-                f.write(f"{id_value}\n")
-
-    return output_path
+from typing import Dict, List, Tuple, Optional
 
 
 def read_all_today_titles_from_storage(
@@ -122,11 +51,11 @@ def read_all_today_titles_from_storage(
 
             for item in news_list:
                 title = item.title
-                ranks = getattr(item, 'ranks', [item.rank])
-                first_time = getattr(item, 'first_time', item.crawl_time)
-                last_time = getattr(item, 'last_time', item.crawl_time)
-                count = getattr(item, 'count', 1)
-                rank_timeline = getattr(item, 'rank_timeline', [])
+                ranks = item.ranks or [item.rank]
+                first_time = item.first_time or item.crawl_time
+                last_time = item.last_time or item.crawl_time
+                count = item.count
+                rank_timeline = item.rank_timeline
 
                 all_results[source_id][title] = {
                     "ranks": ranks,
@@ -233,7 +162,7 @@ def detect_latest_new_titles_from_storage(
 
             historical_titles[source_id] = set()
             for item in news_list:
-                first_time = getattr(item, 'first_time', item.crawl_time)
+                first_time = item.first_time or item.crawl_time
                 # 如果该记录的首次出现时间早于最新批次,则该标题是历史标题
                 if first_time < latest_time:
                     historical_titles[source_id].add(item.title)

+ 58 - 25
trendradar/core/loader.py

@@ -112,24 +112,64 @@ def _load_notification_config(config_data: Dict) -> Dict:
     }
 
 
-def _load_push_window_config(config_data: Dict) -> Dict:
-    """加载推送窗口配置"""
-    notification = config_data.get("notification", {})
-    push_window = notification.get("push_window", {})
+def _load_schedule_config(config_data: Dict) -> Dict:
+    """
+    加载统一调度配置
+
+    从 config.yaml 的 schedule 段读取,支持环境变量覆盖。
+    """
+    schedule = config_data.get("schedule", {})
+
+    # 环境变量覆盖
+    enabled_env = _get_env_bool("SCHEDULE_ENABLED")
+    preset_env = _get_env_str("SCHEDULE_PRESET")
 
-    enabled_env = _get_env_bool("PUSH_WINDOW_ENABLED")
-    once_per_day_env = _get_env_bool("PUSH_WINDOW_ONCE_PER_DAY")
+    enabled = enabled_env if enabled_env is not None else schedule.get("enabled", False)
+    preset = preset_env or schedule.get("preset", "always_on")
 
     return {
-        "ENABLED": enabled_env if enabled_env is not None else push_window.get("enabled", False),
-        "TIME_RANGE": {
-            "START": _get_env_str("PUSH_WINDOW_START") or push_window.get("start", "08:00"),
-            "END": _get_env_str("PUSH_WINDOW_END") or push_window.get("end", "22:00"),
-        },
-        "ONCE_PER_DAY": once_per_day_env if once_per_day_env is not None else push_window.get("once_per_day", True),
+        "enabled": enabled,
+        "preset": preset,
     }
 
 
+def _load_timeline_data(config_dir: str = "config") -> Dict:
+    """
+    加载 timeline.yaml
+
+    Args:
+        config_dir: 配置目录路径
+
+    Returns:
+        timeline.yaml 的完整数据,找不到时返回空模板
+    """
+    timeline_path = Path(config_dir) / "timeline.yaml"
+    if not timeline_path.exists():
+        print(f"[调度] timeline.yaml 未找到: {timeline_path},使用空模板")
+        return {
+            "presets": {},
+            "custom": {
+                "default": {
+                    "collect": True,
+                    "analyze": False,
+                    "push": False,
+                    "report_mode": "current",
+                    "ai_mode": "follow_report",
+                    "once": {"analyze": False, "push": False},
+                },
+                "periods": {},
+                "day_plans": {"all_day": {"periods": []}},
+                "week_map": {i: "all_day" for i in range(1, 8)},
+            },
+        }
+
+    with open(timeline_path, "r", encoding="utf-8") as f:
+        data = yaml.safe_load(f)
+
+    print(f"[调度] timeline.yaml 加载成功: {timeline_path}")
+    return data or {}
+
+
 def _load_weight_config(config_data: Dict) -> Dict:
     """加载权重配置"""
     advanced = config_data.get("advanced", {})
@@ -245,11 +285,8 @@ def _load_ai_config(config_data: Dict) -> Dict:
 def _load_ai_analysis_config(config_data: Dict) -> Dict:
     """加载 AI 分析配置(功能配置,模型配置见 _load_ai_config)"""
     ai_config = config_data.get("ai_analysis", {})
-    analysis_window = ai_config.get("analysis_window", {})
 
     enabled_env = _get_env_bool("AI_ANALYSIS_ENABLED")
-    window_enabled_env = _get_env_bool("AI_ANALYSIS_WINDOW_ENABLED")
-    window_once_per_day_env = _get_env_bool("AI_ANALYSIS_WINDOW_ONCE_PER_DAY")
 
     return {
         "ENABLED": enabled_env if enabled_env is not None else ai_config.get("enabled", False),
@@ -259,14 +296,7 @@ def _load_ai_analysis_config(config_data: Dict) -> Dict:
         "MAX_NEWS_FOR_ANALYSIS": ai_config.get("max_news_for_analysis", 50),
         "INCLUDE_RSS": ai_config.get("include_rss", True),
         "INCLUDE_RANK_TIMELINE": ai_config.get("include_rank_timeline", False),
-        "ANALYSIS_WINDOW": {
-            "ENABLED": window_enabled_env if window_enabled_env is not None else analysis_window.get("enabled", False),
-            "TIME_RANGE": {
-                "START": _get_env_str("AI_ANALYSIS_WINDOW_START") or analysis_window.get("start", "09:00"),
-                "END": _get_env_str("AI_ANALYSIS_WINDOW_END") or analysis_window.get("end", "22:00"),
-            },
-            "ONCE_PER_DAY": window_once_per_day_env if window_once_per_day_env is not None else analysis_window.get("once_per_day", False),
-        },
+        "INCLUDE_STANDALONE": ai_config.get("include_standalone", False),
     }
 
 
@@ -489,8 +519,11 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
     # 通知配置
     config.update(_load_notification_config(config_data))
 
-    # 推送窗口配置
-    config["PUSH_WINDOW"] = _load_push_window_config(config_data)
+    # 统一调度配置
+    config["SCHEDULE"] = _load_schedule_config(config_data)
+    config["_TIMELINE_DATA"] = _load_timeline_data(
+        str(Path(config_path).parent) if config_path else "config"
+    )
 
     # 权重配置
     config["WEIGHT_CONFIG"] = _load_weight_config(config_data)

+ 420 - 0
trendradar/core/scheduler.py

@@ -0,0 +1,420 @@
+# coding=utf-8
+"""
+时间线调度器
+
+统一的时间线调度系统,替代分散的 push_window / analysis_window 逻辑。
+基于 periods + day_plans + week_map 模型实现灵活的时间段调度。
+"""
+
+import copy
+import re
+from dataclasses import dataclass
+from typing import Any, Callable, Dict, List, Optional
+
+from datetime import datetime
+
+
+@dataclass
+class ResolvedSchedule:
+    """当前时间解析后的调度结果"""
+    period_key: Optional[str]       # 命中的 period key,None=默认配置
+    period_name: Optional[str]      # 命中的展示名称
+    day_plan: str                   # 当前日计划
+    collect: bool
+    analyze: bool
+    push: bool
+    report_mode: str
+    ai_mode: str
+    once_analyze: bool
+    once_push: bool
+
+
+class Scheduler:
+    """
+    时间线调度器
+
+    根据 timeline 配置(periods + day_plans + week_map)解析当前时间应执行的行为。
+    支持:
+    - 预设模板 + 自定义模式
+    - 跨日时间段(如 22:00-07:00)
+    - 每天 / 每周差异化配置
+    - once 执行去重(analyze / push 独立维度)
+    - 冲突策略(error_on_overlap / last_wins)
+    """
+
+    def __init__(
+        self,
+        schedule_config: Dict[str, Any],
+        timeline_data: Dict[str, Any],
+        storage_backend: Any,
+        get_time_func: Callable[[], datetime],
+    ):
+        """
+        初始化调度器
+
+        Args:
+            schedule_config: config.yaml 中的 schedule 段(含 preset 等)
+            timeline_data: timeline.yaml 的完整数据
+            storage_backend: 存储后端(用于 once 去重记录)
+            get_time_func: 获取当前时间的函数(应使用配置的时区)
+        """
+        self.schedule_config = schedule_config
+        self.storage = storage_backend
+        self.get_time = get_time_func
+        self.enabled = schedule_config.get("enabled", True)
+
+        # 加载并构建最终 timeline
+        self.timeline = self._build_timeline(schedule_config, timeline_data)
+        if self.enabled:
+            self._validate_timeline(self.timeline)
+
+    def _build_timeline(
+        self,
+        schedule_config: Dict[str, Any],
+        timeline_data: Dict[str, Any],
+    ) -> Dict[str, Any]:
+        """从 preset 或 custom 构建 timeline"""
+        preset = schedule_config.get("preset", "always_on")
+
+        if preset == "custom":
+            timeline = copy.deepcopy(timeline_data.get("custom", {}))
+        else:
+            presets = timeline_data.get("presets", {})
+            if preset not in presets:
+                raise ValueError(
+                    f"未知的预设模板: '{preset}',可选值: "
+                    f"{', '.join(presets.keys())}, custom"
+                )
+            timeline = copy.deepcopy(presets[preset])
+
+        # 确保 periods 是 dict(可能为空 {})
+        if timeline.get("periods") is None:
+            timeline["periods"] = {}
+
+        return timeline
+
+    def resolve(self) -> ResolvedSchedule:
+        """
+        解析当前时间对应的调度配置
+
+        Returns:
+            ResolvedSchedule 包含当前应执行的行为
+        """
+        if not self.enabled:
+            # 调度未启用时返回默认的全功能配置
+            return ResolvedSchedule(
+                period_key=None,
+                period_name=None,
+                day_plan="disabled",
+                collect=True,
+                analyze=True,
+                push=True,
+                report_mode="current",
+                ai_mode="follow_report",
+                once_analyze=False,
+                once_push=False,
+            )
+
+        now = self.get_time()
+        weekday = now.isoweekday()  # 1=周一 ... 7=周日
+        now_hhmm = now.strftime("%H:%M")
+
+        # 查找当天的日计划
+        day_plan_key = self.timeline["week_map"].get(weekday)
+        if day_plan_key is None:
+            raise ValueError(f"week_map 缺少星期映射: {weekday}")
+
+        day_plan = self.timeline["day_plans"].get(day_plan_key)
+        if day_plan is None:
+            raise ValueError(f"week_map[{weekday}] 引用了不存在的 day_plan: {day_plan_key}")
+
+        # 查找当前活跃的时间段
+        period_key = self._find_active_period(now_hhmm, day_plan)
+
+        # 合并默认配置和时间段配置
+        merged = self._merge_with_default(period_key)
+
+        # 打印调度日志
+        weekday_names = {1: "一", 2: "二", 3: "三", 4: "四", 5: "五", 6: "六", 7: "日"}
+        period_display = "默认配置(未命中任何时间段)"
+        if period_key:
+            period_cfg = self.timeline["periods"][period_key]
+            period_name = period_cfg.get("name", period_key)
+            start = period_cfg.get("start", "?")
+            end = period_cfg.get("end", "?")
+            period_display = f"{period_name} ({start}-{end})"
+
+        print(f"[调度] 星期{weekday_names.get(weekday, '?')},日计划: {day_plan_key}")
+        print(f"[调度] 当前时间段: {period_display}")
+
+        resolved = ResolvedSchedule(
+            period_key=period_key,
+            period_name=(
+                self.timeline["periods"][period_key].get("name")
+                if period_key
+                else None
+            ),
+            day_plan=day_plan_key,
+            collect=merged.get("collect", True),
+            analyze=merged.get("analyze", False),
+            push=merged.get("push", False),
+            report_mode=merged.get("report_mode", "current"),
+            ai_mode=self._resolve_ai_mode(merged),
+            once_analyze=merged.get("once", {}).get("analyze", False),
+            once_push=merged.get("once", {}).get("push", False),
+        )
+
+        # 打印行为摘要
+        actions = []
+        if resolved.collect:
+            actions.append("采集")
+        if resolved.analyze:
+            actions.append(f"分析(AI:{resolved.ai_mode})")
+        if resolved.push:
+            actions.append(f"推送(模式:{resolved.report_mode})")
+        print(f"[调度] 行为: {', '.join(actions) if actions else '无'}")
+
+        return resolved
+
+    def _find_active_period(
+        self, now_hhmm: str, day_plan: Dict[str, Any]
+    ) -> Optional[str]:
+        """
+        查找当前时间命中的活跃时间段
+
+        Args:
+            now_hhmm: 当前时间 HH:MM
+            day_plan: 日计划配置
+
+        Returns:
+            命中的 period key,或 None
+        """
+        candidates = []
+        for idx, key in enumerate(day_plan.get("periods", [])):
+            period = self.timeline["periods"].get(key)
+            if period is None:
+                continue
+            if self._in_range(now_hhmm, period["start"], period["end"]):
+                candidates.append((idx, key))
+
+        if not candidates:
+            return None
+
+        # 检查冲突
+        if len(candidates) > 1:
+            policy = self.timeline.get("overlap", {}).get("policy", "error_on_overlap")
+            conflicting = [c[1] for c in candidates]
+
+            if policy == "error_on_overlap":
+                raise ValueError(
+                    f"检测到时间段重叠冲突: {', '.join(conflicting)} 在 {now_hhmm} 重叠。"
+                    f"请调整时间段配置,或将 overlap.policy 设为 'last_wins'"
+                )
+
+            # last_wins:输出重叠警告,列表中后面的优先
+            print(
+                f"[调度] 检测到时间段重叠: {', '.join(conflicting)} 在 {now_hhmm} 重叠"
+            )
+            winner = candidates[-1]
+            print(f"[调度] 冲突策略: last_wins,生效时间段: {winner[1]}")
+            return winner[1]
+
+        return candidates[0][1]
+
+    @staticmethod
+    def _in_range(now_hhmm: str, start: str, end: str) -> bool:
+        """
+        检查时间是否在范围内(支持跨日)
+
+        Args:
+            now_hhmm: 当前时间 HH:MM
+            start: 开始时间 HH:MM
+            end: 结束时间 HH:MM
+
+        Returns:
+            是否在范围内
+        """
+        if start <= end:
+            # 正常范围,如 08:00-09:00
+            return start <= now_hhmm <= end
+        else:
+            # 跨日范围,如 22:00-07:00
+            return now_hhmm >= start or now_hhmm <= end
+
+    def _merge_with_default(self, period_key: Optional[str]) -> Dict[str, Any]:
+        """合并默认配置和时间段配置"""
+        base = copy.deepcopy(self.timeline.get("default", {}))
+        if not period_key:
+            return base
+
+        period = copy.deepcopy(self.timeline["periods"][period_key])
+
+        # 先合并 once 子对象
+        merged_once = dict(base.get("once", {}))
+        merged_once.update(period.get("once", {}))
+
+        # 标量字段覆盖
+        base.update(period)
+
+        # 恢复合并后的 once
+        if merged_once:
+            base["once"] = merged_once
+
+        return base
+
+    @staticmethod
+    def _resolve_ai_mode(cfg: Dict[str, Any]) -> str:
+        """解析最终的 AI 模式"""
+        ai_mode = cfg.get("ai_mode", "follow_report")
+        if ai_mode == "follow_report":
+            return cfg.get("report_mode", "current")
+        return ai_mode
+
+    def already_executed(self, period_key: str, action: str, date_str: str) -> bool:
+        """
+        检查指定时间段的某个 action 今天是否已执行
+
+        Args:
+            period_key: 时间段 key
+            action: 动作类型 (analyze / push)
+            date_str: 日期 YYYY-MM-DD
+
+        Returns:
+            是否已执行
+        """
+        return self.storage.has_period_executed(date_str, period_key, action)
+
+    def record_execution(self, period_key: str, action: str, date_str: str) -> None:
+        """
+        记录时间段的 action 执行
+
+        Args:
+            period_key: 时间段 key
+            action: 动作类型 (analyze / push)
+            date_str: 日期 YYYY-MM-DD
+        """
+        self.storage.record_period_execution(date_str, period_key, action)
+
+    # ========================================
+    # 校验
+    # ========================================
+
+    def _validate_timeline(self, timeline: Dict[str, Any]) -> None:
+        """
+        启动时校验 timeline 配置
+
+        Raises:
+            ValueError: 配置不合法时抛出
+        """
+        required_top_keys = ["default", "periods", "day_plans", "week_map"]
+        for key in required_top_keys:
+            if key not in timeline:
+                raise ValueError(f"timeline 缺少必须字段: {key}")
+
+        # week_map 必须覆盖 1..7
+        for day in range(1, 8):
+            if day not in timeline["week_map"]:
+                raise ValueError(f"week_map 缺少星期映射: {day}")
+
+        # day_plan 引用完整性
+        for day, plan_key in timeline["week_map"].items():
+            if plan_key not in timeline["day_plans"]:
+                raise ValueError(
+                    f"week_map[{day}] 引用了不存在的 day_plan: {plan_key}"
+                )
+
+        # period 引用完整性
+        for plan_key, plan in timeline["day_plans"].items():
+            for period_key in plan.get("periods", []):
+                if period_key not in timeline["periods"]:
+                    raise ValueError(
+                        f"day_plan[{plan_key}] 引用了不存在的 period: {period_key}"
+                    )
+
+        # 时间格式校验
+        for period_key, period in timeline["periods"].items():
+            if "start" not in period or "end" not in period:
+                raise ValueError(
+                    f"period '{period_key}' 缺少 start 或 end 字段"
+                )
+            self._validate_hhmm(period["start"], f"{period_key}.start")
+            self._validate_hhmm(period["end"], f"{period_key}.end")
+            if period["start"] == period["end"]:
+                raise ValueError(
+                    f"period '{period_key}' 的 start 与 end 不能相同: {period['start']}"
+                )
+
+        # 检查冲突策略下的重叠
+        policy = timeline.get("overlap", {}).get("policy", "error_on_overlap")
+        if policy == "error_on_overlap":
+            self._check_period_overlaps(timeline)
+
+    def _check_period_overlaps(self, timeline: Dict[str, Any]) -> None:
+        """
+        检查每个日计划中的时间段是否存在重叠
+
+        仅在 overlap.policy == "error_on_overlap" 时调用
+        """
+        periods = timeline.get("periods", {})
+
+        for plan_key, plan in timeline["day_plans"].items():
+            period_keys = plan.get("periods", [])
+            if len(period_keys) <= 1:
+                continue
+
+            # 收集每个时间段的范围
+            ranges = []
+            for pk in period_keys:
+                p = periods.get(pk, {})
+                if "start" in p and "end" in p:
+                    ranges.append((pk, p["start"], p["end"]))
+
+            # 两两检查重叠
+            for i in range(len(ranges)):
+                for j in range(i + 1, len(ranges)):
+                    if self._ranges_overlap(
+                        ranges[i][1], ranges[i][2],
+                        ranges[j][1], ranges[j][2],
+                    ):
+                        raise ValueError(
+                            f"day_plan '{plan_key}' 中时间段 '{ranges[i][0]}' "
+                            f"({ranges[i][1]}-{ranges[i][2]}) 与 '{ranges[j][0]}' "
+                            f"({ranges[j][1]}-{ranges[j][2]}) 存在重叠。"
+                            f"请调整时间段,或将 overlap.policy 设为 'last_wins'"
+                        )
+
+    @staticmethod
+    def _ranges_overlap(s1: str, e1: str, s2: str, e2: str) -> bool:
+        """检查两个时间范围是否重叠(支持跨日)"""
+        def to_minutes(t: str) -> int:
+            h, m = t.split(":")
+            return int(h) * 60 + int(m)
+
+        def expand_range(start: str, end: str) -> List[tuple]:
+            """将时间范围展开为分钟段列表,跨日时拆分为两段"""
+            s = to_minutes(start)
+            e = to_minutes(end)
+            if s <= e:
+                return [(s, e)]
+            else:
+                # 跨日:拆分为 [start, 23:59] 和 [00:00, end]
+                return [(s, 24 * 60 - 1), (0, e)]
+
+        segs1 = expand_range(s1, e1)
+        segs2 = expand_range(s2, e2)
+
+        for a_start, a_end in segs1:
+            for b_start, b_end in segs2:
+                # 两个区间有重叠的条件
+                if a_start <= b_end and b_start <= a_end:
+                    return True
+        return False
+
+    @staticmethod
+    def _validate_hhmm(value: str, field_name: str) -> None:
+        """校验 HH:MM 格式"""
+        if not re.match(r"^\d{2}:\d{2}$", value):
+            raise ValueError(f"{field_name} 格式错误: '{value}',期望 HH:MM")
+        h, m = value.split(":")
+        if not (0 <= int(h) <= 23 and 0 <= int(m) <= 59):
+            raise ValueError(f"{field_name} 时间值超出范围: '{value}'")

+ 0 - 4
trendradar/notification/__init__.py

@@ -8,7 +8,6 @@
 - Email、ntfy、Bark
 
 模块结构:
-- push_manager: 推送记录管理
 - formatters: 内容格式转换
 - batch: 批次处理工具
 - renderer: 通知内容渲染
@@ -17,7 +16,6 @@
 - dispatcher: 多账号通知调度器
 """
 
-from trendradar.notification.push_manager import PushRecordManager
 from trendradar.notification.formatters import (
     strip_markdown,
     convert_markdown_to_mrkdwn,
@@ -50,8 +48,6 @@ from trendradar.notification.senders import (
 from trendradar.notification.dispatcher import NotificationDispatcher
 
 __all__ = [
-    # 推送记录管理
-    "PushRecordManager",
     # 格式转换
     "strip_markdown",
     "convert_markdown_to_mrkdwn",

+ 12 - 10
trendradar/notification/dispatcher.py

@@ -117,17 +117,19 @@ class NotificationDispatcher:
                 titles_to_translate.append(title_data.get("title", ""))
                 title_locations.append(("new_titles", source_idx, title_idx))
 
-        # 3. RSS 统计标题
+        # 3. RSS 统计标题(结构与 stats 一致:[{word, count, titles: [{title, ...}]}])
         if rss_items:
-            for item_idx, item in enumerate(rss_items):
-                titles_to_translate.append(item.get("title", ""))
-                title_locations.append(("rss_items", item_idx, None))
+            for stat_idx, stat in enumerate(rss_items):
+                for title_idx, title_data in enumerate(stat.get("titles", [])):
+                    titles_to_translate.append(title_data.get("title", ""))
+                    title_locations.append(("rss_items", stat_idx, title_idx))
 
-        # 4. RSS 新增标题
+        # 4. RSS 新增标题(结构与 stats 一致)
         if rss_new_items:
-            for item_idx, item in enumerate(rss_new_items):
-                titles_to_translate.append(item.get("title", ""))
-                title_locations.append(("rss_new_items", item_idx, None))
+            for stat_idx, stat in enumerate(rss_new_items):
+                for title_idx, title_data in enumerate(stat.get("titles", [])):
+                    titles_to_translate.append(title_data.get("title", ""))
+                    title_locations.append(("rss_new_items", stat_idx, title_idx))
 
         if not titles_to_translate:
             print("[翻译] 没有需要翻译的内容")
@@ -153,9 +155,9 @@ class NotificationDispatcher:
                 elif loc_type == "new_titles":
                     report_data["new_titles"][idx1]["titles"][idx2]["title"] = translated
                 elif loc_type == "rss_items" and rss_items:
-                    rss_items[idx1]["title"] = translated
+                    rss_items[idx1]["titles"][idx2]["title"] = translated
                 elif loc_type == "rss_new_items" and rss_new_items:
-                    rss_new_items[idx1]["title"] = translated
+                    rss_new_items[idx1]["titles"][idx2]["title"] = translated
 
         return report_data, rss_items, rss_new_items
 

+ 0 - 206
trendradar/notification/push_manager.py

@@ -1,206 +0,0 @@
-# coding=utf-8
-"""
-推送记录管理模块
-
-管理推送记录,支持每日只推送一次和时间窗口控制
-通过 storage_backend 统一存储,支持本地 SQLite 和远程云存储
-"""
-
-from datetime import datetime
-from typing import Callable, Optional, Any, Tuple
-
-import pytz
-
-from trendradar.utils.time import DEFAULT_TIMEZONE, TimeWindowChecker
-
-
-class PushRecordManager:
-    """
-    推送记录管理器
-
-    通过 storage_backend 统一管理推送记录:
-    - 本地环境:使用 LocalStorageBackend,数据存储在本地 SQLite
-    - GitHub Actions:使用 RemoteStorageBackend,数据存储在云端
-
-    这样 once_per_day 功能在 GitHub Actions 上也能正常工作。
-    """
-
-    def __init__(
-        self,
-        storage_backend: Any,
-        get_time_func: Optional[Callable[[], datetime]] = None,
-    ):
-        """
-        初始化推送记录管理器
-
-        Args:
-            storage_backend: 存储后端实例(LocalStorageBackend 或 RemoteStorageBackend)
-            get_time_func: 获取当前时间的函数(应使用配置的时区)
-        """
-        self.storage_backend = storage_backend
-        self.get_time = get_time_func or self._default_get_time
-
-        print(f"[推送记录] 使用 {storage_backend.backend_name} 存储后端")
-
-    def _default_get_time(self) -> datetime:
-        """默认时间获取函数(使用 storage_backend 的时区配置)"""
-        timezone = getattr(self.storage_backend, 'timezone', DEFAULT_TIMEZONE)
-        return datetime.now(pytz.timezone(timezone))
-
-    def has_pushed_today(self) -> bool:
-        """
-        检查今天是否已经推送过
-
-        Returns:
-            是否已推送
-        """
-        return self.storage_backend.has_pushed_today()
-
-    def record_push(self, report_type: str) -> bool:
-        """
-        记录推送
-
-        Args:
-            report_type: 报告类型
-
-        Returns:
-            是否记录成功
-        """
-        return self.storage_backend.record_push(report_type)
-
-    def is_in_time_range(self, start_time: str, end_time: str) -> bool:
-        """
-        检查当前时间是否在指定时间范围内
-
-        Args:
-            start_time: 开始时间(格式:HH:MM)
-            end_time: 结束时间(格式:HH:MM)
-
-        Returns:
-            是否在时间范围内
-        """
-        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]:
-        """
-        检查推送窗口控制
-
-        Args:
-            window_config: 推送窗口配置
-
-        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 分析窗口控制
-
-        Args:
-            window_config: AI 分析窗口配置
-
-        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:
-        """
-        获取推送状态信息
-
-        Args:
-            window_config: 推送窗口配置
-
-        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:
-        """
-        重置今日推送状态
-
-        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 分析状态
-
-        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

+ 0 - 2
trendradar/storage/__init__.py

@@ -15,7 +15,6 @@ from trendradar.storage.base import (
     RSSItem,
     RSSData,
     convert_crawl_results_to_news_data,
-    convert_news_data_to_results,
 )
 from trendradar.storage.sqlite_mixin import SQLiteStorageMixin
 from trendradar.storage.local import LocalStorageBackend
@@ -40,7 +39,6 @@ __all__ = [
     "SQLiteStorageMixin",
     # 转换函数
     "convert_crawl_results_to_news_data",
-    "convert_news_data_to_results",
     # 后端实现
     "LocalStorageBackend",
     "RemoteStorageBackend",

+ 17 - 85
trendradar/storage/base.py

@@ -435,61 +435,35 @@ class StorageBackend(ABC):
         """
         pass
 
-    # === 推送记录相关方法 ===
+    # === 时间段执行记录(调度系统)===
 
-    @abstractmethod
-    def has_pushed_today(self, date: Optional[str] = None) -> bool:
+    def has_period_executed(self, date_str: str, period_key: str, action: str) -> bool:
         """
-        检查指定日期是否已推送过
+        检查指定时间段的某个 action 是否已执行
 
         Args:
-            date: 日期字符串(YYYY-MM-DD),默认为今天
+            date_str: 日期字符串 YYYY-MM-DD
+            period_key: 时间段 key
+            action: 动作类型 (analyze / push)
 
         Returns:
-            是否已推送
+            是否已执行
         """
-        pass
+        return False
 
-    @abstractmethod
-    def record_push(self, report_type: str, date: Optional[str] = None) -> bool:
+    def record_period_execution(self, date_str: str, period_key: str, action: str) -> bool:
         """
-        记录推送
+        记录时间段的 action 执行
 
         Args:
-            report_type: 报告类型
-            date: 日期字符串(YYYY-MM-DD),默认为今天
+            date_str: 日期字符串 YYYY-MM-DD
+            period_key: 时间段 key
+            action: 动作类型 (analyze / push)
 
         Returns:
             是否记录成功
         """
-        pass
-
-    @abstractmethod
-    def has_ai_analyzed_today(self, date: Optional[str] = None) -> bool:
-        """
-        检查指定日期是否已进行过 AI 分析
-
-        Args:
-            date: 日期字符串(YYYY-MM-DD),默认为今天
-
-        Returns:
-            是否已分析
-        """
-        pass
-
-    @abstractmethod
-    def record_ai_analysis(self, analysis_mode: str, date: Optional[str] = None) -> bool:
-        """
-        记录 AI 分析
-
-        Args:
-            analysis_mode: 分析模式(daily/current/incremental)
-            date: 日期字符串(YYYY-MM-DD),默认为今天
-
-        Returns:
-            是否记录成功
-        """
-        pass
+        return False
 
 
 def convert_crawl_results_to_news_data(
@@ -519,15 +493,9 @@ def convert_crawl_results_to_news_data(
         news_list = []
 
         for title, data in titles_data.items():
-            if isinstance(data, dict):
-                ranks = data.get("ranks", [])
-                url = data.get("url", "")
-                mobile_url = data.get("mobileUrl", "")
-            else:
-                # 兼容旧格式
-                ranks = data if isinstance(data, list) else []
-                url = ""
-                mobile_url = ""
+            ranks = data.get("ranks", [])
+            url = data.get("url", "")
+            mobile_url = data.get("mobileUrl", "")
 
             rank = ranks[0] if ranks else 99
 
@@ -555,39 +523,3 @@ def convert_crawl_results_to_news_data(
         id_to_name=id_to_name,
         failed_ids=failed_ids,
     )
-
-
-def convert_news_data_to_results(data: NewsData) -> tuple:
-    """
-    将 NewsData 转换回原有的 results 格式(用于兼容现有代码)
-
-    Args:
-        data: NewsData 对象
-
-    Returns:
-        (results, id_to_name, title_info) 元组
-    """
-    results = {}
-    title_info = {}
-
-    for source_id, news_list in data.items.items():
-        results[source_id] = {}
-        title_info[source_id] = {}
-
-        for item in news_list:
-            results[source_id][item.title] = {
-                "ranks": item.ranks,
-                "url": item.url,
-                "mobileUrl": item.mobile_url,
-            }
-
-            title_info[source_id][item.title] = {
-                "first_time": item.first_time,
-                "last_time": item.last_time,
-                "count": item.count,
-                "ranks": item.ranks,
-                "url": item.url,
-                "mobileUrl": item.mobile_url,
-            }
-
-    return results, data.id_to_name, title_info

+ 10 - 30
trendradar/storage/local.py

@@ -179,42 +179,22 @@ class LocalStorageBackend(SQLiteStorageMixin, StorageBackend):
             return []
         return self._get_crawl_times_impl(date)
 
-    def has_pushed_today(self, date: Optional[str] = None) -> bool:
-        """检查指定日期是否已推送过"""
-        return self._has_pushed_today_impl(date)
-
-    def record_push(self, report_type: str, date: Optional[str] = None) -> bool:
-        """记录推送"""
-        success = self._record_push_impl(report_type, date)
-        if success:
-            now_str = self._get_configured_time().strftime("%Y-%m-%d %H:%M:%S")
-            print(f"[本地存储] 推送记录已保存: {report_type} at {now_str}")
-        return success
+    # ========================================
+    # 时间段执行记录(调度系统)
+    # ========================================
 
-    def has_ai_analyzed_today(self, date: Optional[str] = None) -> bool:
-        """检查指定日期是否已进行过 AI 分析"""
-        return self._has_ai_analyzed_today_impl(date)
+    def has_period_executed(self, date_str: str, period_key: str, action: str) -> bool:
+        """检查指定时间段的某个 action 是否已执行"""
+        return self._has_period_executed_impl(date_str, period_key, action)
 
-    def record_ai_analysis(self, analysis_mode: str, date: Optional[str] = None) -> bool:
-        """记录 AI 分析"""
-        success = self._record_ai_analysis_impl(analysis_mode, date)
+    def record_period_execution(self, date_str: str, period_key: str, action: str) -> bool:
+        """记录时间段的 action 执行"""
+        success = self._record_period_execution_impl(date_str, period_key, action)
         if success:
             now_str = self._get_configured_time().strftime("%Y-%m-%d %H:%M:%S")
-            print(f"[本地存储] AI 分析记录已保存: {analysis_mode} at {now_str}")
+            print(f"[本地存储] 时间段执行记录已保存: {period_key}/{action} 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 数据存储方法
     # ========================================

+ 6 - 49
trendradar/storage/manager.py

@@ -281,57 +281,14 @@ class StorageManager:
         """是否支持 TXT 快照"""
         return self.get_backend().supports_txt
 
-    # === 推送记录相关方法 ===
+    def has_period_executed(self, date_str: str, period_key: str, action: str) -> bool:
+        """检查指定时间段的某个 action 是否已执行"""
+        return self.get_backend().has_period_executed(date_str, period_key, action)
 
-    def has_pushed_today(self, date: Optional[str] = None) -> bool:
-        """
-        检查指定日期是否已推送过
-
-        Args:
-            date: 日期字符串(YYYY-MM-DD),默认为今天
-
-        Returns:
-            是否已推送
-        """
-        return self.get_backend().has_pushed_today(date)
-
-    def record_push(self, report_type: str, date: Optional[str] = None) -> bool:
-        """
-        记录推送
-
-        Args:
-            report_type: 报告类型
-            date: 日期字符串(YYYY-MM-DD),默认为今天
-
-        Returns:
-            是否记录成功
-        """
-        return self.get_backend().record_push(report_type, date)
+    def record_period_execution(self, date_str: str, period_key: str, action: str) -> bool:
+        """记录时间段的 action 执行"""
+        return self.get_backend().record_period_execution(date_str, period_key, action)
 
-    def has_ai_analyzed_today(self, date: Optional[str] = None) -> bool:
-        """
-        检查指定日期是否已进行过 AI 分析
-
-        Args:
-            date: 日期字符串(YYYY-MM-DD),默认为今天
-
-        Returns:
-            是否已分析
-        """
-        return self.get_backend().has_ai_analyzed_today(date)
-
-    def record_ai_analysis(self, analysis_mode: str, date: Optional[str] = None) -> bool:
-        """
-        记录 AI 分析
-
-        Args:
-            analysis_mode: 分析模式(daily/current/incremental)
-            date: 日期字符串(YYYY-MM-DD),默认为今天
-
-        Returns:
-            是否记录成功
-        """
-        return self.get_backend().record_ai_analysis(analysis_mode, date)
 
 
 def get_storage_manager(

+ 14 - 84
trendradar/storage/remote.py

@@ -394,102 +394,32 @@ class RemoteStorageBackend(SQLiteStorageMixin, StorageBackend):
         """检查是否是当天第一次抓取"""
         return self._is_first_crawl_today_impl(date)
 
-    def has_pushed_today(self, date: Optional[str] = None) -> bool:
-        """检查指定日期是否已推送过"""
-        return self._has_pushed_today_impl(date)
-
-    def record_push(self, report_type: str, date: Optional[str] = None) -> bool:
-        """记录推送"""
-        success = self._record_push_impl(report_type, date)
-
-        if success:
-            now_str = self._get_configured_time().strftime("%Y-%m-%d %H:%M:%S")
-            print(f"[远程存储] 推送记录已保存: {report_type} at {now_str}")
-
-            # 上传到远程存储 确保记录持久化
-            if self._upload_sqlite(date):
-                print(f"[远程存储] 推送记录已同步到远程存储")
-                return True
-            else:
-                print(f"[远程存储] 推送记录同步到远程存储失败")
-                return False
-
-        return False
+    # ========================================
+    # 时间段执行记录(调度系统)
+    # ========================================
 
-    def has_ai_analyzed_today(self, date: Optional[str] = None) -> bool:
-        """检查指定日期是否已进行过 AI 分析"""
-        return self._has_ai_analyzed_today_impl(date)
+    def has_period_executed(self, date_str: str, period_key: str, action: str) -> bool:
+        """检查指定时间段的某个 action 是否已执行"""
+        return self._has_period_executed_impl(date_str, period_key, action)
 
-    def record_ai_analysis(self, analysis_mode: str, date: Optional[str] = None) -> bool:
-        """记录 AI 分析"""
-        success = self._record_ai_analysis_impl(analysis_mode, date)
+    def record_period_execution(self, date_str: str, period_key: str, action: str) -> bool:
+        """记录时间段的 action 执行"""
+        success = self._record_period_execution_impl(date_str, period_key, action)
 
         if success:
             now_str = self._get_configured_time().strftime("%Y-%m-%d %H:%M:%S")
-            print(f"[远程存储] AI 分析记录已保存: {analysis_mode} at {now_str}")
+            print(f"[远程存储] 时间段执行记录已保存: {period_key}/{action} at {now_str}")
 
-            # 上传到远程存储 确保记录持久化
-            if self._upload_sqlite(date):
-                print(f"[远程存储] AI 分析记录已同步到远程存储")
+            # 上传到远程存储确保记录持久化
+            if self._upload_sqlite(date_str):
+                print(f"[远程存储] 时间段执行记录已同步到远程存储")
                 return True
             else:
-                print(f"[远程存储] AI 分析记录同步到远程存储失败")
+                print(f"[远程存储] 时间段执行记录同步到远程存储失败")
                 return False
 
         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 数据存储方法
     # ========================================

+ 13 - 12
trendradar/storage/schema.sql

@@ -81,20 +81,17 @@ CREATE TABLE IF NOT EXISTS crawl_source_status (
 );
 
 -- ============================================
--- 推送记录表
--- 用于 push_window once_per_day 功能
--- 以及 ai_analysis analysis_window once_per_day 功能
+-- 时间段执行记录表
+-- 记录每天每个时间段在各 action 维度的执行状态(用于 once 功能)
+-- 替代旧的 push_records 表
 -- ============================================
-CREATE TABLE IF NOT EXISTS push_records (
+CREATE TABLE IF NOT EXISTS period_executions (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
-    date TEXT NOT NULL UNIQUE,
-    pushed INTEGER DEFAULT 0,
-    push_time TEXT,
-    report_type TEXT,
-    ai_analyzed INTEGER DEFAULT 0,
-    ai_analysis_time TEXT,
-    ai_analysis_mode TEXT,
-    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+    execution_date TEXT NOT NULL,          -- YYYY-MM-DD
+    period_key TEXT NOT NULL,              -- period 的稳定 key
+    action TEXT NOT NULL,                  -- analyze | push
+    executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE(execution_date, period_key, action)
 );
 
 -- ============================================
@@ -119,3 +116,7 @@ CREATE INDEX IF NOT EXISTS idx_crawl_status_record ON crawl_source_status(crawl_
 
 -- 排名历史索引
 CREATE INDEX IF NOT EXISTS idx_rank_history_news ON rank_history(news_item_id);
+
+-- 时间段执行记录索引
+CREATE INDEX IF NOT EXISTS idx_period_exec_lookup
+ON period_executions(execution_date, period_key, action);

+ 42 - 199
trendradar/storage/sqlite_mixin.py

@@ -608,7 +608,7 @@ class SQLiteStorageMixin:
             for source_id, news_list in historical_data.items.items():
                 historical_titles[source_id] = set()
                 for item in news_list:
-                    first_time = getattr(item, 'first_time', item.crawl_time)
+                    first_time = item.first_time or item.crawl_time
                     if first_time < current_time:
                         historical_titles[source_id].add(item.title)
 
@@ -689,243 +689,86 @@ class SQLiteStorageMixin:
             return []
 
     # ========================================
-    # 推送记录
+    # 时间段执行记录(调度系统)
     # ========================================
 
-    def _has_pushed_today_impl(self, date: Optional[str] = None) -> bool:
+    def _has_period_executed_impl(self, date_str: str, period_key: str, action: str) -> bool:
         """
-        检查指定日期是否已推送过
+        检查指定时间段的某个 action 今天是否已执行
 
         Args:
-            date: 日期字符串(YYYY-MM-DD),默认为今天
+            date_str: 日期字符串 YYYY-MM-DD
+            period_key: 时间段 key
+            action: 动作类型 (analyze / push)
 
         Returns:
-            是否已推送
+            是否已执行
         """
         try:
-            conn = self._get_connection(date)
+            conn = self._get_connection(date_str)
             cursor = conn.cursor()
 
-            target_date = self._format_date_folder(date)
-
+            # 先检查表是否存在
             cursor.execute("""
-                SELECT pushed FROM push_records WHERE date = ?
-            """, (target_date,))
-
-            row = cursor.fetchone()
-            if row:
-                return bool(row[0])
-            return False
-
-        except Exception as e:
-            print(f"[存储] 检查推送记录失败: {e}")
-            return False
-
-    def _record_push_impl(self, report_type: str, date: Optional[str] = None) -> bool:
-        """
-        记录推送
-
-        Args:
-            report_type: 报告类型
-            date: 日期字符串(YYYY-MM-DD),默认为今天
-
-        Returns:
-            是否记录成功
-        """
-        try:
-            conn = self._get_connection(date)
-            cursor = conn.cursor()
-
-            target_date = self._format_date_folder(date)
-            now_str = self._get_configured_time().strftime("%Y-%m-%d %H:%M:%S")
-
-            cursor.execute("""
-                INSERT INTO push_records (date, pushed, push_time, report_type, created_at)
-                VALUES (?, 1, ?, ?, ?)
-                ON CONFLICT(date) DO UPDATE SET
-                    pushed = 1,
-                    push_time = excluded.push_time,
-                    report_type = excluded.report_type
-            """, (target_date, now_str, report_type, now_str))
-
-            conn.commit()
-            return True
-
-        except Exception as e:
-            print(f"[存储] 记录推送失败: {e}")
-            return False
-
-    def _has_ai_analyzed_today_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)
+                SELECT name FROM sqlite_master
+                WHERE type='table' AND name='period_executions'
+            """)
+            if not cursor.fetchone():
+                return False
 
             cursor.execute("""
-                SELECT ai_analyzed FROM push_records WHERE date = ?
-            """, (target_date,))
+                SELECT 1 FROM period_executions
+                WHERE execution_date = ? AND period_key = ? AND action = ?
+            """, (date_str, period_key, action))
 
-            row = cursor.fetchone()
-            if row:
-                return bool(row[0])
-            return False
+            return cursor.fetchone() is not None
 
         except Exception as e:
-            print(f"[存储] 检查 AI 分析记录失败: {e}")
+            print(f"[存储] 检查时间段执行记录失败: {e}")
             return False
 
-    def _record_ai_analysis_impl(self, analysis_mode: str, date: Optional[str] = None) -> bool:
+    def _record_period_execution_impl(self, date_str: str, period_key: str, action: str) -> bool:
         """
-        记录 AI 分析
+        记录时间段的 action 执行
 
         Args:
-            analysis_mode: 分析模式(daily/current/incremental)
-            date: 日期字符串(YYYY-MM-DD),默认为今天
+            date_str: 日期字符串 YYYY-MM-DD
+            period_key: 时间段 key
+            action: 动作类型 (analyze / push)
 
         Returns:
             是否记录成功
         """
         try:
-            conn = self._get_connection(date)
-            cursor = conn.cursor()
-
-            target_date = self._format_date_folder(date)
-            now_str = self._get_configured_time().strftime("%Y-%m-%d %H:%M:%S")
-
-            cursor.execute("""
-                INSERT INTO push_records (date, ai_analyzed, ai_analysis_time, ai_analysis_mode, created_at)
-                VALUES (?, 1, ?, ?, ?)
-                ON CONFLICT(date) DO UPDATE SET
-                    ai_analyzed = 1,
-                    ai_analysis_time = excluded.ai_analysis_time,
-                    ai_analysis_mode = excluded.ai_analysis_mode
-            """, (target_date, now_str, analysis_mode, now_str))
-
-            conn.commit()
-            return True
-
-        except Exception as e:
-            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)
+            conn = self._get_connection(date_str)
             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()
+                CREATE TABLE IF NOT EXISTS period_executions (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    execution_date TEXT NOT NULL,
+                    period_key TEXT NOT NULL,
+                    action TEXT NOT NULL,
+                    executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+                    UNIQUE(execution_date, period_key, action)
+                )
+            """)
 
-            target_date = self._format_date_folder(date)
+            now_str = self._get_configured_time().strftime("%Y-%m-%d %H:%M:%S")
 
             cursor.execute("""
-                UPDATE push_records
-                SET ai_analyzed = 0, ai_analysis_time = NULL, ai_analysis_mode = NULL
-                WHERE date = ?
-            """, (target_date,))
+                INSERT OR IGNORE INTO period_executions (execution_date, period_key, action, executed_at)
+                VALUES (?, ?, ?, ?)
+            """, (date_str, period_key, action, now_str))
 
             conn.commit()
-            print(f"[存储] 已重置 {target_date} 的 AI 分析状态")
             return True
 
         except Exception as e:
-            print(f"[存储] 重置 AI 分析状态失败: {e}")
+            print(f"[存储] 记录时间段执行失败: {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 数据存储
     # ========================================
@@ -1188,7 +1031,7 @@ class SQLiteStorageMixin:
             for feed_id, rss_list in historical_data.items.items():
                 historical_urls[feed_id] = set()
                 for item in rss_list:
-                    first_time = getattr(item, 'first_time', item.crawl_time)
+                    first_time = item.first_time or item.crawl_time
                     if first_time < current_time:
                         if item.url:
                             historical_urls[feed_id].add(item.url)

+ 1 - 1
version

@@ -1 +1 @@
-5.5.3
+6.0.0

+ 4 - 3
version_configs

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

+ 1 - 1
version_mcp

@@ -1 +1 @@
-3.2.0
+4.0.0