[
  {
    "path": ".dockerignore",
    "content": ".git/\n.gitignore\n*.md\nREADME.md\n\noutput/\n\n__pycache__/\n*.pyc\n*.pyo\n*.pyd\n.Python\n*.so\n.pytest_cache/\n\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n.DS_Store\nThumbs.db\n\ndocker/.env\n\n_image/\n\n.github/\n\n*.log\n.env.local\n.env.*.local\nversion\nindex.html"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/01-bug-report.yml",
    "content": "# yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json\n\nname: 🐛 遇到问题了\ndescription: 程序运行不正常、报错或功能失效（含 AI 分析问题）\ntitle: \"[问题] \"\nlabels: [\"bug\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ### ⚠️ 提交前必读\n        **请确保你正在使用 TrendRadar 的最新版本。**\n        很多问题在最新代码中可能已经修复。如果你使用的是旧版本，我将无法处理，请先更新后再试。\n\n        **简单的描述 + 关键截图** 是最有效的沟通方式。\n\n        ---\n        ### 📌 如何查看版本号？\n\n        | 部署方式 | 查看方法 |\n        |---------|---------|\n        | **Docker** | 查看容器启动日志，版本号显示在日志开头 |\n        | **GitHub Actions** | 查看 [README 文档](https://github.com/sansan0/TrendRadar) 顶部的 ![version](https://img.shields.io/badge/version-blue) 徽章 |\n        | **本地 Python** | 查看项目根目录的 `version` 文件 |\n\n  - type: input\n    id: version\n    attributes:\n      label: 📦 TrendRadar 版本\n      description: |\n        请务必提供版本号（如：v5.2.0 或 git commit id）\n        💡 Docker 用户：查看容器启动日志 | GitHub Actions 用户：查看文档顶部 version 徽章\n      placeholder: v5.2.0 或 commit hash\n    validations:\n      required: true\n\n  - type: input\n    id: mcp-version\n    attributes:\n      label: 🔌 MCP Server 版本 (可选)\n      description: 如果你是通过 MCP 使用，请填写 MCP Server 的版本。\n      placeholder: v3.1.6 (非 MCP 用户留空)\n    validations:\n      required: false\n\n  - type: dropdown\n    id: bug-category\n    attributes:\n      label: 🏷️ 问题类别\n      options:\n        - AI 分析相关（报错、内容异常、提示词失效等）\n        - 数据获取相关（爬不到新闻、平台失效等）\n        - 通知推送相关（收不到消息、推送报错等）\n        - 部署运行相关（Docker、Actions、Python 报错）\n        - 其他\n    validations:\n      required: true\n\n  - type: input\n    id: ai-model\n    attributes:\n      label: 🤖 AI 模型名称（AI 问题必填）\n      description: |\n        如果是 AI 分析相关问题，请提供你使用的具体模型名称。\n        AI 问题与模型能力密切相关，不同模型表现差异很大。\n      placeholder: \"例如：deepseek/deepseek-chat、openai/gpt-4o、gemini/gemini-2.5-flash\"\n    validations:\n      required: false\n\n  - type: textarea\n    id: bug-description\n    attributes:\n      label: 📝 描述发生了什么\n      placeholder: |\n        请描述：\n        1. 你在做什么？\n        2. 出现了什么错误？（如果是 AI 问题，请贴出分析失败的错误提示）\n        3. 建议上传一张截图，这比文字更有力！\n    validations:\n      required: true\n\n  - type: textarea\n    id: error-logs\n    attributes:\n      label: 📋 错误日志/配置（可选）\n      description: |\n        贴出相关的错误日志或 config.yaml 片段（记得隐藏 API Key 等敏感信息）\n        💡 Docker 用户：使用 `docker logs trendradar` 查看日志\n      placeholder: |\n        贴出相关的错误日志或 config.yaml 片段：\n        ```\n        在这里贴内容...\n        ```\n    validations:\n      required: false\n\n  - type: textarea\n    id: screenshots\n    attributes:\n      label: 📷 截图（强烈建议）\n      description: |\n        ⚠️ **重要提示**：请提供**完整截图**，不要只截取局部！\n        - 错误截图应包含完整的错误信息和上下文\n        - 推送截图应包含完整的消息内容\n        - 配置截图应包含相关配置段的完整内容\n\n        局部截图往往缺少关键信息，会导致问题难以定位。\n      placeholder: 拖拽截图到这里，请确保截图完整，包含足够的上下文信息。\n    validations:\n      required: false\n\n  - type: dropdown\n    id: environment\n    attributes:\n      label: 🖥️ 使用环境\n      options:\n        - Docker (本地/NAS)\n        - GitHub Actions\n        - 本地 Python 运行\n        - MCP Server 客户端 (Cherry Studio等)\n    validations:\n      required: true"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/02-feature-request.yml",
    "content": "# yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json\n\nname: 💡 我有个想法\ndescription: 建议新功能、推送样式改进或体验优化\ntitle: \"[建议] \"\nlabels: [\"enhancement\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ### 💝 欢迎分享你的创意\n        你的好点子能让 TrendRadar 变得更好！\n        \n        目前主要关注以下方向的改进：\n        - ✨ **AI 分析能力**：更智能的解读、更丰富的分析维度\n        - 🎨 **推送体验**：更好看的排版、更合理的信息展示\n        - 🛠️ **易用性优化**：配置更简单、运行更稳定\n        \n        *注：目前暂不接受新爬虫平台的接入申请，感谢理解。*\n\n  - type: textarea\n    id: feature-description\n    attributes:\n      label: 💭 你的想法是什么？\n      placeholder: |\n        请简要描述：\n        - 你希望增加什么功能？\n        - 它能解决什么问题？\n        - 如果有参考的图片或工具，欢迎上传截图。\n    validations:\n      required: true\n\n  - type: textarea\n    id: use-case\n    attributes:\n      label: 🎯 使用场景（可选）\n      placeholder: 例如：当我在...的时候，如果能...就太棒了。\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/03-ai-and-config.yml",
    "content": "# yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json\n\nname: ✨ AI 提示词分享与配置求助\ndescription: 分享你调优的 ai_analysis_prompt.txt 或寻求设置帮助\ntitle: \"[AI/配置] \"\nlabels: [\"config\", \"AI\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        ### ✨ 提示词分享计划\n        欢迎在此分享你精心调优的 `ai_analysis_prompt.txt` 内容！\n        优秀的提示词可以让 AI 分析更精准、更有趣。\n\n        ---\n        如果是**寻求配置帮助**，请尽量贴出你的错误表现。\n\n        ---\n        ### 📌 如何查看版本号？\n\n        | 部署方式 | 查看方法 |\n        |---------|---------|\n        | **Docker** | 查看容器启动日志，版本号显示在日志开头 |\n        | **GitHub Actions** | 查看 [README 文档](https://github.com/sansan0/TrendRadar) 顶部的 ![version](https://img.shields.io/badge/version-blue) 徽章 |\n        | **本地 Python** | 查看项目根目录的 `version` 文件 |\n\n  - type: dropdown\n    id: category\n    attributes:\n      label: 🏷️ 目的\n      options:\n        - 分享我的 AI 提示词 (ai_analysis_prompt.txt)\n        - 寻求 AI 分析设置帮助\n        - 寻求基础功能配置帮助 (Webhook/RSS等)\n    validations:\n      required: true\n\n  - type: input\n    id: version\n    attributes:\n      label: 📦 TrendRadar 版本（求助时必填）\n      description: |\n        如果是寻求帮助，请提供版本号。\n        💡 Docker 用户：查看容器启动日志 | GitHub Actions 用户：查看文档顶部 version 徽章\n      placeholder: v5.2.0 或 commit hash（分享提示词可留空）\n    validations:\n      required: false\n\n  - type: input\n    id: ai-model\n    attributes:\n      label: 🤖 AI 模型名称\n      description: |\n        请提供你使用的具体模型名称。\n        AI 分析效果与模型能力密切相关，不同模型表现差异很大。\n        分享提示词时也请注明，方便其他用户参考。\n      placeholder: \"例如：deepseek/deepseek-chat、openai/gpt-4o、gemini/gemini-2.5-flash\"\n    validations:\n      required: false\n\n  - type: textarea\n    id: share-content\n    attributes:\n      label: 📄 内容描述\n      placeholder: |\n        - 如果是分享：请贴出你的提示词代码块，并简述它的分析风格。\n        - 如果是求助：请贴出你的配置片段（隐藏 Key）和遇到的现象。\n    validations:\n      required: true\n\n  - type: textarea\n    id: screenshots\n    attributes:\n      label: 📷 效果截图（推荐）\n      description: |\n        ⚠️ **重要提示**：请提供**完整截图**，不要只截取局部！\n        - 分享时：展示 AI 分析的完整输出效果\n        - 求助时：展示完整的错误信息或异常表现\n\n        局部截图往往缺少关键信息，会导致问题难以定位。\n      placeholder: 拖拽分析结果截图或配置截图到这里，请确保截图完整。\n    validations:\n      required: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "# yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json\n\nblank_issues_enabled: false"
  },
  {
    "path": ".github/workflows/clean-crawler.yml",
    "content": "name: Check In\n\n# ✅ 签到续期：运行此 workflow 可重置 7 天计时，保持 \"Get Hot News\" 正常运行\n# ✅ Renewal: Run this workflow to reset the 7-day timer and keep \"Get Hot News\" active\n#\n# 📌 操作方法 / How to use:\n#   1. 点击 \"Run workflow\" 按钮 / Click \"Run workflow\" button\n#   2. 每 7 天内至少运行一次 / Run at least once every 7 days\n\non:\n  workflow_dispatch:\n\njobs:\n  del_runs:\n    runs-on: ubuntu-latest\n    permissions:\n      actions: write\n      contents: read\n    steps:\n      - name: Delete all workflow runs\n        uses: Mattraks/delete-workflow-runs@v2\n        with:\n          token: ${{ github.token }}\n          repository: ${{ github.repository }}\n          retain_days: 0\n          keep_minimum_runs: 0\n          delete_workflow_by_state_pattern: \"ALL\"\n          delete_run_by_conclusion_pattern: \"ALL\""
  },
  {
    "path": ".github/workflows/crawler.yml",
    "content": "name: Get Hot News\n\non:\n  schedule:\n    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    # ⚠️ 试用版说明 / Trial Mode\n    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    #\n    # 🔄 运行机制 / How it works:\n    #    - 每个周期为 7 天，届时自动停止\n    #    - 运行 \"Check In\" 会重置周期（重新开始 7 天倒计时，而非累加）\n    #    - Each cycle is 7 days, then auto-stops\n    #    - \"Check In\" resets the cycle (restarts 7-day countdown, not cumulative)\n    #\n    # 💡 设计初衷 / Why this design:\n    #    如果 7 天都忘了签到，或许这些资讯对你来说并非刚需\n    #    适时的暂停，能帮你从信息流中抽离，给大脑留出喘息的空间\n    #    If you forget for 7 days, maybe you don't really need it\n    #    A timely pause helps you detach from the stream and gives your mind space\n    #\n    # 🙏 珍惜资源 / Respect shared resources:\n    #    GitHub Actions 是平台提供的公共资源，每次运行都会消耗算力\n    #    签到机制确保资源分配给真正需要的用户，感谢你的理解与配合\n    #    GitHub Actions is a shared public resource provided by the platform\n    #    Check-in ensures resources go to those who truly need it — thank you\n    #\n    # 🚀 长期使用请部署 Docker 版本 / For long-term use, deploy Docker version\n    #\n    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n    #\n    # 📝 修改运行时间：只改第一个数字（0-59），表示每小时第几分钟运行\n    # 📝 Change time: Only modify the first number (0-59) = minute of each hour\n    #\n    # 示例 / Examples:\n    #   \"15 * * * *\"     → 每小时第15分钟 / minute 15 every hour\n    #   \"30 0-14 * * *\"  → 北京时间 8:00-22:00 每小时第30分钟 / Beijing 8am-10pm\n    #\n    - cron: \"33 * * * *\"\n\n  workflow_dispatch:\n\nconcurrency:\n  group: crawler-${{ github.ref_name }}\n  cancel-in-progress: true\n\npermissions:\n  contents: read\n  actions: write\n\njobs:\n  crawl:\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1\n          clean: true\n\n      - name: Check Expiration\n        env:\n          GH_TOKEN: ${{ github.token }}\n        run: |\n          WORKFLOW_FILE=\"crawler.yml\"\n          API_URL=\"repos/${{ github.repository }}/actions/workflows/$WORKFLOW_FILE/runs\"\n\n          TOTAL=$(gh api \"$API_URL\" --jq '.total_count')\n          if [ -z \"$TOTAL\" ] || [ \"$TOTAL\" -eq 0 ]; then\n            echo \"No previous runs found, skipping expiration check\"\n            exit 0\n          fi\n\n          LAST_PAGE=$(( (TOTAL + 99) / 100 ))\n          FIRST_RUN_DATE=$(gh api \"$API_URL?per_page=100&page=$LAST_PAGE\" --jq '.workflow_runs[-1].created_at')\n\n          if [ -n \"$FIRST_RUN_DATE\" ]; then\n            CURRENT_TIMESTAMP=$(date +%s)\n            FIRST_RUN_TIMESTAMP=$(date -d \"$FIRST_RUN_DATE\" +%s)\n            DIFF_SECONDS=$((CURRENT_TIMESTAMP - FIRST_RUN_TIMESTAMP))\n            LIMIT_SECONDS=604800\n\n            if [ $DIFF_SECONDS -gt $LIMIT_SECONDS ]; then\n              echo \"⚠️ 试用期已结束，请运行 'Check In' 签到续期\"\n              echo \"⚠️ Trial expired. Run 'Check In' to renew.\"\n              gh workflow disable \"$WORKFLOW_FILE\"\n              exit 1\n            else\n              DAYS_LEFT=$(( (LIMIT_SECONDS - DIFF_SECONDS) / 86400 ))\n              echo \"✅ 试用期剩余 ${DAYS_LEFT} 天，到期前请运行 'Check In' 签到续期\"\n              echo \"✅ Trial: ${DAYS_LEFT} days left. Run 'Check In' before expiry to renew.\"\n            fi\n          fi\n\n\n      # --------------------------------------------------------------------------------\n      # 🚦 TRAFFIC CONTROL / 流量控制\n      # --------------------------------------------------------------------------------\n      # EN: Generates a random delay between 1 and 300 seconds (5 minutes).\n      #     Critical for load balancing.\n      #\n      # CN: 生成 1 到 300 秒（5分钟）之间的随机延迟。\n      #     这对负载均衡至关重要。\n      # - name: Random Delay (Traffic Control)\n      #   if: success()\n      #   run: |\n      #     echo \"🎲 Traffic Control: Generating random delay...\"\n      #     DELAY=$(( ( RANDOM % 300 )  + 1 ))\n      #     echo \"⏸️  Sleeping for ${DELAY} seconds to spread the load...\"\n      #     sleep ${DELAY}s\n      #     echo \"▶️  Delay finished. Starting crawler...\"\n\n      - name: Set up Python\n        if: success()\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.10\"\n          cache: \"pip\"\n\n      - name: Install dependencies\n        if: success()\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements.txt\n\n      - name: Verify required files\n        if: success()\n        run: |\n          if [ ! -f config/config.yaml ]; then\n            echo \"Error: Config missing\"\n            exit 1\n          fi\n\n      - name: Run crawler\n        if: success()\n        env:\n          FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}\n          TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}\n          TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}\n          DINGTALK_WEBHOOK_URL: ${{ secrets.DINGTALK_WEBHOOK_URL }}\n          WEWORK_WEBHOOK_URL: ${{ secrets.WEWORK_WEBHOOK_URL }}\n          WEWORK_MSG_TYPE: ${{ secrets.WEWORK_MSG_TYPE }}\n          EMAIL_FROM: ${{ secrets.EMAIL_FROM }}\n          EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }}\n          EMAIL_TO: ${{ secrets.EMAIL_TO }}\n          EMAIL_SMTP_SERVER: ${{ secrets.EMAIL_SMTP_SERVER }}\n          EMAIL_SMTP_PORT: ${{ secrets.EMAIL_SMTP_PORT }}\n          NTFY_TOPIC: ${{ secrets.NTFY_TOPIC }}\n          NTFY_SERVER_URL: ${{ secrets.NTFY_SERVER_URL }}\n          NTFY_TOKEN: ${{ secrets.NTFY_TOKEN }}\n          BARK_URL: ${{ secrets.BARK_URL }}\n          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}\n          # 通用Webhook配置\n          GENERIC_WEBHOOK_URL: ${{ secrets.GENERIC_WEBHOOK_URL }}\n          GENERIC_WEBHOOK_TEMPLATE: ${{ secrets.GENERIC_WEBHOOK_TEMPLATE }}\n          # AI 配置（ai_analysis 和 ai_translation 共享模型配置）\n          AI_ANALYSIS_ENABLED: ${{ secrets.AI_ANALYSIS_ENABLED }}\n          AI_API_KEY: ${{ secrets.AI_API_KEY }}\n          AI_MODEL: ${{ secrets.AI_MODEL }}\n          AI_API_BASE: ${{ secrets.AI_API_BASE }}\n          # 远程存储配置\n          S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}\n          S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}\n          S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}\n          S3_ENDPOINT_URL: ${{ secrets.S3_ENDPOINT_URL }}\n          S3_REGION: ${{ secrets.S3_REGION }}\n          GITHUB_ACTIONS: true\n        run: python -m trendradar\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: Build and Push Docker Images\n\non:\n  push:\n    tags:\n      - \"v*\" # 主项目版本\n      - \"mcp-v*\" # MCP 版本\n  workflow_dispatch:\n    inputs:\n      image:\n        description: \"选择要构建的镜像\"\n        required: true\n        default: \"all\"\n        type: choice\n        options:\n          - all\n          - crawler\n          - mcp\n\nenv:\n  REGISTRY: docker.io\n\njobs:\n  build-crawler:\n    runs-on: ubuntu-latest\n    # 条件：v* 标签（排除 mcp-v*）或手动触发选择 all/crawler\n    if: |\n      (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && !startsWith(github.ref, 'refs/tags/mcp-v')) ||\n      (github.event_name == 'workflow_dispatch' && (github.event.inputs.image == 'all' || github.event.inputs.image == 'crawler'))\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n        with:\n          driver-opts: |\n            network=host\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: wantcat/trendradar\n          tags: |\n            type=semver,pattern={{version}}\n            type=semver,pattern={{major}}.{{minor}}\n            type=raw,value=latest\n\n      - name: Build and push\n        uses: docker/build-push-action@v5\n        env:\n          BUILDKIT_PROGRESS: plain\n        with:\n          context: .\n          file: ./docker/Dockerfile\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n\n  build-mcp:\n    runs-on: ubuntu-latest\n    # 条件：mcp-v* 标签 或手动触发选择 all/mcp\n    if: |\n      (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/mcp-v')) ||\n      (github.event_name == 'workflow_dispatch' && (github.event.inputs.image == 'all' || github.event.inputs.image == 'mcp'))\n\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n        with:\n          driver-opts: |\n            network=host\n\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKERHUB_USERNAME }}\n          password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n      - name: Extract version from tag\n        id: version\n        run: |\n          if [[ \"${{ github.ref }}\" == refs/tags/mcp-v* ]]; then\n            VERSION=\"${GITHUB_REF#refs/tags/mcp-v}\"\n            echo \"version=${VERSION}\" >> $GITHUB_OUTPUT\n            echo \"major_minor=$(echo $VERSION | cut -d. -f1,2)\" >> $GITHUB_OUTPUT\n          else\n            echo \"version=latest\" >> $GITHUB_OUTPUT\n            echo \"major_minor=latest\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: wantcat/trendradar-mcp\n          tags: |\n            type=raw,value=${{ steps.version.outputs.version }}\n            type=raw,value=${{ steps.version.outputs.major_minor }}\n            type=raw,value=latest\n\n      - name: Build and push\n        uses: docker/build-push-action@v5\n        env:\n          BUILDKIT_PROGRESS: plain\n        with:\n          context: .\n          file: ./docker/Dockerfile.mcp\n          platforms: linux/amd64,linux/arm64\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha\n          cache-to: type=gha,mode=max\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README-Cherry-Studio.md",
    "content": "# TrendRadar × Cherry Studio 部署指南 🍒\n\n> **适合人群**：零编程基础的用户\n> **客户端**：Cherry Studio（免费开源 GUI 客户端）\n\n---\n\n## 📥 第一步：下载 Cherry Studio\n\n### Windows 用户\n\n访问官网下载：https://cherry-ai.com/\n或直接下载：[Cherry-Studio-Windows.exe](https://github.com/kangfenmao/cherry-studio/releases/latest)\n\n### Mac 用户\n\n访问官网下载：https://cherry-ai.com/\n或直接下载：[Cherry-Studio-Mac.dmg](https://github.com/kangfenmao/cherry-studio/releases/latest)\n\n\n---\n\n## 📦 第二步：获取项目代码\n\n为什么需要获取项目代码？\n\nAI 分析功能需要读取项目中的新闻数据才能工作。无论你使用 GitHub Actions 还是 Docker 部署，爬虫生成的新闻数据都保存在项目的 output 目录中。因此，在配置 MCP 服务器之前，需要先获取完整的项目代码（包含数据文件）。\n\n根据你的技术水平，可以选择以下任一方式获取：：\n\n### 方法一：Git Clone（推荐给技术用户）\n\n如果你熟悉 Git，可以使用以下命令克隆项目：\n\n```bash\ngit clone https://github.com/你的用户名/你的项目名.git\ncd 你的项目名\n```\n\n**优点**：\n\n- 可以随时拉取一个命令就可以更新最新数据到本地了（`git pull`）\n\n### 方法二：直接下载 ZIP 压缩包（推荐给初学者）\n\n\n1. **访问 GitHub 项目页面**\n\n   - 项目链接：`https://github.com/你的用户名/你的项目名`\n\n2. **下载压缩包**\n\n   - 点击绿色的 \"Code\" 按钮\n   - 选择 \"Download ZIP\"\n   - 或直接访问：`https://github.com/你的用户名/你的项目名/archive/refs/heads/master.zip`\n\n\n**注意事项**：\n\n- 步骤稍微麻烦，后续更新数据需要重复上面步骤，然后覆盖本地数据(output 目录)\n\n---\n\n## 🚀 第三步：一键部署 MCP 服务器\n\n### Windows 用户\n\n1. **双击运行**项目文件夹中的 `setup-windows.bat`，如果有问题，就运行 `setup-windows-en.bat`\n2. **等待安装完成**\n3. **记录显示的配置信息**（命令路径和参数）\n\n### Mac 用户\n\n1. **打开终端**（在启动台搜索\"终端\"）\n2. **拖拽**项目文件夹中的 `setup-mac.sh` 到终端窗口\n3. **按回车键**\n4. **记录显示的配置信息**\n\n---\n\n## 🔧 第四步：配置 Cherry Studio\n\n### 1. 打开设置\n\n启动 Cherry Studio，点击右上角 ⚙️ **设置** 按钮\n\n### 2. 添加 MCP 服务器\n\n在设置页面找到：**MCP** → 点击 **添加**\n\n### 3. 填写配置（重要！）\n\n根据刚才的安装脚本显示的信息填写\n\n### 4. 保存并启用\n\n- 点击 **保存** 按钮\n- 确保 MCP 服务器列表中的开关是 **开启** 状态 ✅\n\n---\n\n## ✅ 第五步：验证是否成功\n\n### 1. 测试连接\n\n在 Cherry Studio 的对话框中输入：\n\n```\n帮我爬取最新的新闻\n```\n\n或者尝试其他测试命令：\n\n```\n搜索最近3天关于\"人工智能\"的新闻\n查找2025年1月的\"特斯拉\"相关报道\n分析\"iPhone\"的热度趋势\n```\n\n**提示**：当你说\"最近3天\"时，AI会自动计算日期范围并搜索。\n\n### 2. 成功标志\n\n如果配置成功，AI 会：\n\n- ✅ 调用 TrendRadar 工具\n- ✅ 返回真实的新闻数据\n- ✅ 显示平台、标题、排名等信息\n\n\n---\n\n## 🎯 进阶配置\n\n### HTTP 模式（可选）\n\n如果需要远程访问或多客户端共享，可以使用 HTTP 模式：\n\n#### Windows\n\n双击运行 `start-http.bat`\n\n#### Mac\n\n```bash\n./start-http.sh\n```\n\n然后在 Cherry Studio 中配置：\n\n```\n类型: streamableHttp\nURL: http://localhost:3333/mcp\n```\n"
  },
  {
    "path": "README-EN.md",
    "content": "<div align=\"center\" id=\"trendradar\">\n\n<a href=\"https://github.com/sansan0/TrendRadar\" title=\"TrendRadar\">\n  <img src=\"/_image/banner.webp\" alt=\"TrendRadar Banner\" width=\"80%\">\n</a>\n\nDeploy in <strong>30 seconds</strong> — Say goodbye to endless scrolling, only see the news you truly care about\n\n<a href=\"https://trendshift.io/repositories/14726\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/14726\" alt=\"sansan0%2FTrendRadar | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n[![GitHub Stars](https://img.shields.io/github/stars/sansan0/TrendRadar?style=flat-square&logo=github&color=yellow)](https://github.com/sansan0/TrendRadar/stargazers)\n[![GitHub Forks](https://img.shields.io/github/forks/sansan0/TrendRadar?style=flat-square&logo=github&color=blue)](https://github.com/sansan0/TrendRadar/network/members)\n[![License](https://img.shields.io/badge/license-GPL--3.0-blue.svg?style=flat-square)](LICENSE)\n[![Version](https://img.shields.io/badge/version-v6.5.0-blue.svg)](https://github.com/sansan0/TrendRadar)\n[![MCP](https://img.shields.io/badge/MCP-v4.0.0-green.svg)](https://github.com/sansan0/TrendRadar)\n[![RSS](https://img.shields.io/badge/RSS-Feed_Support-orange.svg?style=flat-square&logo=rss&logoColor=white)](https://github.com/sansan0/TrendRadar)\n[![AI Translation](https://img.shields.io/badge/AI-Multi--Language-purple.svg?style=flat-square)](https://github.com/sansan0/TrendRadar)\n\n[![WeWork](https://img.shields.io/badge/WeWork-Notification-00D4AA?style=flat-square)](https://work.weixin.qq.com/)\n[![WeChat](https://img.shields.io/badge/WeChat-Notification-00D4AA?style=flat-square)](https://weixin.qq.com/)\n[![Telegram](https://img.shields.io/badge/Telegram-Notification-00D4AA?style=flat-square)](https://telegram.org/)\n[![DingTalk](https://img.shields.io/badge/DingTalk-Notification-00D4AA?style=flat-square)](#)\n[![Feishu](https://img.shields.io/badge/Feishu-Notification-00D4AA?style=flat-square)](https://www.feishu.cn/)\n[![Email](https://img.shields.io/badge/Email-Notification-00D4AA?style=flat-square)](#)\n[![ntfy](https://img.shields.io/badge/ntfy-Notification-00D4AA?style=flat-square)](https://github.com/binwiederhier/ntfy)\n[![Bark](https://img.shields.io/badge/Bark-Notification-00D4AA?style=flat-square)](https://github.com/Finb/Bark)\n[![Slack](https://img.shields.io/badge/Slack-Notification-00D4AA?style=flat-square)](https://slack.com/)\n[![Generic Webhook](https://img.shields.io/badge/Generic-Webhook-607D8B?style=flat-square&logo=webhook&logoColor=white)](#)\n\n\n[![GitHub Actions](https://img.shields.io/badge/GitHub_Actions-Automation-2088FF?style=flat-square&logo=github-actions&logoColor=white)](https://github.com/sansan0/TrendRadar)\n[![GitHub Pages](https://img.shields.io/badge/GitHub_Pages-Deployment-4285F4?style=flat-square&logo=github&logoColor=white)](https://sansan0.github.io/TrendRadar)\n[![Docker](https://img.shields.io/badge/Docker-Deployment-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/r/wantcat/trendradar)\n[![MCP Support](https://img.shields.io/badge/MCP-AI_Analysis-FF6B6B?style=flat-square&logo=ai&logoColor=white)](https://modelcontextprotocol.io/)\n[![AI Analysis Push](https://img.shields.io/badge/AI-Analysis_Push-FF6B6B?style=flat-square&logo=openai&logoColor=white)](#)\n[![AI Smart Filter](https://img.shields.io/badge/AI-Smart_News_Filter-9B59B6?style=flat-square&logo=openai&logoColor=white)](#)\n\n</div>\n\n<div align=\"center\">\n\n**[中文](README.md)** | **English**\n\n</div>\n\n> This project is designed to be lightweight and easy to deploy\n\n<br>\n\n## 📑 Quick Navigation\n\n> 💡 **Click the links below** to jump to the corresponding section. Start with \"**Quick Start**\" for deployment, see \"**Configuration Guide**\" for detailed customization\n\n<div align=\"center\">\n\n|   |   |   |\n|:---:|:---:|:---:|\n| [🚀 **Quick Start**](#-quick-start) | [AI Analysis](#-ai-analysis) | [⚙️ **Configuration Guide**](#configuration-guide) |\n| [Docker Deployment](#6-docker-deployment) | [MCP Clients](#-mcp-clients) | [📝 **Changelog**](#-changelog) |\n| [🎯 **Core Features**](#-core-features) | [☕ **Support Project**](#-support-project) | [📚 **Related Projects**](#-related-projects) |\n\n</div>\n\n<br>\n\n- Thanks to **stargazers**, your stars and forks are the best support for open source 😍\n\n<details>\n<summary>👉 Click to view <strong>Acknowledgments</strong> (Angel Round Honor Roll 🔥73+🔥 supporters)</summary>\n\n### Acknowledgments to Early Supporters\n\n> 💡 **Special Note**:\n>\n> 1. **About the List**: The table below records supporters from the early stage (Angel Round) of the project. Due to the manual nature of statistics in the early days, **there may be omissions or incomplete records. If anyone was missed, it was unintentional, and we ask for your kind understanding**.\n> 2. **Future Plan**: To focus limited energy back on code development and feature iteration, **this list will no longer be manually maintained as of today**.\n>\n> Whether your name is on the list or not, your every bit of support is the cornerstone that allows TrendRadar to be where it is today. 🙏\n\n### Infrastructure Support\n\nThanks to **GitHub** for providing free infrastructure, which is the biggest prerequisite for this project to run conveniently with **one-click fork**.\n\n### Data Support\n\nThis project uses the API from [newsnow](https://github.com/ourongxing/newsnow) to fetch multi-platform data. Special thanks to the author for providing this service.\n\nAfter communication, the author indicated no concerns about server pressure, but this is based on their goodwill and trust. Please everyone:\n- **Visit the [newsnow project](https://github.com/ourongxing/newsnow) and give it a star**\n- When deploying with Docker, please control the frequency reasonably and avoid being overly greedy\n\n### Promotion Support\n\n> Thanks to the following platforms and individuals for recommendations (in chronological order)\n\n- [Appinn (小众软件)](https://mp.weixin.qq.com/s/fvutkJ_NPUelSW9OGK39aA) - Open source software recommendation platform\n- [LinuxDo Community](https://linux.do/) - Tech enthusiasts community\n- [Ruan Yifeng's Weekly](https://github.com/ruanyf/weekly) - Influential tech weekly in Chinese tech circle\n\n### Community Support\n\n> Thanks to **financial supporters**. Your generosity has transformed into snacks and drinks beside my keyboard, accompanying every iteration of this project\n>\n> **Return of \"One-Yuan Appreciation\"**:\n> With the release of v5.0.0, the project enters a new phase. To support growing API costs and caffeine consumption, the \"One-Yuan Appreciation\" channel is now reopened. Every bit of your kindness translates into Tokens and motivation in the code world. 🚀 [Support Now](#-support-project)\n\n| Supporter | Amount (CNY) | Date | Note |\n| :-------: | :----------: | :--: | :--: |\n| D*5 | 1.8 * 3 | 2025.11.24 | |\n| *鬼 | 1 | 2025.11.17 | |\n| *超 | 10 | 2025.11.17 | |\n| R*w | 10 | 2025.11.17 | Great agent work! |\n| J*o | 1 | 2025.11.17 | Thanks for open source |\n| *晨 | 8.88 | 2025.11.16 | Nice project |\n| *海 | 1 | 2025.11.15 | |\n| *德 | 1.99 | 2025.11.15 | |\n| *疏 | 8.8 | 2025.11.14 | Great project |\n| M*e | 10 | 2025.11.14 | Open source is not easy |\n| **柯 | 1 | 2025.11.14 | |\n| *云 | 88 | 2025.11.13 | Good project |\n| *W | 6 | 2025.11.13 | |\n| *凯 | 1 | 2025.11.13 | |\n| 对*. | 1 | 2025.11.13 | Thanks for TrendRadar |\n| s*y | 1 | 2025.11.13 | |\n| **翔 | 10 | 2025.11.13 | Wish I found it earlier |\n| *韦 | 9.9 | 2025.11.13 | TrendRadar is awesome |\n| h*p | 5 | 2025.11.12 | Support Chinese open source |\n| c*r | 6 | 2025.11.12 | |\n| a*n | 5 | 2025.11.12 | |\n| 。*c | 1 | 2025.11.12 | Thanks for sharing |\n| ... | ... | ... | **(More 50+ supporters)** |\n\n</details>\n\n<br>\n\n## 🪄 Sponsors\n\n<div align=\"center\">\n\n> **Sponsorship Open**\n\n</div>\n\n<br>\n\n<a name=\"-support-project\"></a>\n\n### ❤️ Find it useful? Support TrendRadar\n\n> If TrendRadar has captured value for you, give it some fuel to keep evolving\n>\n> Any amount is welcome; even 1 RMB is a gesture of encouragement for open source. Feel free to leave a note with your donation (´▽`ʃ♡ƪ)\n\n<div align=\"center\">\n\n| WeChat Pay | Alipay |\n| --- | --- |\n| <img src=\"https://cdn-1258574687.cos.ap-shanghai.myqcloud.com/img/%2F2025%2F07%2F17%2F2ae0a88d98079f7e876c2b4dc85233c6-9e8025.JPG\" width=\"240\" alt=\"WeChat Pay\"> | <img src=\"https://cdn-1258574687.cos.ap-shanghai.myqcloud.com/img/%2F2025%2F07%2F17%2F1ed4f20ab8e35be51f8e84c94e6e239b4-fe4947.JPG\" width=\"240\" alt=\"Alipay\"> |\n\n</div>\n\n\n### 🤝 Attribution & Secondary Development\n\nIf 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.\n\nThis contributes to the sustainable maintenance of the project and the growth of the community. Thank you for your respect and support! ❤️\n\n\n### 💬 Feedback & Community\n\n* **GitHub Issues**: Best for specific technical issues. Please provide complete information (screenshots, error logs, etc.) to help locate the problem quickly.\n* **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 (´▽`ʃ♡ƪ).\n\n> **Friendly Reminder**:\n> This project is for open-source sharing, not a commercial product. Treat the author as a friend, not customer service, for better communication efficiency!\n\n<div align=\"center\">\n\n| Follow on WeChat |\n| --- |\n| <img src=\"_image/weixin.png\" width=\"500\" title=\"Silicon-based Tea Room\"/> |\n\n</div>\n\n<br>\n\n## 📝 Changelog\n\n>**📌 Check Latest Updates**: **[Original Repository Changelog](https://github.com/sansan0/TrendRadar?tab=readme-ov-file#-changelog)**:\n- **Tip**: Check [Changelog] to understand specific [Features]\n\n\n### 2026/03/12 - v6.5.0\n\n- **AI Smart News Filtering**: No more manual keyword setup! Describe your interests in everyday language in `ai_interests.txt` (e.g., \"I want AI and renewable energy news\"), and AI automatically extracts tags, scores every headline, and only pushes what truly matters to you. If AI filtering encounters issues, it auto-falls back to keyword matching — push delivery never stops\n- **Per-Period Filter Strategy & Interests**: Each time period in Timeline can now independently choose its filtering method and what topics to focus on. For example: mornings use a \"tech keyword list\" for quick filtering, evenings switch to \"finance AI interests\" for in-depth AI filtering — same system, different content at different times\n- **AI Analysis Independent from Push Mode**: AI analysis scope can differ from push content. For example: push only delivers new items (avoiding repeated notifications), while AI analyzes the full day's news (capturing complete trends). Each time period can also set its own AI analysis mode\n- **AI Filter Token Savings**: Previously analyzed news won't be re-processed; when you edit your interests, AI auto-evaluates the change magnitude — minor tweaks only update affected tags, major changes trigger full reclassification\n- **Multi-File Config & Tag Isolation**: Custom keyword files go in `config/custom/keyword/`, AI interest files go in `config/custom/ai/` — tags from different files are fully isolated and independent\n- **AI Translation Precision Control**: Independently toggle translation for hotlist, RSS, and standalone sections; regions with display turned off are automatically skipped, saving tokens\n- **Remote Storage Batch Upload**: Multiple write operations are batched and submitted to cloud in one go, reducing API call count\n- **Per-Group Display Limit**: New `max_news_per_keyword` controls max items shown per keyword/tag group, preventing a single hot topic from filling the entire push\n- **Time Period Conflict Detection**: Overlapping time periods are automatically detected — system alerts you to fix the config, preventing unexpected behavior\n- Various bug fixes\n\n\n\n### 2026/02/09 - mcp-v4.0.0\n\n- **🔥 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\n- **New format guide tool**: `get_channel_format_guide` tells AI what each channel supports and its limitations, so generated content looks great everywhere\n- **Smart batch splitting**: Long messages auto-split per channel byte limits (Feishu 30KB, DingTalk 20KB, etc.), reads config from config.yaml\n- **Fixed channel detection**: ntfy no longer falsely reported as \"configured\" due to default server URL\n- **Code reuse**: Batch utilities now imported from trendradar core instead of duplicated\n\n\n<details>\n<summary>👉 Click to expand: <strong>Historical Updates</strong></summary>\n\n### 2026/02/09 - v6.0.0\n\n> **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\n\n- **Unified Scheduling System**: New `timeline.yaml` — one config to control when to crawl / push / AI analyze\n- **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\n- **Flexible Time Period Config**: Supports weekday/weekend differentiation, cross-midnight time periods, per-period once deduplication\n- **Visual Config Editor**:\n  - New `timeline.yaml` editor tab, alongside config.yaml / frequency_words.txt\n  - Preset mode card selector: click to switch, auto-syncs config.yaml's `schedule.preset`\n  - Week view timeline: 7 days × 24 hours horizontal bars, color-coded for push/analysis/crawl status\n  - Interactive controls: toggles, dropdowns, time pickers — right-side changes sync to left-side YAML in real time\n  - Week mapping dropdown: dynamically populated from day plans, configure scheduling by drag and click\n- **AI Prompt Stability Overhaul** (ai_analysis_prompt.txt v2.0.0):\n  - Formatting rules extracted from JSON values into a standalone spec section, reducing AI output format inconsistencies\n  - JSON template simplified: field descriptions shortened to one sentence + word limit\n  - Removed Markdown from system prompt to align with the \"no Markdown\" instruction\n  - All JSON fields declared optional — missing any field won't cause errors, improving fault tolerance\n- **Standalone Source AI Summaries** (`ai_analysis.include_standalone`):\n  - New independent toggle: when enabled, AI generates a concise summary for each standalone source\n  - Decoupled from display: AI can analyze full hotlist data without enabling standalone display in push notifications\n  - Supports both trending platforms and RSS feeds, including rank/time/trajectory data\n  - Trajectory analysis linked with `include_rank_timeline`: uses trajectory data for deep trend analysis when enabled, falls back to rank-based summary when disabled\n  - New `standalone_summaries` JSON field (\"Source Snapshot\"), all notification channels adapted for rendering\n\n\n### 2026/01/28 - v5.5.0\n\n> Like the MCP feature, I'm not creating a separate repo for this tool either — it's pure frontend, so bundling it together\n\n- Added visual configuration editor for TrendRadar\n\n\n### 2026/02/02 - mcp-v3.2.0\n\n- **New read_article tool**: Read a single article body via Jina AI Reader (Markdown format)\n- **New read_articles_batch tool**: Batch read multiple articles (up to 5, auto rate-limited)\n- **Recommended workflow**: `search_news(query=\"keyword\", include_url=True)` → `read_article(url=...)` to read article body\n- **Docs update**: README-MCP-FAQ.md and README-MCP-FAQ-EN.md added Q19-Q20 for article reading\n\n\n### 2026/01/23 - v5.4.0\n\n- Added independent control for AI analysis mode, options: follow_report | daily | current | incremental\n- Added time window control for AI analysis, supporting custom execution periods and daily frequency limits\n- Added configuration file version management function\n- Fixed several bugs\n\n### 2026/01/19 - v5.3.0\n\n> **Major Refactor: AI Module Migration to LiteLLM**\n\n- **Unified AI Interface**: Replaced manual implementation with LiteLLM, supporting 100+ AI providers\n- **Simplified Configuration**: Removed `provider` field, now using `model: \"provider/model_name\"` format\n- **New Features**: Auto-retry (`num_retries`), fallback models (`fallback_models`)\n- **Configuration Changes**:\n  - `ai.provider` → Removed (merged into model)\n  - `ai.base_url` → `ai.api_base`\n  - `AI_PROVIDER` environment variable → Removed\n  - `AI_BASE_URL` environment variable → `AI_API_BASE`\n- **Model Format Examples**:\n  - DeepSeek: `deepseek/deepseek-chat`\n  - OpenAI: `openai/gpt-4o`\n  - Gemini: `gemini/gemini-2.5-flash`\n  - Anthropic: `anthropic/claude-3-5-sonnet`\n\n### 2026/01/17 - v5.2.0\n\n> See config.yaml for details\n\n**🌐 AI Translation**\n\n- **Multi-language Translation**: Translate push content to any language\n- **Batch Translation**: Smart batch processing to reduce API calls\n- **Custom Prompts**: Customize translation style\n\n**🔧 Configuration Optimization**\n\n- **Standalone AI Model Config**: Analysis and translation share model config\n- **Unified Region Switches**: Unified management of push region display\n- **Custom Region Order**: Customize display order of each region\n\n**✨ AI Analysis Enhancement**\n\n- **AI Analysis Embedded in HTML**: Analysis results directly embedded in HTML reports, used by email notifications\n- **Rich Style AI Section**: Gradient blue card layout, clearly separating analysis dimensions\n- **Ranking Timeline Support**: AI can access precise ranking at each crawl time point\n- **Section Reorganization (7→4)**: Consolidated into Core Trends, Sentiment & Controversy, Signals & Anomalies, Outlook & Strategy\n\n**🔧 Multi-Model Adaptation**\n\n- **Universal Parameter Passthrough**: Pass any advanced parameters to API\n- **Gemini Adaptation**: Native parameter support with relaxed safety settings\n\n**🐛 Bug Fixes**\n\n- Fixed various known issues, improved system stability\n\n\n### 2026/01/10 - mcp-v3.0.0~v3.1.5\n\n- **Breaking Change**: All tool return values unified to `{success, summary, data, error}` structure\n- **Async Consistency**: All 21 tool functions wrapped with `asyncio.to_thread()` for sync calls\n- **MCP Resources**: Added 4 resources (platforms, rss-feeds, available-dates, keywords)\n- **RSS Enhancement**: `get_latest_rss` supports multi-day queries (days param), cross-date URL deduplication\n- **Regex Matching Fix**: `get_trending_topics` supports `/pattern/` regex syntax and `display_name`\n- **Cache Optimization**: Added `make_cache_key()` function with param sorting + MD5 hash for consistency\n- **New check_version Tool**: Check TrendRadar and MCP Server version updates simultaneously\n\n\n### 2026/01/10 - v5.0.0\n\n> **Dev Anecdote**:\n> A salute to a certain 'C' model provider that accompanied me for over two years, only to slap me with `\"This organization has been disabled\"` right after I renewed my subscription.\n\n**✨ \"Five Major Sections\" Content Refactoring**\n\nThis update refactors the push message structure into five distinct core sections:\n\n1.  **📊 Trending News**: Aggregated trending topics from across the web, precisely filtered by your keywords.\n2.  **📰 RSS Feeds**: Your personalized subscription content, supporting keyword-based grouping.\n3.  **🆕 New Items**: Real-time capture of brand new trending topics since the last run (marked with 🆕).\n4.  **📋 Independent Display**: Complete trending lists or RSS feeds from specified platforms, **completely unaffected by keyword filtering**.\n5.  **✨ AI Analysis**: Deep insights driven by AI, including trend overview, popularity trends, and **critically important** sentiment analysis.\n\n**✨ AI Smart Analysis Push Feature**\n\n- **AI Analysis Integration**: Use AI models to deeply analyze push content, automatically generate trending insights, keyword analysis, cross-platform correlation, potential impact assessment\n- **Sentiment Analysis**: New deep sentiment recognition to accurately capture positive, negative, controversial, or concerned public opinions (v5.0.0 key enhancement)\n- **Multi AI Provider Support**: Supports DeepSeek (default, cost-effective), OpenAI, Google Gemini, and any OpenAI-compatible API\n- **Two Push Modes**: `only_analysis` (AI analysis only), `both` (push both)\n- **Custom Prompts**: Customize AI analysis role and output format via `config/ai_analysis_prompt.txt`\n- **Multi-dimensional Analysis**: AI can analyze ranking changes, trending duration, cross-platform performance, trend prediction\n\n\n### 2026/01/02 - v4.7.0\n\n- **Fix RSS HTML Display**: Fixed RSS data format mismatch causing rendering issues, now displays correctly grouped by keyword\n- **New Regex Syntax**: Keyword config supports `/pattern/` regex syntax, solves English substring mismatch issues (e.g., `ai` matching `training`) [📖 View Syntax Details](#keyword-basic-syntax)\n- **New Display Name Syntax**: Use `=> alias` to give complex regex a friendly name, cleaner push notifications (e.g., `/\\bai\\b/ => AI Related`)\n- **Can't Write Regex?** README now includes AI prompt guide - just tell ChatGPT/Gemini/DeepSeek what you want to match\n\n\n### 2025/12/30 - mcp-v2.0.0\n\n- **Architecture Refactoring**: Removed TXT support, unified to SQLite database\n- **RSS Query**: Added `get_latest_rss`, `search_rss`, `get_rss_feeds_status`\n- **Unified Search**: `search_news` supports `include_rss` parameter to search both trending and RSS\n\n\n### 2026/01/01 - v4.6.0\n\n- **Fix RSS HTML Display**: Merged RSS content into trending HTML page, grouped by source\n- **New display_mode Config**: Support `keyword` (group by keyword) and `platform` (group by platform) display modes\n\n\n### 2025/12/30 - v4.5.0\n\n- **RSS Feed Support**: Added RSS/Atom feed crawling, keyword-based grouping and statistics (consistent with trending format)\n- **Storage Structure Refactoring**: Flattened directory structure `output/{type}/{date}.db`\n- **Unified Sorting Config**: `sort_by_position_first` affects both trending and RSS\n- **Config Structure Refactoring**: `config.yaml` reorganized into 7 logical groups (app, report, notification, storage, platforms, rss, advanced) with clearer config paths\n\n\n### 2025/12/26 - mcp-v1.2.0\n\n  **MCP Module Update - Optimized toolset, added aggregation & comparison features, merged redundant tools:**\n  - Added `aggregate_news` tool - Cross-platform news deduplication and aggregation\n  - Added `compare_periods` tool - Period comparison analysis (week-over-week/month-over-month)\n  - Merged `find_similar_news` + `search_related_news_history` → `find_related_news`\n  - Enhanced `get_trending_topics` - Added `auto_extract` mode for automatic trending extraction\n  - Fixed miscellaneous bugs\n  - Updated README-MCP-FAQ.md documentation in both Chinese and English (Q1-Q18)\n\n\n### 2025/12/20 - v4.0.3\n\n- Added URL normalization to fix duplicate push issues caused by dynamic parameters (e.g., Weibo's `band_rank`)\n- Fixed incremental mode detection logic to correctly identify historical titles\n\n\n### 2025/12/13 - mcp-v1.1.0\n\n**MCP Module Update:**\n- Adapted for v4.0.0, while maintaining compatibility with v3.x data.\n- Added storage sync tools:\n  - `sync_from_remote`: Pull data from remote storage to local\n  - `get_storage_status`: Get storage configuration and status\n  - `list_available_dates`: List available dates in local/remote storage\n\n\n### 2025/12/17 - v4.0.1\n\n- StorageManager adds push record proxy methods\n- S3 client switches to virtual-hosted style for better compatibility (supports Tencent Cloud COS and more services)\n\n\n### 2025/12/13 - v4.0.0\n\n**🎉 Major Update: Comprehensive Refactoring of Storage and Core Architecture**\n\n- **Multi-Storage Backend Support**: Introduced a brand new storage module supporting local SQLite and remote cloud storage (S3-compatible protocols, e.g., Cloudflare R2), adaptable to GitHub Actions, Docker, and local environments.\n- **Database Structure Optimization**: Refactored SQLite database table structures to improve data efficiency and query performance.\n- **Enhanced Features**: Implemented date format standardization, data retention policies, timezone configuration support, and optimized time display. Fixed remote storage data persistence issues to ensure accurate data merging.\n- **Cleanup and Compatibility**: Removed most legacy compatibility code and unified data storage and retrieval methods.\n\n\n### 2025/12/03 - v3.5.0\n\n**🎉 Core Feature Enhancements**\n\n1. **Multi-Account Push Support**\n   - All push channels (Feishu, DingTalk, WeWork, Telegram, ntfy, Bark, Slack) support multiple account configuration\n   - Use semicolon `;` to separate multiple accounts, e.g., `FEISHU_WEBHOOK_URL=url1;url2`\n   - Automatic validation for paired configurations (e.g., Telegram's token and chat_id)\n\n2. **Push Region Configuration**\n   - Customize display order of all regions via `display.region_order` (v5.2.0, replaces `reverse_content_order`)\n   - Control visibility of each region via `display.regions` (hotlist, new items, RSS, standalone, AI analysis)\n\n3. **Global Filter Keywords**\n   - Added `[GLOBAL_FILTER]` region marker for filtering unwanted content globally\n   - Use cases: Filter ads, marketing, low-quality content, etc.\n\n**🐳 Docker Dual-Path HTML Generation Optimization**\n\n- **Bug Fix**: Resolved issue where `index.html` could not sync to host in Docker environment\n- **Dual-Path Generation**: Daily summary HTML is generated to two locations simultaneously\n  - `index.html` (project root): For GitHub Pages access\n  - `output/index.html`: Accessible on host via Docker Volume mount\n- **Compatibility**: Ensures web reports are accessible in Docker, GitHub Actions, and local environments\n\n**🐳 Docker MCP Image Support**\n\n- Added independent MCP service image `wantcat/trendradar-mcp`\n- Supports Docker deployment of AI analysis features via HTTP interface (port 3333)\n- Dual-container architecture: News push service and MCP service run independently, can be scaled and restarted separately\n- See [Docker Deployment - MCP Service](#6-docker-deployment) for details\n\n**🌐 Web Server Support**\n\n- Added built-in web server for browser access to generated reports\n- Control via `manage.py` commands: `docker exec -it trendradar python manage.py start_webserver`\n- Access URL: `http://localhost:8080` (port configurable)\n- Security features: Static file service, directory restriction, localhost binding\n- Supports both auto-start and manual control modes\n\n**📖 Documentation Optimization**\n\n- Added [Report Configuration](#7-report-configuration) section: report-related parameter details\n- Added [Push Window Configuration](#8-push-window-configuration) section: push_window configuration tutorial\n- Added [Execution Frequency Configuration](#9-execution-frequency-configuration) section: Cron expression explanation and common examples\n- Added [Multi-Account Push Configuration](#10-multiple-account-push-configuration) section: multi-account push configuration details\n- Optimized all configuration sections: Unified \"Configuration Location\" instructions\n- Simplified Quick Start configuration: Three core files at a glance\n- Optimized [Docker Deployment](#6-docker-deployment) section: Added image description, recommended git clone deployment, reorganized deployment methods\n\n**🔧 Upgrade Instructions**:\n- **GitHub Fork Users**: Update `main.py`, `config/config.yaml` (Added multi-account push support, existing single-account configuration unaffected)\n- **Docker Users**: Update `.env`, `docker-compose.yml` or set environment variables `REVERSE_CONTENT_ORDER`, `MAX_ACCOUNTS_PER_CHANNEL`\n- **Multi-Account Push**: New feature, disabled by default, existing single-account configuration unaffected\n\n\n### 2025/11/28 - v3.4.1\n\n**🔧 Format Optimization**\n\n1. **Bark Push Enhancement**\n   - Bark now supports Markdown rendering\n   - Enabled native Markdown format: bold, links, lists, code blocks, etc.\n   - Removed plain text conversion to fully utilize Bark's native rendering capabilities\n\n2. **Slack Format Precision**\n   - Use dedicated mrkdwn format for batch content processing\n   - Improved byte size estimation accuracy (avoid message overflow)\n   - Optimized link format: `<url|text>` and bold syntax: `*text*`\n\n3. **Performance Improvement**\n   - Format conversion completed during batching process, avoiding secondary processing\n   - Accurate message size estimation reduces send failure rate\n\n**🔧 Upgrade Instructions**:\n- **GitHub Fork Users**: Update `main.py`，`config.yaml`\n\n\n### 2025/11/26 - mcp-v1.0.3\n\n  **MCP Module Update:**\n  - Added date parsing tool resolve_date_range to resolve AI model date calculation inconsistencies\n  - Support natural language date expression parsing (this week, last 7 days, last month, etc.)\n  - Tool count increased from 13 to 14\n\n\n### 2025/11/25 - v3.4.0\n\n**🎉 Added Slack Push Support**\n\n1. **Team Collaboration Push Channel**\n   - Supports Slack Incoming Webhooks (globally popular team collaboration tool)\n   - Centralized message management, suitable for team-shared trending news\n   - Supports mrkdwn format (bold, links, etc.)\n\n2. **Multiple Deployment Methods**\n   - GitHub Actions: Configure `SLACK_WEBHOOK_URL` Secret\n   - Docker: Environment variable `SLACK_WEBHOOK_URL`\n   - Local: `config/config.yaml` configuration file\n\n\n> 📖 **Detailed Configuration Tutorial**: [Quick Start - Slack Push](#-quick-start)\n\n- Optimized the one-click installation experience for setup-windows.bat and setup-windows-en.bat\n\n**🔧 Upgrade Instructions**:\n- **GitHub Fork Users**: Update `main.py`, `config/config.yaml`, `.github/workflows/crawler.yml`\n\n\n### 2025/11/24 - v3.3.0\n\n**🎉 Added Bark Push Support**\n\n1. **iOS Exclusive Push Channel**\n   - Supports Bark push (based on APNs, iOS platform)\n   - Free, open-source, clean, efficient, ad-free\n   - Supports both official server and self-hosted server\n\n2. **Multiple Deployment Methods**\n   - GitHub Actions: Configure `BARK_URL` Secret\n   - Docker: Environment variable `BARK_URL`\n   - Local: `config/config.yaml` configuration file\n\n> 📖 **Detailed Configuration Tutorial**: [Quick Start - Bark Push](#-quick-start)\n\n**🐛 Bug Fix**\n- Fixed issue where `ntfy_server_url` in `config.yaml` was ignored ([#345](https://github.com/sansan0/TrendRadar/issues/345))\n\n**🔧 Upgrade Instructions**:\n- **GitHub Fork Users**: Update `main.py`, `config/config.yaml`, `.github/workflows/crawler.yml`\n\n\n### 2025/11/23 - v3.2.0\n\n**🎯 New Advanced Customization Features**\n\n1. **Keyword Sorting Priority Configuration**\n   - Two sorting strategies: Popularity first vs Config order first\n   - For different use cases: Hot topic tracking or personalized focus\n\n2. **Display Count Precise Control**\n   - Global config: Unified limit for all keywords\n   - Individual config: Use `@number` syntax to set specific limits\n   - Effectively control push length, highlight key content\n\n> 📖 **Detailed Tutorial**: [Keyword Configuration - Advanced Settings](#keyword-advanced-settings)\n\n**🔧 Upgrade Instructions**:\n- **GitHub Fork Users**: Update `main.py`, `config/config.yaml`\n\n### 2025/11/22 - v3.1.1\n\n- **Fixed data anomaly crash issue**: Resolved `'float' object has no attribute 'lower'` error encountered by some users in GitHub Actions environment\n- Added dual protection mechanism: Filter invalid titles (None, float, empty strings) at data acquisition stage, with type checking at function call sites\n- Enhanced system stability to ensure normal operation even when data sources return abnormal formats\n\n**Upgrade Instructions** (GitHub Fork Users):\n- Required update: `main.py`\n- Recommended: Use minor version upgrade method - copy and replace the file above\n\n\n### 2025/11/18 - mcp-v1.0.2\n\n  **MCP Module Update:**\n  - Fix issue where today's news query may return articles from past dates\n\n\n### 2025/11/20 - v3.1.0\n\n- **Added Personal WeChat Push Support**: WeWork application can push to personal WeChat without installing WeWork APP\n- Supports two message formats: `markdown` (WeWork group bot) and `text` (personal WeChat app)\n- Added `WEWORK_MSG_TYPE` environment variable configuration, supporting GitHub Actions, Docker, docker compose and other deployment methods\n- `text` mode automatically strips Markdown syntax for clean plain text push\n- See \"Personal WeChat Push\" configuration in Quick Start\n\n**Upgrade Instructions** (GitHub Fork Users):\n- Required updates: `main.py`, `config/config.yaml`\n- Optional update: `.github/workflows/crawler.yml` (if using GitHub Actions)\n- Recommended: Use minor version upgrade method - copy and replace the files above\n\n\n### 2025/11/12 - v3.0.5\n\n- Fixed email sending SSL/TLS port configuration logic error\n- Optimized email service providers (QQ/163/126) to default use port 465 (SSL)\n- **Added Docker environment variable support**: Core config items (`enable_crawler`, `report_mode`, `push_window`, etc.) support override via environment variables, solving config file modification issues for NAS users (see [🐳 Docker Deployment](#-docker-deployment) chapter)\n\n\n### 2025/10/26 - mcp-v1.0.1\n\n  **MCP Module Update:**\n  - Fixed date query parameter passing error\n  - Unified time parameter format for all tools\n\n\n### 2025/10/31 - v3.0.4\n\n- Solved Feishu error due to overly long push content, implemented batch pushing\n\n\n### 2025/10/23 - v3.0.3\n\n- Expanded ntfy error message display range\n\n\n### 2025/10/21 - v3.0.2\n\n- Fixed ntfy push encoding issue\n\n### 2025/10/20 - v3.0.0\n\n**Major Update - AI Analysis Feature Launched** ✨\n\n- **Core Features**:\n  - New MCP (Model Context Protocol) based AI analysis server\n  - 13 smart analysis tools: basic query, smart search, advanced analysis, system management\n  - Natural language interaction: Query and analyze news data through conversation\n  - Multi-client support: Claude Desktop, Cherry Studio, Cursor, Cline, etc.\n\n- **Analysis Capabilities**:\n  - Topic trend analysis (popularity tracking, lifecycle, viral detection, trend prediction)\n  - Data insights (platform comparison, activity stats, keyword co-occurrence)\n  - Sentiment analysis, similar news finding, smart summary generation\n  - Historical related news search, multi-mode search\n\n- **Update Note**:\n  - This is an independent AI analysis feature, does not affect existing push functionality\n  - Optional use, no need to upgrade existing deployment\n\n\n### 2025/10/15 - v2.4.4\n\n- **Updates**:\n  - Fixed ntfy push encoding issue + 1\n  - Fixed push time window judgment issue\n\n- **Upgrade Note**:\n  - Recommended minor version upgrade\n\n\n### 2025/10/10 - v2.4.3\n\n> Thanks to [nidaye996](https://github.com/sansan0/TrendRadar/issues/98) for discovering the UX issue\n\n- **Updates**:\n  - Refactored \"Silent Push Mode\" naming to \"Push Time Window Control\", improving feature comprehension\n  - Clarified push time window as optional additional feature, can work with three push modes\n  - Improved comments and documentation, making feature positioning clearer\n\n- **Upgrade Note**:\n  - This is just refactoring, upgrade optional\n\n\n### 2025/10/8 - v2.4.2\n\n- **Updates**:\n  - Fixed ntfy push encoding issue\n  - Fixed missing config file issue\n  - Optimized ntfy push effect\n  - Added GitHub Pages image segmented export feature\n\n- **Upgrade Note**:\n  - Recommend major version update\n\n\n### 2025/10/2 - v2.4.0\n\n**Added ntfy Push Notification**\n\n- **Core Features**:\n  - Supports ntfy.sh public service and self-hosted servers\n\n- **Use Cases**:\n  - Suitable for privacy-conscious users (supports self-hosting)\n  - Cross-platform push (iOS, Android, Desktop, Web)\n  - No account registration needed (public servers)\n  - Open-source and free (MIT License)\n\n- **Upgrade Note**:\n  - Recommend major version update\n\n\n### 2025/09/26 - v2.3.2\n\n- Fixed email notification config check being missed ([#88](https://github.com/sansan0/TrendRadar/issues/88))\n\n**Fix Description**:\n- Solved the issue where system still prompted \"No webhook configured\" even with correct email notification setup\n\n\n### 2025/09/22 - v2.3.1\n\n- **Added email push feature**, supports sending trending news reports to email\n- **Smart SMTP Recognition**: Auto-detects Gmail, QQ Mail, Outlook, NetEase Mail and 10+ email service providers\n- **Beautiful HTML Format**: Email content uses same HTML format as web version, well-formatted, mobile-adapted\n- **Batch Sending Support**: Supports multiple recipients, separated by commas\n- **Custom SMTP**: Can customize SMTP server and port\n- Fixed Docker build network connection issue\n\n**Usage Notes**:\n- Use cases: Suitable for users needing email archiving, team sharing, scheduled reports\n- Supported emails: Gmail, QQ Mail, Outlook/Hotmail, 163/126 Mail, Sina Mail, Sohu Mail, etc.\n\n**Upgrade Note**:\n- This update has many changes, if upgrading, recommend major version upgrade\n\n\n### 2025/09/17 - v2.2.0\n\n- Added one-click save news as image feature, easily share trending topics you care about\n\n**Usage Notes**:\n- Use case: After enabling web version feature (GitHub Pages)\n- How to use: Open webpage on phone or PC, click \"Save as Image\" button at top\n- Actual effect: System auto-creates beautiful image of current news report, saves to phone album or desktop\n- Sharing convenience: Directly send this image to friends, Moments, or work groups, letting others see your discovered important info\n\n\n### 2025/09/13 - v2.1.2\n\n- Solved DingTalk push capacity limit causing news push failure (using batch push)\n\n\n### 2025/09/04 - v2.1.1\n\n- Fixed Docker unable to run properly on certain architectures\n- Officially released official Docker image wantcat/trendradar, supports multi-architecture\n- Optimized Docker deployment process, can use quickly without local build\n\n\n### 2025/08/30 - v2.1.0\n\n**Core Improvements**:\n- **Push Logic Optimization**: Changed from \"push every execution\" to \"controllable push within time window\"\n- **Time Window Control**: Can set push time range, avoid non-work hour disturbance\n- **Push Frequency Options**: Supports single push or multiple pushes within time window\n\n**Upgrade Note**:\n- This feature is disabled by default, need to manually enable push time window control in config.yaml\n- Upgrade requires updating both main.py and config.yaml files\n\n\n### 2025/08/27 - v2.0.4\n\n- This version is not a bug fix, but an important reminder\n- Please keep webhooks properly, do not make public, do not make public, do not make public\n- If you deployed this project on GitHub via fork, please put webhooks in GitHub Secret, not config.yaml\n- If you already exposed webhooks or put them in config.yaml, suggest deleting and regenerating\n\n\n### 2025/08/06 - v2.0.3\n\n- Optimized GitHub Pages web version effect, convenient for mobile use\n\n\n### 2025/07/28 - v2.0.2\n\n- Refactored code\n- Solved version number easily being missed for modification\n\n\n### 2025/07/27 - v2.0.1\n\n**Fixed Issues**:\n\n1. Docker shell script line ending as CRLF causing execution exception issue\n2. frequency_words.txt being empty causing news sending also empty logic issue\n  - After fix, when you choose frequency_words.txt empty, will **push all news**, but limited by message push size, please adjust as follows\n    - Option 1: Turn off mobile push, only choose GitHub Pages deployment (this is the way to get most complete info, will re-sort all platform trending by your **custom trending algorithm**)\n    - Option 2: Reduce push platforms, prioritize **WeWork** or **Telegram**, these two pushes I made batch push feature (because batch push affects push experience, and only these two platforms give very little push capacity, so had to make batch push feature, but at least can ensure complete info)\n    - Option 3: Can combine with Option 2, mode choose current or incremental can effectively reduce one-time push content\n\n\n### 2025/07/17 - v2.0.0\n\n**Major Refactoring**:\n- Config management refactoring: All configs now managed through `config/config.yaml` file (main.py I still didn't split, convenient for you to copy and upgrade)\n- Run mode upgrade: Supports three modes - `daily` (daily summary), `current` (current rankings), `incremental` (incremental monitoring)\n- Docker support: Complete Docker deployment solution, supports containerized operation\n\n**Config File Description**:\n- `config/config.yaml` - Main config file (application settings, crawler config, notification config, platform config, etc.)\n- `config/frequency_words.txt` - Keyword config (monitoring vocabulary settings)\n\n\n### 2025/07/09 - v1.4.1\n\n**New Feature**: Added incremental push (configure FOCUS_NEW_ONLY at top of main.py), this switch only cares about new topics not sustained heat, only sends notification when new content appears.\n\n**Fixed Issue**: Under certain circumstances, some news containing special symbols caused occasional formatting exceptions.\n\n\n### 2025/06/23 - v1.3.0\n\nWeWork and Telegram push messages have length limits, I adopted splitting messages for pushing. Development docs see [WeWork](https://developer.work.weixin.qq.com/document/path/91770) and [Telegram](https://core.telegram.org/bots/api)\n\n\n### 2025/06/21 - v1.2.1\n\nBefore this version, not only main.py needs copy replacement, crawler.yml also needs you to copy replacement\nhttps://github.com/sansan0/TrendRadar/blob/master/.github/workflows/crawler.yml\n\n\n### 2025/06/19 - v1.2.0\n\n> Thanks to Claude Research for organizing various platform APIs, helping me quickly complete platform adaptation (although code is more redundant~\n\n1. Supports Telegram, WeWork, DingTalk push channels, supports multi-channel config and simultaneous push\n\n\n### 2025/06/18 - v1.1.0\n\n> **200 stars⭐** reached, continue celebrating with everyone~\n\n1. Important update, added weight, news you see now is hottest most concerned appearing at top\n2. Updated documentation usage, because recently updated many features, and previous usage docs I was lazy wrote simple (see ⚙️ frequency_words.txt complete configuration tutorial below)\n\n\n### 2025/06/16 - v1.0.0\n\n1. Added project new version update reminder, default on, if want to turn off, can change \"FEISHU_SHOW_VERSION_UPDATE\": True to False in main.py\n\n\n### 2025/06/13+14\n\n1. Removed compatibility code, students who forked before, directly copying code will show exception on same day (will recover normal next day)\n2. Feishu and html bottom added new news display\n\n\n### 2025/06/09\n\n**100 stars⭐** reached, writing small feature to celebrate\n\nfrequency_words.txt file added **required word** feature, using + sign\n\n1. Required word syntax as follows:\n   Tang Monk or Pig must both appear in title, will be included in push news\n\n```\n+Tang Monk\n+Pig\n```\n\n2. Filter word priority higher:\n   If title filter word matches Tang Monk reciting sutras, then even if required word has Tang Monk, also not display\n\n```\n+Tang Monk\n!Tang Monk reciting sutras\n```\n\n\n### 2025/06/02\n\n1. **Webpage** and **Feishu messages** support phone directly jumping to detailed news\n2. Optimized display effect + 1\n\n\n### 2025/05/26\n\n1. Feishu message display effect optimized\n\n</details>\n\n<br>\n\n## ✨ Core Features\n\n### **Multi-Platform Trending News Aggregation**\n\n- Zhihu (知乎)\n- Douyin (抖音)\n- Bilibili Hot Search\n- Wallstreetcn (华尔街见闻)\n- Tieba (贴吧)\n- Baidu Hot Search\n- Yicai (财联社)\n- Thepaper (澎湃新闻)\n- Ifeng (凤凰网)\n- Toutiao (今日头条)\n- Weibo (微博)\n\nDefault monitoring of 11 mainstream platforms, with support for adding custom platforms.\n\n> 💡 For detailed configuration, see [Configuration Guide - Platform Configuration](#1-platform-configuration)\n\n### **RSS Feed Support** (v4.5.0 New)\n\nSupports RSS/Atom feed crawling, keyword-based grouping and statistics (consistent with trending format):\n\n- **Unified Format**: RSS and trending use the same keyword matching and display format\n- **Simple Config**: Add RSS sources directly in `config.yaml`\n- **Merged Push**: Trending and RSS are merged into a single notification\n- **Freshness Filter**: Automatically filters out articles older than a specified number of days to avoid repeated pushes. Supports both global default and per-feed settings\n\n> 💡 RSS uses the same `frequency_words.txt` for keyword filtering as trending\n\n### **Visual Configuration Editor**\n\nA web-based graphical configuration interface — no need to manually edit YAML files. Complete all configuration changes and exports through simple forms.\n\n👉 **Try it online**: [https://sansan0.github.io/TrendRadar/](https://sansan0.github.io/TrendRadar/)\n\n<img src=\"/_image/editor.png\" alt=\"Visual Configuration Editor\" width=\"80%\">\n\n### **Smart Push Strategies**\n\n**Three Push Modes**:\n\n| Mode | Target Users | Push Feature |\n|------|--------------|--------------|\n| **Daily Summary** (daily) | Managers/Regular Users | Push all matched news of the day (includes previously pushed) |\n| **Current Rankings** (current) | Content Creators | Push current ranking matches (continuously ranked news appear each time) |\n| **Incremental Monitor** (incremental) | Traders/Investors | Push only new content, zero duplication |\n\n> 💡 **Quick Selection Guide:**\n> - Don't want duplicate news → Use `incremental`\n> - Want complete ranking trends → Use `current`\n> - Need daily summary reports → Use `daily`\n>\n> For detailed comparison and configuration, see [Configuration Guide - Push Mode Details](#3-push-mode-details)\n\n**Additional Features** (Optional):\n\n| Feature | Description | Default |\n|---------|-------------|---------|\n| **Scheduling System** | Per-day-of-week scheduling: assign different time periods, push modes, and AI analysis strategies to each day (Mon–Sun). **Each period can independently set its filter method (keyword/AI) and interest focus**, enabling different content at different times. 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, per-period once-only dedup, and overlap conflict detection (v6.0.0 + v6.5.0) | morning_evening |\n| **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 |\n| **Display Mode Switch** | `keyword`=group by keyword, `platform`=group by platform (v4.6.0 new) | keyword |\n\n> 💡 For detailed configuration, see [Configuration Guide - Report Configuration](#7-report-configuration) and [Configuration Guide - Scheduling System](#8-when-will-i-receive-pushes)\n\n### **Precise Content Filtering**\n\nSet personal keywords (e.g., AI, BYD, Education Policy) to receive only relevant trending news, filtering out noise.\n\n> 💡 **Basic Configuration**: [Keyword Configuration - Basic Syntax](#keyword-basic-syntax)\n>\n> 💡 **Advanced Configuration**: [Keyword Configuration - Advanced Settings](#keyword-advanced-settings)\n>\n> 💡 You can also skip filtering and receive all trending news (leave frequency_words.txt empty)\n\n\n### **AI Smart News Filtering** (v6.5.0 New)\n\nDescribe your interests in natural language and let AI automatically classify news — replacing traditional keyword matching\n\n- **Natural Language Interests**: Write your focus areas in everyday language in `ai_interests.txt`, no keyword syntax to learn\n- **Two-Stage Smart Processing**: AI first extracts structured tags from interest descriptions, then batch-classifies and scores news against those tags\n- **Score Threshold Control**: Fine-tune push quality with `ai_filter.min_score` — only highly relevant news gets delivered\n- **Auto Fallback**: Automatically falls back to keyword matching if AI filtering fails, ensuring uninterrupted push delivery\n- **Smart Tag Updates**: When interests change, AI evaluates the change magnitude to decide incremental or full reclassification\n- **Flexible Switching**: `filter.method` supports `keyword` (default) and `ai` modes, Timeline can override per time period\n- **Per-Period Personalization**: Different time periods can use different keyword files or AI interest descriptions. For example: mornings use a \"tech keyword list\" for quick filtering, evenings switch to \"finance interests\" for AI deep filtering\n\n```yaml\n# config.yaml quick enable example\nfilter:\n  method: ai          # keyword (default) | ai\nai_filter:\n  min_score: 6         # Minimum push score threshold (1-10)\n```\n\n> 💡 AI filtering shares model config with AI analysis/translation — just configure `ai.api_key` once\n\n### **Trending Analysis**\n\nReal-time tracking of news popularity changes helps you understand not just \"what's trending\" but \"how trends evolve.\"\n\n- **Timeline Tracking**: Records complete time span from first to last appearance\n- **Popularity Changes**: Tracks ranking changes and appearance frequency across time periods\n- **New Detection**: Real-time identification of emerging topics, marked with 🆕\n- **Continuity Analysis**: Distinguishes between one-time hot topics and continuously developing news\n- **Cross-Platform Comparison**: Same news across different platforms, showing media attention differences\n\n> 💡 Push format reference: [Configuration Guide - Push Format Reference](#5-push-format-reference)\n\n### **Personalized Trending Algorithm**\n\nNo longer controlled by platform algorithms, TrendRadar reorganizes all trending searches\n\n> 💡 Weight adjustment guide: [Configuration Guide - Advanced Configuration](#4-advanced-configuration---hotspot-weight-adjustment)\n\n### **Multi-Channel Multi-Account Push**\n\nSupports **WeWork** (+ WeChat push solution), **Feishu**, **DingTalk**, **Telegram**, **Email**, **ntfy**, **Bark**, **Slack**, **Generic Webhook** (connect to Discord, IFTTT, or any platform) — messages delivered directly to phone and email.\n\n> 💡 For detailed configuration, see [Configuration Guide - Multi-Account Push Configuration](#10-multiple-account-push-configuration)\n\n### **AI Multi-Language Translation** (v5.2.0 New)\n\nTranslate push content into any language, breaking language barriers — whether reading domestic trends or subscribing to international news via RSS, access everything in your native language\n\n- **One-Click Translation**: Set `ai_translation.enabled: true` and target language in `config.yaml`\n- **Multi-Language Support**: Supports English, Korean, Japanese, French, and any other language\n- **Smart Batch Processing**: Automatically batches translations to reduce API calls and save costs\n- **Custom Style**: Customize translation style and terminology via `ai_translation_prompt.txt`\n- **Shared Model Config**: Shares the `ai` config section with AI analysis feature\n\n```yaml\n# config.yaml quick enable example\nai_translation:\n  enabled: true\n  language: \"English\"  # Target translation language\n```\n\n> 💡 Translation shares model config with AI analysis — just configure `ai.api_key` once to use both features\n\n**RSS Source References**: Here are some RSS feed collections for your reference\n- [awesome-tech-rss](https://github.com/tuan3w/awesome-tech-rss) - Tech, startup, and programming blogs & media\n- [awesome-rss-feeds](https://github.com/plenaryapp/awesome-rss-feeds) - Mainstream news media RSS from countries worldwide\n\n> ⚠️ Some international media content may involve sensitive topics that AI models might refuse to translate. Please filter subscription sources based on your actual needs\n\n### **Flexible Storage Architecture (v4.0.0 Major Update)**\n\n**Multi-Backend Support**:\n- **Remote Cloud Storage**: GitHub Actions environment default, supports S3-compatible protocols (R2/OSS/COS, etc.), data stored in cloud, keeping repository clean\n- **Local SQLite**: Traditional SQLite database, stable and efficient (Docker/local deployment)\n- **Auto Selection**: Auto-selects appropriate backend based on runtime environment\n\n> 💡 For storage configuration details, see [Configuration Details - Storage Configuration](#11-storage-configuration-v400-new)\n\n### **Multi-Platform Deployment**\n- **GitHub Actions**: Cloud automated operations (7-day check-in cycle + remote cloud storage)\n- **Docker Deployment**: Supports multi-architecture containerized operation\n- **Local Running**: Python environment direct execution\n\n\n### **AI Analysis Push (v5.0.0 New)**\n\nUse AI models to deeply analyze push content, automatically generate trending insights report\n\n- **Smart Analysis**: Automatically analyze trending topics, keyword popularity, cross-platform correlation, potential impact\n- **Multi Provider**: Built on LiteLLM unified interface, supports 100+ AI providers (DeepSeek, OpenAI, Gemini, Anthropic, local Ollama, etc.), with automatic fallback model switching\n- **Independent Analysis Mode**: AI analysis scope can differ from push content — push only new items (less noise), while AI analyzes the full day's news (complete trend picture)\n- **Flexible Push**: Choose original content only, AI analysis only, or both\n- **Custom Prompts**: Customize analysis perspective via `config/ai_analysis_prompt.txt`\n\n> 💡 Detailed configuration tutorial: [Let AI help me analyze hot topics](#12-let-ai-help-me-analyze-hot-topics)\n\n### **Independent Display Section (v5.0.0 New)**\n\nProvide complete trending display for specified platforms, unaffected by keyword filtering\n\n- **Full Trending**: Specified platforms show complete trending list, for users who want to see full rankings\n- **RSS Independent Display**: RSS source content can be fully displayed, not limited by keywords\n- **AI Deep Analysis**: Independently enable AI trend analysis on full hotlists, without displaying in push\n- **Flexible Configuration**: Support configuring display platforms, RSS sources, max count\n\n> 💡 Detailed configuration tutorial: [Report Configuration - Independent Display](#7-report-configuration)\n\n### **AI Smart Analysis (v3.0.0 New)**\n\nAI conversational analysis system based on MCP (Model Context Protocol), enabling deep data mining with natural language.\n\n> **💡 Usage Tip**: AI features require local news data support\n> - Project includes test data for immediate feature experience\n> - Recommend deploying the project yourself to get more real-time data\n>\n> See [AI Analysis](#-ai-analysis) for details\n\n### **Web Deployment**\n\nAfter running, the `index.html` generated in the root directory is the complete news report page.\n\n> **Deployment**: Click **Use this template** to create your repository, then deploy to Cloudflare Pages or GitHub Pages.\n>\n> **💡 Tip**: Enable GitHub Pages for an online URL. Go to Settings → Pages to enable. [Preview Effect](https://sansan0.github.io/TrendRadar/)\n>\n> ⚠️ The GitHub Actions auto-storage feature has been discontinued (this approach caused excessive load on GitHub servers, affecting platform stability).\n\n### **Reduce APP Dependencies**\n\nTransform from \"algorithm recommendation captivity\" to \"actively getting the information you want\"\n\n**Target Users:** Investors, content creators, PR professionals, news-conscious general users\n\n**Typical Scenarios:** Stock investment monitoring, brand sentiment tracking, industry trend watching, lifestyle news gathering\n\n\n| Web Effect (Email Push) | Feishu Push Effect | AI Analysis Push Effect |\n|:---:|:---:|:---:|\n| ![Web Effect](_image/github-pages.png) | ![Feishu Push Effect](_image/feishu.jpg) | ![AI Analysis Push Effect](_image/ai.jpg) |\n\n\n<br>\n\n## 🚀 Quick Start\n\n> **Reminder**: You should first **[check the latest official documentation](https://github.com/sansan0/TrendRadar?tab=readme-ov-file)** to ensure the configuration steps are up to date.\n\n### Choose the Deployment Method That Fits You\n\n#### 🅰️ Option A: Docker Deployment (Recommended 🔥)\n\n* **Features**: More stable than GitHub Actions\n* **Best for**: Users with their own server, NAS, or an always-on PC\n\n👉 **[Jump to Docker Deployment Tutorial](#6-docker-deployment)**\n\n#### 🅱️ Option B: GitHub Actions Deployment (This Chapter ⬇️)\n\n* **Features**: Data is stored in **Remote Cloud Storage** (no longer written to Git repo)\n* **Storage**: Configure cloud storage service (e.g. Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, etc.)\n* **Note**: Requires periodic check-in renewal (every 7 days)\n\n### 1️⃣ Step 1: Get project code\n\n   Click the green **[Use this template]** button in the upper right corner of this repository → select \"Create a new repository\".\n\n   > ⚠️ Note:\n   > - Any mention of \"Fork\" in this document can be understood as \"Use this template\"\n   > - Using Fork may cause runtime issues, see [Issue #606](https://github.com/sansan0/TrendRadar/issues/606)\n\n   <br>\n\n### 2️⃣ Step 2: Setup GitHub Secrets\n\n   In your Forked repository, go to `Settings` > `Secrets and variables` > `Actions` > `New repository secret`\n\n   **📌 Important Instructions (Please Read Carefully):**\n\n   - **One Name for One Secret**: For each configuration item, click the \"New repository secret\" button once and fill in a pair of \"Name\" and \"Secret\"\n   - **Cannot See Value After Saving is Normal**: For security reasons, after saving, you can only see the Name when re-editing, but not the Secret value\n   - **DO NOT Create Custom Names**: The Secret Name must **strictly use** the names listed below (e.g., `WEWORK_WEBHOOK_URL`, `FEISHU_WEBHOOK_URL`, etc.). Do not modify or create new names arbitrarily, or the system will not recognize them\n   - **Can Configure Multiple Platforms**: The system will send notifications to all configured platforms\n\n   **Configuration Example:**\n\n   <img src=\"_image/secrets.png\" alt=\"GitHub Secrets Configuration Example\"/>\n\n   As shown above, each row is a configuration item:\n   - **Name**: Must use the fixed names listed in the expanded sections below (e.g., `WEWORK_WEBHOOK_URL`)\n   - **Secret (Value)**: Fill in the actual content obtained from the corresponding platform (e.g., Webhook URL, Token, etc.)\n\n   <br>\n\n<details>\n<summary> <strong>👉 Click to expand: WeWork Bot</strong> (Simplest and fastest configuration)</summary>\n<br>\n\n**GitHub Secret Configuration (⚠️ Name must match exactly):**\n- **Name**: `WEWORK_WEBHOOK_URL` (Please copy and paste this name, do not type manually to avoid typos)\n- **Secret (Value)**: Your WeWork bot Webhook address\n\n<br>\n\n**Bot Setup Steps:**\n\n#### Mobile Setup:\n1. Open WeWork App → Enter target internal group chat\n2. Click \"…\" button at top right → Select \"Message Push\"\n3. Click \"Add\" → Name input \"TrendRadar\"\n4. Copy Webhook address, click save, paste the copied content into GitHub Secret above\n\n#### PC Setup Process Similar\n</details>\n\n<details>\n<summary> <strong>👉 Click to expand: Personal WeChat Push</strong> (Based on WeWork app, push to personal WeChat)</summary>\n<br>\n\n> This solution is based on WeWork's plugin mechanism. The push style is plain text (no markdown format), but it can push directly to personal WeChat without installing WeWork App.\n\n**GitHub Secret Configuration (⚠️ Name must match exactly):**\n- **Name**: `WEWORK_WEBHOOK_URL` (Please copy and paste this name, do not type manually)\n- **Secret (Value)**: Your WeWork app Webhook address\n\n- **Name**: `WEWORK_MSG_TYPE` (Please copy and paste this name, do not type manually)\n- **Secret (Value)**: `text`\n\n<br>\n\n**Setup Steps:**\n\n1. Complete the WeWork bot Webhook setup above\n2. Add `WEWORK_MSG_TYPE` Secret with value `text`\n3. Follow the image below to link personal WeChat\n4. After configuration, WeWork App can be deleted from phone\n\n<img src=\"_image/wework.png\" title=\"Personal WeChat Push Configuration\"/>\n\n**Notes**:\n- Uses the same Webhook address as WeWork bot\n- Difference is message format: `text` for plain text, `markdown` for rich text (default)\n- Plain text format will automatically remove all markdown syntax (bold, links, etc.)\n\n</details>\n\n<details>\n<summary> <strong>👉 Click to expand: Feishu Bot</strong> (Message display is relatively friendly)</summary>\n<br>\n\n**Note**: If **AI Analysis** is enabled, Feishu push notifications may occasionally (approx. 5% probability) experience a few minutes of delay. This is likely due to the platform's internal compliance auditing for AI-generated content.\n\n**GitHub Secret Configuration (⚠️ Name must match exactly):**\n- **Name**: `FEISHU_WEBHOOK_URL` (Please copy and paste this name, do not type manually)\n- **Secret (Value)**: Your Feishu bot Webhook address (link starts with https://www.feishu.cn/flow/api/trigger-webhook/********)\n<br>\n\nTwo methods available, **Method 1** is simpler, **Method 2** is more complex (but stable push)\n\nMethod 1 discovered and suggested by **ziventian**, thanks to them. Default is personal push, group push can be configured via [#97](https://github.com/sansan0/TrendRadar/issues/97)\n\n**Method 1:**\n\n> For some users, additional operations needed to avoid \"System Error\". Need to search for the bot on mobile and enable Feishu bot application (suggestion from community, can refer)\n\n1. Open in PC browser https://botbuilder.feishu.cn/home/my-command\n\n2. Click \"New Bot Command\"\n\n3. Click \"Select Trigger\", scroll down, click \"Webhook Trigger\"\n\n4. Now you'll see \"Webhook Address\", copy this link to local notepad temporarily, continue with next steps\n\n5. In \"Parameters\" put the following content, then click \"Done\"\n\n```json\n{\n  \"message_type\": \"text\",\n  \"content\": {\n    \"text\": \"{{Content}}\"\n  }\n}\n```\n\n6. Click \"Select Action\" > \"Send via Official Bot\"\n\n7. Message title fill \"TrendRadar Trending Monitor\"\n\n8. Most critical part, click + button, select \"Webhook Trigger\", then arrange as shown in image\n\n![Feishu Bot Config Example](_image/feishu.png)\n\n9. After configuration, put Webhook address from step 4 into GitHub Secrets `FEISHU_WEBHOOK_URL`\n\n<br>\n\n**Method 2:**\n\n1. Open in PC browser https://botbuilder.feishu.cn/home/my-app\n\n2. Click \"New Bot Application\"\n\n3. After entering the created application, click \"Process Design\" > \"Create Process\" > \"Select Trigger\"\n\n4. Scroll down, click \"Webhook Trigger\"\n\n5. Now you'll see \"Webhook Address\", copy this link to local notepad temporarily, continue with next steps\n\n6. In \"Parameters\" put the following content, then click \"Done\"\n\n```json\n{\n  \"message_type\": \"text\",\n  \"content\": {\n    \"text\": \"{{Content}}\"\n  }\n}\n```\n\n7. Click \"Select Action\" > \"Send Feishu Message\", check \"Group Message\", then click the input box below, click \"Groups I Manage\" (if no group, you can create one in Feishu app)\n\n8. Message title fill \"TrendRadar Trending Monitor\"\n\n9. Most critical part, click + button, select \"Webhook Trigger\", then arrange as shown in image\n\n![Feishu Bot Config Example](_image/feishu.png)\n\n10. After configuration, put Webhook address from step 5 into GitHub Secrets `FEISHU_WEBHOOK_URL`\n\n</details>\n\n<details>\n<summary> <strong>👉 Click to expand: DingTalk Bot</strong></summary>\n<br>\n\n**GitHub Secret Configuration (⚠️ Name must match exactly):**\n- **Name**: `DINGTALK_WEBHOOK_URL` (Please copy and paste this name, do not type manually)\n- **Secret (Value)**: Your DingTalk bot Webhook address\n\n<br>\n\n**Bot Setup Steps:**\n\n1. **Create Bot (PC Only)**:\n   - Open DingTalk PC client, enter target group chat\n   - Click group settings icon (⚙️) → Scroll down to find \"Bot\" and click\n   - Select \"Add Bot\" → \"Custom\"\n\n2. **Configure Bot**:\n   - Set bot name\n   - **Security Settings**:\n     - **Custom Keywords**: Set \"Trending\" or \"热点\"\n\n3. **Complete Setup**:\n   - Check service terms agreement → Click \"Done\"\n   - Copy the obtained Webhook URL\n   - Put URL into GitHub Secrets `DINGTALK_WEBHOOK_URL`\n\n**Note**: Mobile can only receive messages, cannot create new bots.\n</details>\n\n<details>\n<summary> <strong>👉 Click to expand: Telegram Bot</strong></summary>\n<br>\n\n**GitHub Secret Configuration (⚠️ Name must match exactly):**\n- **Name**: `TELEGRAM_BOT_TOKEN` (Please copy and paste this name, do not type manually)\n- **Secret (Value)**: Your Telegram Bot Token\n\n- **Name**: `TELEGRAM_CHAT_ID` (Please copy and paste this name, do not type manually)\n- **Secret (Value)**: Your Telegram Chat ID\n\n**Note**: Telegram requires **two** Secrets, please click \"New repository secret\" button twice to add them separately\n\n<br>\n\n**Bot Setup Steps:**\n\n1. **Create Bot**:\n   - Search `@BotFather` in Telegram (note case, has blue verification checkmark, shows ~37849827 monthly users, this is official, beware of fake accounts)\n   - Send `/newbot` command to create new bot\n   - Set bot name (must end with \"bot\", easily runs into duplicate names, so think creatively)\n   - Get Bot Token (format like: `123456789:AAHfiqksKZ8WmR2zSjiQ7_v4TMAKdiHm9T0`)\n\n2. **Get Chat ID**:\n\n   **Method 1: Via Official API**\n   - First send a message to your bot\n   - Visit: `https://api.telegram.org/bot<Your Bot Token>/getUpdates`\n   - Find the number in `\"chat\":{\"id\":number}` in returned JSON\n\n   **Method 2: Using Third-Party Tool**\n   - Search `@userinfobot` and send `/start`\n   - Get your user ID as Chat ID\n\n3. **Configure to GitHub**:\n   - `TELEGRAM_BOT_TOKEN`: Fill in Bot Token from step 1\n   - `TELEGRAM_CHAT_ID`: Fill in Chat ID from step 2\n</details>\n\n<details>\n<summary> <strong>👉 Click to expand: Email Push</strong> (Supports all mainstream email providers)</summary>\n<br>\n\n- Note: To prevent email bulk sending abuse, current bulk sending allows all recipients to see each other's email addresses.\n- If you haven't configured email sending before, not recommended to try\n\n> ⚠️ **Important Configuration Dependency**: Email push requires HTML report file. Make sure `storage.formats.html` is set to `true` in `config/config.yaml`:\n> ```yaml\n> storage:\n>   formats:\n>     sqlite: true\n>     txt: false\n>     html: true   # Must be enabled, otherwise email push will fail\n> ```\n> If set to `false`, email push will report error: `Error: HTML file does not exist or not provided: None`\n\n<br>\n\n**GitHub Secret Configuration (⚠️ Name must match exactly):**\n- **Name**: `EMAIL_FROM` (Please copy and paste this name, do not type manually)\n- **Secret (Value)**: Sender email address\n\n- **Name**: `EMAIL_PASSWORD` (Please copy and paste this name, do not type manually)\n- **Secret (Value)**: Email password or authorization code\n\n- **Name**: `EMAIL_TO` (Please copy and paste this name, do not type manually)\n- **Secret (Value)**: Recipient email address (multiple separated by comma, or can be same as EMAIL_FROM to send to yourself)\n\n- **Name**: `EMAIL_SMTP_SERVER` (Optional, please copy and paste this name)\n- **Secret (Value)**: SMTP server address (leave empty for auto-detection)\n\n- **Name**: `EMAIL_SMTP_PORT` (Optional, please copy and paste this name)\n- **Secret (Value)**: SMTP port (leave empty for auto-detection)\n\n**Note**: Email push requires at least **3 required** Secrets (EMAIL_FROM, EMAIL_PASSWORD, EMAIL_TO), the last two are optional\n\n<br>\n\n**Supported Email Providers** (Auto-detect SMTP config):\n\n| Provider | Domain | SMTP Server | Port | Encryption |\n|----------|--------|-------------|------|-----------|\n| **Gmail** | gmail.com | smtp.gmail.com | 587 | TLS |\n| **QQ Mail** | qq.com | smtp.qq.com | 465 | SSL |\n| **Outlook** | outlook.com | smtp-mail.outlook.com | 587 | TLS |\n| **Hotmail** | hotmail.com | smtp-mail.outlook.com | 587 | TLS |\n| **Live** | live.com | smtp-mail.outlook.com | 587 | TLS |\n| **163 Mail** | 163.com | smtp.163.com | 465 | SSL |\n| **126 Mail** | 126.com | smtp.126.com | 465 | SSL |\n| **Sina Mail** | sina.com | smtp.sina.com | 465 | SSL |\n| **Sohu Mail** | sohu.com | smtp.sohu.com | 465 | SSL |\n| **189 Mail** | 189.cn | smtp.189.cn | 465 | SSL |\n| **Aliyun Mail** | aliyun.com | smtp.aliyun.com | 465 | TLS |\n| **Yandex Mail** | yandex.com | smtp.yandex.com | 465 | TLS |\n| **iCloud Mail** | icloud.com | smtp.mail.me.com | 587 | SSL |\n\n> **Auto-detect**: When using above emails, no need to manually configure `EMAIL_SMTP_SERVER` and `EMAIL_SMTP_PORT`, system auto-detects.\n>\n> **Feedback Notice**:\n> - If you successfully test with **other email providers**, please open an [Issue](https://github.com/sansan0/TrendRadar/issues) to let us know, we'll add to support list\n> - If above email configurations are incorrect or unusable, please also open an [Issue](https://github.com/sansan0/TrendRadar/issues) for feedback to help improve the project\n>\n> **Special Thanks**:\n> - Thanks to [@DYZYD](https://github.com/DYZYD) for contributing 189 Mail (189.cn) configuration and completing self-send-receive testing ([#291](https://github.com/sansan0/TrendRadar/issues/291))\n> - Thanks to [@longzhenren](https://github.com/longzhenren) for contributing Aliyun Mail (aliyun.com) configuration and completing testing ([#344](https://github.com/sansan0/TrendRadar/issues/344))\n> - Thanks to [@ACANX](https://github.com/ACANX) for contributing Yandex Mail (yandex.com) configuration and completing testing ([#663](https://github.com/sansan0/TrendRadar/issues/663))\n> - Thanks to [@Sleepy-Tianhao](https://github.com/Sleepy-Tianhao) for contributing iCloud Mail (icloud.com) configuration and completing testing ([#728](https://github.com/sansan0/TrendRadar/issues/728))\n\n**Common Email Settings:**\n\n#### QQ Mail:\n1. Login QQ Mail web version → Settings → Account\n2. Enable POP3/SMTP service\n3. Generate authorization code (16-letter code)\n4. `EMAIL_PASSWORD` fill authorization code, not QQ password\n\n#### Gmail:\n1. Enable two-step verification\n2. Generate app-specific password\n3. `EMAIL_PASSWORD` fill app-specific password\n\n#### 163/126 Mail:\n1. Login web version → Settings → POP3/SMTP/IMAP\n2. Enable SMTP service\n3. Set client authorization code\n4. `EMAIL_PASSWORD` fill authorization code\n<br>\n\n**Advanced Configuration**:\nIf auto-detect fails, manually configure SMTP:\n- `EMAIL_SMTP_SERVER`: Like smtp.gmail.com\n- `EMAIL_SMTP_PORT`: Like 587 (TLS) or 465 (SSL)\n<br>\n\n**Multiple Recipients (note: English comma separator)**:\n- EMAIL_TO=\"user1@example.com,user2@example.com,user3@example.com\"\n\n</details>\n\n<details>\n<summary> <strong>👉 Click to expand: ntfy Push</strong> (Open-source, free, self-hostable)</summary>\n<br>\n\n**Two Usage Methods:**\n\n### Method 1: Free Use (Recommended for Beginners) 🆓\n\n**Features**:\n- ✅ No account registration, use immediately\n- ✅ 250 messages/day (enough for 90% users)\n- ✅ Topic name is \"password\" (need to choose hard-to-guess name)\n- ⚠️ Messages unencrypted, not for sensitive info, but suitable for our non-sensitive project info\n\n**Quick Start:**\n\n1. **Download ntfy App**:\n   - Android: [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/)\n   - iOS: [App Store](https://apps.apple.com/us/app/ntfy/id1625396347)\n   - Desktop: Visit [ntfy.sh](https://ntfy.sh)\n\n2. **Subscribe to Topic** (choose a hard-to-guess name):\n   ```\n   Suggested format: trendradar-{your initials}-{random numbers}\n\n   Cannot use Chinese\n\n   ✅ Good example: trendradar-zs-8492\n   ❌ Bad example: news, alerts (too easy to guess)\n   ```\n\n3. **Configure GitHub Secret (⚠️ Name must match exactly)**:\n   - **Name**: `NTFY_TOPIC` (Please copy and paste this name, do not type manually)\n   - **Secret (Value)**: Fill in your subscribed topic name\n\n   - **Name**: `NTFY_SERVER_URL` (Optional, please copy and paste this name)\n   - **Secret (Value)**: Leave empty (default uses ntfy.sh)\n\n   - **Name**: `NTFY_TOKEN` (Optional, please copy and paste this name)\n   - **Secret (Value)**: Leave empty\n\n   **Note**: ntfy requires at least 1 required Secret (NTFY_TOPIC), the last two are optional\n\n4. **Test**:\n   ```bash\n   curl -d \"Test message\" ntfy.sh/your-topic-name\n   ```\n\n---\n\n### Method 2: Self-Hosting (Complete Privacy Control) 🔒\n\n**Target Users**: Have server, pursue complete privacy, strong technical ability\n\n**Advantages**:\n- ✅ Completely open-source (Apache 2.0 + GPLv2)\n- ✅ Complete data self-control\n- ✅ No restrictions\n- ✅ Zero cost\n\n**Docker One-Click Deploy**:\n```bash\ndocker run -d \\\n  --name ntfy \\\n  -p 80:80 \\\n  -v /var/cache/ntfy:/var/cache/ntfy \\\n  binwiederhier/ntfy \\\n  serve --cache-file /var/cache/ntfy/cache.db\n```\n\n**Configure TrendRadar**:\n```yaml\nNTFY_SERVER_URL: https://ntfy.yourdomain.com\nNTFY_TOPIC: trendradar-alerts  # Self-hosting can use simple name\nNTFY_TOKEN: tk_your_token  # Optional: Enable access control\n```\n\n**Subscribe in App**:\n- Click \"Use another server\"\n- Enter your server address\n- Enter topic name\n- (Optional) Enter login credentials\n\n---\n\n**FAQ:**\n\n<details>\n<summary><strong>Q1: Is the free version enough?</strong></summary>\n\n250 messages/day is enough for most users. With 30-minute crawl intervals, about 48 pushes/day, completely sufficient.\n</details>\n\n<details>\n<summary><strong>Q2: Is the Topic name really secure?</strong></summary>\n\nIf you choose a random, sufficiently long name (like `trendradar-zs-8492-news`), brute force is nearly impossible:\n- ntfy has strict rate limiting (1 request/second)\n- 64 character choices (A-Z, a-z, 0-9, _, -)\n- 10 random characters have 64^10 possibilities (would take years to crack)\n</details>\n\n---\n\n**Recommended Choice:**\n\n| User Type | Recommended | Reason |\n|-----------|-------------|--------|\n| Regular Users | Method 1 (Free) | Simple, fast, enough |\n| Technical Users | Method 2 (Self-Host) | Complete control, unlimited |\n| High-Frequency Users | Method 3 (Paid) | Check official website |\n\n**Related Links:**\n- [ntfy Official Docs](https://docs.ntfy.sh/)\n- [Self-Hosting Tutorial](https://docs.ntfy.sh/install/)\n- [GitHub Repository](https://github.com/binwiederhier/ntfy)\n\n</details>\n\n<details>\n<summary>👉 Click to expand: <strong>Bark Push</strong> (iOS exclusive, clean & efficient)</summary>\n<br>\n\n**GitHub Secret Configuration (⚠️ Name must be exact):**\n- **Name**: `BARK_URL` (copy and paste this name, don't type manually)\n- **Secret**: Your Bark push URL\n\n<br>\n\n**Bark Introduction:**\n\nBark is a free open-source push tool for iOS platform, featuring simplicity, speed, and no ads.\n\n**Usage Methods:**\n\n### Method 1: Use Official Server (Recommended for beginners) 🆓\n\n1. **Download Bark App**:\n   - iOS: [App Store](https://apps.apple.com/us/app/bark-customed-notifications/id1403753865)\n\n2. **Get Push URL**:\n   - Open Bark App\n   - Copy the push URL displayed on the home page (format: `https://api.day.app/your_device_key`)\n   - Configure the URL to GitHub Secrets as `BARK_URL`\n\n### Method 2: Self-Hosted Server (Complete Privacy Control) 🔒\n\n**Suitable for**: Users with servers, pursuing complete privacy, strong technical skills\n\n**Docker One-Click Deployment**:\n```bash\ndocker run -d \\\n  --name bark-server \\\n  -p 8080:8080 \\\n  finab/bark-server\n```\n\n**Configure TrendRadar**:\n```yaml\nBARK_URL: http://your-server-ip:8080/your_device_key\n```\n\n---\n\n**Notes:**\n- ✅ Bark uses APNs push, max 4KB per message\n- ✅ Supports automatic batch sending, no worry about long messages\n- ✅ Push format is plain text (automatically removes Markdown syntax)\n- ⚠️ Only supports iOS platform\n\n**Related Links:**\n- [Bark Official Website](https://bark.day.app/)\n- [Bark GitHub Repository](https://github.com/Finb/Bark)\n- [Bark Server Self-Hosting Tutorial](https://github.com/Finb/bark-server)\n\n</details>\n\n<details>\n<summary>👉 Click to expand: <strong>Slack Push</strong></summary>\n<br>\n\n**GitHub Secret Configuration (⚠️ Name must be exact):**\n- **Name**: `SLACK_WEBHOOK_URL` (copy and paste this name, don't type manually)\n- **Secret**: Your Slack Incoming Webhook URL\n\n<br>\n\n**Slack Introduction:**\n\nSlack is a team collaboration tool, Incoming Webhooks can push messages to Slack channels.\n\n**Setup Steps:**\n\n### Step 1: Create Slack App\n\n1. **Visit Slack API Page**:\n   - Open https://api.slack.com/apps?new_app=1\n   - Login to your Slack workspace if not logged in\n\n2. **Choose Creation Method**:\n   - Click **\"From scratch\"**\n\n3. **Fill in App Information**:\n   - **App Name**: Enter app name (e.g., `TrendRadar` or `Hot News Monitor`)\n   - **Workspace**: Select your workspace from dropdown\n   - Click **\"Create App\"** button\n\n### Step 2: Enable Incoming Webhooks\n\n1. **Navigate to Incoming Webhooks**:\n   - Find and click **\"Incoming Webhooks\"** in left menu\n\n2. **Enable Feature**:\n   - Find **\"Activate Incoming Webhooks\"** toggle\n   - Switch from `OFF` to `ON`\n   - Page will auto-refresh showing new configuration options\n\n### Step 3: Generate Webhook URL\n\n1. **Add New Webhook**:\n   - Scroll to page bottom\n   - Click **\"Add New Webhook to Workspace\"** button\n\n2. **Select Target Channel**:\n   - System will show authorization page\n   - Select channel to receive messages from dropdown (e.g., `#hot-news`)\n   - ⚠️ For private channels, must join the channel first\n\n3. **Authorize App**:\n   - Click **\"Allow\"** button to complete authorization\n   - System will auto-redirect back to config page\n\n### Step 4: Copy and Save Webhook URL\n\n1. **View Generated URL**:\n   - In \"Webhook URLs for Your Workspace\" section\n   - You'll see the newly generated Webhook URL\n   - Format: `https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX`\n\n2. **Copy URL**:\n   - Click **\"Copy\"** button on the right of URL\n   - Or manually select and copy URL\n\n3. **Configure to TrendRadar**:\n   - **GitHub Actions**: Add URL to GitHub Secrets as `SLACK_WEBHOOK_URL`\n   - **Local Testing**: Fill URL in `config/config.yaml` `slack_webhook_url` field\n   - **Docker Deployment**: Add URL to `docker/.env` file as `SLACK_WEBHOOK_URL` variable\n\n---\n\n**Notes:**\n- ✅ Supports Markdown format (auto-converts to Slack mrkdwn)\n- ✅ Supports automatic batch sending (4KB per batch)\n- ✅ Suitable for team collaboration, centralized message management\n- ⚠️ Webhook URL contains secret key, never make it public\n\n**Message Format Preview:**\n```\n*[Batch 1/2]*\n\n📊 *Trending Topics Statistics*\n\n🔥 *[1/3] AI ChatGPT* : 2 articles\n\n  1. [Baidu Hot] 🆕 ChatGPT-5 Official Release *[1]* - 09:15 (1 time)\n\n  2. [Toutiao] AI Chip Stocks Surge *[3]* - [08:30 ~ 10:45] (3 times)\n```\n\n**Related Links:**\n- [Slack Incoming Webhooks Official Docs](https://api.slack.com/messaging/webhooks)\n- [Slack API App Management](https://api.slack.com/apps)\n\n</details>\n\n<details>\n<summary>👉 Click to expand: <strong>Generic Webhook Push</strong> (Supports Discord, Matrix, IFTTT, etc.)</summary>\n<br>\n\n**GitHub Secret Configuration (⚠️ Name must be exact):**\n- **Name**: `GENERIC_WEBHOOK_URL` (copy and paste this name, don't type manually)\n- **Secret**: Your Webhook URL\n\n- **Name**: `GENERIC_WEBHOOK_TEMPLATE` (optional, copy and paste this name)\n- **Secret**: JSON template string, supports `{title}` and `{content}` placeholders\n\n<br>\n\n**Generic Webhook Introduction:**\n\nGeneric Webhook supports any platform that accepts HTTP POST requests, including but not limited to:\n- **Discord**: Push to channels via Webhook\n- **Matrix**: Push via Webhook bridge\n- **IFTTT**: Trigger automation workflows\n- **Custom Services**: Any custom service supporting Webhooks\n\n**Configuration Examples:**\n\n### Discord Configuration\n\n1. **Get Webhook URL**:\n   - Go to Discord Server Settings → Integrations → Webhooks\n   - Create new Webhook, copy URL\n\n2. **Configure Template**:\n   ```json\n   {\"content\": \"{content}\"}\n   ```\n\n3. **GitHub Secret Configuration**:\n   - `GENERIC_WEBHOOK_URL`: Discord Webhook URL\n   - `GENERIC_WEBHOOK_TEMPLATE`: `{\"content\": \"{content}\"}`\n\n### Custom Templates\n\nTemplates support two placeholders:\n- `{title}` - Message title\n- `{content}` - Message content\n\n**Template Examples**:\n```json\n# Default format (used when empty)\n{\"title\": \"{title}\", \"content\": \"{content}\"}\n\n# Discord format\n{\"content\": \"{content}\"}\n\n# Custom format\n{\"text\": \"{content}\", \"username\": \"TrendRadar\"}\n```\n\n---\n\n**Notes:**\n- ✅ Supports Markdown format (same as WeWork format)\n- ✅ Supports automatic batch sending\n- ✅ Supports multi-account configuration (use `;` separator)\n- ⚠️ Template must be valid JSON format\n- ⚠️ Different platforms have different message format requirements, please refer to target platform documentation\n\n</details>\n\n> ⚠️ Note:\n> - For first deployment, suggest completing **GitHub Secrets** configuration first (choose one push platform), then jump to [Step 3] to test push success.\n> - **Don't modify** `config/config.yaml` and `frequency_words.txt` temporarily, adjust these configs after push test succeeds as needed.\n\n   <br>\n\n### 3️⃣ Step 3: Manual Test News Push\n\n   > ⚠️ Reminder:\n   > - Complete Step 1-2 first, then test immediately! Test success first, then adjust configuration (Step 4) as needed.\n   > - IMPORTANT: Enter your own forked project, not this project!\n\n   **How to find your Actions page**:\n\n   - **Method 1**: Open your forked project homepage, click the **Actions** tab at the top\n   - **Method 2**: Direct access `https://github.com/YourUsername/TrendRadar/actions`\n\n   **Example comparison**:\n   - ❌ Author's project: `https://github.com/sansan0/TrendRadar/actions`\n   - ✅ Your project: `https://github.com/YourUsername/TrendRadar/actions`\n\n   **Testing steps**:\n   1. Enter your project's Actions page\n   2. Find **\"Hot News Crawler\"** and click in\n      - If you don't see this text, refer to [#109](https://github.com/sansan0/TrendRadar/issues/109) to solve\n   3. Click **\"Run workflow\"** button on the right to run\n   4. Wait about 1 minute, messages will be pushed to your configured platform\n\n   > ⚠️ Note:\n   > - Don't test too frequently to avoid triggering GitHub Actions limits\n   > - After clicking Run workflow, you need to **refresh the browser page** to see the new run record\n\n   <br>\n\n### 4️⃣ Step 4: Configuration Notes (Optional)\n\n   The default configuration is ready to use. If you need personalized adjustments, just understand the following files:\n\n   | File | Purpose |\n   |------|---------|\n   | `config/config.yaml` | Main config file: push mode, time window, platform list, hotspot weights, etc. |\n   | `config/frequency_words.txt` | Keyword file: set your interested keywords, filter push content |\n   | `.github/workflows/crawler.yml` | Execution frequency: control how often to run (⚠️ modify carefully) |\n\n   👉 **Detailed Configuration Tutorial**: [Configuration Guide](#configuration-guide)\n\n   <br>\n\n### 5️⃣ Step 5: GitHub Actions Check-In & Remote Cloud Storage\n\n   **v4.0.0 Important Change**: Introduced the \"Activity Detection\" mechanism; GitHub Actions need periodic check-ins to maintain operation.\n\n   - **Running Cycle**: Valid for **7 days**—service will automatically suspend when countdown ends.\n   - **Renewal Method**: Manually trigger the \"Check In\" workflow on the Actions page to reset the 7-day validity period.\n   - **Operation Path**: `Actions` → `Check In` → `Run workflow`\n   - **Design Philosophy**:\n     - If you forget for 7 days, maybe you don't really need it. Letting it stop is a digital detox, freeing you from the constant impact.\n     - GitHub Actions is a valuable public computing resource. The check-in mechanism aims to prevent wasted computing cycles, ensuring resources are allocated to truly active users who need them. Thank you for your understanding and support.\n\n   ---\n\n   **You can also choose NOT to configure remote cloud storage**, but then you will be in **Lite Mode** with some advanced features unavailable.\n\n   **Two Deployment Modes Comparison:**\n\n   | Mode | Configuration Required | Features |\n   |------|------------------------|----------|\n   | **Lite Mode** | No storage configuration needed | Real-time crawling + Keyword filtering + Multi-channel push |\n   | **Full Mode** | Configure remote cloud storage | Lite Mode + New detection + Trend tracking + Incremental push + AI analysis |\n\n   **Lite Mode Description**:\n   - ✅ Available: Real-time news crawling, keyword filtering, hotspot weight ranking, current list push\n   - ❌ Not Available: New news detection (🆕), trend tracking, incremental mode, daily summary accumulation, MCP AI analysis\n\n   **Full Mode Description**: Configure remote cloud storage to unlock all features. Continue with the configuration below.\n\n   <details>\n   <summary>👉 Click to expand: <strong>Remote Cloud Storage Configuration (Determines Feature Completeness) (Optional)</strong></summary>\n   <br>\n\n   **⚠️ Prerequisites for Cloudflare R2 Configuration:**\n\n   According to Cloudflare platform rules, enabling R2 requires binding a payment method.\n\n   * **Purpose**: Verify identity only, **no charges will be incurred**.\n   * **Payment**: Supports dual-currency credit cards or regional PayPal.\n   * **Usage**: R2's free tier (10GB storage/month) is sufficient for this project's daily operation, no need to worry about costs.\n\n   ---\n\n   **GitHub Secret Configuration:**\n\n   **Required Configuration (4 items):**\n\n   | Name | Secret (Value) Description |\n   |------|----------------------------|\n   | `S3_BUCKET_NAME` | Bucket name (e.g., `trendradar-data`) |\n   | `S3_ACCESS_KEY_ID` | Access key ID |\n   | `S3_SECRET_ACCESS_KEY` | Access key |\n   | `S3_ENDPOINT_URL` | S3 API endpoint (e.g., R2: `https://<account-id>.r2.cloudflarestorage.com`) |\n\n   **Optional Configuration:**\n\n   | Name | Secret (Value) Description |\n   |------|----------------------------|\n   | `S3_REGION` | Region (default `auto`, some providers may require specification) |\n\n   > 💡 **More storage configuration options**: See [Storage Configuration Details](#11-storage-configuration-v400-new)\n\n   <br>\n\n   **How to Get Credentials (Using Cloudflare R2 as Example):**\n\n   1. Visit [Cloudflare Dashboard](https://dash.cloudflare.com/) and log in\n   2. Select `R2` in left menu → Click `Create Bucket` → Enter name (e.g., `trendradar-data`)\n   3. Click `Manage R2 API Tokens` at top right → `Create API Token`\n   4. Select `Object Read & Write` permission → After creation, it will display `Access Key ID` and `Secret Access Key`\n   5. Endpoint URL can be found in bucket details page (format: `https://<account-id>.r2.cloudflarestorage.com`)\n\n   </details>\n\n   <br>\n\n### 6️⃣ Step 6: Enable AI Analysis Push\n\n   This is a core feature of v5.0.0, letting AI summarize and analyze news for you. Highly recommended.\n\n   **Configuration Method:**\n   Add the following to GitHub Secrets (or `.env` / `config.yaml`):\n   - `AI_API_KEY`: Your API Key (Supports DeepSeek, OpenAI, etc.)\n   - `AI_PROVIDER`: Provider name (e.g., `deepseek`, `openai`)\n\n   That's it! No complex deployment needed. You'll see the smart analysis report in the next push.\n\n   <br>\n\n### 7️⃣ Step 7: 🎉 Deployment Success!\n\n   Congratulations! Now you can start enjoying the efficient information flow brought by TrendRadar.\n\n   💬 Many users are sharing their experiences on the official account, we look forward to your insights~\n\n   - Want to learn more tips and advanced techniques?\n   - Need quick answers to problems?\n   - Have great ideas to share?\n\n   👉 Follow the WeChat Official Account「**[Silicon Tea Room](#-support-project)**」, your likes and comments are the motivation for continuous updates.\n\n   <br>\n\n### 8️⃣ Step 8: Advanced: Choose Your AI Assistant\n\n   TrendRadar provides two ways to use AI to meet different needs:\n\n   | Feature | ✨ AI Analysis Push (Step 6) | 🧠 AI Smart Analysis |\n   | :--- | :--- | :--- |\n   | **Mode** | **Passive Receipt** (Daily Report) | **Active Conversation** (Deep Research) |\n   | **Scenario** | \"What's big today?\" | \"Analyze AI industry changes over the past week\" |\n   | **Deployment** | Minimalist (Just add Key) | Advanced (Requires Local/Docker) |\n   | **Client** | Mobile | PC |\n\n   👉 **Conclusion**: Start with **AI Analysis Push** for daily needs; if you are a data analyst or need deep mining, try **[MCP Smart Analysis](#-ai-analysis)**.\n\n<br>\n\n<a name=\"configuration-guide\"></a>\n\n## ⚙️ Configuration Guide\n\n> **📖 Reminder**: This chapter provides detailed configuration explanations. Suggest completing [Quick Start](#-quick-start) basic configuration first, then refer to detailed options here as needed.\n\n### 1. Platform Configuration\n\n<details id=\"custom-monitoring-platforms\">\n<summary>👉 Click to expand: <strong>Custom Monitoring Platforms</strong></summary>\n<br>\n\n**Configuration Location:** `platforms` section in `config/config.yaml`\n\nThis project's news data comes from [newsnow](https://github.com/ourongxing/newsnow). You can click the [website](https://newsnow.busiyi.world/), click [More], to see if there are platforms you want.\n\nFor specific additions, visit [project source code](https://github.com/ourongxing/newsnow/tree/main/server/sources), based on the file names there, modify the `platforms` configuration in `config/config.yaml` file:\n\n```yaml\nplatforms:\n  enabled: true                       # Enable trending platform crawling\n  sources:\n    - id: \"toutiao\"\n      name: \"Toutiao\"\n    - id: \"baidu\"\n      name: \"Baidu Hot Search\"\n    - id: \"wallstreetcn-hot\"\n      name: \"Wallstreetcn\"\n    # Add more platforms...\n```\n\n> 💡 **Shortcut**: If you don't know how to read source code, you can copy from others' organized [Platform Configuration Summary](https://github.com/sansan0/TrendRadar/issues/95)\n\n> ⚠️ **Note**: More platforms is not always better, suggest choosing 10-15 core platforms. Too many platforms will cause information overload and actually reduce user experience.\n\n</details>\n\n### 2. Keyword Configuration\n\n**Configuration Location:** `config/frequency_words.txt`\n\nConfigure monitoring keywords in `frequency_words.txt` with seven syntax types, region markers, and grouping features.\n\n| Syntax Type | Symbol | Purpose | Example | Matching Logic |\n|------------|--------|---------|---------|----------------|\n| **Normal** | None | Basic matching | `Huawei` | Match any one |\n| **Required** | `+` | Scope limiting | `+phone` | Must include both |\n| **Filter** | `!` | Noise exclusion | `!ad` | Exclude if included |\n| **Count Limit** | `@` | Control display count | `@10` | Max 10 news (v3.2.0 new) |\n| **Global Filter** | `[GLOBAL_FILTER]` | Globally exclude content | See example below | Filter under any circumstances (v3.5.0 new) |\n| **Regex** | `/pattern/` | Precise matching | `/\\bai\\b/` | Match using regex (v4.7.0 new) |\n| **Display Name** | `=> alias` | Custom display text | `/\\bai\\b/ => AI Related` | Show alias in push/HTML (v4.7.0 new) |\n\n#### 2.1 Basic Syntax\n\n<a name=\"keyword-basic-syntax\"></a>\n\n<details>\n<summary>👉 Click to expand: <strong>Basic Syntax Tutorial</strong></summary>\n<br>\n\n##### 1. **Normal Keywords** - Basic Matching\n```txt\nHuawei\nOPPO\nApple\n```\n**Effect:** News containing **any one** of these words will be captured\n\n##### 2. **Required Words** `+word` - Scope Limiting\n```txt\nHuawei\nOPPO\n+phone\n```\n**Effect:** Must include both normal word **and** required word to be captured\n\n##### 3. **Filter Words** `!word` - Noise Exclusion\n```txt\nApple\nHuawei\n!fruit\n!price\n```\n**Effect:** News containing filter words will be **excluded**, even if it contains keywords\n\n##### 4. **Count Limit** `@number` - Control Display Count (v3.2.0 new)\n```txt\nTesla\nMusk\n@5\n```\n**Effect:** Limit maximum news count for this keyword group\n\n**Priority:** `@number` > Global config > Unlimited\n\n##### 5. **Global Filter** `[GLOBAL_FILTER]` - Globally Exclude Content (v3.5.0 new)\n```txt\n[GLOBAL_FILTER]\nadvertisement\npromotion\nmarketing\nshocking\nclickbait\n\n[WORD_GROUPS]\ntechnology\nAI\n\nHuawei\nHarmonyOS\n!car\n```\n**Effect:** Filters news containing specified words under **any circumstances**, with **highest priority**\n\n**Use Cases:**\n- Filter low-quality content: shocking, clickbait, breaking news, etc.\n- Filter marketing content: advertisement, promotion, sponsorship, etc.\n- Filter specific topics: entertainment, gossip (based on needs)\n\n**Filter Priority:** Global Filter > Group Filter(`!`) > Group Matching\n\n**Region Markers:**\n- `[GLOBAL_FILTER]`: Global filter region, words are filtered under any circumstances\n- `[WORD_GROUPS]`: Keyword groups region, maintains existing syntax (`!`, `+`, `@`)\n- If no region markers are used, all content is treated as keyword groups (backward compatible)\n\n**Matching Examples:**\n```txt\n[GLOBAL_FILTER]\nadvertisement\n\n[WORD_GROUPS]\ntechnology\nAI\n```\n- ❌ \"Advertisement: Latest tech product launch\" ← Contains global filter word \"advertisement\", rejected\n- ✅ \"Tech company launches new AI product\" ← No global filter words, matches \"technology\" group\n- ✅ \"AI technology breakthrough draws attention\" ← No global filter words, matches \"AI\" in \"technology\" group\n\n**Important Notes:**\n- Use global filter words carefully to avoid over-filtering and missing valuable content\n- Recommended to keep global filter words under 5-15\n- For group-specific filtering, prioritize using group filter words (`!` prefix)\n\n##### 6. **Regex** `/pattern/` - Precise Matching (v4.7.0 new)\n\nNormal keywords use substring matching, which is convenient for Chinese but may cause false matches in English. For example, `ai` would match the `ai` in `training`.\n\nUse regex syntax `/pattern/` to achieve precise matching:\n\n```txt\n/(?<![a-z])ai(?![a-z])/\nartificial intelligence\n```\n\n**Effect:** Match using regular expressions, supports all Python regex syntax\n\n**Common Regex Patterns:**\n\n| Need | Regex | Description |\n|------|-------|-------------|\n| Word boundary | `/\\bword\\b/` | Match standalone word, e.g., `/\\bai\\b/` matches \"AI\" but not \"training\" |\n| Non-letter boundary | `/(?<![a-z])ai(?![a-z])/` | Looser boundary, suitable for mixed Chinese-English |\n| Start match | `/^breaking/` | Only match titles starting with \"breaking\" |\n| End match | `/release$/` | Only match titles ending with \"release\" |\n| Multiple options | `/apple\\|huawei\\|xiaomi/` | Match any one (note escaped `\\|`) |\n\n**Matching Examples:**\n```txt\n# Config\n/(?<![a-z])ai(?![a-z])/\nartificial intelligence\n```\n\n- ✅ \"AI is the future\" ← Matches standalone \"AI\"\n- ✅ \"Hello ai here\" ← Non-letter boundaries, matches \"ai\"\n- ✅ \"Artificial intelligence grows rapidly\" ← Matches \"artificial intelligence\"\n- ❌ \"Resistance training is important\" ← \"ai\" in \"training\" doesn't match\n- ❌ \"The maid cleaned the room\" ← \"ai\" in \"maid\" doesn't match\n\n**Combined Usage:**\n```txt\n# Regex + Normal + Filter\n/\\bai\\b/\nartificial intelligence\nmachine learning\n!advertisement\n```\n\n**Notes:**\n- Regex automatically enables case-insensitive matching (`re.IGNORECASE`)\n- Supports JavaScript-style `/pattern/i` syntax (flags are ignored since case-insensitive is always enabled)\n- Invalid regex syntax will be treated as normal words\n- Regex can be used for normal words, required words(`+`), and filter words(`!`)\n\n**💡 Can't Write Regex? Let AI Help!**\n\nIf you're not familiar with regular expressions, just ask ChatGPT / Gemini / DeepSeek to generate one:\n\n> I need a Python regex to match the word \"ai\" but not match \"ai\" in \"training\".\n> Please give me the regex in `/pattern/` format without extra explanation.\n\nAI will give you something like: `/(?<![a-zA-Z])ai(?![a-zA-Z])/`\n\n##### 7. **Display Name** `=> alias` - Custom Display Text (v4.7.0 new)\n\nRegex patterns can look unfriendly in push notifications and HTML pages. Use `=> alias` syntax to set a display name:\n\n```txt\n/(?<![a-zA-Z])ai(?![a-zA-Z])/ => AI Related\nartificial intelligence\n```\n\n**Effect:** Push notifications and HTML pages show \"AI Related\" instead of the complex regex\n\n**Syntax Format:**\n```txt\n# Regex + Display Name\n/pattern/ => Display Name\n/pattern/i => Display Name    # Supports flags syntax (flags are ignored)\n/pattern/=>Display Name       # Spaces around => are optional\n\n# Normal Word + Display Name\ndeepseek => DeepSeek News\n```\n\n**Example:**\n```txt\n# Config\n/(?<![a-zA-Z])ai(?![a-zA-Z])/ => AI Related\nartificial intelligence\n```\n\n| Original Config | Push/HTML Display |\n|----------------|-------------------|\n| `/(?<![a-z])ai(?![a-z])/` + `artificial intelligence` | `(?<![a-z])ai(?![a-z]) artificial intelligence` |\n| `/(?<![a-z])ai(?![a-z])/ => AI Related` + `artificial intelligence` | **`AI Related`** |\n\n**Notes:**\n- Display name only needs to be set on the first word of a group\n- If multiple words have display names, the first one is used\n- Without display name, all words in the group are concatenated\n\n---\n\n#### 🔗 Group Feature - Importance of Empty Lines\n\n**Core Rule:** Use **empty lines** to separate different groups, each group is independently counted\n\n##### Example Configuration:\n```txt\niPhone\nHuawei\nOPPO\n+launch\n\nA-shares\nShanghai Index\nShenzhen Index\n+fluctuation\n!prediction\n\nWorld Cup\nEuro Cup\nAsian Cup\n+match\n```\n\n##### Group Explanation and Matching Effects:\n\n**Group 1 - Phone Launches:**\n- Keywords: iPhone, Huawei, OPPO\n- Required: launch\n- Effect: Must include phone brand name and \"launch\"\n\n**Matching Examples:**\n- ✅ \"iPhone 15 officially launched with pricing\" ← Has \"iPhone\" + \"launch\"\n- ✅ \"Huawei Mate60 series launch livestream\" ← Has \"Huawei\" + \"launch\"\n- ✅ \"OPPO Find X7 launch date confirmed\" ← Has \"OPPO\" + \"launch\"\n- ❌ \"iPhone sales hit record high\" ← Has \"iPhone\" but missing \"launch\"\n\n**Group 2 - Stock Market:**\n- Keywords: A-shares, Shanghai Index, Shenzhen Index\n- Required: fluctuation\n- Filter: prediction\n- Effect: Include stock-related words and \"fluctuation\", but exclude \"prediction\"\n\n**Matching Examples:**\n- ✅ \"A-shares major fluctuation analysis today\" ← Has \"A-shares\" + \"fluctuation\"\n- ✅ \"Shanghai Index fluctuation reasons explained\" ← Has \"Shanghai Index\" + \"fluctuation\"\n- ❌ \"Experts predict A-shares fluctuation trends\" ← Has \"A-shares\" + \"fluctuation\" but contains \"prediction\"\n- ❌ \"A-shares trading volume hits new high\" ← Has \"A-shares\" but missing \"fluctuation\"\n\n**Group 3 - Football Events:**\n- Keywords: World Cup, Euro Cup, Asian Cup\n- Required: match\n- Effect: Must include cup name and \"match\"\n\n**Matching Examples:**\n- ✅ \"World Cup group stage match results\" ← Has \"World Cup\" + \"match\"\n- ✅ \"Euro Cup final match time\" ← Has \"Euro Cup\" + \"match\"\n- ❌ \"World Cup tickets on sale\" ← Has \"World Cup\" but missing \"match\"\n\n#### 🎯 Configuration Tips\n\n##### 1. **From Broad to Strict Strategy**\n```txt\n# Step 1: Start with broad keywords for testing\nArtificial Intelligence\nAI\nChatGPT\n\n# Step 2: After finding mismatches, add required words\nArtificial Intelligence\nAI\nChatGPT\n+technology\n\n# Step 3: After finding noise, add filter words\nArtificial Intelligence\nAI\nChatGPT\n+technology\n!advertisement\n!training\n```\n\n##### 2. **Avoid Over-Complexity**\n❌ **Not Recommended:** Too many words in one group\n```txt\nHuawei\nOPPO\nApple\nSamsung\nvivo\nOnePlus\nMeizu\n+phone\n+launch\n+sales\n!fake\n!repair\n!second-hand\n```\n\n**Recommended:** Split into precise groups\n```txt\nHuawei\nOPPO\n+new product\n\nApple\nSamsung\n+launch\n\nphone\nsales\n+market\n```\n\n</details>\n\n#### 2.2 Advanced Settings (v3.2.0 new)\n\n<a name=\"keyword-advanced-settings\"></a>\n\n<details>\n<summary>👉 Click to expand: <strong>Advanced Settings Tutorial</strong></summary>\n<br>\n\n##### Keyword Sorting Priority\n\n**Config Location:** `config/config.yaml`\n\n```yaml\nreport:\n  sort_by_position_first: false  # Sorting priority config\n```\n\n| Value | Sorting Rule | Use Case |\n|-------|-------------|----------|\n| `false` (default) | News count ↓ → Config position ↑ | Focus on popularity trends |\n| `true` | Config position ↑ → News count ↓ | Focus on personal priority |\n\n**Example:** Config order A, B, C, news count A(3), B(10), C(5)\n- `false`: B(10) → C(5) → A(3)\n- `true`: A(3) → B(10) → C(5)\n\n##### Global Display Count Limit\n\n```yaml\nreport:\n  max_news_per_keyword: 10  # Max 10 per keyword (0=unlimited)\n```\n\n**Docker Environment Variables:**\n```bash\nSORT_BY_POSITION_FIRST=true\nMAX_NEWS_PER_KEYWORD=10\n```\n\n**Combined Example:**\n```yaml\n# config.yaml\nreport:\n  sort_by_position_first: true   # Config order priority\n  max_news_per_keyword: 10       # Global default max 10 per keyword\n```\n\n```txt\n# frequency_words.txt\nTesla\nMusk\n@20              # Key focus, show 20 (override global)\n\nHuawei           # Use global config, show 10\n\nBYD\n@5               # Limit to 5\n```\n\n**Final Effect:** Display in config order: Tesla(20) → Huawei(10) → BYD(5)\n\n</details>\n\n### 3. Which push mode should I choose?\n\n<details>\n<summary>👉 Click to expand: <strong>Detailed Comparison of 3 Modes</strong></summary>\n<br>\n\n**Configuration Location:** `report.mode` in `config/config.yaml`\n\n```yaml\nreport:\n  mode: \"daily\"  # Options: \"daily\" | \"incremental\" | \"current\"\n```\n\n#### Detailed Comparison Table\n\n| Mode | Target Users | Push Timing | Display Content | Typical Use Case |\n|------|----------|----------|----------|--------------|\n| **Daily Summary**<br/>`daily` | 📋 Managers/Regular Users | Scheduled push (default hourly) | All matched news of the day<br/>+ New news section | **Example**: Check all important news of the day at 6 PM<br/>**Feature**: See full-day trend, don't miss any hot topic<br/>**Note**: Will include previously pushed news |\n| **Current Rankings**<br/>`current` | 📰 Content Creators | Scheduled push (default hourly) | Current ranking matches<br/>+ New news section | **Example**: Track \"which topics are hottest now\" hourly<br/>**Feature**: Real-time understanding of current popularity ranking changes<br/>**Note**: Continuously ranked news appear each time |\n| **Incremental Monitor**<br/>`incremental` | 📈 Traders/Investors | Push only when new | Newly appeared frequency word matches | **Example**: Monitor \"Tesla\", only notify when new news appears<br/>**Feature**: Zero duplication, only see first-time news<br/>**Suitable for**: High-frequency monitoring, avoid information disturbance |\n\n#### Actual Push Effect Example\n\nAssume you monitor \"Apple\" keyword, execute once per hour:\n\n| Time | daily Mode Push | current Mode Push | incremental Mode Push |\n|-----|--------------|----------------|-------------------|\n| 10:00 | News A, News B | News A, News B | News A, News B |\n| 11:00 | News A, News B, News C | News B, News C, News D | **Only** News C |\n| 12:00 | News A, News B, News C | News C, News D, News E | **Only** News D, News E |\n\n**Explanation**:\n- `daily`: Cumulative display of all news of the day (A, B, C all retained)\n- `current`: Display current ranking news (ranking changed, News D on list, News A off list)\n- `incremental`: **Only push newly appeared news** (avoid duplicate disturbance)\n\n#### Common Questions\n\n> **💡 Encountered this problem?** 👉 \"Execute once per hour, news output in first execution still appears in next hour execution\"\n> - **Reason**: You might have selected `daily` (Daily Summary) or `current` (Current Rankings) mode\n> - **Solution**: Change to `incremental` (Incremental Monitor) mode, only push new content\n\n#### ⚠️ Incremental Mode Important Notice\n\n> **Users who selected `incremental` (Incremental Monitor) mode, please note:**\n>\n> 📌 **Incremental mode only pushes when there are new matching news**\n>\n> **If you haven't received push notifications for a long time, it may be because:**\n> 1. No new hot topics matching your keywords in current time period\n> 2. Keyword configuration is too strict or too broad\n> 3. Too few monitoring platforms\n>\n> **Solutions:**\n> - Solution 1: 👉 [Optimize Keyword Configuration](#2-keyword-configuration) - Adjust keyword precision, add or modify monitoring keywords\n> - Solution 2: Switch push mode - Change to `current` or `daily` mode for scheduled push notifications\n> - Solution 3: 👉 [Add More Platforms](#1-platform-configuration) - Add more news platforms to expand information sources\n\n</details>\n\n### 4. How to adjust hotness algorithm?\n\n<details>\n<summary>👉 Click to expand: <strong>Customize Hotspot Weights</strong></summary>\n<br>\n\n**Configuration Location:** `advanced.weight` section in `config/config.yaml`\n\n```yaml\nadvanced:\n  weight:\n    rank: 0.6           # Ranking weight\n    frequency: 0.3      # Frequency weight\n    hotness: 0.1        # Hotness weight\n```\n\nCurrent default configuration is balanced.\n\n#### Two Core Scenarios\n\n**Real-Time Trending Type**:\n```yaml\nadvanced:\n  weight:\n    rank: 0.8           # Mainly focus on ranking\n    frequency: 0.1      # Less concern about continuity\n    hotness: 0.1\n```\n**Target Users**: Content creators, marketers, users wanting to quickly understand current hot topics\n\n**In-Depth Topic Type**:\n```yaml\nadvanced:\n  weight:\n    rank: 0.4           # Moderate ranking focus\n    frequency: 0.5      # Emphasize sustained heat within the day\n    hotness: 0.1\n```\n**Target Users**: Investors, researchers, journalists, users needing deep trend analysis\n\n#### Adjustment Method\n1. **Three numbers must sum to 1.0**\n2. **Increase what's important**: Increase `rank` for rankings, `frequency` for continuity\n3. **Suggest adjusting 0.1-0.2 at a time**, observe effects\n\nCore idea: Users pursuing speed and timeliness increase ranking weight, users pursuing depth and stability increase frequency weight.\n\n</details>\n\n### 5. What will the messages look like?\n\n<details>\n<summary>👉 Click to expand: <strong>Message Style Preview</strong></summary>\n<br>\n\n#### Push Example\n\n📊 Trending Keywords Stats\n\n🔥 [1/3] AI ChatGPT : 2 items\n\n  1. [Baidu Hot] 🆕 ChatGPT-5 officially launched [**1**] - 09:15 (1 time)\n\n  2. [Toutiao] AI chip concept stocks surge [**3**] - [08:30 ~ 10:45] (3 times)\n\n━━━━━━━━━━━━━━━━━━━\n\n📈 [2/3] BYD Tesla : 2 items\n\n  1. [Weibo] 🆕 BYD monthly sales break record [**2**] - 10:20 (1 time)\n\n  2. [Douyin] Tesla price reduction promotion [**4**] - [07:45 ~ 09:15] (2 times)\n\n━━━━━━━━━━━━━━━━━━━\n\n📌 [3/3] A-shares Stock Market : 1 item\n\n  1. [Wallstreetcn] A-shares midday review [**5**] - [11:30 ~ 12:00] (2 times)\n\n🆕 New Trending News (Total 2 items)\n\n**Baidu Hot** (1 item):\n  1. ChatGPT-5 officially launched [**1**]\n\n**Weibo** (1 item):\n  1. BYD monthly sales break record [**2**]\n\nUpdated: 2025-01-15 12:30:15\n\n#### Message Format Explanation\n\n| Format Element | Example | Meaning | Description |\n| ------------- | ------- | -------- | ----------- |\n| 🔥📈📌 | 🔥 [1/3] AI ChatGPT | Popularity Level | 🔥 High (≥10) 📈 Medium (5-9) 📌 Normal (<5) |\n| [Number/Total] | [1/3] | Rank Position | Current group rank among all matches |\n| Keyword Group | AI ChatGPT | Keyword Group | Group from config, title must contain words |\n| : N items | : 2 items | Match Count | Total news matching this group |\n| [Platform] | [Baidu Hot] | Source Platform | Platform name of the news |\n| 🆕 | 🆕 ChatGPT-5 officially launched | New Mark | First appearance in this round |\n| [**number**] | [**1**] | High Rank | Rank ≤ threshold, bold red display |\n| [number] | [7] | Normal Rank | Rank > threshold, normal display |\n| - time | - 09:15 | First Time | Time when news was first discovered |\n| [time~time] | [08:30 ~ 10:45] | Duration | Time range from first to last appearance |\n| (N times) | (3 times) | Frequency | Total appearances during monitoring |\n| **New Section** | 🆕 **New Trending News** | New Topic Summary | Separately shows newly appeared topics |\n\n</details>\n\n\n### 6. Docker Deployment\n\n<details>\n<summary>👉 Click to expand: <strong>Complete Docker Deployment Guide</strong></summary>\n<br>\n\n**Image Description:**\n\nTrendRadar provides two independent Docker images, deploy according to your needs:\n\n| Image Name | Purpose | Description |\n|---------|------|------|\n| `wantcat/trendradar` | News Push Service | Scheduled news crawling, push notifications (Required) |\n| `wantcat/trendradar-mcp` | AI Analysis Service | MCP protocol support, AI dialogue analysis (Optional) |\n\n> 💡 **Recommendations**:\n> - Only need push functionality: Deploy `wantcat/trendradar` image only\n> - Need AI analysis: Deploy both images\n\n---\n\n#### Method 1: Using docker compose (Recommended)\n\n1. **Create Project Directory and Config**:\n\n   ```bash\n   # Clone project to local\n   git clone https://github.com/sansan0/TrendRadar.git\n   cd TrendRadar\n   ```\n\n   > 💡 **Note**: Key directory structure required for Docker deployment:\n```\ncurrent directory/\n├── config/\n│   ├── config.yaml                 # Core config (required)\n│   ├── frequency_words.txt         # Keyword config (required)\n│   ├── timeline.yaml               # Timeline config\n│   ├── ai_analysis_prompt.txt      # AI analysis prompt (optional)\n│   ├── ai_translation_prompt.txt   # AI translation prompt (optional)\n│   ├── ai_interests.txt            # AI interest filtering config (optional)\n│   ├── ai_filter/                  # AI filter prompts\n│   │   ├── prompt.txt\n│   │   ├── extract_prompt.txt\n│   │   └── update_tags_prompt.txt\n│   └── custom/                     # User custom config (optional)\n│       ├── ai/                     # Custom AI prompts\n│       └── keyword/                # Custom keyword files\n└── docker/\n    ├── .env                        # Sensitive info + Docker-specific config\n    └── docker-compose.yml          # Docker Compose orchestration file\n```\n\n2. **Config File Description**:\n\n   **Configuration Division Principles (v4.6.0 optimized)**:\n\n   | File | Purpose | Change Frequency | Description |\n   |------|---------|-----------------|-------------|\n   | `config/config.yaml` | **Core config** | Low | Report mode, push settings, storage format, push window, AI analysis toggle, platform enable/disable, etc. |\n   | `config/frequency_words.txt` | **Keyword config** | High | Set your interested trending keywords, supports groups, regex, aliases, and advanced syntax |\n   | `config/timeline.yaml` | **Timeline config** | Low | Controls news timeline display and filtering rules |\n   | `config/ai_analysis_prompt.txt` | **AI analysis prompt** | Medium | Customize AI analysis role definition and output format (v5.0.0+) |\n   | `config/ai_translation_prompt.txt` | **AI translation prompt** | Low | Customize AI translation prompt template |\n   | `config/ai_interests.txt` | **AI interest filtering** | Medium | Define rules for AI to auto-filter news based on interests |\n   | `config/ai_filter/` | **AI filter prompts** | Low | Internal prompts for AI filter module (usually no need to modify) |\n   | `config/custom/` | **User custom extensions** | As needed | `custom/ai/` for custom AI prompts, `custom/keyword/` for custom keyword files |\n   | `docker/.env` | **Sensitive info + Docker-specific config** | Low | Webhook URLs, API Keys, S3 credentials, scheduled tasks, **not tracked by git** |\n\n   > 💡 **Division Guidelines**:\n   > - **Feature behavior** → Edit `config.yaml` (e.g., enable/disable platforms, adjust push mode)\n   > - **Content of interest** → Edit `frequency_words.txt` (e.g., add new keywords to follow)\n   > - **AI output style** → Edit `ai_analysis_prompt.txt` or `ai_translation_prompt.txt`\n   > - **Keys & credentials** → Edit `docker/.env` (API Keys, Webhook URLs, and other sensitive info go here)\n   > - **Custom extensions** → Use `config/custom/` directory to avoid default configs being overwritten by upgrades\n\n   **⚙️ Environment Variable Override Mechanism (v3.0.5+)**\n\n   If you encounter **config.yaml modifications not taking effect** in NAS or other Docker environments, you can directly override configs via environment variables:\n\n   | Environment Variable | Corresponding Config | Example Value | Description |\n   |---------------------|---------------------|---------------|-------------|\n   | `ENABLE_WEBSERVER` | - | `true` / `false` | Auto-start web server |\n   | `WEBSERVER_PORT` | - | `8080` | Web server port |\n   | `WEBSERVER_WATCHDOG` | - | `true` / `false` | Turn on \"auto-recover web page service\" (restarts it if it crashes) |\n   | `WEBSERVER_WATCHDOG_INTERVAL` | - | `60` | How often to check and auto-recover (seconds) |\n   | `FEISHU_WEBHOOK_URL` | `notification.channels.feishu.webhook_url` | `https://...` | Feishu Webhook (multi-account use `;` separator) |\n   | `AI_ANALYSIS_ENABLED` | `ai_analysis.enabled` | `true` / `false` | Enable AI analysis (v5.0.0 new) |\n   | `AI_API_KEY` | `ai.api_key` | `sk-xxx...` | AI API Key (shared by ai_analysis and ai_translation) |\n   | `AI_PROVIDER` | `ai.provider` | `deepseek` / `openai` / `gemini` | AI provider (v5.0.0 new) |\n   | `S3_*` | `storage.remote.*` | - | Remote storage config (5 params) |\n\n   **Config Priority**: Environment Variables > config.yaml\n\n   **Usage Method**:\n   - Modify `.env` file, uncomment and fill in needed configs\n   - Or add directly in NAS/Synology Docker management interface's \"Environment Variables\"\n   - Restart container to take effect: `docker compose up -d`\n\n\n3. **Start Service**:\n\n   **Option A: Start All Services (Push + AI Analysis)**\n   ```bash\n   # Pull latest images\n   docker compose pull\n\n   # Start all services (trendradar + trendradar-mcp)\n   docker compose up -d\n   ```\n\n   **Option B: Start News Push Service Only**\n   ```bash\n   # Start trendradar only (scheduled crawling and push)\n   docker compose pull trendradar\n   docker compose up -d trendradar\n   ```\n\n   **Option C: Start MCP AI Analysis Service Only**\n   ```bash\n   # Start trendradar-mcp only (AI analysis interface)\n   docker compose pull trendradar-mcp\n   docker compose up -d trendradar-mcp\n   ```\n\n   > 💡 **Tips**:\n   > - Most users only need to start `trendradar` for news push functionality\n   > - Only need to start `trendradar-mcp` when using ChatGPT/Gemini for AI dialogue analysis\n   > - Both services are independent and can be flexibly combined\n\n4. **Check Running Status**:\n   ```bash\n   # View news push service logs\n   docker logs -f trendradar\n\n   # View MCP AI analysis service logs\n   docker logs -f trendradar-mcp\n\n   # View all container status\n   docker ps | grep trendradar\n\n   # Stop specific service\n   docker compose stop trendradar      # Stop push service\n   docker compose stop trendradar-mcp  # Stop MCP service\n   ```\n\n#### Method 2: Local Build (Developer Option)\n\nIf you need custom code modifications or build your own image:\n\n```bash\n# Clone project\ngit clone https://github.com/sansan0/TrendRadar.git\ncd TrendRadar\n\n# Modify config files\nvim config/config.yaml\nvim config/frequency_words.txt\n\n# Use build version docker compose\ncd docker\ncp docker-compose-build.yml docker-compose.yml\n```\n\n**Build and Start Services**:\n\n```bash\n# Option A: Build and start all services\ndocker compose build\ndocker compose up -d\n\n# Option B: Build and start news push service only\ndocker compose build trendradar\ndocker compose up -d trendradar\n\n# Option C: Build and start MCP AI analysis service only\ndocker compose build trendradar-mcp\ndocker compose up -d trendradar-mcp\n```\n\n> 💡 **Architecture Parameter Notes**:\n> - Default builds `amd64` architecture images (suitable for most x86_64 servers)\n> - To build `arm64` architecture (Apple Silicon, Raspberry Pi, etc.), set environment variable:\n>   ```bash\n>   export DOCKER_ARCH=arm64\n>   docker compose build\n>   ```\n\n#### Image Update\n\n```bash\n# Method 1: Manual update (Crawler + MCP images)\ndocker pull wantcat/trendradar:latest\ndocker pull wantcat/trendradar-mcp:latest\ndocker compose down\ndocker compose up -d\n\n# Method 2: Using docker compose update\ndocker compose pull\ndocker compose up -d\n```\n\n**Available Images**:\n\n| Image Name | Purpose | Description |\n|---------|------|---------|\n| `wantcat/trendradar` | News Push Service | Scheduled news crawling, push notifications |\n| `wantcat/trendradar-mcp` | MCP Service | AI analysis features (optional) |\n\n#### Service Management Commands\n\n```bash\n# View running status\ndocker exec -it trendradar python manage.py status\n\n# Manually execute crawler once\ndocker exec -it trendradar python manage.py run\n\n# View real-time logs\ndocker exec -it trendradar python manage.py logs\n\n# Display current config\ndocker exec -it trendradar python manage.py config\n\n# Display output files\ndocker exec -it trendradar python manage.py files\n\n# Web server management (for browser access to generated reports)\ndocker exec -it trendradar python manage.py start_webserver   # Start web server\ndocker exec -it trendradar python manage.py stop_webserver    # Stop web server\ndocker exec -it trendradar python manage.py webserver_status  # Check web server status\n\n# View help info\ndocker exec -it trendradar python manage.py help\n\n# Restart container\ndocker restart trendradar\n\n# Stop container\ndocker stop trendradar\n\n# Remove container (keep data)\ndocker rm trendradar\n```\n\n> 💡 **Web Server Notes**:\n> - After starting, access latest report at `http://localhost:8080`\n> - Access historical reports via directory navigation (e.g., `http://localhost:8080/2025-xx-xx/`)\n> - Port can be configured in `.env` file with `WEBSERVER_PORT` parameter\n> - Auto-start: Set `ENABLE_WEBSERVER=true` in `.env`\n> - Auto-recover: `WEBSERVER_WATCHDOG=true` (default). It checks every `WEBSERVER_WATCHDOG_INTERVAL` seconds and restarts the web page service if needed\n> - `stop_webserver` means you manually turn off the web page service (command: `docker exec -it trendradar python manage.py stop_webserver`)\n> - \"Auto restart\" means the system turns that web page service back on automatically. If you stopped it manually and want it back, run `docker exec -it trendradar python manage.py start_webserver`\n> - Security: Static files only, limited to output directory, localhost binding only\n\n#### Data Persistence\n\nGenerated reports and data are saved in `./output` directory by default. Data persists even if container is restarted or removed.\n\n**📊 Web Report Access Paths**:\n\nTrendRadar generates daily summary HTML reports to two locations simultaneously:\n\n| File Location | Access Method | Use Case |\n|--------------|---------------|----------|\n| `output/index.html` | Direct host access | **Docker Deployment** (via Volume mount, visible on host) |\n| `index.html` | Root directory access | **GitHub Pages** (repository root, auto-detected by Pages) |\n| `output/html/YYYY-MM-DD/当日汇总.html` | Historical reports | All environments (archived by date) |\n\n**Local Access Examples**:\n```bash\n# Method 1: Via Web Server (recommended, Docker environment)\n# 1. Start web server\ndocker exec -it trendradar python manage.py start_webserver\n# 2. Access in browser\nhttp://localhost:8080                           # Access latest report (default index.html)\nhttp://localhost:8080/html/2025-xx-xx/          # Access reports for specific date\n\n# Method 2: Direct file access (local environment)\nopen ./output/index.html             # macOS\nstart ./output/index.html            # Windows\nxdg-open ./output/index.html         # Linux\n\n# Method 3: Access historical archives\nopen ./output/html/2025-xx-xx/当日汇总.html\n```\n\n**Why two index.html files?**\n- `output/index.html`: Docker Volume mounted to host, can be opened locally\n- `index.html`: Pushed to repository by GitHub Actions, auto-deployed by GitHub Pages\n\n> 💡 **Tip**: Both files have identical content, choose either one to access.\n\n#### Troubleshooting\n\n```bash\n# Check container status\ndocker inspect trendradar\n\n# View container logs\ndocker logs --tail 100 trendradar\n\n# Enter container for debugging\ndocker exec -it trendradar /bin/bash\n\n# Verify config files\ndocker exec -it trendradar ls -la /app/config/\n```\n\n#### MCP Service Deployment (AI Analysis Feature)\n\nIf you need to use AI analysis features, you can deploy the standalone MCP service container.\n\n**Architecture Description**:\n\n```mermaid\nflowchart TB\n    subgraph trendradar[\"trendradar\"]\n        A1[Scheduled News Fetching]\n        A2[Push Notifications]\n    end\n    \n    subgraph trendradar-mcp[\"trendradar-mcp\"]\n        B1[127.0.0.1:3333]\n        B2[AI Analysis API]\n    end\n    \n    subgraph shared[\"Shared Volume\"]\n        C1[\"config/ (ro)\"]\n        C2[\"output/ (ro)\"]\n    end\n    \n    trendradar --> shared\n    trendradar-mcp --> shared\n```\n\n**Quick Start**:\n\nUse docker compose to start both news push and MCP services:\n\n```bash\n# Clone project (Recommended)\ngit clone https://github.com/sansan0/TrendRadar.git\ncd TrendRadar/docker\ndocker compose up -d\n\n# Check running status\ndocker ps | grep trendradar\n```\n\n**Start MCP Service Separately**:\n\n```bash\n# Linux/Mac\ndocker run -d --name trendradar-mcp \\\n  -p 127.0.0.1:3333:3333 \\\n  -v $(pwd)/config:/app/config:ro \\\n  -v $(pwd)/output:/app/output:ro \\\n  -e TZ=Asia/Shanghai \\\n  wantcat/trendradar-mcp:latest\n\n# Windows PowerShell\ndocker run -d --name trendradar-mcp `\n  -p 127.0.0.1:3333:3333 `\n  -v ${PWD}/config:/app/config:ro `\n  -v ${PWD}/output:/app/output:ro `\n  -e TZ=Asia/Shanghai `\n  wantcat/trendradar-mcp:latest\n```\n\n> ⚠️ **Note**: Ensure `config/` and `output/` folders exist in current directory with config files and news data before running.\n\n**Verify Service**:\n\n```bash\n# Check MCP service health\ncurl http://127.0.0.1:3333/mcp\n\n# View MCP service logs\ndocker logs -f trendradar-mcp\n```\n\n**Configure in AI Clients**:\n\nAfter MCP service starts, configure based on your client:\n\n**Cherry Studio** (Recommended, GUI config):\n- Settings → MCP Server → Add\n- Type: `streamableHttp`\n- URL: `http://127.0.0.1:3333/mcp`\n\n**Claude Desktop / Cline** (JSON config):\n```json\n{\n  \"mcpServers\": {\n    \"trendradar\": {\n      \"url\": \"http://127.0.0.1:3333/mcp\",\n      \"type\": \"streamableHttp\"\n    }\n  }\n}\n```\n\n> 💡 **Tip**: MCP service only listens on local port (127.0.0.1) for security. For remote access, configure reverse proxy and authentication yourself.\n\n</details>\n\n### 7. How is the push content displayed?\n\n<details>\n<summary>👉 Click to expand: <strong>Customize Push Style and Content</strong></summary>\n<br>\n\n**Configuration Location:** `report` and `display` sections in `config/config.yaml`\n\n```yaml\nreport:\n  mode: \"daily\"                    # Push mode\n  display_mode: \"keyword\"          # Display mode (v4.6.0 new)\n  rank_threshold: 5                # Ranking highlight threshold\n  sort_by_position_first: false    # Sorting priority\n  max_news_per_keyword: 0          # Maximum display count per keyword\n\ndisplay:\n  region_order:                    # Region display order (v5.2.0 new)\n    - new_items                    # New trending section\n    - hotlist                      # Hotlist section\n    - rss                          # RSS subscription section\n    - standalone                   # Independent display section\n    - ai_analysis                  # AI analysis section\n```\n\n#### Configuration Details\n\n| Config Item | Type | Default | Description |\n|------------|------|---------|-------------|\n| `mode` | string | `daily` | Push mode, options: `daily`/`incremental`/`current`, see [Push Mode Details](#3-push-mode-details) |\n| `display_mode` | string | `keyword` | Display mode, options: `keyword`/`platform`, see below |\n| `rank_threshold` | int | `5` | Ranking highlight threshold, news with rank ≤ this value will be displayed in bold |\n| `sort_by_position_first` | bool | `false` | Sorting priority: `false`=sort by news count, `true`=sort by config position |\n| `max_news_per_keyword` | int | `0` | Maximum display count per keyword, `0`=unlimited |\n| `display.region_order` | list | See config above | Adjust list order to control region display positions |\n\n#### Display Mode Configuration (v4.6.0 New)\n\nControls how news is grouped in push messages and HTML reports:\n\n| Mode | Grouping | Title Prefix | Use Case |\n|------|----------|--------------|----------|\n| `keyword` (default) | Group by keyword | `[Platform]` | Users focusing on specific topics |\n| `platform` | Group by platform | `[Keyword]` | Users focusing on specific platforms |\n\n**Example Comparison:**\n\n```\n# keyword mode (group by keyword)\n📊 Trending Keywords Stats\n🔥 [1/3] AI : 12 items\n  1. [Weibo] OpenAI releases GPT-5 #1-#3 - 08:30 (5 times)\n  2. [Zhihu] How to view AI replacing programmers #2 - 09:15 (3 times)\n\n# platform mode (group by platform)\n📊 Trending News Stats\n🔥 [1/4] Weibo : 12 items\n  1. [AI] OpenAI releases GPT-5 #1-#3 - 08:30 (5 times)\n  2. [Trump] Trump announces major policy #2 - 09:15 (3 times)\n```\n\n#### Region Display Order (region_order)\n\nControl the display position of each section in push messages by adjusting the order of `display.region_order` list.\n\n**Default Order**: New Items → Hotlist → RSS → Standalone → AI Analysis\n\n**Custom Example**: Want AI analysis at the top?\n\n```yaml\ndisplay:\n  region_order:\n    - ai_analysis                  # Move to first line\n    - new_items\n    - hotlist\n    - rss\n    - standalone\n```\n\n**Note**: A region will only be displayed when both conditions are met:\n1. Listed in `region_order`\n2. Corresponding switch in `display.regions` is `true`\n\n#### Region Switches (regions)\n\nControl whether each region is displayed in push notifications via `display.regions`:\n\n```yaml\ndisplay:\n  regions:\n    hotlist: true                    # Hotlist region (keyword-matched trending news)\n    new_items: false                 # New items region (new hotlist + new RSS items)\n    rss: true                       # RSS region (keyword-matched RSS content)\n    standalone: false                # Standalone section (full hotlist/RSS, unfiltered by keywords)\n    ai_analysis: true                # AI analysis region\n```\n\n| Region | Config Key | Default | Description |\n|--------|-----------|---------|-------------|\n| **Hotlist** | `hotlist` | `true` | Keyword-matched trending news aggregation |\n| **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 |\n| **RSS** | `rss` | `true` | Keyword-matched RSS subscription content. When disabled, RSS analysis is skipped, but RSS in standalone section is unaffected |\n| **Standalone** | `standalone` | `false` | Full content display for specified platforms/RSS, unfiltered by keywords |\n| **AI Analysis** | `ai_analysis` | `true` | AI-generated trending analysis summary |\n\n#### Sorting Priority Configuration\n\n**Example Scenario:** Config order A, B, C, news count A(3), B(10), C(5)\n\n| Config Value | Display Order | Use Case |\n|-------------|--------------|----------|\n| `false` (default) | B(10) → C(5) → A(3) | Focus on popularity trends |\n| `true` | A(3) → B(10) → C(5) | Focus on personal priority |\n\n**Docker Environment Variables:**\n```bash\nSORT_BY_POSITION_FIRST=true\nMAX_NEWS_PER_KEYWORD=10\n```\n\n#### Independent Display Section Configuration (v5.0.0 New)\n\nProvides full trending list display for specified platforms, unaffected by `frequency_words.txt` keyword filtering.\n\n**Configuration Location:** `display` section in `config/config.yaml`\n\n```yaml\ndisplay:\n  regions:\n    standalone: true                  # Show standalone section in push (disabling doesn't affect AI analysis)\n\n  standalone:\n    platforms: [\"zhihu\", \"weibo\"]     # Trending platform ID list\n    rss_feeds: [\"hacker-news\"]        # RSS feed ID list\n    max_items: 20                     # Max display count per source (0=unlimited)\n```\n\n> 💡 **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.\n\n**Use Cases:**\n- Want to view the complete trending ranking of a platform (like Zhihu) instead of just keyword-matched content\n- Subscribed to RSS feeds with few updates (like personal blogs) and want full push every time\n\n**Effect Example:**\n```\n📋 Independent Display Section (Total 15 items)\n\nZhihu Trending (10 items):\n  1. [Zhihu] How to view OpenAI releasing Sora?\n  2. [Zhihu] 2024 postgraduate entrance exam scores released...\n  ...\n\nHacker News (5 items):\n  1. [Hacker News] Launch HN: TrendRadar...\n  ...\n```\n\n</details>\n\n### 8. When will I receive pushes?\n\n<details>\n<summary>👉 Click to expand: <strong>Set Push Time (Scheduling System)</strong></summary>\n<br>\n\n**Configuration Location:** `schedule` section in `config/config.yaml` + `config/timeline.yaml`\n\n#### Quick Start\n\nJust pick a preset template in `config.yaml` — no need to edit `timeline.yaml`:\n\n```yaml\nschedule:\n  enabled: true\n  preset: \"morning_evening\"     # Change this line\n```\n\n#### Available Preset Templates\n\n| Template | Description | Push Behavior |\n|----------|-------------|---------------|\n| `morning_evening` | Incremental + evening summary (recommended) | Push new content all day + 19:00-21:00 daily summary |\n| `always_on` | 24/7 monitoring | Push whenever new content appears, no time restrictions |\n| `office_hours` | Office hours | Three-phase weekday push (morning briefing → noon update → closing summary), weekends incremental |\n| `night_owl` | Night owl | Afternoon peek + late-night daily summary (22:00-01:00 cross-midnight) |\n| `custom` | Fully customizable | Edit the `custom` section at the bottom of `timeline.yaml` |\n\n#### Full Customization\n\nIf 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.\n\n#### Important Notice\n\n> ⚠️ **Users upgrading from older versions:**\n> - v6.0.0 removed the old `notification.push_window` and `ai_analysis.analysis_window` configs\n> - Please switch to the new `schedule` + `timeline.yaml` scheduling system\n> - Old \"push once per day\" can be replaced with the `morning_evening` preset\n> - Old \"working hours push\" can be replaced with the `office_hours` preset\n\n> ⚠️ **GitHub Actions Users Note:**\n> - GitHub Actions execution time is unstable, may have ±15 minutes deviation\n> - Time period ranges should be at least **2 hours** wide\n> - For precise timed push, recommend **Docker deployment** on personal server\n\n</details>\n\n### 9. How often does it run?\n\n<details>\n<summary>👉 Click to expand: <strong>Set Auto-Run Frequency</strong></summary>\n<br>\n\n**Configuration Location:** `schedule` section in `.github/workflows/crawler.yml`\n\n```yaml\non:\n  schedule:\n    - cron: \"0 * * * *\"  # Run every hour\n```\n\n#### How to change the schedule?\n\nGitHub Actions uses a time format called \"Cron\". You don't need to understand it deeply; just copy and replace the code below.\n\n**Configuration Location**: `schedule` section in `.github/workflows/crawler.yml`\n\n| I want... | Copy this line | Note |\n|-----------|----------------|------|\n| **Every Hour** | `- cron: \"0 * * * *\"` | **Default**, runs at minute 0 |\n| **Every 30 Mins** | `- cron: \"*/30 * * * *\"` | Runs every 30 minutes |\n| **Daily at 8 AM** | `- cron: \"0 0 * * *\"` | ⚠️ `0` because UTC 0:00 = Beijing 8:00 AM |\n| **Work Hours (30m)** | `- cron: \"*/30 0-14 * * *\"` | Beijing 8:00 - 22:00 |\n| **3 Times Daily** | `- cron: \"0 0,6,12 * * *\"` | Beijing 8:00, 14:00, 20:00 |\n\n#### ⚠️ Two Important Notes\n\n1. **Time Zone**: GitHub servers use **UTC time**.\n   - **Math**: Your desired Beijing time **minus 8 hours** = value to fill.\n   - *Example: For Beijing 20:00, fill in 12:00.*\n\n2. **Don't run too often**: Suggest intervals no shorter than 30 minutes.\n   - GitHub free resources are limited; running too frequently might get flagged.\n   - Actions have startup delays, so precise timing isn't guaranteed anyway.\n\n#### Step-by-Step Guide\n\n1. In your GitHub repository, find `.github/workflows/crawler.yml`.\n2. Click the ✏️ (Edit) button top right.\n3. Find the line `cron: \"...\"` and replace the content inside quotes with the code above.\n4. Click the green **Commit changes** button to save.\n\n</details>\n\n### 10. Push to multiple groups/devices\n\n<details>\n<summary>👉 Click to expand: <strong>Send to Multiple Recipients</strong></summary>\n<br>\n\n**Configuration Location:** `notification` section in `config/config.yaml`\n\n> ### ⚠️ **Security First**\n> **DO NOT write passwords/Tokens directly in `config.yaml`!**\n> If you upload a file containing passwords to GitHub, the whole world can see it.\n>\n> **Correct Method**:\n> - **GitHub Actions Users**: Add in Settings -> Secrets\n> - **Docker Users**: Write in `.env` file (this file won't be uploaded)\n\n#### How to push to multiple places?\n\nSimple, just separate multiple addresses with a semicolon `;`.\n\n**Example**:\nSuppose you have two Feishu groups:\n- Group 1: `https://.../webhook/aaa`\n- Group 2: `https://.../webhook/bbb`\n\nConfig value:\n`https://.../webhook/aaa;https://.../webhook/bbb`\n\n#### Supported Platforms\n\n| Platform | Method | Note |\n|----------|--------|------|\n| **Feishu/DingTalk/WeWork** | Separate URLs with `;` | Just chain them up |\n| **Bark (iOS)** | Separate URLs with `;` | Push to multiple iPhones |\n| **Telegram** | Separate Tokens and ChatIDs with `;` | ⚠️ **Order must match**: <br>Token1 ↔ ChatID1<br>Token2 ↔ ChatID2 |\n| **ntfy** | Separate Topics and Tokens with `;` | If a topic needs no token, leave empty:<br>`token1;;token3` (middle is empty) |\n\n#### Common Config Examples (GitHub Secrets / .env)\n\n```bash\n# Send to 3 Feishu groups\nFEISHU_WEBHOOK_URL=https://hook1...;https://hook2...;https://hook3...\n\n# Send to 2 DingTalk groups\nDINGTALK_WEBHOOK_URL=https://oapi...;https://oapi...\n\n# Send to 2 Telegram users (Match one-to-one)\nTELEGRAM_BOT_TOKEN=tokenA;tokenB\nTELEGRAM_CHAT_ID=userA;userB\n```\n\n> **Tip**: Default limit is 3 accounts per platform to prevent abuse. Adjust `MAX_ACCOUNTS_PER_CHANNEL` if needed.\n\n</details>\n\n<br>\n\n### 11. Where is the data saved?\n\n<details id=\"storage-config\">\n<summary>👉 Click to expand: <strong>Choose Data Storage Location</strong></summary>\n<br>\n\n#### Where is the data saved?\n\nThe system automatically selects the best location for you, so you usually don't need to worry about it:\n\n| Your Environment | Data Location | Description |\n|------------------|---------------|-------------|\n| **Docker / Local** | **Local Disk** | Saved in the `output/` folder within the project directory. |\n| **GitHub Actions** | **Cloud Storage** | Since GitHub Actions environments are destroyed after running, cloud storage (e.g., Cloudflare R2) is required. |\n\n#### How to configure cloud storage? (For GitHub Actions Users)\n\nIf you run on GitHub Actions, you need a \"cloud drive\" to save data. For example, Cloudflare R2 (free tier is generous).\n\n**Add these 5 variables to GitHub Secrets:**\n\n| Variable Name | Value |\n|---------------|-------|\n| `STORAGE_BACKEND` | `remote` |\n| `S3_BUCKET_NAME` | Your bucket name |\n| `S3_ACCESS_KEY_ID` | Your Access Key |\n| `S3_SECRET_ACCESS_KEY` | Your Secret Key |\n| `S3_ENDPOINT_URL` | Your R2 endpoint URL |\n\n> 💡 **Tutorial**: How to apply for R2? See [Quick Start - Remote Storage Configuration](#-quick-start)\n\n#### How long is data kept?\n\nBy default, we never delete your data. If you want to save space, you can enable \"Auto Cleanup\".\n\n**Config Location**: `config/config.yaml`\n\n```yaml\nstorage:\n  local:\n    retention_days: 30    # Keep local data for 30 days (0 = forever)\n  remote:\n    retention_days: 30    # Keep cloud data for 30 days\n```\n\n#### Push time is wrong? (Timezone Settings)\n\nIf you are overseas or find the push time doesn't match your local time, you can change the timezone.\n\n**Config Location**: `config/config.yaml`\n\n```yaml\napp:\n  timezone: \"Asia/Shanghai\"  # Default is China Standard Time\n```\n- Example for Los Angeles: `America/Los_Angeles`\n- Example for London: `Europe/London`\n\n</details>\n\n### 12. Let AI help me analyze hot topics\n\n<details id=\"ai-analysis-config\">\n<summary>👉 Click to expand: <strong>Enable AI Smart Analysis</strong></summary>\n\n#### What can AI do for me?\n\nAfter enabling this feature, AI acts as a professional analyst. When pushing a batch of news, it will:\n1. **Auto-Read**: Read all matched trending news.\n2. **Deep Think**: Analyze connections between seemingly isolated news items.\n3. **Write Report**: Append a concise and profound \"Insight Report\" at the end of the push message.\n\n**Includes**: Trending topic summary, public opinion direction, cross-platform correlation, potential impact assessment, etc.\n\n#### How to enable AI Analysis?\n\nThe simplest way is via environment variables (Recommended for GitHub Secrets or .env).\n\n**Required Configurations**:\n\n| Variable Name | Value | Description |\n|--------------|-------|-------------|\n| `AI_ANALYSIS_ENABLED` | `true` | Enable switch |\n| `AI_API_KEY` | `sk-xxxxxx` | Your API Key |\n| `AI_MODEL` | `deepseek/deepseek-chat` | Model identifier (format: `provider/model`) |\n\n**Supported AI Providers** (Based on LiteLLM, supports 100+ providers):\n\n| Provider | AI_MODEL Value | Description |\n|----------|----------------|-------------|\n| **DeepSeek** (Recommended) | `deepseek/deepseek-chat` | Excellent cost-performance ratio for high-frequency analysis |\n| **OpenAI** | `openai/gpt-4o`<br>`openai/gpt-4o-mini` | GPT-4o series |\n| **Google Gemini** | `gemini/gemini-1.5-flash`<br>`gemini/gemini-1.5-pro` | Gemini series |\n| **Custom API** | Any format | Use with `AI_API_BASE` |\n\n> 💡 **New Feature**: Now based on [LiteLLM](https://github.com/BerriAI/litellm) unified interface, supporting 100+ AI providers with simpler configuration and better error handling.\n\n**Optional Configurations**:\n\n| Variable Name | Default | Description |\n|--------------|---------|-------------|\n| `AI_API_BASE` | (auto) | Custom API endpoint (e.g., OneAPI, local models) |\n| `AI_TEMPERATURE` | `1.0` | Sampling temperature (0-2, higher = more random) |\n| `AI_MAX_TOKENS` | `5000` | Maximum tokens to generate |\n| `AI_TIMEOUT` | `120` | Request timeout (seconds) |\n| `AI_NUM_RETRIES` | `2` | Number of retries on failure |\n\n#### Advanced: AI Translation\n\nIf you subscribe to foreign RSS feeds (like Hacker News), AI can translate the content into your native language.\n\n**Configuration Location**: `config/config.yaml`\n\n```yaml\nai_translation:\n  enabled: true          # Enable translation\n  language: \"Chinese\"    # Target language (Chinese, English, Japanese...)\n```\n\n#### Advanced: Customize AI \"Persona\"\n\nThink the AI sounds too official? You can modify its prompt to change its style (e.g., \"Sarcastic Commentator\", \"Senior Investment Advisor\").\n\n- **File**: `config/ai_analysis_prompt.txt`\n- **Method**: Edit with a text editor, tell AI what analysis style you want.\n\n</details>\n\n<br>\n\n## ✨ AI Analysis\n\nTrendRadar v3.0.0 added **MCP (Model Context Protocol)** based AI analysis feature, allowing natural language conversations with news data for deep analysis.\n\n\n### ⚠️ Important Notice Before Use\n\n\n**Critical: AI features require local news data support**\n\nAI analysis **does not** query real-time online data directly, but analyzes **locally accumulated news data** (stored in the `output` folder)\n\n\n#### Usage Instructions:\n\n1. **Built-in Test Data**: The `output` directory includes one week of trending news data from **December 21-27, 2025** for quick feature testing\n\n2. **Query Limitations**:\n   - ✅ Only query data within available date range (Dec 21-27, 7 days total)\n   - ❌ Cannot query real-time news or future dates\n\n3. **Getting Latest Data**:\n   - Test data is for quick experience only, **recommend deploying the project yourself** to get real-time data\n   - Follow [Quick Start](#-quick-start) to deploy and run the project\n   - After accumulating news data for at least 1 day, you can query the latest trending topics\n\n---\n\n### 1. Quick Deployment\n\nCherry Studio provides GUI config interface, 5-minute quick deployment, complex parts are one-click install.\n\n**Illustrated Deployment Tutorial**: Now updated to my WeChat Official Account (see [Support Project](#-support-project)), reply \"mcp\" to get\n\n**Detailed Deployment Tutorial**: [README-Cherry-Studio.md](README-Cherry-Studio.md)\n\n**Deployment Mode Description**:\n- **STDIO Mode (Recommended)**: One-time configuration, no need to reconfigure later. The **illustrated deployment tutorial** only demonstrates this mode's configuration.\n- **HTTP Mode (Alternative)**: If STDIO mode configuration encounters issues, you can use HTTP mode. This mode's configuration is basically the same as STDIO, but only requires copy-pasting one line, less error-prone. The only thing to note is that you need to manually start the service before each use. For details, refer to the HTTP mode section at the bottom of [README-Cherry-Studio.md](README-Cherry-Studio.md).\n\n### 2. Learning to Talk with AI\n\n**Detailed Conversation Tutorial**: [README-MCP-FAQ.md](README-MCP-FAQ.md)\n\n**Question Effect**:\n\n> 💡 **Tip**: Actually not recommended to ask multiple questions at once. If your chosen AI model cannot even sequentially call as shown below, suggest switching models.\n\n<img src=\"/_image/ai4.png\" alt=\"MCP usage effect\" width=\"600\">\n\n<br>\n\n## 🔌 MCP Clients\n\nTrendRadar MCP service supports standard Model Context Protocol (MCP), can connect to various AI clients supporting MCP for smart analysis.\n\n### Supported Clients\n\n**Note**:\n- Replace `/path/to/TrendRadar` with your actual project path\n- Windows paths use double backslashes: `C:\\\\Users\\\\YourName\\\\TrendRadar`\n- Remember to restart after saving\n\n<details>\n<summary><b>👉 Click to expand: Cursor</b></summary>\n\n#### Method 1: HTTP Mode\n\n1. **Start HTTP Service**:\n   ```bash\n   # Windows\n   start-http.bat\n\n   # Mac/Linux\n   ./start-http.sh\n   ```\n\n2. **Configure Cursor**:\n\n   **Project Level Config** (Recommended):\n   Create `.cursor/mcp.json` in project root:\n   ```json\n   {\n     \"mcpServers\": {\n       \"trendradar\": {\n         \"url\": \"http://localhost:3333/mcp\",\n         \"description\": \"TrendRadar News Trending Aggregation Analysis\"\n       }\n     }\n   }\n   ```\n\n   **Global Config**:\n   Create `~/.cursor/mcp.json` in user directory (same content)\n\n3. **Usage Steps**:\n   - Restart Cursor after saving config\n   - Check connected tools in chat interface \"Available Tools\"\n   - Start using: `Search today's \"AI\" related news`\n\n#### Method 2: STDIO Mode (Recommended)\n\nCreate `.cursor/mcp.json`:\n```json\n{\n  \"mcpServers\": {\n    \"trendradar\": {\n      \"command\": \"uv\",\n      \"args\": [\n        \"--directory\",\n        \"/path/to/TrendRadar\",\n        \"run\",\n        \"python\",\n        \"-m\",\n        \"mcp_server.server\"\n      ]\n    }\n  }\n}\n```\n\n</details>\n\n<details>\n<summary><b>👉 Click to expand: VSCode (Cline/Continue)</b></summary>\n\n#### Cline Configuration\n\nAdd in Cline's MCP settings:\n\n**HTTP Mode**:\n```json\n{\n  \"trendradar\": {\n    \"url\": \"http://localhost:3333/mcp\",\n    \"type\": \"streamableHttp\",\n    \"autoApprove\": [],\n    \"disabled\": false\n  }\n}\n```\n\n**STDIO Mode** (Recommended):\n```json\n{\n  \"trendradar\": {\n    \"command\": \"uv\",\n    \"args\": [\n      \"--directory\",\n      \"/path/to/TrendRadar\",\n      \"run\",\n      \"python\",\n      \"-m\",\n      \"mcp_server.server\"\n    ],\n    \"type\": \"stdio\",\n    \"disabled\": false\n  }\n}\n```\n\n#### Continue Configuration\n\nEdit `~/.continue/config.json`:\n```json\n{\n  \"experimental\": {\n    \"modelContextProtocolServers\": [\n      {\n        \"transport\": {\n          \"type\": \"stdio\",\n          \"command\": \"uv\",\n          \"args\": [\n            \"--directory\",\n            \"/path/to/TrendRadar\",\n            \"run\",\n            \"python\",\n            \"-m\",\n            \"mcp_server.server\"\n          ]\n        }\n      }\n    ]\n  }\n}\n```\n\n**Usage Examples**:\n```\nAnalyze recent 7 days \"Tesla\" popularity trend\nGenerate today's trending summary report\nSearch \"Bitcoin\" related news and analyze sentiment\n```\n\n</details>\n\n<details>\n<summary><b>👉 Click to expand: MCP Inspector</b> (Debug Tool)</summary>\n<br>\n\nMCP Inspector is the official debug tool for testing MCP connections:\n\n#### Usage Steps\n\n1. **Start TrendRadar HTTP Service**:\n   ```bash\n   # Windows\n   start-http.bat\n\n   # Mac/Linux\n   ./start-http.sh\n   ```\n\n2. **Start MCP Inspector**:\n   ```bash\n   npx @modelcontextprotocol/inspector\n   ```\n\n3. **Connect in Browser**:\n   - Visit: `http://localhost:3333/mcp`\n   - Test \"Ping Server\" function to verify connection\n   - Check \"List Tools\" returns 14 tools:\n     - Date Parsing: resolve_date_range\n     - Basic Query: get_latest_news, get_news_by_date, get_trending_topics\n     - Smart Search: search_news, search_related_news_history\n     - Advanced Analysis: analyze_topic_trend, analyze_data_insights, analyze_sentiment, find_similar_news, generate_summary_report\n     - System Management: get_current_config, get_system_status, trigger_crawl\n\n</details>\n\n<details>\n<summary><b>👉 Click to expand: Other MCP-Compatible Clients</b></summary>\n<br>\n\nAny client supporting Model Context Protocol can connect to TrendRadar:\n\n#### HTTP Mode\n\n**Service Address**: `http://localhost:3333/mcp`\n\n**Basic Config Template**:\n```json\n{\n  \"name\": \"trendradar\",\n  \"url\": \"http://localhost:3333/mcp\",\n  \"type\": \"http\",\n  \"description\": \"News Trending Aggregation Analysis\"\n}\n```\n\n#### STDIO Mode (Recommended)\n\n**Basic Config Template**:\n```json\n{\n  \"name\": \"trendradar\",\n  \"command\": \"uv\",\n  \"args\": [\n    \"--directory\",\n    \"/path/to/TrendRadar\",\n    \"run\",\n    \"python\",\n    \"-m\",\n    \"mcp_server.server\"\n  ],\n  \"type\": \"stdio\"\n}\n```\n\n**Notes**:\n- Replace `/path/to/TrendRadar` with actual project path\n- Windows paths use backslash escape: `C:\\\\Users\\\\...`\n- Ensure project dependencies installed (ran setup script)\n\n</details>\n\n\n### Common Questions\n\n<details>\n<summary><b>👉 Click to expand: Q1: HTTP Service Cannot Start?</b></summary>\n<br>\n\n**Check Steps**:\n1. Confirm port 3333 is not occupied:\n   ```bash\n   # Windows\n   netstat -ano | findstr :3333\n\n   # Mac/Linux\n   lsof -i :3333\n   ```\n\n2. Check if project dependencies installed:\n   ```bash\n   # Re-run install script\n   # Windows: setup-windows.bat or setup-windows-en.bat\n   # Mac/Linux: ./setup-mac.sh\n   ```\n\n3. View detailed error logs:\n   ```bash\n   uv run python -m mcp_server.server --transport http --port 3333\n   ```\n4. Try custom port:\n   ```bash\n   uv run python -m mcp_server.server --transport http --port 33333\n   ```\n\n</details>\n\n<details>\n<summary><b>👉 Click to expand: Q2: Client Cannot Connect to MCP Service?</b></summary>\n<br>\n\n**Solutions**:\n\n1. **STDIO Mode**:\n   - Confirm UV path correct (run `which uv` or `where uv`)\n   - Confirm project path correct and no Chinese characters\n   - Check client error logs\n\n2. **HTTP Mode**:\n   - Confirm service started (visit `http://localhost:3333/mcp`)\n   - Check firewall settings\n   - Try using 127.0.0.1 instead of localhost\n\n3. **General Checks**:\n   - Restart client application\n   - Check MCP service logs\n   - Use MCP Inspector to test connection\n\n</details>\n\n<details>\n<summary><b>👉 Click to expand: Q3: Tool Call Failed or Returns Error?</b></summary>\n<br>\n\n**Possible Reasons**:\n\n1. **Data Does Not Exist**:\n   - Confirm crawler has run (have output directory data)\n   - Check query date range has data\n   - Check available dates in output directory\n\n2. **Parameter Error**:\n   - Check date format: `YYYY-MM-DD`\n   - Confirm correct platform ID: `zhihu`, `weibo`, etc.\n   - See parameter descriptions in tool docs\n\n3. **Config Issues**:\n   - Confirm `config/config.yaml` exists\n   - Confirm `config/frequency_words.txt` exists\n   - Check config file format is correct\n\n</details>\n\n<br>\n\n## 📚 Related Projects\n\n> **4 Related Articles** (Chinese):\n\n- [Comment here for mobile Q&A by project author](https://mp.weixin.qq.com/s/KYEPfTPVzZNWFclZh4am_g)\n- [Breaking 1000 stars in 2 months - My GitHub project promotion experience](https://mp.weixin.qq.com/s/jzn0vLiQFX408opcfpPPxQ)\n- [Important notes for running this project via GitHub fork](https://mp.weixin.qq.com/s/C8evK-U7onG1sTTdwdW2zg)\n- [How to write WeChat Official Account or news articles based on this project](https://mp.weixin.qq.com/s/8ghyfDAtQZjLrnWTQabYOQ)\n\n> **AI Development**:\n- If you have niche requirements, you can develop based on my project yourself, even with zero programming experience\n- All my open-source projects use my own **AI-assisted software** to improve development efficiency, this tool is now open-source\n- **Core Function**: Quickly filter project code to feed AI, you just need to add personal requirements\n- **Project Address**: https://github.com/sansan0/ai-code-context-helper\n\n### Other Projects\n\n> 📍 Chairman Mao's Footprint Map - Interactive dynamic display of complete trajectory 1893-1976. Welcome comrades to contribute data\n\n- https://github.com/sansan0/mao-map\n\n> Bilibili Comment Data Visualization Analysis Software\n\n- https://github.com/sansan0/bilibili-comment-analyzer\n\n\n[![Star History Chart](https://api.star-history.com/svg?repos=sansan0/TrendRadar&type=Date)](https://www.star-history.com/#sansan0/TrendRadar&Date)\n\n<br>\n\n## 📄 License\n\nGPL-3.0 License\n\n---\n\n<div align=\"center\">\n\n[🔝 Back to Top](#trendradar)\n\n</div>\n"
  },
  {
    "path": "README-MCP-FAQ-EN.md",
    "content": "<div align=\"center\">\n\n**[中文](README-MCP-FAQ.md)** | **English**\n\n</div>\n\n# TrendRadar MCP Tool Usage Q&A\n\n> AI Query Guide - How to Use News Trend Analysis Tools Through Natural Conversation (v3.1.7)\n\n---\n\n## 📋 Tools Overview\n\n| Category | Tool Name | Description |\n|:--------:|-----------|-------------|\n| **Date** | `resolve_date_range` | Parse \"this week\", \"last 7 days\" to standard dates |\n| **Query** | `get_latest_news` | Get the latest batch of trending news |\n| | `get_news_by_date` | Query historical news by date range |\n| | `get_trending_topics` | Get trending topics statistics (auto-extract supported) |\n| **RSS** | `get_latest_rss` | Get latest RSS subscription content |\n| | `search_rss` | Search keywords in RSS data |\n| | `get_rss_feeds_status` | View RSS feed config and data status |\n| **Search** | `search_news` | Unified search (keyword/fuzzy/entity, RSS optional) |\n| | `find_related_news` | Find news similar to a given title |\n| **Analysis** | `analyze_topic_trend` | Topic trend analysis (hotness/lifecycle/viral/predict) |\n| | `analyze_data_insights` | Data insights (platform compare/activity/co-occurrence) |\n| | `analyze_sentiment` | News sentiment analysis |\n| | `aggregate_news` | Cross-platform news aggregation & dedup |\n| | `compare_periods` | Period comparison (week-over-week/month-over-month) |\n| | `generate_summary_report` | Generate daily/weekly summary reports |\n| **System** | `get_current_config` | Get current system configuration |\n| | `get_system_status` | Get system running status |\n| | `check_version` | Check version updates (TrendRadar + MCP Server) |\n| | `trigger_crawl` | Manually trigger a crawl task |\n| **Storage** | `sync_from_remote` | Pull data from remote storage to local |\n| | `get_storage_status` | Get storage config and status |\n| | `list_available_dates` | List available dates (local/remote) |\n| **Article** | `read_article` | Read single article content (Markdown format) |\n| | `read_articles_batch` | Batch read multiple articles (max 5) |\n| **Notification** | `get_notification_channels` | Get all configured notification channels and their status |\n| | `send_notification` | Send messages to configured notification channels (auto format conversion) |\n\n---\n\n## ⚙️ Default Settings Explanation (Important!)\n\nThe following optimization strategies are adopted by default, mainly to save AI token consumption:\n\n| Default Setting | Description | How to Adjust |\n| -------------- | --------------------------------------- | ------------------------------------- |\n| **Result Limit** | Default returns 50 news items | Say \"return top 10\" or \"give me 100 items\" in conversation |\n| **Time Range** | Default queries today's data | Say \"query yesterday\", \"last week\" or \"Jan 1 to 7\" |\n| **URL Links** | Default no links (saves ~160 tokens/item) | Say \"need links\" or \"include URLs\" |\n| **Keyword List** | Default does not use frequency_words.txt to filter news | Only used when calling \"trending topics\" tool |\n\n**⚠️ Important:** The choice of AI model directly affects the tool call effectiveness. The smarter the AI, the more accurate the calls. When you remove the above restrictions, for example, from querying today to querying a week, first you need to have a week's data locally, and secondly, token consumption may multiply.\n\n**💡 Tip:** This project provides a dedicated date parsing tool that can accurately parse natural language date expressions like \"last 7 days\", \"this week\", ensuring all AI models get consistent date ranges. See Q18 below for details.\n\n\n## 💰 AI Models\n\nBelow I use the **[SiliconFlow](https://cloud.siliconflow.cn)** platform as an example, which has many large models to choose from. During the development and testing of this project, I used this platform for many functional tests and validations.\n\n### 📊 Registration Method Comparison\n\n| Registration Method | Direct Registration Without Referral | Registration With Referral Link |\n|:-------:|:-------:|:-----------------:|\n| Registration Link | [siliconflow.cn](https://cloud.siliconflow.cn) | [Referral Link](https://cloud.siliconflow.cn/i/fqnyVaIU) |\n| Free Quota | 0 tokens | **20 million tokens** (≈$2) |\n| Extra Benefits | ❌ | ✅ Referrer also gets 20 million tokens |\n\n> 💡 **Tip**: The above gift quota should allow for **200+ queries**\n\n\n### 🚀 Quick Start\n\n#### 1️⃣ Register and Get API Key\n\n1. Complete registration using the link above\n2. Visit [API Key Management Page](https://cloud.siliconflow.cn/me/account/ak)\n3. Click \"Create New API Key\"\n4. Copy the generated key (please keep it safe)\n\n#### 2️⃣ Configure in Cherry Studio\n\n1. Open **Cherry Studio**\n2. Go to \"Model Service\" settings\n3. Find \"SiliconFlow\"\n4. Paste the copied key into the **[API Key]** input box\n5. Ensure the checkbox in the top right corner shows **green** when enabled ✅\n\n---\n\n### ✨ Configuration Complete!\n\nNow you can start using this project and enjoy stable and fast AI services!\n\nAfter testing one query, please immediately check the [SiliconFlow Billing](https://cloud.siliconflow.cn/me/bills) to see the consumption and have an estimate in mind.\n\n\n---\n\n## Basic Queries\n\n### Q1: How to view the latest news?\n\n**You can ask like this:**\n\n- \"Show me the latest news\"\n- \"Query today's trending news\"\n- \"Get the latest 10 news from Zhihu and Weibo\"\n- \"View latest news, need links included\"\n\n**Tool return behavior:**\n\n- Tool returns the latest 50 news items from all platforms\n- Does not include URL links by default (saves tokens)\n\n**AI display behavior (Important):**\n\n- ⚠️ **AI usually auto-summarizes**, only showing partial news (like TOP 10-20 items)\n- ✅ If you want to see all 50 items, need to explicitly request: \"show all news\" or \"list all 50 items completely\"\n- 💡 This is the AI model's natural behavior, not a tool limitation\n\n**Can be adjusted:**\n\n- Specify platform: like \"only Zhihu\"\n- Adjust quantity: like \"return top 20\"\n- Include links: like \"need links\"\n- **Request full display**: like \"show all, don't summarize\"\n\n---\n\n### Q2: How to query news from a specific date?\n\n**You can ask like this:**\n\n- \"Query yesterday's news\"\n- \"Check Zhihu news from 3 days ago\"\n- \"What news was there on 2025-10-10\"\n- \"News from last Monday\"\n- \"Show me the latest news\" (automatically queries today)\n\n**Supported date formats:**\n\n- Relative dates: today, yesterday, day before yesterday, 3 days ago\n- Days of week: last Monday, this Wednesday\n- Absolute dates: 2025-10-10, October 10\n\n**Tool return behavior:**\n\n- Automatically queries today when date not specified (saves tokens)\n- Tool returns 50 news items from all platforms\n- Does not include URL links by default\n\n**AI display behavior (Important):**\n\n- ⚠️ **AI usually auto-summarizes**, only showing partial news (like TOP 10-20 items)\n- ✅ If you want to see all, need to explicitly request: \"show all news, don't summarize\"\n\n---\n\n### Q3: How to view trending topic statistics?\n\n**You can ask like this:**\n\n- \"How many times did my followed words appear today\" (using preset keywords)\n- \"Automatically analyze what hot topics are in today's news\" (auto extract)\n- \"See what are the hottest words in the news\" (auto extract)\n\n**Two extraction modes:**\n\n| Mode | Description | Example Question |\n|------|------|---------|\n| **Preset keywords** | Count preset followed words (based on config file, default) | \"How many times did my followed words appear\" |\n| **Auto extract** | Auto-extract high-frequency words from news titles (no preset needed) | \"Auto-analyze hot topics\" |\n\n---\n\n## RSS Feed Queries\n\n### Q4.1: How to view latest RSS feed content?\n\n**You can ask like this:**\n\n- \"Show me the latest RSS feed content\"\n- \"Get the latest articles from Hacker News\"\n- \"View latest 20 items from all RSS feeds\"\n- \"Get RSS feeds, need to include summaries\"\n- \"Show me RSS content from the last week\" (multi-day query support)\n- \"Get Hacker News articles from last 7 days\"\n\n**Tool return behavior:**\n\n- Returns today's RSS items by default (up to 50)\n- Supports `days` parameter for multi-day queries (1-30 days)\n- Does not include summaries by default (saves tokens)\n- Sorted by publication time in descending order\n- Auto-deduplication across dates (by URL)\n\n**AI display behavior (Important):**\n\n- ⚠️ **AI usually auto-summarizes**, only showing partial items\n- ✅ If you want to see all, need to explicitly request: \"show all RSS content\"\n\n**Can be adjusted:**\n\n- Specify RSS feed: like \"only Hacker News\"\n- Specify days: like \"last 7 days\", \"past week\"\n- Adjust quantity: like \"return top 20\"\n- Include summary: like \"need summaries\"\n\n---\n\n### Q4.2: How to search content in RSS feeds?\n\n**You can ask like this:**\n\n- \"Search for 'AI' related articles in RSS\"\n- \"Search RSS content about 'machine learning' from last 7 days\"\n- \"Search 'Python' in Hacker News\"\n\n**Tool return behavior:**\n\n- Searches RSS item titles using keywords\n- Default searches last 7 days of data\n- Tool returns up to 50 results\n\n**Can be adjusted:**\n\n- Specify RSS feed: like \"only search Hacker News\"\n- Adjust days: like \"search last 14 days\"\n- Include summary: like \"need summaries\"\n\n---\n\n### Q4.3: How to view RSS feed status?\n\n**You can ask like this:**\n\n- \"View RSS feed status\"\n- \"How much data has RSS crawled\"\n- \"Which RSS feeds have data\"\n\n**Return information:**\n\n| Field | Description |\n|-------|-------------|\n| **Available dates** | List of dates with RSS data |\n| **Total date count** | How many days of data total |\n| **Today's feed stats** | Today's data statistics by RSS feed |\n| **Generation time** | Status generation time |\n\n---\n\n## Search and Retrieval\n\n### Q4: How to search for news containing specific keywords?\n\n**You can ask like this:**\n\n- \"Search for news containing 'artificial intelligence'\"\n- \"Find reports about 'Tesla price cut'\"\n- \"Search for news about Musk, return top 20\"\n- \"Find news about 'iPhone 16' in the last 7 days\"\n- \"Find news about 'Tesla' from January 1 to 7, 2025\"\n- \"Find the link to the news 'iPhone 16 release'\"\n\n**Tool return behavior:**\n\n- Uses keyword mode search\n- Default searches today's data\n- AI automatically converts relative time like \"last 7 days\", \"last week\" to specific date ranges\n- Tool returns up to 50 results\n- Does not include URL links by default\n\n**AI display behavior (Important):**\n\n- ⚠️ **AI usually auto-summarizes**, only showing partial search results\n- ✅ If you want to see all, need to explicitly request: \"show all search results\"\n\n**Can be adjusted:**\n\n- Specify time range:\n  - Relative way: \"search last week\" (AI automatically calculates dates)\n  - Absolute dates: \"search from January 1 to 7, 2025\"\n- Specify platform: like \"only search Zhihu\"\n- Adjust sorting: like \"sort by weight\"\n- Include links: like \"need links\"\n\n---\n\n### Q4.4: How to search both hot news and RSS content simultaneously?\n\n**You can ask like this:**\n\n- \"Search for 'AI' content, including RSS\"\n- \"Find news about 'artificial intelligence', also search RSS subscriptions\"\n- \"Search for 'Tesla', both hot news and RSS\"\n\n**Tool return behavior:**\n\n- Hot news results and RSS results are **displayed separately**\n- Hot news sorted by rank/relevance, RSS sorted by publish time\n- RSS results do not affect hot news ranking display\n- Default returns 50 hot news + 20 RSS items\n\n**Can be adjusted:**\n\n- RSS count: like \"return 10 RSS items\"\n- Only search hot news: don't say \"including RSS\" (default behavior)\n- Only search RSS: say \"only search in RSS\"\n\n---\n\n### Q5: How to find related news?\n\n**You can ask like this:**\n\n- \"Find news similar to 'Tesla price cut'\" (today)\n- \"Find news related to 'AI breakthrough' from yesterday\" (history)\n- \"Search for historical reports about 'Tesla' from last week\" (history)\n- \"See if there are reports similar to this news in the last 7 days\" (history)\n\n**Supported time ranges:**\n\n| Method | Description | Example |\n|--------|-------------|---------|\n| Not specified | Only query today's data (default) | \"Find similar news\" |\n| Preset values | yesterday, last week, last month | \"Find related news from yesterday\" |\n| Date range | Specify start and end dates | \"Find related reports from Jan 1 to 7\" |\n\n**Tool return behavior:**\n\n- Similarity threshold 0.5 (adjustable)\n- Tool returns up to 50 results\n- Sorted by similarity\n- Does not include URL links by default\n\n**AI display behavior (Important):**\n\n- ⚠️ **AI usually auto-summarizes**, only showing partial related news\n- ✅ If you want to see all, need to explicitly request: \"show all related news\"\n\n**Can be adjusted:**\n\n- Specify time: like \"find from last week\"\n- Adjust threshold: like \"similarity above 0.3\"\n- Include links: say \"need links\"\n\n---\n\n## Trend Analysis\n\n### Q6: How to analyze topic heat trends?\n\n**You can ask like this:**\n\n- \"Analyze the heat trend of 'artificial intelligence' in the last week\"\n- \"See if 'Tesla' topic is a flash in the pan or sustained hot topic\"\n- \"Detect which topics suddenly went viral today\"\n- \"Predict potential hot topics coming up\"\n- \"Analyze the lifecycle of 'Bitcoin' in December 2024\"\n\n**Four analysis modes:**\n\n| Mode | Description | Example Question |\n|------|------|---------|\n| **Heat trend** | Track topic heat changes | \"Analyze 'AI' heat trend\" |\n| **Lifecycle** | Complete cycle from emergence to disappearance | \"See if 'XX' is a flash in the pan or sustained hot topic\" |\n| **Anomaly detection** | Identify suddenly viral topics | \"What topics suddenly went viral today\" |\n| **Prediction** | Predict future hot topics | \"Predict upcoming hot topics\" |\n\n**Tool return behavior:**\n\n- AI automatically converts relative time like \"last week\" to specific date ranges\n- Default analyzes last 7 days of data\n- Statistics by day granularity\n\n---\n\n## Data Insights\n\n### Q7: How to compare different platforms' attention to topics?\n\n**You can ask like this:**\n\n- \"Compare different platforms' attention to 'artificial intelligence' topic\"\n- \"See which platform updates most frequently\"\n- \"Analyze which keywords often appear together\"\n\n**Three insight modes:**\n\n| Mode | Function | Example Question |\n| -------------- | ---------------- | -------------------------- |\n| **Platform Compare** | Compare platform attention | \"Compare platforms' attention to 'AI'\" |\n| **Activity Stats** | Count platform posting frequency | \"See which platform updates most frequently\" |\n| **Keyword Co-occurrence** | Analyze keyword associations | \"Which keywords often appear together\" |\n\n**Tool return behavior:**\n\n- Default uses platform compare mode\n- Analyzes today's data\n- Keyword co-occurrence minimum frequency 3 times\n\n---\n\n## Sentiment Analysis\n\n### Q8: How to analyze news sentiment?\n\n**You can ask like this:**\n\n- \"Analyze today's news sentiment\"\n- \"See if 'Tesla' related news is positive or negative\"\n- \"Analyze different platforms' sentiment towards 'artificial intelligence'\"\n- \"See the sentiment of 'Bitcoin' within a week, choose the top 20 most important\"\n\n**Tool return behavior:**\n\n- Default analyzes today's data\n- Tool returns up to 50 news items\n- Sorted by weight (prioritizing important news)\n- Does not include URL links by default\n\n**AI display behavior (Important):**\n\n- ⚠️ This tool returns **AI prompts**, not direct sentiment analysis results\n- AI generates sentiment analysis reports based on prompts\n- Usually displays sentiment distribution, key findings, and representative news\n\n**Can be adjusted:**\n\n- Specify topic: like \"about 'Tesla'\"\n- Specify time: like \"last week\"\n- Adjust quantity: like \"return top 20\"\n\n---\n\n### Q9: How to get deduplicated cross-platform news?\n\n**You can ask like this:**\n\n- \"Help me aggregate today's news, remove duplicates\"\n- \"See which news is reported on multiple platforms\"\n- \"Show me deduplicated hotspot news\"\n- \"Which news are cross-platform hot topics\"\n\n**Tool functionality:**\n\n- Automatically identifies the same event reported by different platforms\n- Merges similar news into one aggregated news item\n- Shows platform coverage for each news item\n- Calculates comprehensive heat weight\n\n**Return information:**\n\n| Field | Description |\n|-------|-------------|\n| **Representative title** | Representative title of this news group |\n| **Covered platforms** | Which platforms reported this news |\n| **Platform count** | How many platforms covered |\n| **Is cross-platform** | Whether it's a cross-platform hot topic |\n| **Best rank** | Best ranking across platforms |\n| **Comprehensive weight** | Comprehensive heat score |\n| **Platform sources** | Detailed info from each platform |\n\n**Can be adjusted:**\n\n- Specify time: like \"from last week\"\n- Adjust similarity threshold: like \"stricter matching\" or \"looser matching\"\n- Specify platform: like \"only Zhihu and Weibo\"\n\n---\n\n### Q10: How to generate daily or weekly hotspot summaries?\n\n**You can ask like this:**\n\n- \"Generate today's news summary report\"\n- \"Give me a weekly hotspot summary\"\n- \"Generate news analysis report for the past 7 days\"\n\n**Report types:**\n\n- Daily summary: Summarizes the day's hotspot news\n- Weekly summary: Summarizes a week's hotspot trends\n\n---\n\n### Q11: How to compare hotspot changes across different periods?\n\n**You can ask like this:**\n\n- \"Compare this week and last week's hotspot changes\"\n- \"See what's different between this month and last month\"\n- \"Analyze 'artificial intelligence' heat difference in two periods\"\n- \"Compare platform activity changes\"\n\n**Three comparison modes:**\n\n| Mode | Description | Use Case |\n|------|-------------|----------|\n| **Overview** | News count change, keyword change, TOP news comparison | Quick understanding of overall changes |\n| **Topic shift** | Rising topics, falling topics, newly appeared topics | Analyze hotspot migration |\n| **Platform activity** | News count change by platform | Understand platform dynamics |\n\n**Time period presets:**\n\n- Today / Yesterday\n- This week / Last week\n- This month / Last month\n- Or use custom date range\n\n---\n\n## System Management\n\n### Q12: How to view system configuration?\n\n**You can ask like this:**\n\n- \"View current system configuration\"\n- \"Display configuration file content\"\n- \"What platforms are available?\"\n- \"What's the current weight configuration?\"\n\n**Can query:**\n\n- Available platform list\n- Crawler configuration (request interval, timeout settings)\n- Weight configuration (ranking weight, frequency weight)\n- Notification configuration (Feishu, DingTalk, WeCom, Telegram, Email, ntfy, Bark, Slack, Generic Webhook)\n\n---\n\n### Q13: How to check system running status?\n\n**You can ask like this:**\n\n- \"Check system status\"\n- \"Is the system running normally?\"\n- \"When was the last crawl?\"\n- \"How many days of historical data?\"\n\n**Return information:**\n\n- System version and status\n- Last crawl time\n- Historical data days\n- Health check results\n\n---\n\n### Q13.1: How to check for version updates?\n\n**You can ask like this:**\n\n- \"Check for version updates\"\n- \"Is there a new version?\"\n- \"Is the current version up to date?\"\n\n**Return information:**\n\nWill check both components' versions simultaneously:\n\n| Component | Description |\n|-----------|-------------|\n| **TrendRadar** | Core crawler and analysis engine |\n| **MCP Server** | AI conversation tool service |\n\nFor each component, you'll get:\n- Currently installed version\n- Latest available version\n- Whether an update is needed\n- Update recommendation\n\n**Can be adjusted:**\n\n- If GitHub access is slow, say \"check version updates, use proxy http://127.0.0.1:10801\"\n\n---\n\n### Q14: How to manually trigger a crawl task?\n\n**You can ask like this:**\n\n- \"Please crawl current Toutiao news\" (temporary query)\n- \"Help me fetch latest news from Zhihu and Weibo and save\" (persistent)\n- \"Trigger a crawl and save data\" (persistent)\n- \"Get real-time data from 36Kr but don't save\" (temporary query)\n\n**Two modes:**\n\n| Mode | Purpose | Example |\n| -------------- | -------------------- | -------------------- |\n| **Temporary Crawl** | Only return data without saving | \"Crawl Toutiao news\" |\n| **Persistent Crawl** | Save to output folder | \"Fetch and save Zhihu news\" |\n\n**Tool return behavior:**\n\n- Default is temporary crawl mode (no save)\n- Default crawls all platforms\n- Does not include URL links by default\n\n**AI display behavior (Important):**\n\n- ⚠️ **AI usually summarizes crawl results**, only showing partial news\n- ✅ If you want to see all, need to explicitly request: \"show all crawled news\"\n\n**Can be adjusted:**\n\n- Specify platform: like \"only crawl Zhihu\"\n- Save data: say \"and save\" or \"save locally\"\n- Include links: say \"need links\"\n\n---\n\n## Storage Sync\n\n### Q15: How to sync data from remote storage to local?\n\n**You can ask like this:**\n\n- \"Sync last 7 days data from remote\"\n- \"Pull data from remote storage to local\"\n- \"Sync last 30 days of news data\"\n\n**Use cases:**\n\n- Crawler deployed in the cloud (e.g., GitHub Actions), data stored remotely (e.g., Cloudflare R2)\n- MCP Server deployed locally, needs to pull data from remote for analysis\n\n**Return information:**\n\n- Number of successfully synced files\n- List of successfully synced dates\n- Skipped dates (already exist locally)\n- Failed dates and error information\n\n**Prerequisites:**\n\nNeed to configure remote storage in config file or set environment variables:\n- Service endpoint URL\n- Bucket name\n- Access key ID\n- Secret access key\n\n---\n\n### Q16: How to view storage status?\n\n**You can ask like this:**\n\n- \"View current storage status\"\n- \"What's the storage configuration\"\n- \"How much data is stored locally\"\n- \"Is remote storage configured\"\n\n**Return information:**\n\n| Category | Information |\n|----------|-------------|\n| **Local Storage** | Data directory, total size, date count, date range |\n| **Remote Storage** | Whether configured, endpoint URL, bucket name, date count |\n| **Pull Config** | Whether auto-pull enabled, pull days |\n\n---\n\n### Q17: How to view available data dates?\n\n**You can ask like this:**\n\n- \"What dates are available locally\"\n- \"What dates are in remote storage\"\n- \"Compare local and remote data dates\"\n- \"Which dates only exist remotely\"\n\n**Three query modes:**\n\n| Mode | Description | Example Question |\n|------|-------------|------------------|\n| **Local** | View local only | \"What dates are available locally\" |\n| **Remote** | View remote only | \"What dates are in remote\" |\n| **Compare** | Compare both (default) | \"Compare local and remote data\" |\n\n**Return information (compare mode):**\n\n- Dates only existing locally\n- Dates only existing remotely (useful for deciding which dates to sync)\n- Dates existing in both places\n\n---\n\n### Q18: How to parse natural language date expressions? (Recommended to use first)\n\n**You can ask like this:**\n\n- \"Parse what days 'this week' is\"\n- \"What date range does 'last 7 days' correspond to\"\n- \"Last month's date range\"\n- \"Help me convert 'last 30 days' to specific dates\"\n\n**Why is this tool needed?**\n\nUsers often use natural language like \"this week\", \"last 7 days\" to express dates, but different AI models calculating dates on their own will produce inconsistent results. This tool uses server-side precise time calculations to ensure all AI models get consistent date ranges.\n\n**Supported date expressions:**\n\n| Type | Chinese Expression | English Expression |\n|------|---------|---------|\n| Single Day | 今天、昨天 | today, yesterday |\n| Week | 本周、上周 | this week, last week |\n| Month | 本月、上月 | this month, last month |\n| Last N Days | 最近7天、最近30天 | last 7 days, last 30 days |\n| Dynamic | 最近N天 (any number) | last N days |\n\n**Usage advantages:**\n\n- ✅ **Consistency**: All AI models get the same date range\n- ✅ **Accuracy**: Based on server-side precise time calculation\n- ✅ **Standardization**: Returns standard date format\n- ✅ **Flexibility**: Supports Chinese/English, dynamic days\n\n---\n\n## Article Content Reading\n\n### Q19: How to read the full content of a news article?\n\n**You can ask like this:**\n\n- \"Help me read the content of this news: https://example.com/news/123\"\n- \"Get the article body from this link\"\n- \"Read the detailed content of this report\"\n\n**Tool functionality:**\n\n- Converts web pages to clean Markdown format via Jina AI Reader\n- Automatically removes ads, navigation bars, sidebars, and other noise\n- Returns LLM-friendly structured content\n\n**Typical workflow:**\n\n1. First use `search_news(include_url=True)` to search news and get links\n2. Then use `read_article(url=link)` to read the article body\n3. AI analyzes, summarizes, translates the Markdown content\n\n**Return information:**\n\n| Field | Description |\n|-------|-------------|\n| **content** | Article body in Markdown format |\n| **url** | Original link |\n| **content_length** | Content length (characters) |\n\n**Can be adjusted:**\n\n- Timeout: like \"set timeout to 60 seconds\" (default 30 seconds, max 60 seconds)\n\n**Notes:**\n\n- 5-second interval between requests (built-in rate control)\n- Uses Jina AI Reader free service (100 RPM limit)\n- Some paywalled/login-required pages may not be fully accessible\n\n---\n\n### Q20: How to batch read multiple articles?\n\n**You can ask like this:**\n\n- \"Help me read the content of these news articles\"\n- \"Batch get the article bodies from these links\"\n- \"Read the detailed content of the first 3 search results\"\n\n**Typical workflow:**\n\n1. First use `search_news(include_url=True)` to search news and get multiple links\n2. Then use `read_articles_batch(urls=[...])` to batch read article bodies\n3. AI performs comparative analysis, comprehensive reports on multiple articles\n\n**Tool limits:**\n\n| Limit | Value |\n|-------|-------|\n| Max articles per batch | **5** |\n| Request interval | **5 seconds** |\n| Estimated time (5 articles) | **25-30 seconds** |\n\n**Return information:**\n\n| Field | Description |\n|-------|-------------|\n| **summary** | Statistics of batch reading |\n| **articles** | Content and status of each article |\n| **note** | If any articles were skipped, explains why |\n\n**Notes:**\n\n- Articles beyond 5 will be automatically skipped\n- Single article failure doesn't affect other articles\n- More articles mean longer wait time, please be patient\n\n---\n\n## Notification Push\n\n### Q21: How to send notification messages via MCP?\n\n**You can ask like this:**\n\n- \"Show me which notification channels are configured\"\n- \"Send a test message to all channels\"\n- \"Push this content to Feishu\"\n- \"Send today's news summary to DingTalk and Telegram\"\n\n**Supported notification channels (9):**\n\n| Channel | Message Format | Configuration |\n|---------|---------------|---------------|\n| **Feishu** | Plain text | `FEISHU_WEBHOOK_URL` |\n| **DingTalk** | Markdown | `DINGTALK_WEBHOOK_URL` |\n| **WeCom** | Markdown | `WEWORK_WEBHOOK_URL` |\n| **Telegram** | HTML | `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` |\n| **Email** | HTML | `EMAIL_FROM` + `EMAIL_PASSWORD` + `EMAIL_TO` |\n| **ntfy** | Markdown | `NTFY_SERVER_URL` + `NTFY_TOPIC` |\n| **Bark** | Markdown | `BARK_URL` |\n| **Slack** | mrkdwn | `SLACK_WEBHOOK_URL` |\n| **Generic Webhook** | Markdown | `GENERIC_WEBHOOK_URL` |\n\n**Configuration methods:**\n\n- Configure channels in `config.yaml` under `notification.channels`\n- Or set corresponding environment variables in `.env` file (higher priority)\n- Both sources are automatically merged, `.env` values override `config.yaml` values\n\n**Two tools:**\n\n| Tool | Function | Example Question |\n|------|----------|------------------|\n| `get_notification_channels` | Detect configured channels and status | \"View notification channel config\" |\n| `send_notification` | Send message to specified or all channels | \"Send message to Feishu\" |\n\n**Typical workflow:**\n\n1. Check channel status first: \"Show me which notification channels are configured\"\n2. Send after confirming availability: \"Push the following to DingTalk: today's hotspot summary...\"\n3. Or specify multiple channels: \"Send to Feishu and Telegram\"\n4. Without specifying channels, sends to all configured channels\n\n**Message format:**\n\n- The tool accepts messages in **Markdown format**\n- Automatically converts to each channel's required format (Feishu to plain text, Telegram to HTML, Slack to mrkdwn, etc.)\n- No need to manually handle format differences\n\n**Multi-account support:**\n\n- Separate multiple URLs/Tokens with `;` in config values to send to multiple accounts\n- For example: `FEISHU_WEBHOOK_URL=url1;url2` sends to two Feishu groups simultaneously\n\n---\n\n## 💡 Usage Tips\n\n### 1. How to make AI display all data instead of auto-summarizing?\n\n**Background**: Sometimes AI automatically summarizes data, only showing partial content, even if the tool returned complete 50 items of data.\n\n**If AI still summarizes, you can**:\n\n- **Method 1 - Explicit request**: \"Please show all news, don't summarize\"\n- **Method 2 - Specify quantity**: \"Show all 50 news items\"\n- **Method 3 - Question the behavior**: \"Why only showed 15? I want to see all\"\n- **Method 4 - State upfront**: \"Query today's news, fully display all results\"\n\n**Note**: AI may still adjust display method based on context.\n\n\n### 2. How to combine multiple tools?\n\n**Example: In-depth analysis of a topic**\n\n1. Search first: \"Search for news about 'artificial intelligence'\"\n2. Then analyze trends: \"Analyze the heat trend of 'artificial intelligence'\"\n3. Finally sentiment analysis: \"Analyze sentiment of 'artificial intelligence' news\"\n\n**Example: Track an event**\n\n1. View latest: \"Query today's news about 'iPhone'\"\n2. Find history: \"Find historical news related to 'iPhone' from last week\"\n3. Find similar reports: \"Find news similar to 'iPhone launch event'\"\n"
  },
  {
    "path": "README-MCP-FAQ.md",
    "content": "<div align=\"center\">\n\n**中文** | **[English](README-MCP-FAQ-EN.md)**\n\n</div>\n\n# TrendRadar MCP 工具使用问答\n\n> AI 提问指南 - 如何通过自然对话使用新闻热点分析工具（v3.1.7）\n\n---\n\n## 📋 工具一览\n\n| 分类 | 工具名称 | 功能简介 |\n|:----:|---------|---------|\n| **日期** | `resolve_date_range` | 解析\"本周\"、\"最近7天\"等自然语言为标准日期 |\n| **查询** | `get_latest_news` | 获取最新一批爬取的热榜新闻 |\n| | `get_news_by_date` | 按日期范围查询历史新闻 |\n| | `get_trending_topics` | 获取热点话题统计（支持自动提取） |\n| **RSS** | `get_latest_rss` | 获取最新 RSS 订阅内容 |\n| | `search_rss` | 在 RSS 数据中搜索关键词 |\n| | `get_rss_feeds_status` | 查看 RSS 源配置和数据状态 |\n| **搜索** | `search_news` | 统一搜索（关键词/模糊/实体，可含RSS） |\n| | `find_related_news` | 查找与指定标题相似的新闻 |\n| **分析** | `analyze_topic_trend` | 话题趋势分析（热度/生命周期/爆火/预测） |\n| | `analyze_data_insights` | 数据洞察（平台对比/活跃度/关键词共现） |\n| | `analyze_sentiment` | 新闻情感倾向分析 |\n| | `aggregate_news` | 跨平台新闻聚合去重 |\n| | `compare_periods` | 时期对比分析（周环比/月环比） |\n| | `generate_summary_report` | 生成每日/每周摘要报告 |\n| **系统** | `get_current_config` | 获取当前系统配置 |\n| | `get_system_status` | 获取系统运行状态 |\n| | `check_version` | 检查版本更新（TrendRadar + MCP Server） |\n| | `trigger_crawl` | 手动触发一次爬取任务 |\n| **存储** | `sync_from_remote` | 从远程存储拉取数据到本地 |\n| | `get_storage_status` | 获取存储配置和状态 |\n| | `list_available_dates` | 列出本地/远程可用的日期 |\n| **文章** | `read_article` | 读取单篇文章内容（Markdown 格式） |\n| | `read_articles_batch` | 批量读取多篇文章（最多 5 篇） |\n| **通知** | `get_notification_channels` | 获取所有已配置的通知渠道及其状态 |\n| | `send_notification` | 向已配置的通知渠道发送消息（自动格式转换） |\n\n---\n\n## ⚙️ 默认设置说明（重要！）\n\n默认采用以下优化策略，主要是为了节约 AI token 消耗：\n\n| 默认设置       | 说明                                    | 如何调整                              |\n| -------------- | --------------------------------------- | ------------------------------------- |\n| **限制条数**   | 默认返回 50 条新闻                      | 对话中说\"返回前 10 条\"或\"给我 100 条\" |\n| **时间范围**   | 默认查询今天的数据                      | 说\"查询昨天\"、\"最近一周\"或\"1月1日到7日\" |\n| **URL 链接**   | 默认不返回链接（节省约 160 tokens/条）  | 说\"需要链接\"或\"包含 URL\"              |\n| **关键词列表** | 默认不使用 frequency_words.txt 过滤新闻 | 只有调用\"趋势话题\"工具时才使用        |\n\n**⚠️ 重要：** AI 模型的选择直接影响工具调用效果，AI 越智能，调用越准确。当你解除上面的限制，比如从今天的查询，放宽到一周的查询，首先你要在本地有一周的数据，其次，token 消耗量可能会倍增。\n\n**💡 提示：** 本项目提供了专门的日期解析工具，可以准确解析\"最近7天\"、\"本周\"等自然语言日期表达式，确保所有 AI 模型获得一致的日期范围。详见下方 Q18。\n\n\n## 💰 AI 模型\n\n下面我以 **[硅基流动](https://cloud.siliconflow.cn)** 平台作为例子，里面有很多大模型可选择。在开发和测试本项目的过程中，我使用本平台进行了许多的功能测试和验证。\n\n### 📊 注册方式对比\n\n| 注册方式 | 无邀请链接直接注册 | 含有邀请链接注册  |\n|:-------:|:-------:|:-----------------:|\n| 注册链接 | [siliconflow.cn](https://cloud.siliconflow.cn) | [邀请链接](https://cloud.siliconflow.cn/i/fqnyVaIU) |\n| 免费额度 | 0 tokens | **2000万 tokens** (≈14元) |\n| 额外福利 | ❌ | ✅ 邀请者也获得2000万tokens |\n\n> 💡 **提示**：上面的赠送额度，应该可以询问 **200次以上**\n\n\n### 🚀 快速开始\n\n#### 1️⃣ 注册并获取 API 密钥\n\n1. 使用上方链接完成注册\n2. 访问 [API 密钥管理页面](https://cloud.siliconflow.cn/me/account/ak)\n3. 点击「新建 API 密钥」\n4. 复制生成的密钥（请妥善保管）\n\n#### 2️⃣ 在 Cherry Studio 中配置\n\n1. 打开 **Cherry Studio**\n2. 进入「模型服务」设置\n3. 找到「硅基流动」\n4. 将复制的密钥粘贴到 **[API密钥]** 输入框\n5. 确保右上角勾选框打开后显示为 **绿色** ✅\n\n---\n\n### ✨ 配置完成！\n\n现在你可以开始使用本项目，享受稳定快速的 AI 服务了！\n\n在你测试一次询问后，请立刻去 [硅基流动账单](https://cloud.siliconflow.cn/me/bills) 查询这一次的消耗量，心底有个估算。\n\n\n---\n\n## 基础查询\n\n### Q1: 如何查看最新的新闻？\n\n**你可以这样问：**\n\n- \"给我看看最新的新闻\"\n- \"查询今天的热点新闻\"\n- \"获取知乎和微博的最新 10 条新闻\"\n- \"查看最新新闻，需要包含链接\"\n\n**工具返回行为：**\n\n- 工具会返回所有平台的最新 50 条新闻\n- 默认不包含 URL 链接（节省 token）\n\n**AI 展示行为（重要）：**\n\n- ⚠️ **AI 通常会自动总结**，只展示部分新闻（如 TOP 10-20 条）\n- ✅ 如果你想看全部 50 条，需要明确要求：\"展示所有新闻\"或\"完整列出所有 50 条\"\n- 💡 这是 AI 模型的自然行为，不是工具的限制\n\n**可以调整：**\n\n- 指定平台：如\"只看知乎的\"\n- 调整数量：如\"返回前 20 条\"\n- 包含链接：如\"需要链接\"\n- **要求完整展示**：如\"展示全部，不要总结\"\n\n---\n\n### Q2: 如何查询特定日期的新闻？\n\n**你可以这样问：**\n\n- \"查询昨天的新闻\"\n- \"看看 3 天前知乎的新闻\"\n- \"2025-10-10 的新闻有哪些\"\n- \"上周一的新闻\"\n- \"给我看看最新新闻\"（自动查询今天）\n\n**支持的日期格式：**\n\n- 相对日期：今天、昨天、前天、3 天前\n- 星期：上周一、本周三、last monday\n- 绝对日期：2025-10-10、10 月 10 日\n\n**工具返回行为：**\n\n- 不指定日期时自动查询今天（节省 token）\n- 工具会返回所有平台的 50 条新闻\n- 默认不包含 URL 链接\n\n**AI 展示行为（重要）：**\n\n- ⚠️ **AI 通常会自动总结**，只展示部分新闻（如 TOP 10-20 条）\n- ✅ 如果你想看全部，需要明确要求：\"展示所有新闻，不要总结\"\n\n---\n\n### Q3: 如何查看热点话题统计？\n\n**你可以这样问：**\n\n- \"我关注的词今天出现了多少次\"（使用预设关注词）\n- \"自动分析今天新闻里有哪些热门话题\"（自动提取）\n- \"看看新闻里最热门的词是什么\"（自动提取）\n\n**两种提取模式：**\n\n| 模式 | 说明 | 示例问法 |\n|------|------|---------|\n| **预设关注词** | 统计你预先设定的关注词（基于配置文件，默认） | \"我的关注词出现了多少次\" |\n| **自动提取** | 自动从新闻标题提取高频词（无需预设） | \"自动分析热门话题\" |\n\n---\n\n## RSS 订阅查询\n\n### Q4.1: 如何查看最新的 RSS 订阅内容？\n\n**你可以这样问：**\n\n- \"查看最新的 RSS 订阅内容\"\n- \"获取 Hacker News 的最新文章\"\n- \"查看所有 RSS 源的最新 20 条\"\n- \"获取 RSS 订阅，需要包含摘要\"\n- \"看看最近一周的 RSS 内容\"（支持多日查询）\n- \"获取 Hacker News 最近 7 天的文章\"\n\n**工具返回行为：**\n\n- 默认返回今天的 RSS 条目（最多 50 条）\n- 支持 `days` 参数获取多日数据（1-30天）\n- 默认不包含摘要（节省 token）\n- 按发布时间倒序排列\n- 跨日期自动去重（按 URL）\n\n**AI 展示行为（重要）：**\n\n- ⚠️ **AI 通常会自动总结**，只展示部分条目\n- ✅ 如果你想看全部，需要明确要求：\"展示所有 RSS 内容\"\n\n**可以调整：**\n\n- 指定 RSS 源：如\"只看 Hacker News\"\n- 指定天数：如\"最近 7 天\"、\"最近一周\"\n- 调整数量：如\"返回前 20 条\"\n- 包含摘要：如\"需要摘要\"\n\n---\n\n### Q4.2: 如何搜索 RSS 订阅中的内容？\n\n**你可以这样问：**\n\n- \"在 RSS 中搜索'AI'相关的文章\"\n- \"搜索最近 7 天 RSS 中关于'机器学习'的内容\"\n- \"在 Hacker News 中搜索'Python'\"\n\n**工具返回行为：**\n\n- 使用关键词搜索 RSS 条目的标题\n- 默认搜索最近 7 天的数据\n- 工具会返回最多 50 条结果\n\n**可以调整：**\n\n- 指定 RSS 源：如\"只搜索 Hacker News\"\n- 调整天数：如\"搜索最近 14 天\"\n- 包含摘要：如\"需要摘要\"\n\n---\n\n### Q4.3: 如何查看 RSS 源的状态？\n\n**你可以这样问：**\n\n- \"查看 RSS 源状态\"\n- \"RSS 抓取了多少数据\"\n- \"哪些 RSS 源有数据\"\n\n**返回信息：**\n\n| 字段 | 说明 |\n|------|------|\n| **可用日期** | 有 RSS 数据的日期列表 |\n| **总日期数** | 总共有多少天的数据 |\n| **今日各源统计** | 今日各 RSS 源的数据统计 |\n| **生成时间** | 状态生成时间 |\n\n---\n\n## 搜索检索\n\n### Q4: 如何搜索包含特定关键词的新闻？\n\n**你可以这样问：**\n\n- \"搜索包含'人工智能'的新闻\"\n- \"查找关于'特斯拉降价'的报道\"\n- \"搜索马斯克相关的新闻，返回前 20 条\"\n- \"查找最近7天关于'iPhone 16'的新闻\"\n- \"查找2025年1月1日到7日'特斯拉'的相关新闻\"\n- \"查找'iPhone 16 发布'这条新闻的链接\"\n\n**工具返回行为：**\n\n- 使用关键词模式搜索\n- 默认搜索今天的数据\n- AI会自动将\"最近7天\"、\"上周\"等相对时间转换为具体日期范围\n- 工具会返回最多 50 条结果\n- 默认不包含 URL 链接\n\n**AI 展示行为（重要）：**\n\n- ⚠️ **AI 通常会自动总结**，只展示部分搜索结果\n- ✅ 如果你想看全部，需要明确要求：\"展示所有搜索结果\"\n\n**可以调整：**\n\n- 指定时间范围：\n  - 相对方式：\"搜索最近一周的\"（AI 自动计算日期）\n  - 绝对日期：\"搜索2025年1月1日到7日的\"\n- 指定平台：如\"只搜索知乎\"\n- 调整排序：如\"按权重排序\"\n- 包含链接：如\"需要链接\"\n\n---\n\n### Q4.4: 如何同时搜索热榜和 RSS 内容？\n\n**你可以这样问：**\n\n- \"搜索'AI'相关内容，包括 RSS\"\n- \"查找'人工智能'的新闻，同时搜索 RSS 订阅\"\n- \"搜索'特斯拉'，热榜和 RSS 都要\"\n\n**工具返回行为：**\n\n- 热榜结果和 RSS 结果**分开展示**\n- 热榜按排名/相关度排序，RSS 按发布时间排序\n- RSS 结果不影响热榜的排名展示\n- 默认返回热榜 50 条 + RSS 20 条\n\n**可以调整：**\n\n- RSS 数量：如\"RSS 返回 10 条\"\n- 只搜索热榜：不说\"包括 RSS\"（默认行为）\n- 只搜索 RSS：说\"只在 RSS 中搜索\"\n\n---\n\n### Q5: 如何查找相关新闻？\n\n**你可以这样问：**\n\n- \"找出和'特斯拉降价'相似的新闻\"（今天）\n- \"查找昨天与'人工智能突破'相关的新闻\"（历史）\n- \"搜索上周关于'ChatGPT'的相关报道\"（历史）\n- \"看看最近7天有没有和这条新闻相似的报道\"（历史）\n\n**支持的时间范围：**\n\n| 方式 | 说明 | 示例 |\n|------|------|------|\n| 不指定 | 只查询今天的数据（默认） | \"找相似新闻\" |\n| 预设值 | 昨天、上周、上个月 | \"查找昨天的相关新闻\" |\n| 日期范围 | 指定开始和结束日期 | \"查找1月1日到7日的相关报道\" |\n\n**工具返回行为：**\n\n- 相似度阈值 0.5（可调整）\n- 工具会返回最多 50 条结果\n- 按相似度排序\n- 默认不包含 URL 链接\n\n**AI 展示行为（重要）：**\n\n- ⚠️ **AI 通常会自动总结**，只展示部分相关新闻\n- ✅ 如果你想看全部，需要明确要求：\"展示所有相关新闻\"\n\n**可以调整：**\n\n- 指定时间：如\"查找上周的\"\n- 调整阈值：如\"相似度 0.3 以上的都要\"\n- 包含链接：说\"需要链接\"\n\n---\n\n## 趋势分析\n\n### Q6: 如何分析话题的热度趋势？\n\n**你可以这样问：**\n\n- \"分析'人工智能'最近一周的热度趋势\"\n- \"看看'特斯拉'话题是昙花一现还是持续热点\"\n- \"检测今天有哪些突然爆火的话题\"\n- \"预测接下来可能的热点话题\"\n- \"分析'比特币'在2024年12月的生命周期\"\n\n**四种分析模式：**\n\n| 模式 | 说明 | 示例问法 |\n|------|------|---------|\n| **热度趋势** | 追踪话题热度变化 | \"分析'AI'的热度趋势\" |\n| **生命周期** | 从出现到消失的完整周期 | \"看看'XX'是昙花一现还是持续热点\" |\n| **异常检测** | 识别突然爆火的话题 | \"今天有哪些突然爆火的话题\" |\n| **预测** | 预测未来可能的热点 | \"预测接下来可能的热点\" |\n\n**工具返回行为：**\n\n- AI会自动将\"最近一周\"等相对时间转换为具体日期范围\n- 默认分析最近7天数据\n- 按天粒度统计\n\n---\n\n## 数据洞察\n\n### Q7: 如何对比不同平台对话题的关注度？\n\n**你可以这样问：**\n\n- \"对比各个平台对'人工智能'话题的关注度\"\n- \"看看哪个平台更新最频繁\"\n- \"分析一下哪些关键词经常一起出现\"\n\n**三种洞察模式：**\n\n| 模式           | 功能             | 示例问法                   |\n| -------------- | ---------------- | -------------------------- |\n| **平台对比**   | 对比各平台关注度 | \"对比各平台对'AI'的关注度\" |\n| **活跃度统计** | 统计平台发布频率 | \"看看哪个平台更新最频繁\"   |\n| **关键词共现** | 分析关键词关联   | \"哪些关键词经常一起出现\"   |\n\n**工具返回行为：**\n\n- 默认使用平台对比模式\n- 分析今天的数据\n- 关键词共现最小频次 3 次\n\n---\n\n## 情感分析\n\n### Q8: 如何分析新闻的情感倾向？\n\n**你可以这样问：**\n\n- \"分析一下今天新闻的情感倾向\"\n- \"看看'特斯拉'相关新闻是正面还是负面的\"\n- \"分析各平台对'人工智能'的情感态度\"\n- \"看看'比特币'一周内的情感倾向，选择前 20 条最重要的\"\n\n**工具返回行为：**\n\n- 默认分析今天的数据\n- 工具会返回最多 50 条新闻\n- 按权重排序（优先展示重要新闻）\n- 默认不包含 URL 链接\n\n**AI 展示行为（重要）：**\n\n- ⚠️ 本工具返回 **AI 提示词**，不是直接的情感分析结果\n- AI 会根据提示词生成情感分析报告\n- 通常会展示情感分布、关键发现和代表性新闻\n\n**可以调整：**\n\n- 指定话题：如\"关于'特斯拉'\"\n- 指定时间：如\"最近一周\"\n- 调整数量：如\"返回前 20 条\"\n\n---\n\n### Q9: 如何获取去重后的跨平台新闻？\n\n**你可以这样问：**\n\n- \"帮我聚合今天的新闻，去掉重复的\"\n- \"看看哪些新闻在多个平台都有报道\"\n- \"给我看去重后的热点新闻\"\n- \"哪些新闻是跨平台热点\"\n\n**工具功能：**\n\n- 自动识别不同平台报道的同一事件\n- 将相似新闻合并为一条聚合新闻\n- 显示每条新闻的平台覆盖情况\n- 计算综合热度权重\n\n**返回信息：**\n\n| 字段 | 说明 |\n|------|------|\n| **代表性标题** | 这组新闻的代表标题 |\n| **覆盖平台** | 哪些平台报道了这条新闻 |\n| **平台数量** | 覆盖了多少个平台 |\n| **是否跨平台** | 是否为跨平台热点 |\n| **最佳排名** | 在各平台的最佳排名 |\n| **综合权重** | 综合热度评分 |\n| **各平台来源** | 各平台的详细信息 |\n\n**可以调整：**\n\n- 指定时间：如\"最近一周的\"\n- 调整相似度阈值：如\"更严格匹配\"或\"宽松匹配\"\n- 指定平台：如\"只看知乎和微博\"\n\n---\n\n### Q10: 如何生成每日或每周的热点摘要？\n\n**你可以这样问：**\n\n- \"生成今天的新闻摘要报告\"\n- \"给我一份本周的热点总结\"\n- \"生成过去 7 天的新闻分析报告\"\n\n**报告类型：**\n\n- 每日摘要：总结当天的热点新闻\n- 每周摘要：总结一周的热点趋势\n\n---\n\n### Q11: 如何对比不同时期的热点变化？\n\n**你可以这样问：**\n\n- \"对比本周和上周的热点变化\"\n- \"看看这个月和上个月有什么不同\"\n- \"分析'人工智能'在两个时期的热度差异\"\n- \"对比各平台活跃度的变化\"\n\n**三种对比模式：**\n\n| 模式 | 说明 | 适用场景 |\n|------|------|---------|\n| **总体概览** | 新闻数量变化、关键词变化、TOP新闻对比 | 快速了解整体变化 |\n| **话题变化** | 上升话题、下降话题、新出现话题 | 分析热点转移 |\n| **平台活跃度** | 各平台新闻数量变化 | 了解平台动态 |\n\n**时间段预设值：**\n\n- 今天 / 昨天\n- 本周 / 上周\n- 本月 / 上月\n- 或使用自定义日期范围\n\n---\n\n## 系统管理\n\n### Q12: 如何查看系统配置？\n\n**你可以这样问：**\n\n- \"查看当前系统配置\"\n- \"显示配置文件内容\"\n- \"有哪些可用的平台？\"\n- \"当前的权重配置是什么？\"\n\n**可以查询：**\n\n- 可用平台列表\n- 爬虫配置（请求间隔、超时设置）\n- 权重配置（排名权重、频次权重）\n- 通知配置（飞书、钉钉、企业微信、Telegram、Email、ntfy、Bark、Slack、通用 Webhook）\n\n---\n\n### Q13: 如何检查系统运行状态？\n\n**你可以这样问：**\n\n- \"检查系统状态\"\n- \"系统运行正常吗？\"\n- \"最后一次爬取是什么时候？\"\n- \"有多少天的历史数据？\"\n\n**返回信息：**\n\n- 系统版本和状态\n- 最后爬取时间\n- 历史数据天数\n- 健康检查结果\n\n---\n\n### Q13.1: 如何检查版本更新？\n\n**你可以这样问：**\n\n- \"检查版本更新\"\n- \"有没有新版本？\"\n- \"当前版本是最新的吗？\"\n\n**返回信息：**\n\n会同时检查两个组件的版本：\n\n| 组件 | 说明 |\n|------|------|\n| **TrendRadar** | 核心爬虫和分析引擎 |\n| **MCP Server** | AI 对话工具服务 |\n\n每个组件会告诉你：\n- 当前安装的版本\n- 最新可用的版本\n- 是否需要更新\n- 更新建议\n\n**可以调整：**\n\n- 如果访问 GitHub 较慢，可以说\"检查版本更新，使用代理 http://127.0.0.1:10801\"\n\n---\n\n### Q14: 如何手动触发爬取任务？\n\n**你可以这样问：**\n\n- \"请你爬取当前的今日头条的新闻\"（临时查询）\n- \"帮我抓取一下知乎和微博的最新新闻并保存\"（持久化）\n- \"触发一次爬取并保存数据\"（持久化）\n- \"获取 36 氪 的实时数据但不保存\"（临时查询）\n\n**两种模式：**\n\n| 模式           | 用途                 | 示例                 |\n| -------------- | -------------------- | -------------------- |\n| **临时爬取**   | 只返回数据不保存     | \"爬取今日头条的新闻\" |\n| **持久化爬取** | 保存到 output 文件夹 | \"抓取并保存知乎新闻\" |\n\n**工具返回行为：**\n\n- 默认为临时爬取模式（不保存）\n- 默认爬取所有平台\n- 默认不包含 URL 链接\n\n**AI 展示行为（重要）：**\n\n- ⚠️ **AI 通常会总结爬取结果**，只展示部分新闻\n- ✅ 如果你想看全部，需要明确要求：\"展示所有爬取的新闻\"\n\n**可以调整：**\n\n- 指定平台：如\"只爬取知乎\"\n- 保存数据：说\"并保存\"或\"保存到本地\"\n- 包含链接：说\"需要链接\"\n\n---\n\n## 存储同步\n\n### Q15: 如何从远程存储同步数据到本地？\n\n**你可以这样问：**\n\n- \"从远程同步最近 7 天的数据\"\n- \"拉取远程存储的数据到本地\"\n- \"同步最近 30 天的新闻数据\"\n\n**使用场景：**\n\n- 爬虫部署在云端（如 GitHub Actions），数据存储到远程（如 Cloudflare R2）\n- MCP Server 部署在本地，需要从远程拉取数据进行分析\n\n**返回信息：**\n\n- 成功同步的文件数量\n- 成功同步的日期列表\n- 跳过的日期（本地已存在）\n- 失败的日期及错误信息\n\n**前提条件：**\n\n需要在配置文件中配置远程存储或设置环境变量：\n- 服务端点 URL\n- 存储桶名称\n- 访问密钥 ID\n- 访问密钥\n\n---\n\n### Q16: 如何查看存储状态？\n\n**你可以这样问：**\n\n- \"查看当前存储状态\"\n- \"存储配置是什么\"\n- \"本地有多少数据\"\n- \"远程存储配置了吗\"\n\n**返回信息：**\n\n| 类别 | 信息 |\n|------|------|\n| **本地存储** | 数据目录、总大小、日期数量、日期范围 |\n| **远程存储** | 是否配置、端点地址、存储桶名称、日期数量 |\n| **拉取配置** | 是否启用自动拉取、拉取天数 |\n\n---\n\n### Q17: 如何查看可用的数据日期？\n\n**你可以这样问：**\n\n- \"本地有哪些日期的数据\"\n- \"远程存储有哪些日期\"\n- \"对比本地和远程的数据日期\"\n- \"哪些日期只在远程有\"\n\n**三种查询模式：**\n\n| 模式 | 说明 | 示例问法 |\n|------|------|---------|\n| **本地** | 仅查看本地 | \"本地有哪些日期\" |\n| **远程** | 仅查看远程 | \"远程有哪些日期\" |\n| **对比** | 对比两者（默认） | \"对比本地和远程的数据\" |\n\n**返回信息（对比模式）：**\n\n- 仅本地存在的日期\n- 仅远程存在的日期（可用于决定同步哪些日期）\n- 两边都存在的日期\n\n---\n\n### Q18: 如何解析自然语言日期表达式？（推荐优先使用）\n\n**你可以这样问：**\n\n- \"解析'本周'是哪几天\"\n- \"最近7天对应的日期范围是什么\"\n- \"上月的日期范围\"\n- \"帮我把'最近30天'转换为具体日期\"\n\n**为什么需要这个工具？**\n\n用户经常使用\"本周\"、\"最近7天\"等自然语言表达日期，但不同的 AI 模型自行计算日期时会产生不一致的结果。此工具使用服务器端的精确时间计算，确保所有 AI 模型获得一致的日期范围。\n\n**支持的日期表达式：**\n\n| 类型 | 中文表达 | 英文表达 |\n|------|---------|---------|\n| 单日 | 今天、昨天 | today, yesterday |\n| 周 | 本周、上周 | this week, last week |\n| 月 | 本月、上月 | this month, last month |\n| 最近N天 | 最近7天、最近30天 | last 7 days, last 30 days |\n| 动态 | 最近N天（任意数字） | last N days |\n\n**使用优势：**\n\n- ✅ **一致性**：所有 AI 模型获得相同的日期范围\n- ✅ **准确性**：基于服务器端精确时间计算\n- ✅ **标准化**：返回标准日期格式\n- ✅ **灵活性**：支持中英文、动态天数\n\n---\n\n## 文章内容读取\n\n### Q19: 如何读取新闻文章的正文内容？\n\n**你可以这样问：**\n\n- \"帮我读取这篇新闻的内容：https://example.com/news/123\"\n- \"获取这个链接的文章正文\"\n- \"读取这篇报道的详细内容\"\n\n**工具功能：**\n\n- 通过 Jina AI Reader 将网页转换为干净的 Markdown 格式\n- 自动去除广告、导航栏、侧边栏等噪音内容\n- 返回 LLM 友好的结构化内容\n\n**典型使用流程：**\n\n1. 先用 `search_news(include_url=True)` 搜索新闻获取链接\n2. 再用 `read_article(url=链接)` 读取正文内容\n3. AI 对 Markdown 正文进行分析、摘要、翻译等\n\n**返回信息：**\n\n| 字段 | 说明 |\n|------|------|\n| **content** | Markdown 格式的文章正文 |\n| **url** | 原始链接 |\n| **content_length** | 内容长度（字符数） |\n\n**可以调整：**\n\n- 超时时间：如\"超时设为 60 秒\"（默认 30 秒，最大 60 秒）\n\n**注意事项：**\n\n- 每次请求间隔 5 秒（内置速率控制）\n- 使用 Jina AI Reader 免费服务（100 RPM 限制）\n- 部分付费墙/登录墙页面可能无法完整获取\n\n---\n\n### Q20: 如何批量读取多篇文章？\n\n**你可以这样问：**\n\n- \"帮我读取这几篇新闻的内容\"\n- \"批量获取这些链接的文章正文\"\n- \"读取搜索结果中前 3 篇的详细内容\"\n\n**典型使用流程：**\n\n1. 先用 `search_news(include_url=True)` 搜索新闻获取多个链接\n2. 再用 `read_articles_batch(urls=[...])` 批量读取正文\n3. AI 对多篇文章进行对比分析、综合报告\n\n**工具限制：**\n\n| 限制 | 值 |\n|------|------|\n| 单次最多篇数 | **5 篇** |\n| 请求间隔 | **5 秒** |\n| 预计耗时（5篇） | **25-30 秒** |\n\n**返回信息：**\n\n| 字段 | 说明 |\n|------|------|\n| **summary** | 批量读取的统计信息 |\n| **articles** | 每篇文章的内容和状态 |\n| **note** | 如有跳过的文章，会说明原因 |\n\n**注意事项：**\n\n- 超出 5 篇的部分会被自动跳过\n- 单篇失败不影响其他篇的读取\n- 篇数越多耗时越长，请耐心等待\n\n---\n\n## 通知推送\n\n### Q21: 如何通过 MCP 发送通知消息？\n\n**你可以这样问：**\n\n- \"查看当前配置了哪些通知渠道\"\n- \"发送一条测试消息到所有渠道\"\n- \"把这段内容推送到飞书\"\n- \"发送今天的新闻摘要到钉钉和 Telegram\"\n\n**支持的通知渠道（9 个）：**\n\n| 渠道 | 消息格式 | 配置来源 |\n|------|---------|---------|\n| **飞书** (feishu) | 纯文本 | `FEISHU_WEBHOOK_URL` |\n| **钉钉** (dingtalk) | Markdown | `DINGTALK_WEBHOOK_URL` |\n| **企业微信** (wework) | Markdown | `WEWORK_WEBHOOK_URL` |\n| **Telegram** | HTML | `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` |\n| **Email** | HTML | `EMAIL_FROM` + `EMAIL_PASSWORD` + `EMAIL_TO` |\n| **ntfy** | Markdown | `NTFY_SERVER_URL` + `NTFY_TOPIC` |\n| **Bark** | Markdown | `BARK_URL` |\n| **Slack** | mrkdwn | `SLACK_WEBHOOK_URL` |\n| **通用 Webhook** | Markdown | `GENERIC_WEBHOOK_URL` |\n\n**配置方式：**\n\n- 在 `config.yaml` 的 `notification.channels` 中配置对应渠道\n- 或在 `.env` 文件中设置对应的环境变量（优先级更高）\n- 两种方式会自动合并，`.env` 中的值会覆盖 `config.yaml` 中的值\n\n**两个工具：**\n\n| 工具 | 功能 | 示例问法 |\n|------|------|---------|\n| `get_notification_channels` | 检测已配置的渠道及状态 | \"查看通知渠道配置\" |\n| `send_notification` | 发送消息到指定或全部渠道 | \"发送消息到飞书\" |\n\n**典型使用流程：**\n\n1. 先查看渠道状态：\"查看当前配置了哪些通知渠道\"\n2. 确认渠道可用后发送：\"把以下内容推送到钉钉：今日热点摘要...\"\n3. 或指定多个渠道：\"发送到飞书和 Telegram\"\n4. 不指定渠道则发送到所有已配置渠道\n\n**消息格式：**\n\n- 工具接受 **Markdown 格式** 的消息内容\n- 自动按各渠道要求转换格式（飞书转纯文本、Telegram 转 HTML、Slack 转 mrkdwn 等）\n- 无需手动处理格式差异\n\n**多账号支持：**\n\n- 配置值中用 `;` 分隔多个 URL/Token 即可发送到多个账号\n- 例如：`FEISHU_WEBHOOK_URL=url1;url2` 会同时发送到两个飞书群\n\n---\n\n## 💡 使用技巧\n\n### 1. 如何让 AI 展示全部数据而不是自动总结？\n\n**背景**: 有时 AI 会自动总结数据，只展示部分内容，即使工具返回了完整的 50 条数据。\n\n**如果 AI 仍然总结，你可以**:\n\n- **方法 1 - 明确要求**: \"请展示全部新闻，不要总结\"\n- **方法 2 - 指定数量**: \"展示所有 50 条新闻\"\n- **方法 3 - 质疑行为**: \"为什么只展示了 15 条？我要看全部\"\n- **方法 4 - 提前说明**: \"查询今天的新闻，完整展示所有结果\"\n\n**注意**: AI 仍可能根据上下文调整展示方式。\n\n\n### 2. 如何组合使用多个工具？\n\n**示例：深度分析某个话题**\n\n1. 先搜索：\"搜索'人工智能'相关新闻\"\n2. 再分析趋势：\"分析'人工智能'的热度趋势\"\n3. 最后情感分析：\"分析'人工智能'新闻的情感倾向\"\n\n**示例：追踪某个事件**\n\n1. 查看最新：\"查询今天关于'iPhone'的新闻\"\n2. 查找历史：\"查找上周与'iPhone'相关的历史新闻\"\n3. 找相似报道：\"找出和'iPhone 发布会'相似的新闻\"\n\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\" id=\"trendradar\">\n\n<a href=\"https://github.com/sansan0/TrendRadar\" title=\"TrendRadar\">\n  <img src=\"/_image/banner.webp\" alt=\"TrendRadar Banner\" width=\"80%\">\n</a>\n\n最快<strong>30秒</strong>部署的热点助手 —— 告别无效刷屏，只看真正关心的新闻资讯\n\n<a href=\"https://trendshift.io/repositories/14726\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/14726\" alt=\"sansan0%2FTrendRadar | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n\n[![GitHub Stars](https://img.shields.io/github/stars/sansan0/TrendRadar?style=flat-square&logo=github&color=yellow)](https://github.com/sansan0/TrendRadar/stargazers)\n[![GitHub Forks](https://img.shields.io/github/forks/sansan0/TrendRadar?style=flat-square&logo=github&color=blue)](https://github.com/sansan0/TrendRadar/network/members)\n[![License](https://img.shields.io/badge/license-GPL--3.0-blue.svg?style=flat-square)](LICENSE)\n[![Version](https://img.shields.io/badge/version-v6.5.0-blue.svg)](https://github.com/sansan0/TrendRadar)\n[![MCP](https://img.shields.io/badge/MCP-v4.0.0-green.svg)](https://github.com/sansan0/TrendRadar)\n[![RSS](https://img.shields.io/badge/RSS-订阅源支持-orange.svg?style=flat-square&logo=rss&logoColor=white)](https://github.com/sansan0/TrendRadar)\n[![AI翻译](https://img.shields.io/badge/AI-多语言推送-purple.svg?style=flat-square)](https://github.com/sansan0/TrendRadar)\n\n[![企业微信通知](https://img.shields.io/badge/企业微信-通知-00D4AA?style=flat-square)](https://work.weixin.qq.com/)\n[![个人微信通知](https://img.shields.io/badge/个人微信-通知-00D4AA?style=flat-square)](https://weixin.qq.com/)\n[![Telegram通知](https://img.shields.io/badge/Telegram-通知-00D4AA?style=flat-square)](https://telegram.org/)\n[![dingtalk通知](https://img.shields.io/badge/钉钉-通知-00D4AA?style=flat-square)](#)\n[![飞书通知](https://img.shields.io/badge/飞书-通知-00D4AA?style=flat-square)](https://www.feishu.cn/)\n[![邮件通知](https://img.shields.io/badge/Email-通知-00D4AA?style=flat-square)](#)\n[![ntfy通知](https://img.shields.io/badge/ntfy-通知-00D4AA?style=flat-square)](https://github.com/binwiederhier/ntfy)\n[![Bark通知](https://img.shields.io/badge/Bark-通知-00D4AA?style=flat-square)](https://github.com/Finb/Bark)\n[![Slack通知](https://img.shields.io/badge/Slack-通知-00D4AA?style=flat-square)](https://slack.com/)\n[![通用Webhook](https://img.shields.io/badge/通用-Webhook-607D8B?style=flat-square&logo=webhook&logoColor=white)](#)\n\n\n[![GitHub Actions](https://img.shields.io/badge/GitHub_Actions-自动化-2088FF?style=flat-square&logo=github-actions&logoColor=white)](https://github.com/sansan0/TrendRadar)\n[![GitHub Pages](https://img.shields.io/badge/GitHub_Pages-部署-4285F4?style=flat-square&logo=github&logoColor=white)](https://sansan0.github.io/TrendRadar)\n[![Docker](https://img.shields.io/badge/Docker-部署-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/r/wantcat/trendradar)\n[![MCP Support](https://img.shields.io/badge/MCP-AI分析支持-FF6B6B?style=flat-square&logo=ai&logoColor=white)](https://modelcontextprotocol.io/)\n[![AI分析推送](https://img.shields.io/badge/AI-分析推送-FF6B6B?style=flat-square&logo=openai&logoColor=white)](#)\n[![AI智能筛选](https://img.shields.io/badge/AI-智能筛选新闻-9B59B6?style=flat-square&logo=openai&logoColor=white)](#)\n\n</div>\n\n<div align=\"center\">\n\n**中文** | **[English](README-EN.md)**\n\n</div>\n\n> 本项目以轻量，易部署为目标\n\n<br>\n\n## 📑 快速导航\n\n> 💡 **点击下方链接**可快速跳转到对应章节。部署推荐从「**快速开始**」入手，需要详细自定义请看「**配置详解**」\n\n<div align=\"center\">\n\n|   |   |   |\n|:---:|:---:|:---:|\n| [🚀 **快速开始**](#-快速开始) | [AI 智能分析](#-ai-智能分析) | [⚙️ **配置详解**](#配置详解) |\n| [Docker部署](#6-docker-部署) | [MCP客户端](#-mcp-客户端) | [📝 **更新日志**](#-更新日志) |\n| [🎯 **核心功能**](#-核心功能) | [☕ **支持项目**](#-支持项目) | [📚 **项目相关**](#-项目相关) |\n\n</div>\n\n<br>\n\n- 感谢**为项目点 star** 的观众们，**fork** 你所欲也，**star** 我所欲也，两者得兼😍是对开源精神最好的支持\n\n<details>\n<summary>👉 点击展开：<strong>致谢名单</strong> (天使轮荣誉榜 🔥73+🔥 位)</summary>\n\n### 早期支持者致谢\n\n> 💡 **特别说明**：\n>\n> 1. **关于名单**：下方表格记录了项目起步阶段（天使轮）的支持者。因早期人工统计繁琐，**难免存在疏漏或记录不全的情况，如有遗漏，实非本意，万望海涵**。\n> 2. **未来规划**：为了将有限的精力回归代码与功能迭代，**即日起不再人工维护此名单**。\n>\n> 无论名字是否上榜，你们的每一份支持都是 TrendRadar 能够走到今天的基石。🙏\n\n### 基础设施支持\n\n感谢 **GitHub** 免费提供的基础设施，这是本项目得以**一键 fork**便捷运行的最大前提。\n\n### 数据支持\n\n本项目使用 [newsnow](https://github.com/ourongxing/newsnow) 项目的 API 获取多平台数据，特别感谢作者提供的服务。\n\n经联系，作者表示无需担心服务器压力，但这是基于他的善意和信任。请大家：\n- **前往 [newsnow 项目](https://github.com/ourongxing/newsnow) 点 star 支持**\n- Docker 部署时，请合理控制推送频率，勿竭泽而渔\n\n### 推广助力\n\n> 感谢以下平台和个人的推荐(按时间排列)\n\n- [小众软件](https://mp.weixin.qq.com/s/fvutkJ_NPUelSW9OGK39aA) - 开源软件推荐平台\n- [LinuxDo 社区](https://linux.do/) - 技术爱好者的聚集地\n- [阮一峰周刊](https://github.com/ruanyf/weekly) - 技术圈有影响力的周刊\n\n### 观众支持\n\n> 感谢**给予资金支持**的朋友们，你们的慷慨已化身为键盘旁的零食饮料，陪伴着项目的每一次迭代。\n>\n> **关于\"一元点赞\"的回归**：\n> 随着 v5.0.0 版本的发布，项目迈入了一个新的阶段。为了支持日益增长的 API 成本和咖啡因消耗，\"一元点赞\"通道现已重新开启。你的每一份心意，都将转化为代码世界里的 Token 和动力。🚀 [前往支持](#-支持项目)\n\n|           点赞人            |  金额  |  日期  |             备注             |\n| :-------------------------: | :----: | :----: | :-----------------------: |\n|           D*5          |  1.8 * 3 | 2025.11.24  |    | \n|           *鬼          |  1 | 2025.11.17  |    | \n|           *超          |  10 | 2025.11.17  |    | \n|           R*w          |  10 | 2025.11.17  | 这 agent 做的牛逼啊,兄弟    | \n|           J*o          |  1 | 2025.11.17  | 感谢开源,祝大佬事业有成    | \n|           *晨          |  8.88  | 2025.11.16  | 项目不错,研究学习中    | \n|           *海          |  1  | 2025.11.15  |    | \n|           *德          |  1.99  | 2025.11.15  |    | \n|           *疏          |  8.8  | 2025.11.14  |  感谢开源，项目很棒，支持一下   | \n|           M*e          |  10  | 2025.11.14  |  开源不易，大佬辛苦了   | \n|           **柯          |  1  | 2025.11.14  |     | \n|           *云          |  88  | 2025.11.13  |    好项目，感谢开源  | \n|           *W          |  6  | 2025.11.13  |      | \n|           *凯          |  1  | 2025.11.13  |      | \n|           对*.          |  1  | 2025.11.13  |    Thanks for your TrendRadar  | \n|           s*y          |  1  | 2025.11.13  |      | \n|           **翔          |  10  | 2025.11.13  |   好项目，相见恨晚，感谢开源！     | \n|           *韦          |  9.9  | 2025.11.13  |   TrendRadar超赞，请老师喝咖啡~     | \n|           h*p          |  5  | 2025.11.12  |   支持中国开源力量，加油！     | \n|           c*r          |  6  | 2025.11.12  |        | \n|           a*n          |  5  | 2025.11.12  |        | \n|           。*c          |  1  | 2025.11.12  |    感谢开源分享    | \n|           *记          |  1  | 2025.11.11  |        | \n|           *主          |  1  | 2025.11.10  |        | \n|           *了          |  10  | 2025.11.09  |        | \n|           *杰          |  5  | 2025.11.08  |        | \n|           *点          |  8.80  | 2025.11.07  |   开发不易，支持一下。     | \n|           Q*Q          |  6.66  | 2025.11.07  |   感谢开源！     | \n|           C*e          |  1  | 2025.11.05  |        | \n|           Peter Fan          |  20  | 2025.10.29  |        | \n|           M*n          |  1  | 2025.10.27  |      感谢开源  | \n|           *许          |  8.88  | 2025.10.23  |      老师 小白一枚，摸了几天了还没整起来，求教  | \n|           Eason           |  1  | 2025.10.22  |      还没整明白，但你在做好事  | \n|           P*n           |  1  | 2025.10.20  |          |\n|           *杰           |  1  | 2025.10.19  |          |\n|           *徐           |  1  | 2025.10.18  |          |\n|           *志           |  1  | 2025.10.17  |          |\n|           *😀           |  10  | 2025.10.16  |     点赞     |\n|           **杰           |  10  | 2025.10.16  |          |\n|           *啸           |  10  | 2025.10.16  |          |\n|           *纪           |  5  | 2025.10.14  | TrendRadar         |\n|           J*d           |  1  | 2025.10.14  | 谢谢你的工具，很好玩...          |\n|           *H           |  1  | 2025.10.14  |           |\n|           那*O           |  10  | 2025.10.13  |           |\n|           *圆           |  1  | 2025.10.13  |           |\n|           P*g           |  6  | 2025.10.13  |           |\n|           Ocean           |  20  | 2025.10.12  |  ...真的太棒了！！！小白级别也能直接用...         |\n|           **培           |  5.2  | 2025.10.2  |  github-yzyf1312:开源万岁         |\n|           *椿           |  3  | 2025.9.23  |  加油，很不错         |\n|           *🍍           |  10  | 2025.9.21  |           |\n|           E*f           |  1  | 2025.9.20  |           |\n|           *记            |  1  | 2025.9.20  |           |\n|           z*u            |  2  | 2025.9.19  |           |\n|           **昊            |  5  | 2025.9.17  |           |\n|           *号            |  1  | 2025.9.15  |           |\n|           T*T            |  2  | 2025.9.15  |  点赞         |\n|           *家            |  10  | 2025.9.10  |           |\n|           *X            |  1.11  | 2025.9.3  |           |\n|           *飙            |  20  | 2025.8.31  |  来自老童谢谢         |\n|           *下            |  1  | 2025.8.30  |           |\n|           2*D            |  88  | 2025.8.13 下午 |           |\n|           2*D            |  1  | 2025.8.13 上午 |           |\n|           S*o            |  1  | 2025.8.05 |   支持一下        |\n|           *侠            |  10  | 2025.8.04 |           |\n|           x*x            |  2  | 2025.8.03 |  trendRadar 好项目 点赞          |\n|           *远            |  1  | 2025.8.01 |            |\n|           *邪            |  5  | 2025.8.01 |            |\n|           *梦            |  0.1  | 2025.7.30 |            |\n|           **龙            |  10  | 2025.7.29 |      支持一下      |\n\n\n</details>\n\n<br>\n\n## 🪄 赞助商\n\n<div align=\"center\">\n\n> **虚位以待**\n\n</div>\n\n<br>\n\n<a name=\"-支持项目\"></a>\n\n### ❤️ 觉得好用？支持一下\n\n> 若 TrendRadar 曾为你捕捉价值，不妨为它注入动力，助其持续进化\n>\n> 金额随意，1 元也是对开源的鼓励。欢迎在赞赏时备注留言 (´▽`ʃ♡ƪ)\n\n<div align=\"center\">\n\n| 微信赞赏 | 支付宝赞赏 |\n|:---:|:---:|\n| <img src=\"https://cdn-1258574687.cos.ap-shanghai.myqcloud.com/img/%2F2025%2F07%2F17%2F2ae0a88d98079f7e876c2b4dc85233c6-9e8025.JPG\" width=\"240\" alt=\"微信赞赏\"> | <img src=\"https://cdn-1258574687.cos.ap-shanghai.myqcloud.com/img/%2F2025%2F07%2F17%2F1ed4f20ab8e35be51f8e84c94e6e239b4-fe4947.JPG\" width=\"240\" alt=\"支付宝赞赏\"> |\n\n</div>\n\n\n### 🤝 二次开发与引用\n\n如果你在项目中使用或借鉴了本项目的思路、核心代码，**非常欢迎**在 README 或文档中注明来源并附上本仓库链接。\n\n这将有助于项目的持续维护和社区发展，感谢你的尊重与支持！❤️\n\n\n### 💬 交流与反馈\n\n- **GitHub Issues**：适合具体的技术问题。提问时请提供完整信息（截图、错误日志等），有助于快速定位。\n- **公众号交流**：建议优先在相关文章下的留言区交流。若需后台提问，**先点赞/推荐**文章是最好的“敲门砖”，我在后台都能感受到这份心意哟 (´▽`ʃ♡ƪ)。\n\n> **友情提示**：        \n> 本项目为开源分享，非商业产品。把作者当朋友而非客服，沟通效率会更高哦！     \n\n<div align=\"center\">\n\n|公众号关注 |\n|:---:|\n| <img src=\"_image/weixin.png\" width=\"500\" title=\"硅基茶水间\"/> |\n\n</div>\n\n<br>\n\n## 📝 更新日志\n\n> **📌 查看最新更新**：**[原仓库更新日志](https://github.com/sansan0/TrendRadar?tab=readme-ov-file#-更新日志)** ：\n- **提示**：建议查看【历史更新】，明确具体的【功能内容】\n\n\n### 2026/03/12 - v6.5.0\n\n- **AI 智能筛选系统**：不用再手动设关键词！在 `ai_interests.txt` 里用日常语言写下你关注的方向（如\"我想看 AI 和新能源相关新闻\"），AI 会自动提取标签并对每条新闻打分，只推送真正和你相关的内容。万一 AI 筛选出了问题，会自动切回关键词匹配，推送不中断\n- **每个时段支持不同的筛选方式和关注方向**：Timeline 中的每个时间段现在可以独立设置用什么方式筛选、看什么类型的新闻。比如：早上用\"科技关键词\"快速过滤，晚上换成\"金融 AI 兴趣描述\"做深度筛选——同一个系统，不同时段看不同内容\n- **AI 分析范围独立于推送**：AI 分析的数据范围可以和推送内容不同。比如推送只发新增消息（避免重复打扰），但 AI 分析当天全部新闻（看完整趋势）。每个时段也能单独设置 AI 分析模式\n- **AI 筛选智能省钱**：已分析过的新闻不会重复消耗 token；兴趣描述修改后，AI 自动判断变化幅度——小改动只更新受影响的标签，大改动才全量重新分类\n- **多文件配置与标签隔离**：自定义关键词文件放 `config/custom/keyword/`，AI 兴趣文件放 `config/custom/ai/`，不同文件产生的标签各自独立、互不干扰\n- **AI 翻译精准控制**：可分别控制热榜、RSS、独立展示区是否翻译，没开启显示的区域自动跳过，不浪费 token\n- **远程存储批量上传**：多次写操作攒在一起统一提交云端，减少 API 调用次数\n- **每组关键词/标签展示数量限制**：通过 `max_news_per_keyword` 控制每个分组最多显示多少条新闻，避免单个热门话题占满整条推送\n- **时段冲突智能检测**：两个时间段如果有时间重叠，系统会自动报错提醒修改，避免配置冲突导致意外行为\n- 修复若干bug\n\n\n### 2026/02/09 - mcp-v4.0.0\n\n- **🔥 AI 消息直推所有渠道**：让 AI 写好的内容一键推送到飞书、钉钉、Telegram、邮件等 9 个渠道，Markdown 自动适配各平台格式，不用操心格式差异\n- **新增格式化策略指南**：新增 `get_channel_format_guide` 工具，告诉 AI 每个渠道支持什么格式、有什么限制，生成的内容排版更好看\n- **智能分批发送**：超长消息自动按各渠道字节限制拆分（飞书 30KB、钉钉 20KB 等），配置读取自 config.yaml\n- **修复渠道误检测**：ntfy 不再因为默认地址被误报为\"已配置\"\n- **代码复用优化**：批次处理函数直接复用 trendradar 核心模块，不重复造轮子\n\n\n<details>\n<summary>👉 点击展开：<strong>历史更新</strong></summary>\n\n### 2026/02/09 - v6.0.0\n\n> **Breaking Change**：配置文件升级（config.yaml 2.0.0），旧版 `push_window` 和 `analysis_window` 配置不再兼容，请参考新版 config.yaml 迁移\n\n- **统一调度系统**：新增 `timeline.yaml`，用一套配置控制「什么时间采集 / 推送 / AI 分析」\n- **5 种预设模板**：`always_on`（全天候，默认）、`morning_evening`（早晚汇总）、`office_hours`（办公时间）、`night_owl`（夜猫子）、`custom`（自定义）；也支持在 `presets:` 下新增自己的模板，只要 key 不重复，然后在 config.yaml 里填你的模板名即可\n- **灵活的时间段配置**：支持工作日/周末差异化、跨午夜时间段、per-period once 去重\n- **可视化配置编辑器**：\n  - 新增 `timeline.yaml` 编辑标签页，与 config.yaml / frequency_words.txt 并列\n  - 预设模式卡片选择：点击即切换，自动同步 config.yaml 的 `schedule.preset`\n  - 周视图时间线：7 天 × 24 小时水平条，用颜色区分推送/分析/采集状态\n  - 可交互控件：开关、下拉框、时间选择器，右侧修改实时同步到左侧 YAML\n  - 周映射下拉选择：根据日计划动态填充，拖拉点击即可完成调度配置\n- **AI 提示词稳定性优化**（ai_analysis_prompt.txt v2.0.0）：\n  - 格式规范独立说明：将换行/标签/序号/禁止事项从 JSON value 中抽出，作为独立章节\n  - JSON 模板简化：字段描述缩短为一句话 + 字数限制，减少 AI 输出格式混乱\n  - 去除 system prompt 中的 Markdown 格式，与\"禁止 Markdown\"指令保持一致\n  - 所有 JSON 字段声明为可选，缺少任何字段不会报错，增强容错性\n- **新增独立展示区 AI 概括分析**（`ai_analysis.include_standalone`）：\n  - 新增独立开关，开启后 AI 对每个 standalone 源生成核心概括\n  - AI 分析与推送展示解耦：无需开启独立展示区的推送显示，AI 也可独立分析完整热榜数据\n  - 支持热榜平台和 RSS 源，含排名/时间/轨迹数据\n  - 轨迹分析与 `include_rank_timeline` 联动：开启时利用轨迹数据做深度趋势分析，关闭时基于排名做简要判断\n  - 新增 `standalone_summaries` JSON 字段（独立源点速览），所有推送渠道均已适配渲染\n\n\n### 2026/01/28 - v5.5.0\n\n> 和 mcp 功能一样, 这个小工具我也不新开一个仓库维护了, 反正纯前端, 都搁一起吧\n\n- 增加 trendradar 的可视化配置编辑器\n\n\n### 2026/02/02 - mcp-v3.2.0\n\n- **新增 read_article 工具**：通过 Jina AI Reader 读取单篇文章正文（Markdown 格式）\n- **新增 read_articles_batch 工具**：批量读取多篇文章（最多 5 篇，自动限速）\n- **推荐工作流**：`search_news(query=\"关键词\", include_url=True)` → `read_article(url=...)` 读取正文\n- **文档更新**：README-MCP-FAQ.md 和 README-MCP-FAQ-EN.md 新增 Q19-Q20 文章读取相关说明\n\n\n### 2026/01/10 - mcp-v3.0.0~v3.1.5\n\n- **Breaking Change**：所有工具返回值统一为 `{success, summary, data, error}` 结构\n- **异步一致性**：所有 21 个工具函数使用 `asyncio.to_thread()` 包装同步调用\n- **MCP Resources**：新增 4 个资源（platforms、rss-feeds、available-dates、keywords）\n- **RSS 增强**：`get_latest_rss` 支持多日查询（days 参数），跨日期 URL 去重\n- **正则匹配修复**：`get_trending_topics` 支持 `/pattern/` 正则语法和 `display_name`\n- **缓存优化**：新增 `make_cache_key()` 函数，参数排序+MD5 哈希确保一致性\n- **新增 check_version 工具**：支持同时检查 TrendRadar 和 MCP Server 版本更新\n\n\n### 2026/01/23 - v5.4.0\n\n- 增加 AI 分析模式的独立控制功能，可选 follow_report | daily | current | incremental \n- 新增 AI 分析时间窗口控制，支持自定义运行段及每日频次限制\n- 增加配置文件版本管理功能\n- 修复若干bug\n\n\n### 2026/01/19 - v5.3.0\n\n> **重大重构：AI 模块迁移至 LiteLLM**\n\n- **统一 AI 接口**：使用 LiteLLM 替代手动实现，支持 100+ AI 提供商\n- **简化配置**：移除 `provider` 字段，改用 `model: \"provider/model_name\"` 格式\n- **新增功能**：自动重试 (`num_retries`)、备用模型 (`fallback_models`)\n- **配置变更**：\n  - `ai.provider` → 移除（已合并到 model）\n  - `ai.base_url` → `ai.api_base`\n  - `AI_PROVIDER` 环境变量 → 移除\n  - `AI_BASE_URL` 环境变量 → `AI_API_BASE`\n- **模型格式示例**：\n  - DeepSeek: `deepseek/deepseek-chat`\n  - OpenAI: `openai/gpt-4o`\n  - Gemini: `gemini/gemini-2.5-flash`\n  - Anthropic: `anthropic/claude-3-5-sonnet`\n\n### 2026/01/17 - v5.2.0\n\n> 主要见 config.yaml 描述\n\n**🌐 AI 翻译功能**\n\n- **多语言翻译**：支持将推送内容翻译为任意语言\n- **批量翻译**：智能批量处理，减少 API 调用次数\n- **自定义提示词**：支持自定义翻译风格\n\n**🔧 配置架构优化**\n\n- **AI 模型配置独立**：分析和翻译共享模型配置\n- **区域开关统一**：统一管理推送区域显示\n- **区域排序自定义**：支持自定义各区域的显示顺序\n\n**✨ AI 分析增强**\n\n- **AI 分析嵌入 HTML**：分析结果直接嵌入 HTML 报告，邮件通知直接使用\n- **富样式 AI 区块**：渐变蓝色背景卡片式布局，清晰分隔各分析维度\n- **排名时间线支持**：AI 可获取每条新闻在每个抓取时间点的精确排名\n- **板块重组 (7→4)**：整合为核心热点态势、舆论风向争议、异动与弱信号、研判策略建议\n\n**🔧 多模型适配**\n\n- **通用参数透传**：支持向 API 透传任意高级参数\n- **Gemini 适配**：原生参数支持，内置安全策略放宽\n\n**🐛 Bug 修复**\n\n- 修复若干已知问题，提升系统稳定性\n\n### 2026/01/10 - v5.0.0\n\n> **开发小插曲**：\n> 致敬那个陪伴我两年多、却在刚续费后反手弹出 `\"This organization has been disabled\"` 的某 C 厂模型\n\n**✨ 推送内容\"五大板块\"重构**\n\n本次更新对推送消息进行了区域化重构，现在推送内容清晰地划分为五大核心板块：\n\n1.  **📊 热榜新闻**：根据你的关键词精准筛选后的全网热点聚合。\n2.  **📰 RSS 订阅**：你的个性化订阅源内容，支持按关键词分组。\n3.  **🆕 本次新增**：实时捕捉自上次运行以来的全新热点（带 🆕 标记）。\n4.  **📋 独立展示区**：指定平台的完整热榜或 RSS 源展示，**完全不受关键词过滤限制**。\n5.  **✨ AI 分析板块**：由 AI 驱动的深度洞察，包含趋势概述、热度走势及**极其重要**的情感倾向分析。\n\n**✨ AI 智能分析推送功能**\n\n- **AI 分析集成**：使用 AI 大模型对推送内容进行深度分析，自动生成热点趋势概述、关键词热度分析、跨平台关联、潜在影响评估等\n- **情感倾向分析**：新增深度情感识别，精准捕捉舆论的正负面、争议或担忧情绪\n- **多 AI 提供商支持**：支持 DeepSeek（默认，性价比高）、OpenAI、Google Gemini 及任意 OpenAI 兼容接口\n- **两种推送模式**：`only_analysis`（仅 AI 分析）、`both`（两者都推送）\n- **自定义提示词**：通过 `config/ai_analysis_prompt.txt` 文件自定义 AI 分析角色和输出格式\n- **多维度数据分析**：AI 可分析排名变化、热度持续时间、跨平台表现、趋势预测等\n\n**📋 独立展示区功能**\n\n- **完整热榜展示**：指定平台的完整热榜单独展示，不受关键词过滤影响\n- **RSS 独立展示**：RSS 源内容可完整展示，适合内容较少的订阅源\n- **灵活配置**：支持配置展示平台列表、RSS 源列表、最大展示条数\n\n**📊 推送体验重构**\n\n- **排版升级**：重新设计并统一各渠道统计头部，强化区块组织，消息层次一目了然\n- **配置简化**：优化飞书等通知渠道的配置逻辑，上手更简单\n- **热度趋势箭头**：新增 🔺(上升)、🔻(下降)、➖(持平) 趋势标识，直观展示热度变化\n- **通用 Webhook**：支持自定义 Webhook URL 和 JSON 模板，轻松适配 Discord、Matrix、IFTTT 等任意平台\n\n**🔧 配置优化**\n\n- **频率词配置增强**：新增 `[组别名]` 语法，支持 `#` 注释行，配置更清晰（感谢 [@songge8](https://github.com/sansan0/TrendRadar/issues/752) 提出的建议）\n- **环境变量支持**：AI 分析相关配置支持环境变量覆盖（`AI_API_KEY`、`AI_PROVIDER` 等）\n\n> 💡 详细配置教程见 [让 AI 帮我分析热点](#12-让-ai-帮我分析热点)\n\n\n### 2026/01/02 - v4.7.0\n\n- **修复 RSS HTML 显示**：修复 RSS 数据格式不匹配导致的渲染问题，现在按关键词分组正确显示\n- **新增正则表达式语法**：关键词配置支持 `/pattern/` 正则语法，解决英文子字符串误匹配问题（如 `ai` 匹配 `training`）[📖 查看语法详解](#关键词基础语法)\n- **新增显示名称语法**：使用 `=> 备注` 给复杂的正则表达式起个好记的名字，推送消息显示更清晰（如 `/\\bai\\b/ => AI相关`）\n- **不会写正则？** README 新增 AI 生成正则的引导，告诉 ChatGPT/Gemini/DeepSeek 你想匹配什么，让 AI 帮你写\n\n\n### 2025/12/30 - mcp-v2.0.0\n\n- **架构调整**：移除 TXT 支持，统一使用 SQLite 数据库\n- **RSS 查询**：新增 `get_latest_rss`、`search_rss`、`get_rss_feeds_status`\n- **统一搜索**：`search_news` 支持 `include_rss` 参数同时搜索热榜和 RSS\n\n\n### 2026/01/01 - v4.6.0\n\n- **修复 RSS HTML 显示**：将 RSS 内容合并到热榜 HTML 页面，按源分组显示\n- **新增 display_mode 配置**：支持 `keyword`（按关键词分组）和 `platform`（按平台分组）两种显示模式\n\n\n### 2025/12/30 - v4.5.0\n\n- **RSS 订阅源支持**：新增 RSS/Atom 抓取，按关键词分组统计（与热榜格式一致）\n- **存储结构重构**：扁平化目录结构 `output/{type}/{date}.db`\n- **统一排序配置**：`sort_by_position_first` 同时影响热榜和 RSS\n- **配置结构重构**：`config.yaml` 重新组织为 7 个逻辑分组（app、report、notification、storage、platforms、rss、advanced），配置路径更清晰\n\n\n### 2025/12/26 - mcp-v1.2.0\n\n  **MCP 模块更新 - 优化工具集，新增聚合对比功能，合并冗余工具:**\n  - 新增 `aggregate_news` 工具 - 跨平台新闻去重聚合\n  - 新增 `compare_periods` 工具 - 时期对比分析（周环比/月环比）\n  - 合并 `find_similar_news` + `search_related_news_history` → `find_related_news`\n  - 增强 `get_trending_topics` - 新增 `auto_extract` 模式自动提取热点\n  - 修复若干bug\n  - 同步更新 README-MCP-FAQ.md 文档的中英文版 (Q1-Q18)\n\n\n### 2025/12/20 - v4.0.3\n\n- 新增 URL 标准化功能，解决微博等平台因动态参数（如 `band_rank`）导致的重复推送问题\n- 修复增量模式检测逻辑，正确识别历史标题\n\n\n### 2025/12/17 - v4.0.1\n\n- StorageManager 添加推送记录代理方法\n- S3 客户端切换至 virtual-hosted style 以提升兼容性（支持腾讯云 COS 等更多服务）\n\n\n### 2025/12/13 - mcp-v1.1.0\n\n  **MCP 模块更新:**\n  - 适配 v4.0.0，同时也兼容 v3.x 的数据\n  - 新增存储同步工具：`sync_from_remote`、`get_storage_status`、`list_available_dates`\n\n\n### 2025/12/13 - v4.0.0\n\n**🎉 重大更新：全面重构存储和核心架构**\n\n- **多存储后端支持**：引入全新的存储模块，支持本地 SQLite 和远程云存储（S3 兼容协议，例如 Cloudflare R2），适应 GitHub Actions、Docker 和本地环境。\n- **数据库结构优化**：重构 SQLite 数据库表结构，提升数据效率和查询能力。\n- **核心代码模块化**：将主程序逻辑拆分为 trendradar 包的多个模块，显著提升代码可维护性。\n- **增强功能**：实现日期格式标准化、数据保留策略、时区配置支持、时间显示优化，并修复远程存储数据持久化问题，确保数据合并的准确性。\n- **清理和兼容**：移除了大部分历史兼容代码，统一了数据存储和读取方式。\n\n\n### 2025/12/03 - v3.5.0\n\n**🎉 核心功能增强**\n\n1. **多账号推送支持**\n   - 所有推送渠道（飞书、钉钉、企业微信、Telegram、ntfy、Bark、Slack）支持多账号配置\n   - 使用分号 `;` 分隔多个账号，例如：`FEISHU_WEBHOOK_URL=url1;url2`\n   - 自动验证配对配置（如 Telegram 的 token 和 chat_id）数量一致性\n\n2. **推送区域配置**\n   - 通过 `display.region_order` 自定义各区域的显示顺序（v5.2.0 替代原 `reverse_content_order`）\n   - 通过 `display.regions` 控制各区域是否显示（热榜、新增热点、RSS、独立展示区、AI 分析）\n\n3. **全局过滤关键词**\n   - 新增 `[GLOBAL_FILTER]` 区域标记，支持全局过滤不想看到的内容\n   - 适用场景：过滤广告、营销、低质内容等\n\n**🐳 Docker 双路径 HTML 生成优化**\n\n- **问题修复**：解决 Docker 环境下 `index.html` 无法同步到宿主机的问题\n- **双路径生成**：当日汇总 HTML 同时生成到两个位置\n  - `index.html`（项目根目录）：供 GitHub Pages 访问\n  - `output/index.html`：通过 Docker Volume 挂载，宿主机可直接访问\n- **兼容性**：确保 Docker、GitHub Actions、本地运行环境均能正常访问网页版报告\n\n**🐳 Docker MCP 镜像支持**\n\n- 新增独立的 MCP 服务镜像 `wantcat/trendradar-mcp`\n- 支持 Docker 部署 AI 分析功能，通过 HTTP 接口（端口 3333）提供服务\n- 双容器架构：新闻推送服务与 MCP 服务独立运行，可分别扩展和重启\n- 详见 [Docker 部署 - MCP 服务](#6-docker-部署)\n\n**🌐 Web 服务器支持**\n\n- 新增内置 Web 服务器，支持通过浏览器访问生成的报告\n- 通过 `manage.py` 命令控制启动/停止：`docker exec -it trendradar python manage.py start_webserver`\n- 访问地址：`http://localhost:8080`（端口可配置）\n- 安全特性：静态文件服务、目录限制、本地访问\n- 支持自动启动和手动控制两种模式\n\n**📖 文档优化**\n\n- 新增 [推送内容怎么显示？](#7-推送内容怎么显示) 章节：自定义推送样式和内容\n- 新增 [什么时候给我推送？](#8-什么时候给我推送) 章节：设置推送时间段\n- 新增 [多久运行一次？](#9-多久运行一次) 章节：设置自动运行频率\n- 新增 [推送到多个群/设备](#10-推送到多个群设备) 章节：同时推送给多个接收者\n- 优化各配置章节：统一添加\"配置位置\"说明\n- 简化快速开始配置说明：三个核心文件一目了然\n- 优化 [Docker 部署](#6-docker-部署) 章节：新增镜像说明、推荐 git clone 部署、重组部署方式\n\n**🔧 升级说明**：\n- **GitHub Fork 用户**：更新 `main.py`、`config/config.yaml`（新增多账号推送支持，无需修改现有配置）\n- **多账号推送**：新功能，默认不启用，现有单账号配置不受影响\n\n\n### 2025/11/26 - mcp-v1.0.3\n\n  **MCP 模块更新:**\n  - 新增日期解析工具 resolve_date_range,解决 AI 模型计算日期不一致的问题\n  - 支持自然语言日期表达式解析(本周、最近7天、上月等)\n  - 工具总数从 13 个增加到 14 个\n\n\n### 2025/11/28 - v3.4.1\n\n**🔧 格式优化**\n\n1. **Bark 推送增强**\n   - Bark 现支持 Markdown 渲染\n   - 启用原生 Markdown 格式：粗体、链接、列表、代码块等\n   - 移除纯文本转换，充分利用 Bark 原生渲染能力\n\n2. **Slack 格式精准化**\n   - 使用专用 mrkdwn 格式处理分批内容\n   - 提升字节大小估算准确性（避免消息超限）\n   - 优化链接格式：`<url|text>` 和加粗语法：`*text*`\n\n3. **性能提升**\n   - 格式转换在分批过程中完成，避免二次处理\n   - 准确估算消息大小，减少发送失败率\n\n**🔧 升级说明**：\n- **GitHub Fork 用户**：更新 `main.py`，`config.yaml`\n\n\n### 2025/11/25 - v3.4.0\n\n**🎉 新增 Slack 推送支持**\n\n1. **团队协作推送渠道**\n   - 支持 Slack Incoming Webhooks（全球流行的团队协作工具）\n   - 消息集中管理，适合团队共享热点资讯\n   - 支持 mrkdwn 格式（粗体、链接等）\n\n2. **多种部署方式**\n   - GitHub Actions：配置 `SLACK_WEBHOOK_URL` Secret\n   - Docker：环境变量 `SLACK_WEBHOOK_URL`\n   - 本地运行：`config/config.yaml` 配置文件\n\n\n> 📖 **详细配置教程**：[快速开始 - Slack 推送](#-快速开始)\n\n- 优化 setup-windows.bat 和 setup-windows-en.bat 一键安装 MCP 的体验\n\n**🔧 升级说明**：\n- **GitHub Fork 用户**：更新 `main.py`、`config/config.yaml`、`.github/workflows/crawler.yml`\n\n\n### 2025/11/24 - v3.3.0\n\n**🎉 新增 Bark 推送支持**\n\n1. **iOS 专属推送渠道**\n   - 支持 Bark 推送（基于 APNs，iOS 平台）\n   - 免费开源，简洁高效，无广告干扰\n   - 支持官方服务器和自建服务器两种方式\n\n2. **多种部署方式**\n   - GitHub Actions：配置 `BARK_URL` Secret\n   - Docker：环境变量 `BARK_URL`\n   - 本地运行：`config/config.yaml` 配置文件\n\n> 📖 **详细配置教程**：[快速开始 - Bark 推送](#-快速开始)\n\n**🐛 Bug 修复**\n- 修复 `config.yaml` 中 `ntfy_server_url` 配置不生效的问题 ([#345](https://github.com/sansan0/TrendRadar/issues/345))\n\n**🔧 升级说明**：\n- **GitHub Fork 用户**：更新 `main.py`、`config/config.yaml`、`.github/workflows/crawler.yml`\n\n### 2025/11/23 - v3.2.0\n\n**🎯 新增高级定制功能**\n\n1. **关键词排序优先级配置**\n   - 支持两种排序策略：热度优先 vs 配置顺序优先\n   - 满足不同使用场景：热点追踪 or 个性化关注\n\n2. **显示数量精准控制**\n   - 全局配置：统一限制所有关键词显示数量\n   - 单独配置：使用 `@数字` 语法为特定关键词设置限制\n   - 有效控制推送长度，突出重点内容\n\n> 📖 **详细配置教程**：[关键词配置 - 高级配置](#关键词高级配置)\n\n**🔧 升级说明**：\n- **GitHub Fork 用户**：更新 `main.py`、`config/config.yaml`\n\n\n### 2025/11/18 - mcp-v1.0.2\n\n  **MCP 模块更新:**\n  - 优化查询今日新闻却可能错误返回过去日期的情况\n\n\n### 2025/11/22 - v3.1.1\n\n- **修复数据异常导致的崩溃问题**：解决部分用户在 GitHub Actions 环境中遇到的 `'float' object has no attribute 'lower'` 错误\n- 新增双重防护机制：在数据获取阶段过滤无效标题（None、float、空字符串），同时在函数调用处添加类型检查\n- 提升系统稳定性，确保在数据源返回异常格式时仍能正常运行\n\n**升级说明**（GitHub Fork 用户）：\n- 必须更新：`main.py`\n- 建议使用小版本升级方式：复制替换上述文件\n\n\n### 2025/11/20 - v3.1.0\n\n- **新增个人微信推送支持**：企业微信应用可推送到个人微信，无需安装企业微信 APP\n- 支持两种消息格式：`markdown`（企业微信群机器人）和 `text`（个人微信应用）\n- 新增 `WEWORK_MSG_TYPE` 环境变量配置，支持 GitHub Actions、Docker、docker compose 等多种部署方式\n- `text` 模式自动清除 Markdown 语法，提供纯文本推送效果\n- 详见快速开始中的「个人微信推送」配置说明\n\n**升级说明**（GitHub Fork 用户）：\n- 必须更新：`main.py`、`config/config.yaml`\n- 可选更新：`.github/workflows/crawler.yml`（如使用 GitHub Actions 部署）\n- 建议使用小版本升级方式：复制替换上述文件\n\n### 2025/11/12 - v3.0.5\n\n- 修复邮件发送 SSL/TLS 端口配置逻辑错误\n- 优化邮箱服务商（QQ/163/126）默认使用 465 端口（SSL）\n- **新增 Docker 环境变量支持**：核心配置项（`enable_crawler`、`report_mode`、`push_window` 等）支持通过环境变量覆盖，解决 NAS 用户修改配置文件不生效的问题（详见 [🐳 Docker 部署](#-docker-部署) 章节）\n\n\n### 2025/10/26 - mcp-v1.0.1\n\n  **MCP 模块更新:**\n  - 修复日期查询参数传递错误\n  - 统一所有工具的时间参数格式\n\n\n### 2025/10/31 - v3.0.4\n\n- 解决飞书因推送内容过长而产生的错误，实现了分批推送\n\n\n### 2025/10/23 - v3.0.3\n\n- 扩大 ntfy 错误信息显示范围\n\n\n### 2025/10/21 - v3.0.2\n\n- 修复 ntfy 推送编码问题\n\n### 2025/10/20 - v3.0.0\n\n**重大更新 - AI 分析功能上线** ✨\n\n- **核心功能**：\n  - 新增基于 MCP (Model Context Protocol) 的 AI 分析服务器\n  - 支持17种智能分析工具：基础查询、智能检索、高级分析、RSS 查询、系统管理\n  - 自然语言交互：通过对话方式查询和分析新闻数据\n  - 多客户端支持：Claude Desktop、Cherry Studio、Cursor、Cline 等\n\n- **分析能力**：\n  - 话题趋势分析（热度追踪、生命周期、爆火检测、趋势预测）\n  - 数据洞察（平台对比、活跃度统计、关键词共现）\n  - 情感分析、相似新闻查找、智能摘要生成\n  - 历史相关新闻检索、多模式搜索\n\n- **更新提示**：\n  - 这是独立的 AI 分析功能，不影响现有的推送功能\n  - 可选择性使用，无需升级现有部署\n\n\n### 2025/10/15 - v2.4.4\n\n- **更新内容**：\n    - 修复 ntfy 推送编码问题 + 1\n    - 修复推送时间窗口判断问题\n\n- **更新提示**：\n  - 建议【小版本升级】\n\n\n### 2025/10/10 - v2.4.3\n\n> 感谢 [nidaye996](https://github.com/sansan0/TrendRadar/issues/98) 发现的体验问题\n\n- **更新内容**：\n    - 重构\"静默推送模式\"命名为\"推送时间窗口控制\"，提升功能理解度\n    - 明确推送时间窗口作为可选附加功能，可与三种推送模式搭配使用\n    - 改进注释和文档描述，使功能定位更加清晰\n\n- **更新提示**：\n  - 这个仅仅是重构，可以不用升级\n\n\n### 2025/10/8 - v2.4.2\n\n- **更新内容**：\n    - 修复 ntfy 推送编码问题\n    - 修复配置文件缺失问题\n    - 优化 ntfy 推送效果\n    - 增加 github page 图片分段导出功能\n\n- **更新提示**：\n  - 建议使用【大版本更新】\n\n\n### 2025/10/2 - v2.4.0\n\n**新增 ntfy 推送通知**\n\n- **核心功能**：\n  - 支持 ntfy.sh 公共服务和自托管服务器\n\n- **使用场景**：\n  - 适合追求隐私的用户（支持自托管）\n  - 跨平台推送（iOS、Android、Desktop、Web）\n  - 无需注册账号（公共服务器）\n  - 开源免费（MIT 协议）\n\n- **更新提示**：\n  - 建议使用【大版本更新】\n\n\n### 2025/09/26 - v2.3.2\n\n- 修正了邮件通知配置检查被遗漏的问题（[#88](https://github.com/sansan0/TrendRadar/issues/88)）\n\n**修复说明**：\n- 解决了即使正确配置邮件通知，系统仍提示\"未配置任何webhook\"的问题\n\n### 2025/09/22 - v2.3.1\n\n- **新增邮件推送功能**，支持将热点新闻报告发送到邮箱\n- **智能 SMTP 识别**：自动识别 Gmail、QQ邮箱、Outlook、网易邮箱等 10+ 种邮箱服务商配置\n- **HTML 精美格式**：邮件内容采用与网页版相同的 HTML 格式，排版精美，移动端适配\n- **批量发送支持**：支持多个收件人，用逗号分隔即可同时发送给多人\n- **自定义 SMTP**：可自定义 SMTP 服务器和端口\n- 修复Docker构建网络连接问题\n\n**使用说明**：\n- 适用场景：适合需要邮件归档、团队分享、定时报告的用户\n- 支持邮箱：Gmail、QQ邮箱、Outlook/Hotmail、163/126邮箱、新浪邮箱、搜狐邮箱等\n\n**更新提示**：\n- 此次更新的内容比较多，如果想升级，建议采用【大版本升级】\n\n### 2025/09/17 - v2.2.0\n\n- 新增一键保存新闻图片功能，让你轻松分享关注的热点\n\n**使用说明**：\n- 适用场景：当你按照教程开启了网页版功能后(GitHub Pages)\n- 使用方法：用手机或电脑打开该网页链接，点击页面顶部的\"保存为图片\"按钮\n- 实际效果：系统会自动将当前的新闻报告制作成一张精美图片，保存到你的手机相册或电脑桌面\n- 分享便利：你可以直接把这张图片发给朋友、发到朋友圈，或分享到工作群，让别人也能看到你发现的重要资讯\n\n### 2025/09/13 - v2.1.2\n\n- 解决钉钉的推送容量限制导致的新闻推送失败问题(采用分批推送)\n\n### 2025/09/04 - v2.1.1\n\n- 修复docker在某些架构中无法正常运行的问题\n- 正式发布官方 Docker 镜像 wantcat/trendradar，支持多架构\n- 优化 Docker 部署流程，无需本地构建即可快速使用\n\n### 2025/08/30 - v2.1.0\n\n**核心改进**：\n- **推送逻辑优化**：从\"每次执行都推送\"改为\"时间窗口内可控推送\"\n- **时间窗口控制**：可设定推送时间范围，避免非工作时间打扰\n- **推送频率可选**：时间段内支持单次推送或多次推送\n\n**更新提示**：\n- 本功能默认关闭，需手动在 config.yaml 中开启推送时间窗口控制\n- 升级需同时更新 main.py 和 config.yaml 两个文件\n\n### 2025/08/27 - v2.0.4\n\n- 本次版本不是功能修复，而是重要提醒\n- 请务必妥善保管好 webhooks，不要公开，不要公开，不要公开\n- 如果你以 fork 的方式将本项目部署在 GitHub 上，请将 webhooks 填入 GitHub Secret，而非 config.yaml\n- 如果你已经暴露了 webhooks 或将其填入了 config.yaml，建议删除后重新生成\n\n### 2025/08/06 - v2.0.3\n\n- 优化 github page 的网页版效果，方便移动端使用\n\n### 2025/07/28 - v2.0.2\n\n- 重构代码\n- 解决版本号容易被遗漏修改的问题\n\n### 2025/07/27 - v2.0.1\n\n**修复问题**: \n\n1. docker 的 shell 脚本的换行符为 CRLF 导致的执行异常问题\n2. frequency_words.txt 为空时，导致新闻发送也为空的逻辑问题\n  - 修复后，当你选择 frequency_words.txt 为空时，将**推送所有新闻**，但受限于消息推送大小限制，请做如下调整\n    - 方案一：关闭手机推送，只选择 Github Pages 布置(这是能获得最完整信息的方案，将把所有平台的热点按照你**自定义的热搜算法**进行重新排序)\n    - 方案二：减少推送平台，优先选择**企业微信**或**Telegram**，这两个推送我做了分批推送功能(因为分批推送影响推送体验，且只有这两个平台只给一点点推送容量，所以才不得已做了分批推送功能，但至少能保证获得的信息完整)\n    - 方案三：可与方案二结合，模式选择 current 或 incremental 可有效减少一次性推送的内容 \n\n### 2025/07/17 - v2.0.0\n\n**重大重构**：\n- 配置管理重构：所有配置现在通过 `config/config.yaml` 文件管理（main.py 我依旧没拆分，方便你们复制升级）\n- 运行模式升级：支持三种模式 - `daily`（当日汇总）、`current`（当前榜单）、`incremental`（增量监控）\n- Docker 支持：完整的 Docker 部署方案，支持容器化运行\n\n**配置文件说明**：\n- `config/config.yaml` - 主配置文件（应用设置、爬虫配置、通知配置、平台配置等）\n- `config/frequency_words.txt` - 关键词配置（监控词汇设置）\n\n### 2025/07/09 - v1.4.1\n\n**功能新增**：增加增量推送(在 main.py 头部配置 FOCUS_NEW_ONLY)，该开关只关心新话题而非持续热度，只在有新内容时才发通知。\n\n**修复问题**: 某些情况下，由于新闻本身含有特殊符号导致的偶发性排版异常。\n\n### 2025/06/23 - v1.3.0\n\n企业微信 和 Telegram 的推送消息有长度限制，对此我采用将消息拆分推送的方式。开发文档详见[企业微信](https://developer.work.weixin.qq.com/document/path/91770) 和 [Telegram](https://core.telegram.org/bots/api)\n\n### 2025/06/21 - v1.2.1\n\n在本版本之前的旧版本，不仅 main.py 需要复制替换， crawler.yml 也需要你复制替换\nhttps://github.com/sansan0/TrendRadar/blob/master/.github/workflows/crawler.yml\n\n### 2025/06/19 - v1.2.0\n\n> 感谢 claude research 整理的各平台 api ,让我快速完成各平台适配（虽然代码更多冗余了~\n\n1. 支持 telegram ，企业微信，钉钉推送渠道, 支持多渠道配置和同时推送\n\n### 2025/06/18 - v1.1.0\n\n> **200 star⭐** 了, 继续给大伙儿助兴~近期，在我的\"怂恿\"下，挺多人在我公众号点赞分享推荐助力了我，我都在后台看见了具体账号的鼓励数据，很多都成了天使轮老粉（我玩公众号才一个多月，虽然注册是七八年前的事了哈哈，属于上车早，发车晚），但因为你们没有留言或私信我，所以我也无法一一回应并感谢支持，在此一并谢谢！\n\n1. 重要的更新，加了权重，你现在看到的新闻都是最热点最有关注度的出现在最上面\n2. 更新文档使用，因为近期更新了很多功能，而且之前的使用文档我偷懒写的简单（见下面的 ⚙️ frequency_words.txt 配置完整教程）\n\n### 2025/06/16 - v1.0.0\n\n1. 增加了一个项目新版本更新提示，默认打开，如要关掉，可以在 main.py 中把 \"FEISHU_SHOW_VERSION_UPDATE\": True 中的 True 改成 False 即可\n\n### 2025/06/13+14\n\n1. 去掉了兼容代码，之前 fork 的同学，直接复制代码会在当天显示异常（第二天会恢复正常）\n2. feishu 和 html 底部增加一个新增新闻显示\n\n### 2025/06/09\n\n**100 star⭐** 了，写个小功能给大伙儿助助兴\nfrequency_words.txt 文件增加了一个【必须词】功能，使用 + 号\n\n1. 必须词语法如下：  \n   唐僧或者猪八戒必须在标题里同时出现，才会收录到推送新闻中\n\n```\n+唐僧\n+猪八戒\n```\n\n2. 过滤词的优先级更高：  \n   如果标题中过滤词匹配到唐僧念经，那么即使必须词里有唐僧，也不显示\n\n```\n+唐僧\n!唐僧念经\n```\n\n### 2025/06/02\n\n1. **网页**和**飞书消息**支持手机直接跳转详情新闻\n2. 优化显示效果 + 1\n\n### 2025/05/26\n\n1. 飞书消息显示效果优化\n\n<table>\n<tr>\n<td align=\"center\">\n优化前<br>\n<img src=\"_image/before.jpg\" alt=\"飞书消息界面 - 优化前\" width=\"400\"/>\n</td>\n<td align=\"center\">\n优化后<br>\n<img src=\"_image/after.jpg\" alt=\"飞书消息界面 - 优化后\" width=\"400\"/>\n</td>\n</tr>\n</table>\n\n</details>\n\n<br>\n\n## ✨ 核心功能\n\n### **全网热点聚合**\n\n- 知乎\n- 抖音\n- bilibili 热搜\n- 华尔街见闻\n- 贴吧\n- 百度热搜\n- 财联社热门\n- 澎湃新闻\n- 凤凰网\n- 今日头条\n- 微博\n\n默认监控 11 个主流平台，也可自行增加额外的平台\n\n> 💡 详细配置教程见 [配置详解 - 平台配置](#1-平台配置)\n\n### **RSS 订阅源支持**（v4.5.0 新增）\n\n支持 RSS/Atom 订阅源抓取，按关键词分组统计（与热榜格式一致）：\n\n- **统一格式**：RSS 与热榜使用相同的关键词匹配和显示格式\n- **简单配置**：直接在 `config.yaml` 中添加 RSS 源\n- **合并推送**：热榜和 RSS 合并为一条消息推送\n- **新鲜度过滤**：自动过滤超过指定天数的旧文章，避免重复推送。支持全局默认天数和单源独立设置\n\n> 💡 RSS 使用与热榜相同的 `frequency_words.txt` 进行关键词过滤\n\n### **可视化配置编辑器**\n\n提供基于 Web 的图形化配置界面，无需手动编辑 YAML 文件，通过表单即可完成所有配置项的修改与导出。\n\n👉 **在线体验**：[https://sansan0.github.io/TrendRadar/](https://sansan0.github.io/TrendRadar/)\n\n<img src=\"/_image/editor.png\" alt=\"可视化配置编辑器\" width=\"80%\">\n\n### **智能推送策略**\n\n**三种推送模式**：\n\n| 模式 | 适用场景 | 推送特点 |\n|------|---------|---------|\n| **当日汇总** (daily) | 企业管理者/普通用户 | 按时推送当日所有匹配新闻（会包含之前推送过的） |\n| **当前榜单** (current) | 自媒体人/内容创作者 | 按时推送当前榜单匹配新闻（持续在榜的每次都出现） |\n| **增量监控** (incremental) | 投资者/交易员 | 仅推送新增内容，零重复 |\n\n> 💡 **快速选择指南：**\n> - 不想看到重复新闻 → 用 `incremental`（增量监控）\n> - 想看完整榜单趋势 → 用 `current`（当前榜单）\n> - 需要每日汇总报告 → 用 `daily`（当日汇总）\n>\n> 详细对比和配置教程见 [配置详解 - 推送模式详解](#3-推送模式详解)\n\n**附加功能**（可选）：\n\n| 功能 | 说明 | 默认 |\n|------|------|------|\n| **调度系统** | 按周一到周日逐日编排：为每天分配不同时间段、推送模式和 AI 分析策略。**每个时段可独立设置筛选方式（关键词/AI）和关注方向**，实现不同时间看不同类型新闻。内置 5 种预设（always_on / morning_evening / office_hours / night_owl / custom），也可自定义。支持工作日/周末差异化、跨午夜时段、per-period 去重、时段冲突检测（v6.0.0 + v6.5.0） | morning_evening |\n| **内容顺序配置** | 通过 `display.region_order` 调整各区域（热榜、新增热点、RSS、独立展示区、AI 分析）的显示顺序；通过 `display.regions` 控制各区域是否显示（v5.2.0） | 见配置文件 |\n| **显示模式切换** | `keyword`=按关键词分组，`platform`=按平台分组（v4.6.0 新增） | keyword |\n\n> 💡 详细配置教程见 [推送内容怎么显示？](#7-推送内容怎么显示) 和 [什么时候给我推送？](#8-什么时候给我推送)\n\n### **精准内容筛选**\n\n设置个人关键词（如：AI、比亚迪、教育政策），只推送相关热点，过滤无关信息\n\n> 💡 **基础配置教程**：[关键词配置 - 基础语法](#关键词基础语法)\n>\n> 💡 **高级配置教程**：[关键词配置 - 高级配置](#关键词高级配置)\n>\n> 💡 也可以不做筛选，完整推送所有热点（将 frequency_words.txt 留空）\n\n### **AI 智能筛选新闻**（v6.5.0 新增）\n\n用自然语言描述你的兴趣，AI 自动分类新闻，替代传统关键词匹配\n\n- **自然语言兴趣描述**：在 `ai_interests.txt` 中用日常语言写下关注方向，无需学习关键词语法\n- **两阶段智能处理**：AI 先从兴趣描述提取结构化标签，再对新闻按标签批量分类打分\n- **分数阈值控制**：通过 `ai_filter.min_score` 精确控制推送质量，只推送高相关度新闻\n- **自动回退保障**：AI 筛选失败时自动回退到关键词匹配，确保推送不中断\n- **智能标签更新**：兴趣变更时 AI 自动评估变化幅度，决定增量或全量重分类\n- **灵活切换**：`filter.method` 支持 `keyword`（默认）和 `ai` 两种模式，Timeline 可按时段覆盖\n- **分时段个性化**：不同时间段可以使用不同的关键词文件或 AI 兴趣描述。例如早上用\"科技词库\"快速过滤，晚上换成\"金融兴趣\"做 AI 深度筛选\n\n```yaml\n# config.yaml 快速启用示例\nfilter:\n  method: ai          # keyword（默认）| ai\nai_filter:\n  min_score: 6         # 推送最低分数阈值（1-10）\n```\n\n> 💡 AI 筛选与 AI 分析/翻译共享模型配置，只需配置一次 `ai.api_key`\n\n### **热点趋势分析**\n\n实时追踪新闻热度变化，让你不仅知道\"什么在热搜\"，更了解\"热点如何演变\"\n\n- **时间轴追踪**：记录每条新闻从首次出现到最后出现的完整时间跨度\n- **热度变化**：统计新闻在不同时间段的排名变化和出现频次\n- **新增检测**：实时识别新出现的热点话题，用🆕标记第一时间提醒\n- **持续性分析**：区分一次性热点话题和持续发酵的深度新闻\n- **跨平台对比**：同一新闻在不同平台的排名表现，看出媒体关注度差异\n\n> 💡 推送格式说明见 [消息样式说明](#5-我收到的消息长什么样)\n\n### **个性化热点算法**\n\n不再被各个平台的算法牵着走，TrendRadar 会重新整理全网热搜\n\n> 💡 三个比例可以调整，详见 [配置详解 - 热点权重调整](#4-热点权重调整)\n\n### **多渠道多账号推送**\n\n支持**企业微信**(+ 微信推送方案)、**飞书**、**钉钉**、**Telegram**、**邮件**、**ntfy**、**Bark**、**Slack**、**通用 Webhook**（可对接 Discord、IFTTT 等任意平台），消息直达手机和邮箱\n\n> 💡 详细配置教程见 [推送到多个群/设备](#10-推送到多个群设备)\n\n### **AI 多语言翻译**（v5.2.0 新增）\n\n将推送内容翻译为任意语言，打破语言壁垒，无论是阅读国内热点还是通过 RSS 订阅海外资讯，都能以母语轻松获取\n\n- **一键翻译**：在 `config.yaml` 中设置 `ai_translation.enabled: true` 和目标语言即可\n- **多语言支持**：支持 English、Korean、Japanese、French 等任意语言\n- **智能批量处理**：自动批量翻译，减少 API 调用次数，节省成本\n- **自定义风格**：通过 `ai_translation_prompt.txt` 自定义翻译风格和术语\n- **共享模型配置**：与 AI 分析功能共用 `ai` 配置段的模型设置\n\n```yaml\n# config.yaml 快速启用示例\nai_translation:\n  enabled: true\n  language: \"English\"  # 翻译目标语言\n```\n\n> 💡 翻译功能与 AI 分析功能共享模型配置，只需配置一次 `ai.api_key` 即可同时使用两个功能\n\n**RSS 源参考**：以下是一些 RSS 订阅源合集，可按需选用\n- [awesome-tech-rss](https://github.com/tuan3w/awesome-tech-rss) - 科技、创业、编程领域博客和媒体\n- [awesome-rss-feeds](https://github.com/plenaryapp/awesome-rss-feeds) - 世界各国主流新闻媒体 RSS 合集\n\n> ⚠️ 部分海外媒体内容可能涉及敏感话题，AI 模型可能拒绝翻译，建议根据实际需求筛选订阅源\n\n### **灵活存储架构**（v4.0.0 重大更新）\n\n**多存储后端支持**：\n- **远程云存储**：GitHub Actions 环境默认，支持 S3 兼容协议（R2/OSS/COS 等），数据存储在云端，不污染仓库\n- **本地 SQLite 数据库**：Docker/本地环境默认，数据完全可控\n- **自动后端选择**：根据运行环境智能切换存储方式\n\n> 💡 详细说明见 [数据保存在哪里？](#11-数据保存在哪里)\n\n### **多端部署**\n- **GitHub Actions**：定时自动爬取 + 远程云存储（需签到续期）\n- **Docker 部署**：支持多架构容器化运行，数据本地存储\n- **本地运行**：Windows/Mac/Linux 直接运行\n\n\n### **AI 分析推送（v5.0.0 新增）**\n\n使用 AI 大模型对推送内容进行深度分析，自动生成热点洞察报告\n\n- **智能分析**：自动分析热点趋势、关键词热度、跨平台关联、潜在影响\n- **多提供商**：基于 LiteLLM 统一接口，支持 100+ AI 提供商（DeepSeek、OpenAI、Gemini、Anthropic、本地 Ollama 等），还支持备用模型自动切换\n- **分析模式独立**：AI 的分析范围可以和推送不同——推送只发新增消息（避免打扰），但 AI 可以分析当天全部新闻（看完整趋势）\n- **灵活推送**：可选仅原始内容、仅 AI 分析、或两者都推送\n- **自定义提示词**：通过 `config/ai_analysis_prompt.txt` 自定义分析角度\n\n> 💡 详细配置教程见 [让 AI 帮我分析热点](#12-让-ai-帮我分析热点)\n\n### **独立展示区（v5.0.0 新增）**\n\n为指定平台提供完整热榜展示，不受关键词过滤影响\n\n- **完整热榜**：指定平台的热榜完整展示，适合想看完整排名的用户\n- **RSS 独立展示**：RSS 源内容可完整展示，不受关键词限制\n- **AI 深度分析**：可独立开启 AI 对完整热榜的趋势分析，无需在推送中展示\n- **灵活配置**：支持配置展示平台、RSS 源、最大条数\n\n> 💡 详细配置教程见 [推送内容怎么显示？ - 独立展示区](#7-推送内容怎么显示)\n\n### **AI 智能分析（v3.0.0 新增）**\n\n基于 MCP (Model Context Protocol) 协议的 AI 对话分析系统，让你用自然语言深度挖掘新闻数据\n\n> **💡 使用提示**：AI 功能需要本地新闻数据支持\n> - 项目自带测试数据，可立即体验功能\n> - 建议自行部署运行项目，获取更实时的数据\n>\n> 详见 [AI 智能分析](#-ai-智能分析)\n\n### **网页部署**\n\n运行后根目录生成 `index.html`，即为完整的新闻报告页面。\n\n> **部署方式**：点击 **Use this template** 创建仓库，可部署到 Cloudflare Pages 或 GitHub Pages 等静态托管平台。\n>\n> **💡 提示**：启用 GitHub Pages 可获得在线访问地址，进入仓库 Settings → Pages 即可开启。[效果预览](https://sansan0.github.io/TrendRadar/)\n>\n> ⚠️ 原 GitHub Actions 自动存储功能已下线（该方案曾导致 GitHub 服务器负载过高，影响平台稳定性）。\n\n### **减少 APP 依赖**\n\n从\"被算法推荐绑架\"变成\"主动获取自己想要的信息\"\n\n**适合人群：** 投资者、自媒体人、企业公关、关心时事的普通用户\n\n**典型场景：** 股市投资监控、品牌舆情追踪、行业动态关注、生活资讯获取\n\n\n| 网页效果(邮箱推送效果) | 飞书推送效果 | AI 分析推送效果 |\n|:---:|:---:|:---:|\n| ![网页效果](_image/github-pages.png) | ![飞书推送效果](_image/feishu.jpg) | ![AI分析推送效果](_image/ai.jpg) |\n\n\n<br>\n\n## 🚀 快速开始\n\n> **提醒**：建议先 **[查看最新官方文档](https://github.com/sansan0/TrendRadar?tab=readme-ov-file)**，确保配置步骤是最新的。\n\n### 请选择适合你的部署方式\n\n#### 🅰️ 方案一：Docker 部署（推荐 🔥）\n\n* **特点**：比 GitHub Actions 更稳定，数据本地存储（无需配置云存储）\n* **适用**：有自己的服务器、NAS 或长期运行的电脑\n* **注意**：你需要阅读了解下方的基础配置流程，然后跳转到 Docker 教程进行部署。\n\n#### 🅱️ 方案二：GitHub Actions 部署（本章节内容 ⬇️）\n\n* **特点**：无服务器，数据存储在 **远程云存储**（推荐配置）\n* **适用**：没有服务器的用户，利用 GitHub 免费资源\n* **注意**：需配置云存储以获得完整体验，且需定期签到续期\n\n### 1️⃣ 第一步：获取项目代码\n\n   点击本仓库页面右上角的绿色 **[Use this template]** 按钮 → 选择 \"Create a new repository\"。\n\n   > ⚠️ 提醒：\n   > - 后续文档中提到的 \"Fork\" 均可理解为 \"Use this template\"\n   > - 使用 Fork 可能导致运行异常，详见 [Issue #606](https://github.com/sansan0/TrendRadar/issues/606)\n\n   <br>\n\n### 2️⃣ 第二步：设置 GitHub Secrets\n\n   在你 Fork 后的仓库中，进入 `Settings` > `Secrets and variables` > `Actions` > `New repository secret`\n\n   **📌 重要说明（请务必仔细阅读）：**\n\n   - **一个 Name 对应一个 Secret**：每添加一个配置项，点击一次\"New repository secret\"按钮，填写一对\"Name\"和\"Secret\"\n   - **保存后看不到值是正常的**：出于安全考虑，保存后重新编辑时，只能看到 Name（名称），看不到 Secret（值）的内容\n   - **严禁自创名称**：Secret 的 Name（名称）必须**严格使用**下方列出的名称（如 `WEWORK_WEBHOOK_URL`、`FEISHU_WEBHOOK_URL` 等），不能自己随意修改或创造新名称，否则系统无法识别\n   - **可以同时配置多个平台**：系统会向所有配置的平台发送通知\n\n   **配置示例：**\n\n   <img src=\"_image/secrets.png\" alt=\"GitHub Secrets 配置示例\"/>\n\n   如上图所示，每一行是一个配置项：\n   - **Name（名称）**：必须使用下方展开内容中列出的固定名称（如 `WEWORK_WEBHOOK_URL`）\n   - **Secret（值）**：填写你从对应平台获取的实际内容（如 Webhook 地址、Token 等）\n\n   <br>\n\n   <details>\n   <summary>👉 点击展开：<strong>企业微信机器人</strong>（配置最简单最迅速）</summary>\n   <br>\n\n   **GitHub Secret 配置（⚠️ Name 名称必须严格一致）：**\n   - **Name（名称）**：`WEWORK_WEBHOOK_URL`（请复制粘贴此名称，不要手打，避免打错）\n   - **Secret（值）**：你的企业微信机器人 Webhook 地址\n\n   <br>\n\n   **机器人设置步骤：**\n\n   #### 手机端设置：\n   1. 打开企业微信 App → 进入目标内部群聊\n   2. 点击右上角\"…\"按钮 → 选择\"消息推送\"\n   3. 点击\"添加\" → 名称输入\"TrendRadar\"\n   4. 复制 Webhook 地址，点击保存，复制的内容配置到上方的 GitHub Secret 中\n\n   #### PC 端设置流程类似\n   </details>\n\n   <details>\n   <summary>👉 点击展开：<strong>个人微信推送</strong>（基于企业微信应用，推送到个人微信）</summary>\n   <br>\n\n   > 由于该方案是基于企业微信的插件机制，推送样式为纯文本（无 markdown 格式），但可以直接推送到个人微信，无需安装企业微信 App。\n\n   **GitHub Secret 配置（⚠️ Name 名称必须严格一致）：**\n   - **Name（名称）**：`WEWORK_WEBHOOK_URL`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：你的企业微信应用 Webhook 地址\n\n   - **Name（名称）**：`WEWORK_MSG_TYPE`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：`text`\n\n   <br>\n\n   **设置步骤：**\n\n   1. 完成上方的企业微信机器人 Webhook 设置\n   2. 添加 `WEWORK_MSG_TYPE` Secret，值设为 `text`\n   3. 按照下面图片操作，关联个人微信\n   4. 配置好后，手机上的企业微信 App 可以删除\n\n   <img src=\"_image/wework.png\" title=\"个人微信推送配置\"/>\n\n   **说明**：\n   - 与企业微信机器人使用相同的 Webhook 地址\n   - 区别在于消息格式：`text` 为纯文本，`markdown` 为富文本（默认）\n   - 纯文本格式会自动去除所有 markdown 语法（粗体、链接等）\n\n   </details>\n\n   <details>\n   <summary>👉 点击展开：<strong>飞书机器人</strong>（消息显示相对友好）</summary>\n   <br>\n\n   若启用 **AI 分析**，飞书推送偶发（约 5% 概率）会有数分钟延迟（推测为平台对 AI 生成内容的合规性审核）。\n\n   **GitHub Secret 配置（⚠️ Name 名称必须严格一致）：**\n   - **Name（名称）**：`FEISHU_WEBHOOK_URL`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：你的飞书机器人 Webhook 地址（该链接开头类似 https://www.feishu.cn/flow/api/trigger-webhook/********）\n   <br>\n\n   有两个方案，**方案一**配置简单，**方案二**配置复杂(但是稳定推送)\n\n   其中方案一，由 **ziventian**发现并提供建议，在这里感谢他，默认是个人推送，也可以配置群组推送操作[#97](https://github.com/sansan0/TrendRadar/issues/97) ，\n\n   **方案一：**\n\n   > 对部分人存在额外操作，否则会报\"系统错误\"。需要手机端搜索下机器人，然后开启飞书机器人应用(该建议来自于网友，可参考)\n\n   1. 电脑浏览器打开 https://botbuilder.feishu.cn/home/my-command\n\n   2. 点击\"新建机器人指令\" \n\n   3. 点击\"选择触发器\"，往下滑动，点击\"Webhook 触发\"\n\n   4. 此时你会看到\"Webhook 地址\"，把这个链接先复制到本地记事本暂存，继续接下来的操作\n\n   5. \"参数\"里面放上下面的内容，然后点击\"完成\"\n\n   ```json\n   {\n     \"message_type\": \"text\",\n     \"content\": {\n       \"text\": \"{{内容}}\"\n     }\n   }\n   ```\n\n   6. 点击\"选择操作\" > \"通过官方机器人发消息\"\n\n   7. 消息标题填写\"TrendRadar 热点监控\"\n\n   8. 最关键的部分来了，点击 + 按钮，选择\"Webhook 触发\"，然后按照下面的图片摆放\n\n   ![飞书机器人配置示例](_image/feishu.png)\n\n   9. 配置完成后，将第 4 步复制的 Webhook 地址配置到 GitHub Secrets 中的 `FEISHU_WEBHOOK_URL`\n\n   <br>\n\n   **方案二：**\n\n   1. 电脑浏览器打开 https://botbuilder.feishu.cn/home/my-app\n\n   2. 点击\"新建机器人应用\"\n\n   3. 进入创建的应用后，点击\"流程设计\" > \"创建流程\" > \"选择触发器\"\n\n   4. 往下滑动，点击\"Webhook 触发\"\n\n   5. 此时你会看到\"Webhook 地址\"，把这个链接先复制到本地记事本暂存，继续接下来的操作\n\n   6. \"参数\"里面放上下面的内容，然后点击\"完成\"\n\n   ```json\n   {\n     \"message_type\": \"text\",\n     \"content\": {\n       \"text\": \"{{内容}}\"\n     }\n   }\n   ```\n\n   7. 点击\"选择操作\" > \"发送飞书消息\"，勾选 \"群消息\"，然后点击下面的输入框，点击\"我管理的群组\"（如果没有群组，你可以在飞书 app 上创建群组）\n\n   8. 消息标题填写\"TrendRadar 热点监控\"\n\n   9. 最关键的部分来了，点击 + 按钮，选择\"Webhook 触发\"，然后按照下面的图片摆放\n\n   ![飞书机器人配置示例](_image/feishu.png)\n\n   10. 配置完成后，将第 5 步复制的 Webhook 地址配置到 GitHub Secrets 中的 `FEISHU_WEBHOOK_URL`\n\n   </details>\n\n   <details>\n   <summary>👉 点击展开：<strong>钉钉机器人</strong></summary>\n   <br>\n\n   **GitHub Secret 配置（⚠️ Name 名称必须严格一致）：**\n   - **Name（名称）**：`DINGTALK_WEBHOOK_URL`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：你的钉钉机器人 Webhook 地址\n\n   <br>\n\n   **机器人设置步骤：**\n\n   1. **创建机器人（仅 PC 端支持）**：\n      - 打开钉钉 PC 客户端，进入目标群聊\n      - 点击群设置图标（⚙️）→ 往下翻找到\"机器人\"点开\n      - 选择\"添加机器人\" → \"自定义\"\n\n   2. **配置机器人**：\n      - 设置机器人名称\n      - **安全设置**：\n        - **自定义关键词**：设置 \"热点\"\n\n   3. **完成设置**：\n      - 勾选服务条款协议 → 点击\"完成\"\n      - 复制获得的 Webhook URL\n      - 将 URL 配置到 GitHub Secrets 中的 `DINGTALK_WEBHOOK_URL`\n\n   **注意**：移动端只能接收消息，无法创建新机器人。\n   </details>\n\n   <details>\n   <summary>👉 点击展开：<strong>Telegram Bot</strong></summary>\n   <br>\n\n   **GitHub Secret 配置（⚠️ Name 名称必须严格一致）：**\n   - **Name（名称）**：`TELEGRAM_BOT_TOKEN`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：你的 Telegram Bot Token\n\n   - **Name（名称）**：`TELEGRAM_CHAT_ID`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：你的 Telegram Chat ID\n\n   **说明**：Telegram 需要配置**两个** Secret，请分别点击两次\"New repository secret\"按钮添加\n\n   <br>\n\n   **机器人设置步骤：**\n\n   1. **创建机器人**：\n      - 在 Telegram 中搜索 `@BotFather`（大小写注意，有蓝色徽章勾勾，有类似 37849827 monthly users，这个才是官方的，有一些仿官方的账号注意辨别）\n      - 发送 `/newbot` 命令创建新机器人\n      - 设置机器人名称（必须以\"bot\"结尾，很容易遇到重复名字，所以你要绞尽脑汁想不同的名字）\n      - 获取 Bot Token（格式如：`123456789:AAHfiqksKZ8WmR2zSjiQ7_v4TMAKdiHm9T0`）\n\n   2. **获取 Chat ID**：\n\n      **方法一：通过官方 API 获取**\n      - 先向你的机器人发送一条消息\n      - 访问：`https://api.telegram.org/bot<你的Bot Token>/getUpdates`\n      - 在返回的 JSON 中找到 `\"chat\":{\"id\":数字}` 中的数字\n\n      **方法二：使用第三方工具**\n      - 搜索 `@userinfobot` 并发送 `/start`\n      - 获取你的用户 ID 作为 Chat ID\n\n   3. **配置到 GitHub**：\n      - `TELEGRAM_BOT_TOKEN`：填入第 1 步获得的 Bot Token\n      - `TELEGRAM_CHAT_ID`：填入第 2 步获得的 Chat ID\n   </details>\n\n   <details>\n   <summary>👉 点击展开：<strong>邮件推送</strong>（支持所有主流邮箱）</summary>\n   <br>\n\n   - 注意事项：为防止邮件群发功能被**滥用**，当前的群发是所有收件人都能看到彼此的邮箱地址。\n   - 如果你没有过配置下面这种邮箱发送的经历，不建议尝试\n\n   > ⚠️ **重要配置依赖**：邮件推送需要 HTML 报告文件。请确保 `config/config.yaml` 中的 `storage.formats.html` 设置为 `true`：\n   > ```yaml\n   > storage:\n   >   formats:\n   >     sqlite: true\n   >     txt: false\n   >     html: true   # 必须启用，否则邮件推送会失败\n   > ```\n   > 如果设置为 `false`，邮件推送时会报错：`错误：HTML文件不存在或未提供: None`\n\n   <br>\n\n   **GitHub Secret 配置（⚠️ Name 名称必须严格一致）：**\n   - **Name（名称）**：`EMAIL_FROM`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：发件人邮箱地址\n\n   - **Name（名称）**：`EMAIL_PASSWORD`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：邮箱密码或授权码\n\n   - **Name（名称）**：`EMAIL_TO`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：收件人邮箱地址（多个收件人用英文逗号分隔，也可以和 EMAIL_FROM 一样，自己发送给自己）\n\n   - **Name（名称）**：`EMAIL_SMTP_SERVER`（可选配置，请复制粘贴此名称）\n   - **Secret（值）**：SMTP服务器地址（可留空，系统会自动识别）\n\n   - **Name（名称）**：`EMAIL_SMTP_PORT`（可选配置，请复制粘贴此名称）\n   - **Secret（值）**：SMTP端口（可留空，系统会自动识别）\n\n   **说明**：邮件推送需要配置至少**3个必需** Secret（EMAIL_FROM、EMAIL_PASSWORD、EMAIL_TO），后两个为可选配置\n\n   <br>\n\n   **支持的邮箱服务商**（自动识别 SMTP 配置）：\n\n   | 邮箱服务商 | 域名 | SMTP 服务器 | 端口 | 加密方式 |\n   |-----------|------|------------|------|---------|\n   | **Gmail** | gmail.com | smtp.gmail.com | 587 | TLS |\n   | **QQ邮箱** | qq.com | smtp.qq.com | 465 | SSL |\n   | **Outlook** | outlook.com | smtp-mail.outlook.com | 587 | TLS |\n   | **Hotmail** | hotmail.com | smtp-mail.outlook.com | 587 | TLS |\n   | **Live** | live.com | smtp-mail.outlook.com | 587 | TLS |\n   | **163邮箱** | 163.com | smtp.163.com | 465 | SSL |\n   | **126邮箱** | 126.com | smtp.126.com | 465 | SSL |\n   | **新浪邮箱** | sina.com | smtp.sina.com | 465 | SSL |\n   | **搜狐邮箱** | sohu.com | smtp.sohu.com | 465 | SSL |\n   | **天翼邮箱** | 189.cn | smtp.189.cn | 465 | SSL |\n   | **阿里云邮箱** | aliyun.com | smtp.aliyun.com | 465 | TLS |\n   | **Yandex邮箱** | yandex.com | smtp.yandex.com | 465 | TLS |\n   | **iCloud邮箱** | icloud.com | smtp.mail.me.com | 587 | SSL |\n\n   > **自动识别**：使用以上邮箱时，无需手动配置 `EMAIL_SMTP_SERVER` 和 `EMAIL_SMTP_PORT`，系统会自动识别。\n   >\n   > **反馈说明**：\n   > - 如果你使用**其他邮箱**测试成功，欢迎开 [Issues](https://github.com/sansan0/TrendRadar/issues) 告知，我会添加到支持列表\n   > - 如果上述邮箱配置有误或无法使用，也请开 [Issues](https://github.com/sansan0/TrendRadar/issues) 反馈，帮助改进项目\n   >\n   > **特别感谢**：\n   > - 感谢 [@DYZYD](https://github.com/DYZYD) 贡献天翼邮箱（189.cn）配置并完成自发自收测试 ([#291](https://github.com/sansan0/TrendRadar/issues/291))\n   > - 感谢 [@longzhenren](https://github.com/longzhenren) 贡献阿里云邮箱（aliyun.com）配置并完成测试 ([#344](https://github.com/sansan0/TrendRadar/issues/344))\n   > - 感谢 [@ACANX](https://github.com/ACANX) 贡献 Yandex 邮箱（yandex.com）配置并完成测试 ([#663](https://github.com/sansan0/TrendRadar/issues/663))\n   > - 感谢 [@Sleepy-Tianhao](https://github.com/Sleepy-Tianhao) 贡献 iCloud 邮箱（icloud.com）配置并完成测试 ([#728](https://github.com/sansan0/TrendRadar/issues/728))\n\n   **常见邮箱设置：**\n\n   #### QQ邮箱：\n   1. 登录 QQ邮箱网页版 → 设置 → 账户\n   2. 开启 POP3/SMTP 服务\n   3. 生成授权码（16位字母）\n   4. `EMAIL_PASSWORD` 填写授权码，而非 QQ 密码\n\n   #### Gmail：\n   1. 开启两步验证\n   2. 生成应用专用密码\n   3. `EMAIL_PASSWORD` 填写应用专用密码\n\n   #### 163/126邮箱：\n   1. 登录网页版 → 设置 → POP3/SMTP/IMAP\n   2. 开启 SMTP 服务\n   3. 设置客户端授权码\n   4. `EMAIL_PASSWORD` 填写授权码\n   <br>\n\n   **高级配置**：\n   如果自动识别失败，可手动配置 SMTP：\n   - `EMAIL_SMTP_SERVER`：如 smtp.gmail.com\n   - `EMAIL_SMTP_PORT`：如 587（TLS）或 465（SSL）\n   <br>\n\n   **如果有多个收件人(注意是英文逗号分隔)**：\n   - EMAIL_TO=\"user1@example.com,user2@example.com,user3@example.com\"\n\n   </details>\n\n   <details>\n   <summary>👉 点击展开：<strong>ntfy 推送</strong>（开源免费，支持自托管）</summary>\n   <br>\n\n   **两种使用方式：**\n\n   ### 方式一：免费使用（推荐新手） 🆓\n\n   **特点**：\n   - ✅ 无需注册账号，立即使用\n   - ✅ 每天 250 条消息（足够 90% 用户）\n   - ✅ Topic 名称即\"密码\"（需选择不易猜测的名称）\n   - ⚠️ 消息未加密，不适合敏感信息, 但适合我们这个项目的不敏感信息\n\n   **快速开始：**\n\n   1. **下载 ntfy 应用**：\n      - Android：[Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/)\n      - iOS：[App Store](https://apps.apple.com/us/app/ntfy/id1625396347)\n      - 桌面：访问 [ntfy.sh](https://ntfy.sh)\n\n   2. **订阅主题**（选择一个难猜的名称）：\n      ```\n      建议格式：trendradar-{你的名字缩写}-{随机数字}\n   \n      不能使用中文\n      \n      ✅ 好例子：trendradar-zs-8492\n      ❌ 坏例子：news、alerts（太容易被猜到）\n      ```\n\n   3. **配置 GitHub Secret（⚠️ Name 名称必须严格一致）**：\n      - **Name（名称）**：`NTFY_TOPIC`（请复制粘贴此名称，不要手打）\n      - **Secret（值）**：填写你刚才订阅的主题名称\n\n      - **Name（名称）**：`NTFY_SERVER_URL`（可选配置，请复制粘贴此名称）\n      - **Secret（值）**：留空（默认使用 ntfy.sh）\n\n      - **Name（名称）**：`NTFY_TOKEN`（可选配置，请复制粘贴此名称）\n      - **Secret（值）**：留空\n\n      **说明**：ntfy 至少需要配置 1 个必需 Secret (NTFY_TOPIC)，后两个为可选配置\n\n   4. **测试**：\n      ```bash\n      curl -d \"测试消息\" ntfy.sh/你的主题名称\n      ```\n\n   ---\n\n   ### 方式二：自托管（完全隐私控制） 🔒\n\n   **适合人群**：有服务器、追求完全隐私、技术能力强\n\n   **优势**：\n   - ✅ 完全开源（Apache 2.0 + GPLv2）\n   - ✅ 数据完全自主控制\n   - ✅ 无任何限制\n   - ✅ 零费用\n\n   **Docker 一键部署**：\n   ```bash\n   docker run -d \\\n     --name ntfy \\\n     -p 80:80 \\\n     -v /var/cache/ntfy:/var/cache/ntfy \\\n     binwiederhier/ntfy \\\n     serve --cache-file /var/cache/ntfy/cache.db\n   ```\n\n   **配置 TrendRadar**：\n   ```yaml\n   NTFY_SERVER_URL: https://ntfy.yourdomain.com\n   NTFY_TOPIC: trendradar-alerts  # 自托管可用简单名称\n   NTFY_TOKEN: tk_your_token  # 可选：启用访问控制\n   ```\n\n   **在应用中订阅**：\n   - 点击\"Use another server\"\n   - 输入你的服务器地址\n   - 输入主题名称\n   - （可选）输入登录凭据\n\n   ---\n\n   **常见问题：**\n\n   <details>\n   <summary><strong>Q1: 免费版够用吗？</strong></summary>\n\n   每天 250 条消息对大多数用户足够。按 30 分钟抓取一次计算，每天约 48 次推送，完全够用。\n   </details>\n\n   <details>\n   <summary><strong>Q2: Topic 名称真的安全吗？</strong></summary>\n\n   如果你选择随机的、足够长的名称（如 `trendradar-zs-8492-news`），暴力破解几乎不可能：\n   - ntfy 有严格的速率限制（1 秒 1 次请求）\n   - 64 个字符选择（A-Z, a-z, 0-9, _, -）\n   - 10 位随机字符串有 64^10 种可能性（需要数年才能破解）\n   </details>\n\n   ---\n\n   **推荐选择：**\n\n   | 用户类型 | 推荐方案 | 理由 |\n   |---------|---------|------|\n   | 普通用户 | 方式一（免费） | 简单快速，够用 |\n   | 技术用户 | 方式二（自托管） | 完全控制，无限制 |\n   | 高频用户 | 方式三（付费） | 这个自己去官网看吧 |\n\n   **相关链接：**\n   - [ntfy 官方文档](https://docs.ntfy.sh/)\n   - [自托管教程](https://docs.ntfy.sh/install/)\n   - [GitHub 仓库](https://github.com/binwiederhier/ntfy)\n\n   </details>\n\n   <details>\n   <summary>👉 点击展开：<strong>Bark 推送</strong>（iOS 专属，简洁高效）</summary>\n   <br>\n\n   **GitHub Secret 配置（⚠️ Name 名称必须严格一致）：**\n   - **Name（名称）**：`BARK_URL`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：你的 Bark 推送 URL\n\n   <br>\n\n   **Bark 简介：**\n\n   Bark 是一款 iOS 平台的免费开源推送工具，特点是简单、快速、无广告。\n\n   **使用方式：**\n\n   ### 方式一：使用官方服务器（推荐新手） 🆓\n\n   1. **下载 Bark App**：\n      - iOS：[App Store](https://apps.apple.com/cn/app/bark-给你的手机发推送/id1403753865)\n\n   2. **获取推送 URL**：\n      - 打开 Bark App\n      - 复制首页显示的推送 URL（格式如：`https://api.day.app/your_device_key`）\n      - 将 URL 配置到 GitHub Secrets 中的 `BARK_URL`\n\n   ### 方式二：自建服务器（完全隐私控制） 🔒\n\n   **适合人群**：有服务器、追求完全隐私、技术能力强\n\n   **Docker 一键部署**：\n   ```bash\n   docker run -d \\\n     --name bark-server \\\n     -p 8080:8080 \\\n     finab/bark-server\n   ```\n\n   **配置 TrendRadar**：\n   ```yaml\n   BARK_URL: http://your-server-ip:8080/your_device_key\n   ```\n\n   ---\n\n   **注意事项：**\n   - ✅ Bark 使用 APNs 推送，单条消息最大 4KB\n   - ✅ 支持自动分批推送，无需担心消息过长\n   - ✅ 推送格式为纯文本（自动去除 Markdown 语法）\n   - ⚠️ 仅支持 iOS 平台\n\n   **相关链接：**\n   - [Bark 官方网站](https://bark.day.app/)\n   - [Bark GitHub 仓库](https://github.com/Finb/Bark)\n   - [Bark Server 自建教程](https://github.com/Finb/bark-server)\n\n   </details>\n\n   <details>\n   <summary>👉 点击展开：<strong>Slack 推送</strong></summary>\n   <br>\n\n   **GitHub Secret 配置（⚠️ Name 名称必须严格一致）：**\n   - **Name（名称）**：`SLACK_WEBHOOK_URL`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：你的 Slack Incoming Webhook URL\n\n   <br>\n\n   **Slack 简介：**\n\n   Slack 是团队协作工具，Incoming Webhooks 可以将消息推送到 Slack 频道。\n\n   **设置步骤：**\n\n   ### 步骤 1：创建 Slack App\n\n   1. **访问 Slack API 页面**：\n      - 打开 https://api.slack.com/apps?new_app=1\n      - 如果未登录，先登录你的 Slack 工作空间\n\n   2. **选择创建方式**：\n      - 点击 **\"From scratch\"**（从头开始创建）\n\n   3. **填写 App 信息**：\n      - **App Name**：填写应用名称（如 `TrendRadar` 或 `热点新闻监控`）\n      - **Workspace**：从下拉列表选择你的工作空间\n      - 点击 **\"Create App\"** 按钮\n\n   ### 步骤 2：启用 Incoming Webhooks\n\n   1. **导航到 Incoming Webhooks**：\n      - 在左侧菜单中找到并点击 **\"Incoming Webhooks\"**\n\n   2. **启用功能**：\n      - 找到 **\"Activate Incoming Webhooks\"** 开关\n      - 将开关从 `OFF` 切换到 `ON`\n      - 页面会自动刷新显示新的配置选项\n\n   ### 步骤 3：生成 Webhook URL\n\n   1. **添加新的 Webhook**：\n      - 滚动到页面底部\n      - 点击 **\"Add New Webhook to Workspace\"** 按钮\n\n   2. **选择目标频道**：\n      - 系统会弹出授权页面\n      - 从下拉列表中选择要接收消息的频道（如 `#热点新闻`）\n      - ⚠️ 如果要选择私有频道，必须先加入该频道\n\n   3. **授权应用**：\n      - 点击 **\"Allow\"** 按钮完成授权\n      - 系统会自动跳转回配置页面\n\n   ### 步骤 4：复制并保存 Webhook URL\n\n   1. **查看生成的 URL**：\n      - 在 \"Webhook URLs for Your Workspace\" 区域\n      - 会看到刚刚生成的 Webhook URL\n      - 格式如：`https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX`\n\n   2. **复制 URL**：\n      - 点击 URL 右侧的 **\"Copy\"** 按钮\n      - 或手动选中 URL 并复制\n\n   3. **配置到 TrendRadar**：\n      - **GitHub Actions**：将 URL 添加到 GitHub Secrets 中的 `SLACK_WEBHOOK_URL`\n      - **本地测试**：将 URL 填入 `config/config.yaml` 的 `slack_webhook_url` 字段\n      - **Docker 部署**：将 URL 添加到 `docker/.env` 文件的 `SLACK_WEBHOOK_URL` 变量\n\n   ---\n\n   **注意事项：**\n   - ✅ 支持 Markdown 格式（自动转换为 Slack mrkdwn）\n   - ✅ 支持自动分批推送（每批 4KB）\n   - ✅ 适合团队协作，消息集中管理\n   - ⚠️ Webhook URL 包含密钥，切勿公开\n\n   **消息格式预览：**\n   ```\n   *[第 1/2 批次]*\n\n   📊 *热点词汇统计*\n\n   🔥 *[1/3] AI ChatGPT* : 2 条\n\n     1. [百度热搜] 🆕 ChatGPT-5正式发布 *[1]* - 09时15分 (1次)\n\n     2. [今日头条] AI芯片概念股暴涨 *[3]* - [08时30分 ~ 10时45分] (3次)\n   ```\n\n   **相关链接：**\n   - [Slack Incoming Webhooks 官方文档](https://api.slack.com/messaging/webhooks)\n   - [Slack API 应用管理](https://api.slack.com/apps)\n\n   </details>\n\n   <details>\n   <summary>👉 点击展开：<strong>通用 Webhook 推送</strong>（支持 Discord、Matrix、IFTTT 等）</summary>\n   <br>\n\n   **GitHub Secret 配置（⚠️ Name 名称必须严格一致）：**\n   - **Name（名称）**：`GENERIC_WEBHOOK_URL`（请复制粘贴此名称，不要手打）\n   - **Secret（值）**：你的 Webhook URL\n\n   - **Name（名称）**：`GENERIC_WEBHOOK_TEMPLATE`（可选配置，请复制粘贴此名称）\n   - **Secret（值）**：JSON 模板字符串，支持 `{title}` 和 `{content}` 占位符\n\n   <br>\n\n   **通用 Webhook 简介：**\n\n   通用 Webhook 支持任意接受 HTTP POST 请求的平台，包括但不限于：\n   - **Discord**：通过 Webhook 推送到频道\n   - **Matrix**：通过 Webhook 桥接推送\n   - **IFTTT**：触发自动化流程\n   - **自建服务**：任何支持 Webhook 的自定义服务\n\n   **配置示例：**\n\n   ### Discord 配置\n\n   1. **获取 Webhook URL**：\n      - 进入 Discord 服务器设置 → 整合 → Webhooks\n      - 创建新 Webhook，复制 URL\n\n   2. **配置模板**：\n      ```json\n      {\"content\": \"{content}\"}\n      ```\n\n   3. **GitHub Secret 配置**：\n      - `GENERIC_WEBHOOK_URL`：Discord Webhook URL\n      - `GENERIC_WEBHOOK_TEMPLATE`：`{\"content\": \"{content}\"}`\n\n   ### 自定义模板\n\n   模板支持两个占位符：\n   - `{title}` - 消息标题\n   - `{content}` - 消息内容\n\n   **模板示例**：\n   ```json\n   # 默认格式（留空时使用）\n   {\"title\": \"{title}\", \"content\": \"{content}\"}\n\n   # Discord 格式\n   {\"content\": \"{content}\"}\n\n   # 自定义格式\n   {\"text\": \"{content}\", \"username\": \"TrendRadar\"}\n   ```\n\n   ---\n\n   **注意事项：**\n   - ✅ 支持 Markdown 格式（与企业微信格式一致）\n   - ✅ 支持自动分批推送\n   - ✅ 支持多账号配置（用 `;` 分隔）\n   - ⚠️ 模板必须是有效的 JSON 格式\n   - ⚠️ 不同平台对消息格式要求不同，请参考目标平台文档\n\n   </details>\n\n   <br>\n\n### 3️⃣ 第三步：手动测试新闻推送\n\n   > ⚠️ 提醒：\n   > - 完成第 1-2 步后，请立即测试！测试成功后再根据需要调整配置（第 4 步）\n   > - 请进入你自己的项目，不是本项目！\n\n   **如何找到你的 Actions 页面**：\n\n   - **方法一**：打开你 fork 的项目主页，点击顶部的 **Actions** 标签\n   - **方法二**：直接访问 `https://github.com/你的用户名/TrendRadar/actions`\n\n   **示例对比**：\n   - ❌ 作者的项目：`https://github.com/sansan0/TrendRadar/actions`\n   - ✅ 你的项目：`https://github.com/你的用户名/TrendRadar/actions`\n\n   **测试步骤**：\n   1. 进入你项目的 Actions 页面\n   2. 找到 **\"Get Hot News\"**(必须得是这个字)点进去，点击右侧的 **\"Run workflow\"** 按钮运行 \n      - 如果看不到该字样，参照 [#109](https://github.com/sansan0/TrendRadar/issues/109) 解决\n   3. 3 分钟左右，消息会推送到你配置的平台\n\n   <br>\n\n   > ⚠️ 提醒：\n   > - 手动测试不要太频繁，避免触发 GitHub Actions 限制\n   > - 点击 Run workflow 后需要刷新浏览器页面才能看到新的运行记录\n\n   <br>\n\n### 4️⃣ 第四步：配置说明（可选）\n\n   默认配置已可正常使用，如需个性化调整，了解以下文件即可：\n\n   | 文件 | 作用 |\n   |------|------|\n   | `config/config.yaml` | 主配置文件：推送模式、时间窗口、平台列表、热点权重等 |\n   | `config/frequency_words.txt` | 关键词文件：设置你关心的词汇，筛选推送内容 |\n   | `config/ai_analysis_prompt.txt` | AI 提示词模板：自定义 AI 分析师的角色和分析维度 |\n   | `.github/workflows/crawler.yml` | 执行频率：控制多久运行一次（⚠️ 谨慎修改） |\n\n   👉 **详细配置教程**：[配置详解](#配置详解)\n\n   <br>\n\n### 5️⃣ 第五步：远程云存储 & 签到配置\n\n   **v4.0.0 重要变更**：引入「活跃度检测」机制，GitHub Actions 需定期签到以维持运行。\n\n   - **运行周期**：有效期为 **7 天**，倒计时结束后服务将自动挂起。\n   - **续期方式**：在 Actions 页面手动触发 \"Check In\" workflow，即可重置 7 天有效期。\n   - **操作路径**：`Actions` → `Check In` → `Run workflow`\n   - **设计理念**：\n     - 如果 7 天都忘了签到，或许这些资讯对你来说并非刚需。适时的暂停，能帮你从信息流中抽离，给大脑留出喘息的空间。\n     - GitHub Actions 是宝贵的公共计算资源。引入签到机制旨在避免算力的无效空转，确保资源能分配给真正活跃且需要的用户。感谢你的理解与支持。\n\n   ---\n\n   **关于远程云存储配置（请根据部署方式选择）：**\n\n   - **GitHub Actions 用户**：\n     - **现状**：Actions 每次运行都是全新环境，不保存文件。如果不配置云存储，项目将运行在**轻量模式**（无增量推送、无历史追踪）。\n     - **建议**：配置远程云存储以获得完整体验。\n\n   - **Docker / 本地用户**：\n     - **现状**：数据默认保存在本地硬盘。\n     - **建议**：云存储为可选项，可作为异地备份。\n\n   <details>\n   <summary>👉 点击展开：<strong>远程云存储配置教程（以 Cloudflare R2 为例）</strong></summary>\n   <br>\n\n   **⚠️ 前置条件（重要）：**\n\n   根据 Cloudflare 平台规则，开通 R2 需绑定支付方式。\n\n   * **目的**：仅作身份验证（Verify Only），**不产生扣费**。\n   * **支付**：支持双币信用卡或国区 PayPal。\n   * **用量**：R2 的免费额度（10GB存储/月）足以覆盖本项目日常运行，无需担心付费。\n\n   ---\n\n   **GitHub Secret 配置（需添加 4 项）：**\n\n   | Name（名称） | Secret（值）说明 |\n   |-------------|-----------------|\n   | `S3_BUCKET_NAME` | 存储桶名称（如 `trendradar-data`） |\n   | `S3_ACCESS_KEY_ID` | 访问密钥 ID（Access Key ID） |\n   | `S3_SECRET_ACCESS_KEY` | 访问密钥（Secret Access Key） |\n   | `S3_ENDPOINT_URL` | S3 API 端点（如 R2：`https://<account-id>.r2.cloudflarestorage.com`） |\n\n   **可选配置：**\n\n   | Name（名称） | Secret（值）说明 |\n   |-------------|-----------------|\n   | `S3_REGION` | 区域（默认 `auto`，部分服务商可能需要指定） |\n\n   > 💡 **更多存储配置选项**：参见 [数据保存在哪里？](#11-数据保存在哪里)\n\n   <br>\n\n   **详细操作步骤（获取凭据）：**\n\n   1. **进入 R2 概览**：\n      - 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)。\n      - 在左侧侧边栏找到并点击 `R2对象存储`。\n\n   2. **创建存储桶**：\n      - 点击`概述`\n      - 点击右上角的 `创建存储桶` (Create bucket)。\n      - 输入名称（例如 `trendradar-data`），点击 `创建存储桶`。\n\n   3. **创建 API 令牌**：\n      - 回到 **概述**页面。\n      - 点击**右下角** `Account Details `找到并点击 `Manage` (Manage R2 API Tokens)。\n      - 同时你会看到 `S3 API`：`https://<account-id>.r2.cloudflarestorage.com`(这就是 S3_ENDPOINT_URL)\n      - 点击 `创建 Account APl 令牌` 。\n      - **⚠️ 关键设置**：\n        - **令牌名称**：随意填写（如 `github-action-write`）。\n        - **权限**：选择 `管理员读和写` 。\n        - **指定存储桶**：为了安全，建议选择 `仅适用于指定存储桶` 并选中你的桶（如 `trendradar-data`）。\n      - 点击 `创建 API 令牌`，**立即复制** 显示的 `Access Key ID` 和 `Secret Access Key`（只显示一次！）。\n\n   </details>\n\n   <br>\n\n### 6️⃣ 第六步：开启 AI 分析推送\n\n   这是 v5.0.0 的核心功能，让 AI 帮你总结和分析新闻，建议尝试。\n\n   **配置方法：**\n   在 GitHub Secrets (或 `.env` / `config.yaml`) 中添加：\n   - `AI_API_KEY`: 你的 API Key（支持 DeepSeek、OpenAI 等）\n   - `AI_PROVIDER`: 服务商名称（如 `deepseek`, `openai`）\n\n   就这样，无需复杂部署，下次推送时你就会看到智能分析报告了。\n\n   <br>\n\n### 7️⃣ 第七步：🎉 部署成功！\n\n   恭喜！现在你可以开始享受 TrendRadar 带来的高效信息流了。\n\n   💬 **加入社区**：欢迎关注公众号「**[硅基茶水间](#-支持项目)**」，分享你的使用心得和高级玩法。\n\n   <br>\n\n### 8️⃣ 第八步：进阶：选择你的 AI 助手\n\n   TrendRadar 提供了两种 AI 使用方式，满足不同需求：\n\n   | 特性 | ✨ AI 分析推送 | 🧠 AI 智能分析 |\n   | :--- | :--- | :--- |\n   | **模式** | **被动接收** (每日日报) | **主动对话** (深度调研) |\n   | **场景** | \"今天有什么大事？\" | \"分析一下过去一周 AI 行业的变化\" |\n   | **部署** | 极简 (填 Key 即可) | 进阶 (需本地运行/Docker) |\n   | **客户端** | 手机 |  电脑 |\n  \n\n   👉 **结论**：先用 **AI 分析推送** 满足日常需求；如果你是数据分析师或需要深度挖掘，再尝试 **[AI 智能分析](#-ai-智能分析)**。\n\n<br>\n\n<a name=\"配置详解\"></a>\n\n## ⚙️ 配置详解\n\n> **📖 提醒**：本章节提供详细的配置说明，建议先完成 [快速开始](#-快速开始) 的基础配置，再根据需要回来查看详细选项。\n\n### 1. 我要看哪些平台？\n\n<details id=\"自定义监控平台\">\n<summary>👉 点击展开：<strong>选择资讯来源</strong></summary>\n<br>\n\n**配置位置：** `config/config.yaml` 的 `platforms` 部分\n\n本项目的资讯数据来源于 [newsnow](https://github.com/ourongxing/newsnow) ，你可以点击[网站](https://newsnow.busiyi.world/)，点击[更多]，查看是否有你想要的平台。\n\n具体添加可访问 [项目源代码](https://github.com/ourongxing/newsnow/tree/main/server/sources)，根据里面的文件名，在 `config/config.yaml` 文件中修改 `platforms` 配置：\n\n```yaml\nplatforms:\n  enabled: true                       # 是否启用热榜平台抓取\n  sources:\n    - id: \"toutiao\"\n      name: \"今日头条\"\n    - id: \"baidu\"\n      name: \"百度热搜\"\n    - id: \"wallstreetcn-hot\"\n      name: \"华尔街见闻\"\n    # 添加更多平台...\n```\n\n> 💡 **快捷方式**：如果不会看源代码，可以复制他人整理好的 [平台配置汇总](https://github.com/sansan0/TrendRadar/issues/95)\n\n> ⚠️ **注意**：平台不是越多越好，建议选择 10-15 个核心平台。过多平台会导致信息过载，反而降低使用体验。\n\n</details>\n\n### 2. 我关心什么内容？\n\n在 `frequency_words.txt` 文件中告诉机器人你想看什么，它就会帮你盯着。支持普通词、必须词、过滤词等多种玩法。\n\n| 语法类型 | 符号 | 作用 | 示例 | 匹配逻辑 |\n|---------|------|------|------|---------|\n| **普通词** | 无 | 基础匹配 | `华为` | 包含任意一个即可 |\n| **必须词** | `+` | 限定范围 | `+手机` | 必须同时包含 |\n| **过滤词** | `!` | 排除干扰 | `!广告` | 包含则直接排除 |\n| **数量限制** | `@` | 控制显示数量 | `@10` | 最多显示10条新闻（v3.2.0新增） |\n| **全局过滤** | `[GLOBAL_FILTER]` | 全局排除指定内容 | 见下方示例 | 任何情况下都过滤（v3.5.0新增） |\n| **正则表达式** | `/pattern/` | 精确匹配模式 | `/\\bai\\b/` | 使用正则表达式匹配（v4.7.0新增） |\n| **显示名称** | `=> 备注` | 自定义显示文本 | `/\\bai\\b/ => AI相关` | 推送和HTML显示备注名称（v4.7.0新增） |\n\n#### 2.1 基础语法\n\n<a name=\"关键词基础语法\"></a>\n\n<details>\n<summary>👉 点击展开：<strong>基础语法教程</strong></summary>\n<br>\n\n**配置位置：** `config/frequency_words.txt`\n\n##### 1. **普通关键词** - 基础匹配\n```txt\n华为\nOPPO\n苹果\n```\n**作用：** 新闻标题包含其中**任意一个词**就会被捕获\n\n##### 2. **必须词** `+词汇` - 限定范围\n```txt\n华为\nOPPO\n+手机\n```\n**作用：** 必须同时包含普通词**和**必须词才会被捕获\n\n##### 3. **过滤词** `!词汇` - 排除干扰\n```txt\n苹果\n华为\n!水果\n!价格\n```\n**作用：** 包含过滤词的新闻会被**直接排除**，即使包含关键词\n\n##### 4. **数量限制** `@数字` - 控制显示数量（v3.2.0 新增）\n```txt\n特斯拉\n马斯克\n@5\n```\n**作用：** 限制该关键词组最多显示的新闻条数\n\n**配置优先级：** `@数字` > 全局配置 > 不限制\n\n##### 5. **全局过滤** `[GLOBAL_FILTER]` - 全局排除指定内容（v3.5.0 新增）\n```txt\n[GLOBAL_FILTER]\n广告\n推广\n营销\n震惊\n标题党\n\n[WORD_GROUPS]\n科技\nAI\n\n华为\n鸿蒙\n!车\n```\n**作用：** 在任何情况下过滤包含指定词的新闻，**优先级最高**\n\n**使用场景：**\n- 过滤低质内容：震惊、标题党、爆料等\n- 过滤营销内容：广告、推广、赞助等\n- 过滤特定主题：娱乐、八卦（根据需求）\n\n**过滤优先级：** 全局过滤 > 词组内过滤(`!`) > 词组匹配\n\n**区域说明：**\n- `[GLOBAL_FILTER]`：全局过滤区，包含的词在任何情况下都会被过滤\n- `[WORD_GROUPS]`：词组区，保持现有语法（`!`、`+`、`@`）\n- 如果不使用区域标记，默认全部作为词组处理（向后兼容）\n\n**匹配示例：**\n```txt\n[GLOBAL_FILTER]\n广告\n\n[WORD_GROUPS]\n科技\nAI\n```\n- ❌ \"广告：最新科技产品发布\" ← 包含全局过滤词\"广告\"，直接拒绝\n- ✅ \"科技公司发布AI新产品\" ← 不包含全局过滤词，匹配\"科技\"词组\n- ✅ \"AI技术突破引发关注\" ← 不包含全局过滤词，匹配\"科技\"词组中的\"AI\"\n\n**注意事项：**\n- 全局过滤词应谨慎使用，避免过度过滤导致遗漏有价值内容\n- 建议全局过滤词控制在 5-15 个以内\n- 对于特定词组的过滤，优先使用词组内过滤词（`!` 前缀）\n\n##### 6. **正则表达式** `/pattern/` - 精确匹配模式（v4.7.0 新增）\n\n普通关键词使用子字符串匹配，这在中文环境下很方便，但在英文环境可能会产生误匹配。例如 `ai` 会匹配到 `training` 中的 `ai`。\n\n使用正则表达式语法 `/pattern/` 可以实现精确匹配：\n\n```txt\n/(?<![a-z])ai(?![a-z])/\n人工智能\n```\n\n**作用：** 使用正则表达式进行匹配，支持所有 Python 正则语法\n\n**常用正则模式：**\n\n| 需求 | 正则写法 | 说明 |\n|------|---------|------|\n| 英文单词边界 | `/\\bword\\b/` | 匹配独立单词，如 `/\\bai\\b/` 匹配 \"AI\" 但不匹配 \"training\" |\n| 前后非字母 | `/(?<![a-z])ai(?![a-z])/` | 更宽松的边界，适合中英混合场景 |\n| 开头匹配 | `/^breaking/` | 只匹配以 \"breaking\" 开头的标题 |\n| 结尾匹配 | `/发布$/` | 只匹配以 \"发布\" 结尾的标题 |\n| 多选一 | `/苹果\\|华为\\|小米/` | 匹配其中任意一个（注意转义 `\\|`） |\n\n**匹配示例：**\n```txt\n# 配置\n/(?<![a-z])ai(?![a-z])/\n人工智能\n```\n\n- ✅ \"AI is the future\" ← 匹配独立的 \"AI\"\n- ✅ \"你好ai这里\" ← 前后是中文，匹配 \"ai\"\n- ✅ \"人工智能发展迅速\" ← 匹配 \"人工智能\"\n- ❌ \"Resistance training is important\" ← \"training\" 中的 \"ai\" 不匹配\n- ❌ \"The maid cleaned the room\" ← \"maid\" 中的 \"ai\" 不匹配\n\n**组合使用：**\n```txt\n# 正则 + 普通词 + 过滤词\n/\\bai\\b/\n人工智能\n机器学习\n!广告\n```\n\n**注意事项：**\n- 正则表达式自动启用大小写不敏感匹配（`re.IGNORECASE`）\n- 支持 `/pattern/i` 等 JavaScript 风格写法（flags 会被忽略，因为默认已启用忽略大小写）\n- 无效的正则语法会被当作普通词处理\n- 正则可用于普通词、必须词(`+`)、过滤词(`!`)\n\n**💡 不会写正则？让 AI 帮你生成！**\n\n如果你不熟悉正则表达式，可以直接让 ChatGPT / Gemini / DeepSeek 帮你生成。只需告诉 AI：\n\n> 我需要一个 Python 正则表达式，用于匹配英文单词 \"ai\"，但不匹配 \"training\" 中的 \"ai\"。\n> 请直接给出正则表达式，格式为 `/pattern/`，不需要额外解释。\n\nAI 会给你类似这样的结果：`/(?<![a-zA-Z])ai(?![a-zA-Z])/`\n\n##### 7. **显示名称** `=> 备注` - 自定义显示文本（v4.7.0 新增）\n\n正则表达式在推送消息和 HTML 页面显示时可能不太友好。使用 `=> 备注` 语法可以设置显示名称：\n\n```txt\n/(?<![a-zA-Z])ai(?![a-zA-Z])/ => AI 相关\n人工智能\n```\n\n**作用：** 推送消息和 HTML 页面显示 \"AI 相关\" 而不是复杂的正则表达式\n\n**语法格式：**\n```txt\n# 正则 + 显示名称\n/pattern/ => 显示名称\n/pattern/i => 显示名称    # 支持 flags 写法（flags 被忽略）\n/pattern/=>显示名称       # => 两边空格可选\n\n# 普通词 + 显示名称\ndeepseek => DeepSeek 动态\n```\n\n**匹配示例：**\n```txt\n# 配置\n/(?<![a-zA-Z])ai(?![a-zA-Z])/ => AI 相关\n人工智能\n```\n\n| 原始配置 | 推送/HTML 显示 |\n|---------|---------------|\n| `/(?<![a-z])ai(?![a-z])/` + `人工智能` | `(?<![a-z])ai(?![a-z]) 人工智能` |\n| `/(?<![a-z])ai(?![a-z])/ => AI 相关` + `人工智能` | **`AI 相关`** |\n\n**注意事项：**\n- 显示名称只需写在词组的第一个词上\n- 如果词组中多个词都有显示名称，使用第一个\n- 不设置显示名称时，自动使用词组内所有词拼接\n\n---\n\n#### 🔗 词组功能 - 空行分隔的重要作用\n\n**核心规则：** 用**空行**分隔不同的词组，每个词组独立统计\n\n##### 示例配置：\n```txt\niPhone\n华为\nOPPO\n+发布\n\nA股\n上证\n深证\n+涨跌\n!预测\n\n世界杯\n欧洲杯\n亚洲杯\n+比赛\n```\n\n##### 词组解释及匹配效果：\n\n**第1组 - 手机新品类：**\n- 关键词：iPhone、华为、OPPO\n- 必须词：发布\n- 效果：必须包含手机品牌名，同时包含\"发布\"\n\n**匹配示例：**\n- ✅ \"iPhone 15正式发布售价公布\" ← 有\"iPhone\"+\"发布\"\n- ✅ \"华为Mate60系列发布会直播\" ← 有\"华为\"+\"发布\"\n- ✅ \"OPPO Find X7发布时间确定\" ← 有\"OPPO\"+\"发布\"\n- ❌ \"iPhone销量创新高\" ← 有\"iPhone\"但缺少\"发布\"\n\n**第2组 - 股市行情类：**\n- 关键词：A股、上证、深证\n- 必须词：涨跌\n- 过滤词：预测\n- 效果：关注股市涨跌实况，排除预测类内容\n\n**匹配示例：**\n- ✅ \"A股今日大幅涨跌分析\" ← 有\"A股\"+\"涨跌\"\n- ✅ \"上证指数涨跌幅创新高\" ← 有\"上证\"+\"涨跌\"\n- ❌ \"专家预测A股涨跌趋势\" ← 有\"A股\"+\"涨跌\"但包含\"预测\"\n\n**第3组 - 足球赛事类：**\n- 关键词：世界杯、欧洲杯、亚洲杯\n- 必须词：比赛\n- 效果：只关注比赛相关新闻\n\n---\n\n#### 📝 配置技巧\n\n##### 1. **从宽到严**\n```txt\n# 第一步：先用宽泛关键词测试\n人工智能\nAI\nChatGPT\n\n# 第二步：发现误匹配后，加入必须词限定\n人工智能\nAI\nChatGPT\n+技术\n\n# 第三步：发现干扰内容后，加入过滤词\n人工智能\nAI\nChatGPT\n+技术\n!广告\n!培训\n```\n\n##### 2. **避免过度复杂**\n\n❌ **不推荐：** 一个词组包含太多词汇\n```txt\n华为\nOPPO\n苹果\n三星\nvivo\n一加\n魅族\n+手机\n+发布\n+销量\n!假货\n!维修\n!二手\n```\n\n✅ **推荐：** 拆分成多个精确的词组\n```txt\n华为\nOPPO\n+新品\n\n苹果\n三星\n+发布\n\n手机\n销量\n+市场\n```\n\n</details>\n\n#### 2.2 高级配置（v3.2.0 新增）\n\n<a name=\"关键词高级配置\"></a>\n\n<details>\n<summary>👉 点击展开：<strong>高级配置教程</strong></summary>\n<br>\n\n##### 关键词排序优先级\n\n**配置位置：** `config/config.yaml`\n\n```yaml\nreport:\n  sort_by_position_first: false  # 排序优先级配置\n```\n\n| 配置值 | 排序规则 | 适用场景 |\n|--------|---------|---------|\n| `false`（默认） | 热点条数 ↓ → 配置位置 ↑ | 关注热度趋势 |\n| `true` | 配置位置 ↑ → 热点条数 ↓ | 关注个人优先级 |\n\n**示例：** 配置顺序 A、B、C，热点数 A(3条)、B(10条)、C(5条)\n- `false`：B(10条) → C(5条) → A(3条)\n- `true`：A(3条) → B(10条) → C(5条)\n\n##### 全局显示数量限制\n\n```yaml\nreport:\n  max_news_per_keyword: 10  # 每个关键词最多显示10条（0=不限制）\n```\n\n**Docker 环境变量：**\n```bash\nSORT_BY_POSITION_FIRST=true\nMAX_NEWS_PER_KEYWORD=10\n```\n\n**综合示例：**\n```yaml\n# config.yaml\nreport:\n  sort_by_position_first: true   # 按配置顺序优先\n  max_news_per_keyword: 10       # 全局默认每个关键词最多10条\n```\n\n```txt\n# frequency_words.txt\n特斯拉\n马斯克\n@20              # 重点关注，显示20条（覆盖全局配置）\n\n华为            # 使用全局配置，显示10条\n\n比亚迪\n@5               # 限制5条\n```\n\n**最终效果：** 按配置顺序显示 特斯拉(20条) → 华为(10条) → 比亚迪(5条)\n\n</details>\n\n### 3. 推送模式选哪个？\n\n<details>\n<summary>👉 点击展开：<strong>三种推送模式详细对比</strong></summary>\n<br>\n\n**配置位置：** `config/config.yaml` 的 `report.mode`\n\n```yaml\nreport:\n  mode: \"daily\"  # 可选: \"daily\" | \"incremental\" | \"current\"\n```\n\n#### 详细对比表格\n\n| 模式 | 适用人群 | 推送时机 | 显示内容 | 典型使用场景 |\n|------|----------|----------|----------|------------|\n| **当日汇总**<br/>`daily` | 📋 企业管理者/普通用户 | 按时推送(默认每小时推送一次) | 当日所有匹配新闻<br/>+ 新增新闻区域 | **案例**：每天下午6点查看今天所有重要新闻<br/>**特点**：看全天完整趋势，不漏掉任何热点<br/>**提醒**：会包含之前推送过的新闻 |\n| **当前榜单**<br/>`current` | 📰 自媒体人/内容创作者 | 按时推送(默认每小时推送一次) | 当前榜单匹配新闻<br/>+ 新增新闻区域 | **案例**：每小时追踪\"哪些话题现在最火\"<br/>**特点**：实时了解当前热度排名变化<br/>**提醒**：持续在榜的新闻每次都会出现 |\n| **增量监控**<br/>`incremental` | 📈 投资者/交易员 | 有新增才推送 | 新出现的匹配频率词新闻 | **案例**：监控\"特斯拉\"，只在有新消息时通知<br/>**特点**：零重复，只看首次出现的新闻<br/>**适合**：高频监控、避免信息打扰 |\n\n#### 实际推送效果举例\n\n假设你监控\"苹果\"关键词，每小时执行一次：\n\n| 时间 | daily 模式推送 | current 模式推送 | incremental 模式推送 |\n|-----|--------------|----------------|-------------------|\n| 10:00 | 新闻A、新闻B | 新闻A、新闻B | 新闻A、新闻B |\n| 11:00 | 新闻A、新闻B、新闻C | 新闻B、新闻C、新闻D | **仅**新闻C |\n| 12:00 | 新闻A、新闻B、新闻C | 新闻C、新闻D、新闻E | **仅**新闻D、新闻E |\n\n**说明**：\n- `daily`：累积展示当天所有新闻（A、B、C 都保留）\n- `current`：展示当前榜单的新闻（排名变化，新闻D上榜，新闻A掉榜）\n- `incremental`：**只推送新出现的新闻**（避免重复干扰）\n\n#### 常见问题\n\n> **💡 遇到这个问题？** 👉 \"每个小时执行一次，第一次执行完输出的新闻，在下一个小时执行时还会出现\"\n> - **原因**：你可能选择了 `daily`（当日汇总）或 `current`（当前榜单）模式\n> - **解决**：改用 `incremental`（增量监控）模式，只推送新增内容\n\n#### ⚠️ 增量模式重要提示\n\n> **选择了 `incremental`（增量监控）模式的用户请注意：**\n>\n> 📌 **增量模式只在有新增匹配新闻时才会推送**\n>\n> **如果长时间没有收到推送，可能是因为：**\n> 1. 当前时段没有符合你关键词的新热点出现\n> 2. 关键词配置过于严格或过于宽泛\n> 3. 监控平台数量较少\n>\n> **解决方案：**\n> - 方案1：👉 [优化关键词配置](#2-关键词配置) - 调整关键词的精准度，增加或修改监控词汇\n> - 方案2：切换推送模式 - 改用 `current` 或 `daily` 模式，可以定时接收推送\n> - 方案3：👉 [增加监控平台](#1-平台配置) - 添加更多新闻平台，扩大信息来源\n\n</details>\n\n### 4. 调整热点算法\n\n<details>\n<summary>👉 点击展开：<strong>自定义热点权重</strong></summary>\n<br>\n\n**配置位置：** `config/config.yaml` 的 `advanced.weight` 部分\n\n```yaml\nadvanced:\n  weight:\n    rank: 0.6           # 排名权重\n    frequency: 0.3      # 频次权重\n    hotness: 0.1        # 热度权重\n```\n\n当前默认的配置是平衡性配置\n\n#### 两个核心场景\n\n**追实时热点型**：\n```yaml\nadvanced:\n  weight:\n    rank: 0.8           # 主要看排名\n    frequency: 0.1      # 不太在乎持续性\n    hotness: 0.1\n```\n**适用人群**：自媒体博主、营销人员、想快速了解当下最火话题的用户\n\n**追深度话题型**：\n```yaml\nadvanced:\n  weight:\n    rank: 0.4           # 适度看排名\n    frequency: 0.5      # 重视当天内的持续热度\n    hotness: 0.1\n```\n**适用人群**：投资者、研究人员、新闻工作者、需要深度分析趋势的用户\n\n#### 调整的方法\n1. **三个数字加起来必须等于 1.0**\n2. **哪个重要就调大哪个**：在乎排名就调大 `rank`，在乎持续性就调大 `frequency`\n3. **建议每次只调 0.1-0.2**，观察效果\n\n核心思路：追求速度和时效性的用户提高排名权重，追求深度和稳定性的用户提高频次权重。\n\n</details>\n\n### 5. 我收到的消息长什么样？\n\n<details>\n<summary>👉 点击展开：<strong>消息样式预览</strong></summary>\n<br>\n\n#### 推送示例\n\n📊 热点词汇统计\n\n🔥 [1/3] AI ChatGPT : 2 条\n\n  1. [百度热搜] 🆕 ChatGPT-5正式发布 [**1**] - 09时15分 (1次)\n\n  2. [今日头条] AI芯片概念股暴涨 [**3**] - [08时30分 ~ 10时45分] (3次)\n\n━━━━━━━━━━━━━━━━━━━\n\n📈 [2/3] 比亚迪 特斯拉 : 2 条\n\n  1. [微博] 🆕 比亚迪月销量破纪录 [**2**] - 10时20分 (1次)\n\n  2. [抖音] 特斯拉降价促销 [**4**] - [07时45分 ~ 09时15分] (2次)\n\n━━━━━━━━━━━━━━━━━━━\n\n📌 [3/3] A股 股市 : 1 条\n\n  1. [华尔街见闻] A股午盘点评分析 [**5**] - [11时30分 ~ 12时00分] (2次)\n\n🆕 本次新增热点新闻 (共 2 条)\n\n**百度热搜** (1 条):\n  1. ChatGPT-5正式发布 [**1**]\n\n**微博** (1 条):\n  1. 比亚迪月销量破纪录 [**2**]\n\n更新时间：2025-01-15 12:30:15\n\n#### 消息格式说明\n\n| 格式元素      | 示例                        | 含义         | 说明                                    |\n| ------------- | --------------------------- | ------------ | --------------------------------------- |\n| 🔥📈📌        | 🔥 [1/3] AI ChatGPT        | 热度等级     | 🔥高热度(≥10条) 📈中热度(5-9条) 📌普通热度(<5条) |\n| [序号/总数]   | [1/3]                       | 排序位置     | 当前词组在所有匹配词组中的排名          |\n| 频率词组      | AI ChatGPT                  | 关键词组     | 配置文件中的词组，标题必须包含其中词汇   |\n| : N 条        | : 2 条                      | 匹配数量     | 该词组匹配的新闻总数                    |\n| [平台名]      | [百度热搜]                  | 来源平台     | 新闻所属的平台名称                      |\n| 🆕            | 🆕 ChatGPT-5正式发布        | 新增标记     | 本轮抓取中首次出现的热点                |\n| [**数字**]    | [**1**]                     | 高排名       | 排名≤阈值的热搜，红色加粗显示           |\n| [数字]        | [7]                         | 普通排名     | 排名>阈值的热搜，普通显示               |\n| - 时间        | - 09时15分                  | 首次时间     | 该新闻首次被发现的时间                  |\n| [时间~时间]   | [08时30分 ~ 10时45分]       | 持续时间     | 从首次出现到最后出现的时间范围          |\n| (N次)         | (3次)                       | 出现频率     | 在监控期间出现的总次数                  |\n| **新增区域**  | 🆕 **本次新增热点新闻**      | 新话题汇总   | 单独展示本轮新出现的热点话题            |\n\n</details>\n\n\n### 6. Docker 部署\n\n**镜像说明：**\n\nTrendRadar 提供两个独立的 Docker 镜像，可根据需求选择部署：\n\n| 镜像名称 | 用途 | 说明 |\n|---------|------|------|\n| `wantcat/trendradar` | 新闻推送服务 | 定时抓取新闻、推送通知（必选） |\n| `wantcat/trendradar-mcp` | AI 分析服务 | MCP 协议支持、AI 对话分析（可选） |\n\n> 💡 **建议**：\n> - 只需要推送功能：仅部署 `wantcat/trendradar` 镜像\n> - 需要 AI 分析功能：同时部署两个镜像\n\n<details>\n<summary>👉 点击展开：<strong>Docker 部署完整指南</strong></summary>\n<br>\n\n#### 方式一：使用 docker compose（推荐）\n\n1. **创建项目目录和配置**:\n\n   ```bash\n   # 克隆项目到本地\n   git clone https://github.com/sansan0/TrendRadar.git\n   cd TrendRadar\n   ```\n\n   > 💡 **说明**：Docker 部署需要的关键目录结构如下：\n```\n当前目录/\n├── config/\n│   ├── config.yaml                 # 核心功能配置（必需）\n│   ├── frequency_words.txt         # 关键词配置（必需）\n│   ├── timeline.yaml               # 时间线配置\n│   ├── ai_analysis_prompt.txt      # AI 分析提示词（可选）\n│   ├── ai_translation_prompt.txt   # AI 翻译提示词（可选）\n│   ├── ai_interests.txt            # AI 兴趣过滤配置（可选）\n│   ├── ai_filter/                  # AI 过滤相关提示词\n│   │   ├── prompt.txt\n│   │   ├── extract_prompt.txt\n│   │   └── update_tags_prompt.txt\n│   └── custom/                     # 用户自定义配置（可选）\n│       ├── ai/                     # 自定义 AI 提示词\n│       └── keyword/                # 自定义关键词文件\n└── docker/\n    ├── .env                        # 敏感信息 + Docker 特有配置\n    └── docker-compose.yml          # Docker Compose 编排文件\n```\n\n2. **配置文件说明**:\n\n   **配置分工原则（v4.6.0 优化）**：\n\n   | 文件 | 用途 | 修改频率 | 说明 |\n   |------|------|---------|------|\n   | `config/config.yaml` | **核心功能配置** | 低 | 报告模式、推送设置、存储格式、推送窗口、AI 分析开关、平台启用等全局行为控制 |\n   | `config/frequency_words.txt` | **关键词配置** | 高 | 设置你关心的热点词汇，支持分组、正则、别名等高级语法 |\n   | `config/timeline.yaml` | **时间线配置** | 低 | 控制新闻时间线的展示和过滤规则 |\n   | `config/ai_analysis_prompt.txt` | **AI 分析提示词** | 中 | 自定义 AI 分析的角色定义和输出格式（v5.0.0+） |\n   | `config/ai_translation_prompt.txt` | **AI 翻译提示词** | 低 | 自定义 AI 翻译的提示词模板 |\n   | `config/ai_interests.txt` | **AI 兴趣过滤** | 中 | 定义 AI 基于兴趣自动过滤新闻的规则 |\n   | `config/ai_filter/` | **AI 过滤提示词** | 低 | AI 过滤模块的内部提示词（一般无需修改） |\n   | `config/custom/` | **用户自定义扩展** | 按需 | `custom/ai/` 放自定义 AI 提示词，`custom/keyword/` 放自定义关键词文件 |\n   | `docker/.env` | **敏感信息 + Docker 特有配置** | 低 | webhook URLs、API Key、S3 密钥、定时任务等，**不会被 git 追踪** |\n\n   > 💡 **分工要点**：\n   > - **功能行为** → 改 `config.yaml`（如开启/关闭某个平台、调整推送模式）\n   > - **关注内容** → 改 `frequency_words.txt`（如添加新的关注关键词）\n   > - **AI 输出风格** → 改 `ai_analysis_prompt.txt` 或 `ai_translation_prompt.txt`\n   > - **密钥与凭证** → 改 `docker/.env`（API Key、Webhook URL 等敏感信息统一放这里）\n   > - **个性化扩展** → 使用 `config/custom/` 目录，避免直接修改默认配置被升级覆盖\n\n   > 💡 **配置修改生效**：修改 `config.yaml` 后，执行 `docker compose up -d` 重启容器即可生效\n\n   **⚙️ 环境变量覆盖机制（v3.0.5+）**\n\n   `.env` 文件中的环境变量会覆盖 `config.yaml` 中的对应配置：\n\n   | 环境变量 | 对应配置 | 示例值 | 说明 |\n   |---------|---------|-------|------|\n   | `ENABLE_WEBSERVER` | - | `true` / `false` | 是否自动启动 Web 服务器 |\n   | `WEBSERVER_PORT` | - | `8080` | Web 服务器端口 |\n   | `WEBSERVER_WATCHDOG` | - | `true` / `false` | 是否开启“网页服务自动恢复”（服务异常时自动重开） |\n   | `WEBSERVER_WATCHDOG_INTERVAL` | - | `60` | 自动恢复检查间隔（秒） |\n   | `FEISHU_WEBHOOK_URL` | `notification.channels.feishu.webhook_url` | `https://...` | 飞书 Webhook（多账号用 `;` 分隔） |\n   | `AI_ANALYSIS_ENABLED` | `ai_analysis.enabled` | `true` / `false` | 是否启用 AI 分析（v5.0.0 新增） |\n   | `AI_API_KEY` | `ai.api_key` | `sk-xxx...` | AI API Key（ai_analysis 和 ai_translation 共享） |\n   | `AI_PROVIDER` | `ai.provider` | `deepseek` / `openai` / `gemini` | AI 提供商 |\n   | `S3_*` | `storage.remote.*` | - | 远程存储配置（5 个参数） |\n\n   **配置优先级**：环境变量 > config.yaml\n\n   **使用方法**：\n   - 修改 `.env` 文件，填写需要的配置\n   - 或在 NAS/群晖 Docker 管理界面的\"环境变量\"中直接添加\n   - 重启容器后生效：`docker compose up -d`\n\n\n3. **启动服务**:\n\n   **选项 A：启动所有服务（推送 + AI 分析）**\n   ```bash\n   # 拉取最新镜像\n   docker compose pull\n\n   # 启动所有服务（trendradar + trendradar-mcp）\n   docker compose up -d\n   ```\n\n   **选项 B：仅启动新闻推送服务**\n   ```bash\n   # 只启动 trendradar（定时抓取和推送）\n   docker compose pull trendradar\n   docker compose up -d trendradar\n   ```\n\n   **选项 C：仅启动 MCP AI 分析服务**\n   ```bash\n   # 只启动 trendradar-mcp（提供 AI 分析接口）\n   docker compose pull trendradar-mcp\n   docker compose up -d trendradar-mcp\n   ```\n\n   > 💡 **提示**：\n   > - 大多数用户只需启动 `trendradar` 即可实现新闻推送功能\n   > - 只有需要使用 ChatGPT/Gemini 进行 AI 对话分析时，才需启动 `trendradar-mcp`\n   > - 两个服务相互独立，可根据需求灵活组合\n\n4. **查看运行状态**:\n   ```bash\n   # 查看新闻推送服务日志\n   docker logs -f trendradar\n\n   # 查看 MCP AI 分析服务日志\n   docker logs -f trendradar-mcp\n\n   # 查看所有容器状态\n   docker ps | grep trendradar\n\n   # 停止特定服务\n   docker compose stop trendradar      # 停止推送服务\n   docker compose stop trendradar-mcp  # 停止 MCP 服务\n   ```\n\n#### 方式二：本地构建（开发者选项）\n\n如果需要自定义修改代码或构建自己的镜像：\n\n```bash\n# 克隆项目\ngit clone https://github.com/sansan0/TrendRadar.git\ncd TrendRadar\n\n# 修改配置文件\nvim config/config.yaml\nvim config/frequency_words.txt\n\n# 使用构建版本的 docker compose\ncd docker\ncp docker-compose-build.yml docker-compose.yml\n```\n\n**构建并启动服务**：\n\n```bash\n# 选项 A：构建并启动所有服务\ndocker compose build\ndocker compose up -d\n\n# 选项 B：仅构建并启动新闻推送服务\ndocker compose build trendradar\ndocker compose up -d trendradar\n\n# 选项 C：仅构建并启动 MCP AI 分析服务\ndocker compose build trendradar-mcp\ndocker compose up -d trendradar-mcp\n```\n\n> 💡 **架构参数说明**：\n> - 默认构建 `amd64` 架构镜像（适用于大多数 x86_64 服务器）\n> - 如需构建 `arm64` 架构（Apple Silicon、树莓派等），设置环境变量：\n>   ```bash\n>   export DOCKER_ARCH=arm64\n>   docker compose build\n>   ```\n\n#### 镜像更新\n\n```bash\n# 方式一：手动更新（爬虫 + MCP 镜像）\ndocker pull wantcat/trendradar:latest\ndocker pull wantcat/trendradar-mcp:latest\ndocker compose down\ndocker compose up -d\n\n# 方式二：使用 docker compose 更新\ndocker compose pull\ndocker compose up -d\n```\n\n**可用镜像**：\n\n| 镜像名称 | 用途 | 说明 |\n|---------|------|------|\n| `wantcat/trendradar` | 新闻推送服务 | 定时抓取新闻、推送通知 |\n| `wantcat/trendradar-mcp` | MCP 服务 | AI 分析功能（可选） |\n\n#### 服务管理命令\n\n```bash\n# 查看运行状态\ndocker exec -it trendradar python manage.py status\n\n# 手动执行一次爬虫\ndocker exec -it trendradar python manage.py run\n\n# 查看实时日志\ndocker exec -it trendradar python manage.py logs\n\n# 显示当前配置\ndocker exec -it trendradar python manage.py config\n\n# 显示输出文件\ndocker exec -it trendradar python manage.py files\n\n# Web 服务器管理（用于浏览器访问生成的报告）\ndocker exec -it trendradar python manage.py start_webserver   # 启动 Web 服务器\ndocker exec -it trendradar python manage.py stop_webserver    # 停止 Web 服务器\ndocker exec -it trendradar python manage.py webserver_status  # 查看 Web 服务器状态\n\n# 查看帮助信息\ndocker exec -it trendradar python manage.py help\n\n# 重启容器\ndocker restart trendradar\n\n# 停止容器\ndocker stop trendradar\n\n# 删除容器（保留数据）\ndocker rm trendradar\n```\n\n> 💡 **Web 服务器说明**：\n> - 启动后可通过浏览器访问 `http://localhost:8080` 查看最新报告\n> - 通过目录导航访问历史报告（如：`http://localhost:8080/2025-xx-xx/`）\n> - 端口可在 `.env` 文件中配置 `WEBSERVER_PORT` 参数\n> - 自动启动：在 `.env` 中设置 `ENABLE_WEBSERVER=true`\n> - 自动恢复：`WEBSERVER_WATCHDOG=true`（默认开启），每隔 `WEBSERVER_WATCHDOG_INTERVAL` 秒检查一次，异常会自动重开网页服务\n> - `stop_webserver` 的意思是“你主动手动关闭网页服务”（命令：`docker exec -it trendradar python manage.py stop_webserver`）\n> - “自动拉起”就是“系统自动把网页服务重新打开”；若你手动关闭后想恢复，请执行 `docker exec -it trendradar python manage.py start_webserver`\n> - 安全提示：仅提供静态文件访问，限制在 output 目录，只绑定本地访问\n\n#### 数据持久化\n\n生成的报告和数据默认保存在 `./output` 目录下，即使容器重启或删除，数据也会保留。\n\n**📊 网页版报告访问路径**：\n\nTrendRadar 生成的当日汇总 HTML 报告会同时保存到两个位置：\n\n| 文件位置 | 访问方式 | 适用场景 |\n|---------|---------|---------|\n| `output/index.html` | 宿主机直接访问 | **Docker 部署**（通过 Volume 挂载，宿主机可见） |\n| `index.html` | 根目录访问 | **GitHub Pages**（仓库根目录，Pages 自动识别） |\n| `output/html/YYYY-MM-DD/当日汇总.html` | 历史报告访问 | 所有环境（按日期归档） |\n\n**本地访问示例**：\n```bash\n# 方式 1：通过 Web 服务器访问（推荐，Docker 环境）\n# 1. 启动 Web 服务器\ndocker exec -it trendradar python manage.py start_webserver\n# 2. 在浏览器访问\nhttp://localhost:8080                           # 访问最新报告（默认 index.html）\nhttp://localhost:8080/html/2025-xx-xx/          # 访问指定日期的报告\n\n# 方式 2：直接打开文件（本地环境）\nopen ./output/index.html             # macOS\nstart ./output/index.html            # Windows\nxdg-open ./output/index.html         # Linux\n\n# 方式 3：访问历史归档\nopen ./output/html/2025-xx-xx/当日汇总.html\n```\n\n**为什么有两个 index.html？**\n- `output/index.html`：Docker Volume 挂载到宿主机，本地可直接打开\n- `index.html`：GitHub Actions 推送到仓库，GitHub Pages 自动部署\n\n> 💡 **提示**：两个文件内容完全相同，选择任意一个访问即可。\n\n#### 故障排查\n\n```bash\n# 检查容器状态\ndocker inspect trendradar\n\n# 查看容器日志\ndocker logs --tail 100 trendradar\n\n# 进入容器调试\ndocker exec -it trendradar /bin/bash\n\n# 验证配置文件\ndocker exec -it trendradar ls -la /app/config/\n```\n\n#### MCP 服务部署（AI 分析功能）\n\n如果需要使用 AI 分析功能，可以部署独立的 MCP 服务容器。\n\n**架构说明**：\n\n```mermaid\nflowchart TB\n    subgraph trendradar[\"trendradar\"]\n        A1[定时抓取新闻]\n        A2[推送通知]\n    end\n    \n    subgraph trendradar-mcp[\"trendradar-mcp\"]\n        B1[127.0.0.1:3333]\n        B2[AI 分析接口]\n    end\n    \n    subgraph shared[\"共享卷\"]\n        C1[\"config/ (ro)\"]\n        C2[\"output/ (ro)\"]\n    end\n    \n    trendradar --> shared\n    trendradar-mcp --> shared\n```\n\n**快速启动**：\n\n如果已按照 [方式一：使用 docker compose](#方式一使用-docker-compose推荐) 完成部署，只需启动 MCP 服务：\n\n```bash\ncd TrendRadar/docker\ndocker compose up -d trendradar-mcp\n\n# 查看运行状态\ndocker ps | grep trendradar-mcp\n```\n\n**单独启动 MCP 服务**（不使用 docker compose）：\n\n```bash\n# Linux/Mac\ndocker run -d --name trendradar-mcp \\\n  -p 127.0.0.1:3333:3333 \\\n  -v $(pwd)/config:/app/config:ro \\\n  -v $(pwd)/output:/app/output:ro \\\n  -e TZ=Asia/Shanghai \\\n  wantcat/trendradar-mcp:latest\n\n# Windows PowerShell\ndocker run -d --name trendradar-mcp `\n  -p 127.0.0.1:3333:3333 `\n  -v ${PWD}/config:/app/config:ro `\n  -v ${PWD}/output:/app/output:ro `\n  -e TZ=Asia/Shanghai `\n  wantcat/trendradar-mcp:latest\n```\n\n> ⚠️ **注意**：单独运行时，确保当前目录下有 `config/` 和 `output/` 文件夹，且包含配置文件和新闻数据。\n\n**验证服务**：\n\n```bash\n# 检查 MCP 服务健康状态\ncurl http://127.0.0.1:3333/mcp\n\n# 查看 MCP 服务日志\ndocker logs -f trendradar-mcp\n```\n\n**在 AI 客户端中配置**：\n\nMCP 服务启动后，根据不同客户端进行配置：\n\n**Cherry Studio**（推荐，GUI 配置）：\n- 设置 → MCP 服务器 → 添加\n- 类型：`streamableHttp`\n- URL：`http://127.0.0.1:3333/mcp`\n\n**Claude Desktop / Cline**（JSON 配置）：\n```json\n{\n  \"mcpServers\": {\n    \"trendradar\": {\n      \"url\": \"http://127.0.0.1:3333/mcp\",\n      \"type\": \"streamableHttp\"\n    }\n  }\n}\n```\n\n> 💡 **提示**：MCP 服务仅监听本地端口（127.0.0.1），确保安全性。如需远程访问，请自行配置反向代理和认证。\n\n</details>\n\n### 7. 推送内容怎么显示？\n\n<details>\n<summary>👉 点击展开：<strong>自定义推送样式和内容</strong></summary>\n<br>\n\n**配置位置：** `config/config.yaml` 的 `report` 和 `display` 部分\n\n```yaml\nreport:\n  mode: \"daily\"                    # 推送模式\n  display_mode: \"keyword\"          # 显示模式（v4.6.0 新增）\n  rank_threshold: 5                # 排名高亮阈值\n  sort_by_position_first: false    # 排序优先级\n  max_news_per_keyword: 0          # 每个关键词最大显示数量\n\ndisplay:\n  region_order:                    # 区域显示顺序（v5.2.0 新增）\n    - new_items                    # 新增热点区域\n    - hotlist                      # 热榜区域\n    - rss                          # RSS 订阅区域\n    - standalone                   # 独立展示区\n    - ai_analysis                  # AI 分析区域\n```\n\n#### 常用配置项说明\n\n| 我想调整什么 | 修改哪个参数 | 默认值 | 说明 |\n|-------------|-------------|-------|------|\n| **推送模式** | `mode` | `daily` | 决定推送时机和内容，详见 [推送模式详解](#3-推送模式详解) |\n| **分组方式** | `display_mode` | `keyword` | `keyword`=按关键词分组(如\"AI\")，`platform`=按平台分组(如\"微博\") |\n| **高亮重点** | `rank_threshold` | `5` | 排名在前 5 的新闻会**加粗**显示，一眼看到最火的 |\n| **排序规则** | `sort_by_position_first` | `false` | `false`=热度高的排前面，`true`=你配置的词排前面 |\n| **数量限制** | `max_news_per_keyword` | `0` | 每个关键词最多看几条？`0`表示不限制 |\n| **显示顺序** | `display.region_order` | 见上方配置 | 调整列表顺序即可控制各区域的显示位置 |\n\n#### 分组方式对比（display_mode）\n\n你是想看\"这个话题下有哪些新闻\"，还是\"这个平台上有哪些新闻\"？\n\n| 模式 | 分组方式 | 标题前缀 | 适用场景 |\n|------|---------|---------|---------|\n| `keyword`（默认） | **按关键词聚合** | `[平台名]` | 我关注\"AI\"，想看各平台关于AI的新闻 |\n| `platform` | **按平台聚合** | `[关键词]` | 我关注\"微博\"，想看微博上关于我关注词的新闻 |\n\n#### 区域显示顺序（region_order）\n\n通过调整 `display.region_order` 列表的顺序，可以控制推送消息中各区域的显示位置。\n\n**默认顺序**：新增热点 → 热榜 → RSS → 独立展示区 → AI 分析\n\n**自定义示例**：想让 AI 分析放在最前面？\n\n```yaml\ndisplay:\n  region_order:\n    - ai_analysis                  # 移到第一行\n    - new_items\n    - hotlist\n    - rss\n    - standalone\n```\n\n**注意**：区域需同时满足两个条件才会显示：\n1. 在 `region_order` 列表中\n2. 在 `display.regions` 中对应开关为 `true`\n\n#### 区域开关（regions）\n\n通过 `display.regions` 控制各区域是否在推送中显示：\n\n```yaml\ndisplay:\n  regions:\n    hotlist: true                    # 热榜区域（关键词匹配的热点新闻）\n    new_items: false                 # 新增热点区域（含热榜新增 + RSS 新增）\n    rss: true                       # RSS 订阅区域（关键词匹配的 RSS 内容）\n    standalone: false                # 独立展示区（完整热榜/RSS，不受关键词过滤）\n    ai_analysis: true                # AI 分析区域\n```\n\n| 区域 | 配置键 | 默认值 | 说明 |\n|------|--------|-------|------|\n| **热榜** | `hotlist` | `true` | 按关键词匹配的热点新闻聚合 |\n| **新增热点** | `new_items` | `false` | 本轮新出现的热点话题（含热榜新增 + RSS 新增）。注：热榜区域中的 🆕 标记不受此开关影响 |\n| **RSS** | `rss` | `true` | 按关键词匹配的 RSS 订阅内容。关闭后跳过 RSS 分析，但独立展示区中的 RSS 不受影响 |\n| **独立展示区** | `standalone` | `false` | 指定平台/RSS 的完整内容展示，不受关键词过滤 |\n| **AI 分析** | `ai_analysis` | `true` | AI 生成的热点分析摘要 |\n\n#### 排序优先级（sort_by_position_first）\n\n假设你配置了关键词：1.特斯拉，2.比亚迪。\n实际热度：比亚迪(10条)，特斯拉(3条)。\n\n| 配置值 | 排序结果 | 你的想法 |\n|-------|---------|---------|\n| `false`（默认） | 比亚迪(10条) → 特斯拉(3条) | \"谁火谁排前面\" |\n| `true` | 特斯拉(3条) → 比亚迪(10条) | \"我配置的顺序就是优先级，不管它火不火\" |\n\n#### 独立展示区（standalone）\n\n**场景**：有些平台（比如知乎热榜、HackerNews），我想**完整看一遍**，不管有没有匹配我的关键词。\n\n```yaml\ndisplay:\n  regions:\n    standalone: true                  # 推送中展示独立展示区（关闭不影响 AI 分析）\n\n  standalone:\n    platforms: [\"zhihu\", \"weibo\"]     # 这些平台的热榜给我完整显示\n    rss_feeds: [\"hacker-news\"]        # 这些RSS源的内容给我完整显示\n    max_items: 20                     # 最多显示多少条\n```\n\n> 💡 **推送展示与 AI 分析独立控制**：`regions.standalone` 只控制推送中是否显示独立展示区。即使关闭推送展示，只要在 AI 配置中开启 `include_standalone: true`，AI 仍会分析这些平台的完整数据。适合想让 AI 做深度分析、但不想推送消息太长的用户。\n\n</details>\n\n### 8. 什么时候给我推送？\n\n<details>\n<summary>👉 点击展开：<strong>设置推送时间（调度系统）</strong></summary>\n<br>\n\n**配置位置：** `config/config.yaml` 的 `schedule` 部分 + `config/timeline.yaml`\n\n#### 快速上手\n\n只需在 `config.yaml` 中选一个预设模板，不需要编辑 `timeline.yaml`：\n\n```yaml\nschedule:\n  enabled: true\n  preset: \"morning_evening\"     # 改这里就行\n```\n\n#### 可选预设模板\n\n| 模板名 | 说明 | 推送行为 |\n|-------|------|---------|\n| `morning_evening` | 全天增量 + 晚间汇总（推荐） | 全天有新增就推 + 19:00-21:00 晚间当日汇总 |\n| `always_on` | 全天候监控 | 全天有新增就推送，不划分时间段 |\n| `office_hours` | 办公时间 | 工作日三段式（到岗速览→午间热点→收工汇总），周末增量自由推 |\n| `night_owl` | 夜猫子 | 午后速览 + 深夜全天汇总（22:00-01:00 跨午夜） |\n| `custom` | 完全自定义 | 编辑 `timeline.yaml` 底部的 custom 段 |\n\n#### 完全自定义\n\n如果预设模板都不满足需求，可以编辑 `config/timeline.yaml` 底部的 `custom` 段，自由定义时间段、日计划和周映射。详见 `timeline.yaml` 文件内的注释说明。\n\n#### 重要提示\n\n> ⚠️ **从旧版本升级的用户注意：**\n> - v6.0.0 移除了旧的 `notification.push_window` 和 `ai_analysis.analysis_window` 配置\n> - 请改用新的 `schedule` + `timeline.yaml` 调度系统\n> - 旧的\"每天推送一次\"可用 `morning_evening` 预设替代\n> - 旧的\"工作时间推送\"可用 `office_hours` 预设替代\n\n> ⚠️ **GitHub Actions 用户注意：**\n> - GitHub Actions 执行时间不稳定，可能有 ±15 分钟的偏差\n> - 时间段范围建议至少留足 **2 小时**\n> - 如果想要精准的定时推送，建议使用 **Docker 部署**在个人服务器上\n\n</details>\n\n### 9. 多久运行一次？\n\n<details>\n<summary>👉 点击展开：<strong>设置自动运行频率</strong></summary>\n<br>\n\n**配置位置：** `.github/workflows/crawler.yml` 的 `schedule` 部分\n\n```yaml\non:\n  schedule:\n    - cron: \"0 * * * *\"  # 每小时运行一次\n```\n\n#### 怎么修改运行频率？\n\nGitHub Actions 使用一种叫 \"Cron\" 的时间格式，不需要深入理解，直接复制下面的代码替换即可。\n\n**配置位置：** `.github/workflows/crawler.yml` 文件中的 `schedule` 部分\n\n| 我想要... | 复制这行代码 | 说明 |\n|-----------|------------|------|\n| **每小时一次** | `- cron: \"0 * * * *\"` | **默认配置**，第 0 分钟运行 |\n| **每 30 分钟** | `- cron: \"*/30 * * * *\"` | 每隔 30 分钟运行一次 |\n| **每天早 8 点** | `- cron: \"0 0 * * *\"` | ⚠️ 写 `0` 是因为 UTC 时间 (0点) = 北京时间 (8点) |\n| **工作时间每半小时** | `- cron: \"*/30 0-14 * * *\"` | 对应北京时间 8:00 - 22:00 |\n| **一日三餐点** | `- cron: \"0 0,6,12 * * *\"` | 对应北京时间 8:00、14:00、20:00 |\n\n#### ⚠️ 两个重要提醒\n\n1. **时差问题**：GitHub 的服务器在国外，用的是 UTC 时间。\n   - **简单的算术题**：你想设定的北京时间 **减去 8 小时** = 你要填的时间。\n   - *例子：想让它北京时间 20:00 运行，设置里要填 12:00*\n\n2. **不要太频繁**：建议间隔不要少于 30 分钟。\n   - GitHub 免费资源有限，跑得太勤可能会被官方限制账号。\n   - 而且 Actions 启动本身就有几分钟延迟，太精确的控制没有意义。\n\n#### 手把手修改步骤\n\n1. 在你的 GitHub 仓库中，找到 `.github/workflows/crawler.yml` 文件\n2. 点击右上角的 ✏️ (Edit) 按钮\n3. 找到 `cron: \"...\"` 那一行，把引号里的内容换成上面的\"代码\"\n4. 点击右上角的绿色 **Commit changes** 按钮保存\n\n</details>\n\n### 10. 推送到多个群/设备\n\n<details>\n<summary>👉 点击展开：<strong>同时推送给多个接收者</strong></summary>\n\n> ### ⚠️ **安全第一**\n> **不要在 `config.yaml` 里直接写密码/Token！**\n> 如果你把包含密码的文件上传到 GitHub，全世界都能看到。\n>\n> **正确做法**：\n> - **GitHub Actions 用户**：去 Settings -> Secrets 里添加\n> - **Docker 用户**：写在 `.env` 文件里（这个文件不会被上传）\n\n#### 怎么同时推送到多个地方？\n\n很简单，在配置时用分号 `;` 把多个地址隔开就行了。\n\n**举个例子**：\n假设你有两个飞书群，想同时收到推送：\n- 群1地址：`https://.../webhook/aaa`\n- 群2地址：`https://.../webhook/bbb`\n\n配置时填写：\n`https://.../webhook/aaa;https://.../webhook/bbb`\n\n#### 支持多账号的平台\n\n| 平台 | 配置方法 | 注意事项 |\n|------|---------|----------|\n| **飞书/钉钉/企微** | 用 `;` 分隔多个 Webhook URL | 最简单，直接串起来就行 |\n| **Bark (iOS)** | 用 `;` 分隔多个 Key URL | 推送到多台 iPhone |\n| **Telegram** | Token 和 ChatID 都要用 `;` 分隔 | ⚠️ **注意顺序要对应**：<br>Token1 对应 ChatID1<br>Token2 对应 ChatID2 |\n| **ntfy** | Topic 和 Token 都要用 `;` 分隔 | 如果某个Topic不需要Token，留空即可：<br>`token1;;token3` (中间那个是空的) |\n\n#### 常用配置示例 (GitHub Secrets / .env)\n\n```bash\n# 飞书发给 3 个群\nFEISHU_WEBHOOK_URL=https://hook1...;https://hook2...;https://hook3...\n\n# 钉钉发给 2 个群\nDINGTALK_WEBHOOK_URL=https://oapi...;https://oapi...\n\n# Telegram 发给 2 个人 (注意一一对应)\nTELEGRAM_BOT_TOKEN=tokenA;tokenB\nTELEGRAM_CHAT_ID=userA;userB\n```\n\n> **提示**：为了防止滥用，默认限制每个平台最多推送到 3 个账号。如果需要更多，可以修改 `MAX_ACCOUNTS_PER_CHANNEL` 配置。\n\n</details>\n\n### 11. 数据保存在哪里？\n\n<details id=\"storage-config\">\n<summary>👉 点击展开：<strong>选择数据存储位置</strong></summary>\n<br>\n\n#### 数据会存在哪里？\n\n系统会自动帮你选择最合适的地方，你通常不需要操心：\n\n| 你的运行环境 | 数据存在哪 | 说明 |\n|-------------|-----------|------|\n| **Docker / 本地运行** | **本地硬盘** | 存在项目目录下的 `output/` 文件夹里，随时可以查看。 |\n| **GitHub Actions** | **云端存储** | 因为 GitHub Actions 运行完就会销毁环境，所以必须配置云存储（例如 Cloudflare R2）。 |\n\n#### 怎么配置云存储？(GitHub Actions 用户必看)\n\n如果你是用 GitHub Actions 运行，你需要一个\"云端硬盘\"来存数据。例如使用 Cloudflare R2（因为有免费额度）。\n\n**在 GitHub Secrets 里添加这 5 个变量：**\n\n| 变量名 | 填什么 |\n|-------|-------|\n| `STORAGE_BACKEND` | `remote` |\n| `S3_BUCKET_NAME` | 你的存储桶名字 |\n| `S3_ACCESS_KEY_ID` | 你的 Access Key |\n| `S3_SECRET_ACCESS_KEY` | 你的 Secret Key |\n| `S3_ENDPOINT_URL` | 你的 R2 接口地址 |\n\n> 💡 **详细教程**：怎么申请 R2？请看 [快速开始 - 远程存储配置](#-快速开始)\n\n#### 数据会保存多久？\n\n默认情况下，我们不会自动删除你的数据。但如果你觉得数据太多占空间，可以设置\"自动清理\"。\n\n**配置位置**：`config/config.yaml`\n\n```yaml\nstorage:\n  local:\n    retention_days: 30    # 本地数据只保留 30 天 (0 表示永久)\n  remote:\n    retention_days: 30    # 云端数据只保留 30 天\n```\n\n#### 推送时间不对？(时区设置)\n\n如果你身在海外，或者发现推送时间跟你的本地时间对不上，可以修改时区。\n\n**配置位置**：`config/config.yaml`\n\n```yaml\napp:\n  timezone: \"Asia/Shanghai\"  # 默认是中国时间\n```\n- 比如你在美国洛杉矶，改成：`America/Los_Angeles`\n- 比如你在英国伦敦，改成：`Europe/London`\n\n</details>\n\n### 12. 让 AI 帮我分析热点\n\n<details id=\"ai-analysis-config\">\n<summary>👉 点击展开：<strong>开启 AI 智能分析功能</strong></summary>\n<br>\n\n#### AI 能帮我做什么？\n\n开启这个功能后，AI 会像一个专业的分析师，在推送每一批新闻时：\n1. **自动阅读**：阅读所有匹配到的热点新闻\n2. **深度思考**：分析原本孤立的新闻之间的关联\n3. **撰写报告**：在推送消息的末尾，附上一份简短深刻的\"洞察报告\"\n\n**包含内容**：热点趋势总结、舆论风向判断、跨平台关联分析、潜在影响评估等。\n\n#### 怎么开启 AI 分析？\n\n最简单的方法是通过环境变量配置（推荐 GitHub Secrets 或 .env）。\n\n**必需的配置项**：\n\n| 变量名 | 填什么 | 说明 |\n|-------|-------|------|\n| `AI_ANALYSIS_ENABLED` | `true` | 开启开关 |\n| `AI_API_KEY` | `sk-xxxxxx` | 你的 API Key |\n| `AI_MODEL` | `deepseek/deepseek-chat` | 模型标识（格式：`provider/model`） |\n\n**支持的 AI 提供商**（基于 LiteLLM，支持 100+ 提供商）：\n\n| 提供商 | AI_MODEL 填什么 | 说明 |\n|-------|----------------|------|\n| **DeepSeek** (推荐) | `deepseek/deepseek-chat` | 性价比极高，适合高频分析 |\n| **OpenAI** | `openai/gpt-4o`<br>`openai/gpt-4o-mini` | GPT-4o 系列 |\n| **Google Gemini** | `gemini/gemini-1.5-flash`<br>`gemini/gemini-1.5-pro` | Gemini 系列 |\n| **自定义 API** | 任意格式 | 配合 `AI_API_BASE` 使用 |\n\n> 💡 **新特性**：现已基于 [LiteLLM](https://github.com/BerriAI/litellm) 统一接口，支持 100+ AI 提供商，配置更简单、错误处理更完善。\n\n**可选配置项**：\n\n| 变量名 | 默认值 | 说明 |\n|-------|-------|------|\n| `AI_API_BASE` | (自动) | 自定义 API 地址（如 OneAPI、本地模型） |\n| `AI_TEMPERATURE` | `1.0` | 采样温度（0-2，越高越随机） |\n| `AI_MAX_TOKENS` | `5000` | 最大生成 token 数 |\n| `AI_TIMEOUT` | `120` | 请求超时时间（秒） |\n| `AI_NUM_RETRIES` | `2` | 失败重试次数 |\n\n#### 进阶玩法：AI 翻译\n\n如果你关注了国外的 RSS 源（比如 Hacker News），AI 可以帮你把内容翻译成中文推送。\n\n**配置位置**：`config/config.yaml`\n\n```yaml\nai_translation:\n  enabled: true          # 开启翻译\n  language: \"Chinese\"    # 翻译成什么语言 (Chinese, English, Japanese...)\n```\n\n#### 进阶玩法：自定义 AI \"人设\"\n\n觉得 AI 说话太官方？你可以修改它的提示词，让它变成你喜欢的风格（比如\"毒舌评论员\"、\"资深投资顾问\"）。\n\n- **修改文件**：`config/ai_analysis_prompt.txt`\n- **修改方法**：直接用记事本打开编辑，告诉 AI 你想要什么样的分析风格。\n\n</details>\n\n<br>\n\n## ✨ AI 智能分析\n\nTrendRadar v3.0.0 新增了基于 **MCP (Model Context Protocol)** 的 AI 分析功能，让你可以通过自然语言与新闻数据对话，进行深度分析。\n\n\n### ⚠️ 使用前必读\n\n\n**重要提示：AI 功能需要本地新闻数据支持**\n\nAI 分析功能**不是**直接查询网络实时数据，而是分析你**本地已积累的新闻数据**（存储在 `output` 文件夹中）\n\n\n#### 使用说明：\n\n1. **项目自带测试数据**：`output` 目录默认包含 **2025-12-21～2025-12-27** 一周的热榜新闻数据，可用于快速体验 AI 功能\n\n2. **查询限制**：\n   - ✅ 只能查询已有日期范围内的数据（12月21-27日，共7天）\n   - ❌ 无法查询实时新闻或未来日期\n\n3. **获取最新数据**：\n   - 测试数据仅供快速体验，**建议自行部署项目**获取实时数据\n   - 按照 [快速开始](#-快速开始) 部署运行项目\n   - 等待至少 1 天积累新闻数据后，即可查询最新热点\n\n\n### 1. 快速部署\n\nCherry Studio 提供 GUI 配置界面，5 分钟快速部署，复杂的部分是一键安装的。\n\n**图文部署教程**：现已更新到我的[公众号](#-支持项目)，回复 \"mcp\" 即可\n\n**详细部署教程**：[README-Cherry-Studio.md](README-Cherry-Studio.md)\n\n**部署模式说明**：\n- **STDIO 模式（推荐）**：一次配置后续无需重复配置，**图文部署教程**中仅以此模式的配置为例。\n- **HTTP 模式（备选）**：如果 STDIO 模式配置遇到问题，可使用 HTTP 模式。此模式的配置方式与 STDIO 基本一致，但复制粘贴的内容就一行，不易出错。唯一需要注意的是每次使用前都需要手动启动一下服务。详细请参考 [README-Cherry-Studio.md](README-Cherry-Studio.md) 底部的 HTTP 模式说明。\n\n### 2. 学习与 AI 对话的姿势\n\n**详细对话教程**：[README-MCP-FAQ.md](README-MCP-FAQ.md)\n\n> 💡 **提示**：实际不建议一次性问多个问题。如果你选择的 AI 模型连下图的按顺序调用都无法做到，建议换一个。\n\n<img src=\"/_image/ai4.png\" alt=\"mcp 使用效果图\" width=\"600\">\n\n<br>\n\n## 🔌 MCP 客户端\n\nTrendRadar MCP 服务支持标准的 Model Context Protocol (MCP) 协议，可以接入各种支持 MCP 的 AI 客户端进行智能分析。\n\n### 支持的客户端\n\n**注意事项**：\n- 将 `/path/to/TrendRadar` 替换为你的项目实际路径\n- Windows 路径使用双反斜杠：`C:\\\\Users\\\\YourName\\\\TrendRadar`\n- 保存后记得重启\n\n<details>\n<summary>👉 点击展开：<b>Cursor</b></summary>\n\n#### 方式一：HTTP 模式\n\n1. **启动 HTTP 服务**：\n   ```bash\n   # Windows\n   start-http.bat\n   \n   # Mac/Linux\n   ./start-http.sh\n   ```\n\n2. **配置 Cursor**：\n\n   **项目级配置**（推荐）：\n   在项目根目录创建 `.cursor/mcp.json`：\n   ```json\n   {\n     \"mcpServers\": {\n       \"trendradar\": {\n         \"url\": \"http://localhost:3333/mcp\",\n         \"description\": \"TrendRadar 新闻热点聚合分析\"\n       }\n     }\n   }\n   ```\n\n   **全局配置**：\n   在用户目录创建 `~/.cursor/mcp.json`（同样内容）\n\n3. **使用步骤**：\n   - 保存配置文件后重启 Cursor\n   - 在聊天界面的 \"Available Tools\" 中查看已连接的工具\n   - 开始使用：`搜索今天的\"AI\"相关新闻`\n\n#### 方式二：STDIO 模式（推荐）\n\n创建 `.cursor/mcp.json`：\n```json\n{\n  \"mcpServers\": {\n    \"trendradar\": {\n      \"command\": \"uv\",\n      \"args\": [\n        \"--directory\",\n        \"/path/to/TrendRadar\",\n        \"run\",\n        \"python\",\n        \"-m\",\n        \"mcp_server.server\"\n      ]\n    }\n  }\n}\n```\n\n</details>\n\n<details>\n<summary>👉 点击展开：<b>VSCode (Cline/Continue)</b></summary>\n\n#### Cline 配置\n\n在 Cline 的 MCP 设置中添加：\n\n**HTTP 模式**：\n```json\n{\n  \"trendradar\": {\n    \"url\": \"http://localhost:3333/mcp\",\n    \"type\": \"streamableHttp\",\n    \"autoApprove\": [],\n    \"disabled\": false\n  }\n}\n```\n\n**STDIO 模式**（推荐）：\n```json\n{\n  \"trendradar\": {\n    \"command\": \"uv\",\n    \"args\": [\n      \"--directory\",\n      \"/path/to/TrendRadar\",\n      \"run\",\n      \"python\",\n      \"-m\",\n      \"mcp_server.server\"\n    ],\n    \"type\": \"stdio\",\n    \"disabled\": false\n  }\n}\n```\n\n#### Continue 配置\n\n编辑 `~/.continue/config.json`：\n```json\n{\n  \"experimental\": {\n    \"modelContextProtocolServers\": [\n      {\n        \"transport\": {\n          \"type\": \"stdio\",\n          \"command\": \"uv\",\n          \"args\": [\n            \"--directory\",\n            \"/path/to/TrendRadar\",\n            \"run\",\n            \"python\",\n            \"-m\",\n            \"mcp_server.server\"\n          ]\n        }\n      }\n    ]\n  }\n}\n```\n\n**使用示例**：\n```\n分析最近7天\"特斯拉\"的热度变化趋势\n生成今天的热点摘要报告\n搜索\"比特币\"相关新闻并分析情感倾向\n```\n\n</details>\n\n<details>\n<summary>👉 点击展开：<b>MCP Inspector</b>（调试工具）</summary>\n<br>\n\nMCP Inspector 是官方调试工具，用于测试 MCP 连接：\n\n#### 使用步骤\n\n1. **启动 TrendRadar HTTP 服务**：\n   ```bash\n   # Windows\n   start-http.bat\n   \n   # Mac/Linux\n   ./start-http.sh\n   ```\n\n2. **启动 MCP Inspector**：\n   ```bash\n   npx @modelcontextprotocol/inspector\n   ```\n\n3. **在浏览器中连接**：\n   - 访问：`http://localhost:3333/mcp`\n   - 测试 \"Ping Server\" 功能验证连接\n   - 检查 \"List Tools\" 是否返回 17 个工具：\n     - 基础查询：get_latest_news, get_news_by_date, get_trending_topics\n     - 智能检索：search_news, find_related_news\n     - 高级分析：analyze_topic_trend, analyze_data_insights, analyze_sentiment, aggregate_news, compare_periods, generate_summary_report\n     - RSS 查询：get_latest_rss, search_rss, get_rss_feeds_status\n     - 系统管理：get_current_config, get_system_status, resolve_date_range\n\n</details>\n\n<details>\n<summary>👉 点击展开：<b>其他支持 MCP 的客户端</b></summary>\n<br>\n\n任何支持 Model Context Protocol 的客户端都可以连接 TrendRadar：\n\n#### HTTP 模式\n\n**服务地址**：`http://localhost:3333/mcp`\n\n**基本配置模板**：\n```json\n{\n  \"name\": \"trendradar\",\n  \"url\": \"http://localhost:3333/mcp\",\n  \"type\": \"http\",\n  \"description\": \"新闻热点聚合分析\"\n}\n```\n\n#### STDIO 模式（推荐）\n\n**基本配置模板**：\n```json\n{\n  \"name\": \"trendradar\",\n  \"command\": \"uv\",\n  \"args\": [\n    \"--directory\",\n    \"/path/to/TrendRadar\",\n    \"run\",\n    \"python\",\n    \"-m\",\n    \"mcp_server.server\"\n  ],\n  \"type\": \"stdio\"\n}\n```\n\n**注意事项**：\n- 替换 `/path/to/TrendRadar` 为实际项目路径\n- Windows 路径使用反斜杠转义：`C:\\\\Users\\\\...`\n- 确保已完成项目依赖安装（运行过 setup 脚本）\n\n</details>\n\n\n\n### 常见问题\n\n<details>\n<summary>👉 点击展开：<b>Q1: HTTP 服务无法启动？</b></summary>\n<br>\n\n**检查步骤**：\n1. 确认端口 3333 未被占用：\n   ```bash\n   # Windows\n   netstat -ano | findstr :3333\n   \n   # Mac/Linux\n   lsof -i :3333\n   ```\n\n2. 检查项目依赖是否安装：\n   ```bash\n   # 重新运行安装脚本\n   # Windows: setup-windows.bat 或者 setup-windows-en.bat\n   # Mac/Linux: ./setup-mac.sh\n   ```\n\n3. 查看详细错误日志：\n   ```bash\n   uv run python -m mcp_server.server --transport http --port 3333\n   ```\n4. 尝试自定义端口:\n   ```bash\n   uv run python -m mcp_server.server --transport http --port 33333\n   ```\n\n</details>\n\n<details>\n<summary>👉 点击展开：<b>Q2: 客户端无法连接到 MCP 服务？</b></summary>\n<br>\n\n**解决方案**：\n\n1. **STDIO 模式**：\n   - 确认 UV 路径正确（运行 `which uv` 或 `where uv`）\n   - 确认项目路径正确且无中文字符\n   - 查看客户端错误日志\n\n2. **HTTP 模式**：\n   - 确认服务已启动（访问 `http://localhost:3333/mcp`）\n   - 检查防火墙设置\n   - 尝试使用 127.0.0.1 替代 localhost\n\n3. **通用检查**：\n   - 重启客户端应用\n   - 查看 MCP 服务日志\n   - 使用 MCP Inspector 测试连接\n\n</details>\n\n<details>\n<summary>👉 点击展开：<b>Q3: 工具调用失败或返回错误？</b></summary>\n<br>\n\n**可能原因**：\n\n1. **数据不存在**：\n   - 确认已运行过爬虫（有 output 目录数据）\n   - 检查查询日期范围是否有数据\n   - 查看 output 目录的可用日期\n\n2. **参数错误**：\n   - 检查日期格式：`YYYY-MM-DD`\n   - 确认平台 ID 正确：`zhihu`, `weibo` 等\n   - 查看工具文档中的参数说明\n\n3. **配置问题**：\n   - 确认 `config/config.yaml` 存在\n   - 确认 `config/frequency_words.txt` 存在\n   - 检查配置文件格式是否正确\n\n</details>\n\n<br>\n\n## 📚 项目相关\n\n> **4 篇文章**：\n\n- [可在该文章下方留言，方便项目作者用手机答疑](https://mp.weixin.qq.com/s/KYEPfTPVzZNWFclZh4am_g)\n- [2个月破 1000 star，我的GitHub项目推广实战经验](https://mp.weixin.qq.com/s/jzn0vLiQFX408opcfpPPxQ)\n- [github fork 运行本项目的注意事项 ](https://mp.weixin.qq.com/s/C8evK-U7onG1sTTdwdW2zg)\n- [基于本项目，如何开展公众号或者新闻资讯类文章写作](https://mp.weixin.qq.com/s/8ghyfDAtQZjLrnWTQabYOQ)\n\n>**AI 开发**：\n- 如果你有小众需求，完全可以基于我的项目自行开发，零编程基础的也可以试试\n- 我所有的开源项目或多或少都使用了自己写的**AI辅助软件**来提升开发效率，这款工具已开源\n- **核心功能**：迅速筛选项目代码喂给AI，你只需要补充个人需求即可\n- **项目地址**：https://github.com/sansan0/ai-code-context-helper\n\n### 其余项目\n\n> 📍 毛主席足迹地图 - 交互式动态展示1893-1976年完整轨迹。欢迎诸位同志贡献数据\n\n- https://github.com/sansan0/mao-map\n\n> 哔哩哔哩(bilibili)评论区数据可视化分析软件\n\n- https://github.com/sansan0/bilibili-comment-analyzer\n\n\n[![Star History Chart](https://api.star-history.com/svg?repos=sansan0/TrendRadar&type=Date)](https://www.star-history.com/#sansan0/TrendRadar&Date)\n\n<br>\n\n## 📄 许可证\n\nGPL-3.0 License\n\n---\n\n<div align=\"center\">\n\n[🔝 回到顶部](#trendradar)\n\n</div>\n"
  },
  {
    "path": "config/ai_analysis_prompt.txt",
    "content": "# ═══════════════════════════════════════════════════════════════\n#                    TrendRadar AI 分析提示词配置\n#                      Version: 2.0.0\n# ═══════════════════════════════════════════════════════════════\n#\n# 此文件定义 AI 分析热点新闻时使用的提示词模板\n#\n# 可用变量（在分析时会被替换）：\n#   {language}            - 输出语言 (由 ai_analysis.language 配置)\n#   {report_mode}         - 当前报告模式\n#   {report_type}         - 报告类型描述\n#   {current_time}        - 当前时间\n#   {news_count}          - 热榜新闻条数\n#   {rss_count}           - RSS 新闻条数\n#   {keywords}            - 匹配的关键词列表\n#   {platforms}           - 数据来源平台列表\n#   {news_content}        - 热榜新闻内容\n#   {rss_content}         - RSS 订阅内容 (需开启 ai_analysis.include_rss)\n#   {standalone_content}  - 独立展示区数据 (需开启 ai_analysis.include_standalone)\n#\n# ═══════════════════════════════════════════════════════════════\n\n[system]\n你是一名高级情报分析师。你的核心能力是从海量、碎片化的公开来源情报（OSINT）中提炼核心逻辑，并识别被大众忽略的弱信号。\n\n## 核心思维模型 (Mental Models)\n\n1. 见微知著 (Signal Detection)：不要只盯着榜首的大新闻。要善于从\"排名第50的冷门技术贴\"与\"排名第1的热门事件\"中找到潜在的因果联系。\n2. 交叉验证 (Triangulation)：利用\"热榜\"（大众情绪）与\"RSS\"（专家视角）的差异。当两者观点冲突时，通常隐藏着认知套利的机会。\n3. 反直觉思考 (Counter-Intuitive)：当全网都在叫好时，寻找风险；当全网都在恐慌时，寻找机会。拒绝平庸的共识。\n4. 结构化输出 (MECE)：确保分析维度相互独立且完全穷尽，避免逻辑混乱。\n\n## 核心原则\n\n1. 直击要害：拒绝\"综上所述\"、\"众所周知\"等废话。直接输出结论。\n2. 逻辑闭环：不仅描述\"发生了什么\"，必须解释\"为什么发生\"以及\"未来会怎样\"。\n3. 去情绪化：可以分析舆论的情绪，但你自己的分析必须冷静、客观、冷血。\n4. 辩证思维：识别热点背后的\"主要矛盾\"（如技术变革vs既得利益），抓住事物发展的关键内因。\n\n## 数据字段深度解读指南\n\n### 1. 基础维度\n- 来源平台：每一行新闻开头的 [平台名称]（如 [微博]、[知乎]）明确指出了数据来源。请务必注意：后续的排名和轨迹数据仅针对该特定平台的榜单。\n- 排名：\"1\"为该平台榜首，数字越小越热。\"3-8\"表示在该平台排名在第3到第8之间波动。\n- 出现次数：次数越多，说明在热榜停留时间越长，热度越持久。\n- 时间范围：如\"09:30~12:45\"，跨度越大说明话题生命力越强。\n\n### 2. 轨迹量化分析（重要）\n数据格式为 排名(时间)→排名(时间)...，例如 1(09:30)→0(10:00)→2(10:30)。\n\n关键定义：\n- 数值含义：数字代表排名（1为榜首，数字越小越靠前）。0 特指\"未上榜\"或\"脱榜\"（即该时间点不在榜单中）。\n- 符号含义：→ 代表时间推移。\n\n防幻觉警示（关键）：\n- 高位横盘 ≠ 急升：如果轨迹是 2(10:00)→2(10:30)→2(11:00)，说明热度持续稳定，绝对不是\"急升\"或\"爆发\"。只有排名数值显著减小（如 10→5）才是急升。请务必区分\"热度高\"和\"热度升\"。\n\n请重点分析以下模式：\n- 急升/爆发：排名数值在短时间内大幅减小（如 20→3），代表热度飙升，往往意味着突发重大事件。\n- 衰退/僵尸：排名数值持续变大且无反弹（如 10→15→20），代表热度正在自然衰退。\n- 回榜/反转：序列中出现 0 后又变为高排名（如 5→0→2），代表话题曾脱榜但因新进展\"复活\"，通常暗示有新爆料或剧情反转。\n\n### 3. 跨平台特征（分级标准）\n- 全网霸屏：5个及以上平台同时上榜。真正的\"国民级\"话题，无死角覆盖。\n- 破圈扩散：3-4个平台同时上榜。话题已突破单一社区壁垒，正在向外蔓延。\n- 圈层热点：仅在1-2个平台火爆。属于特定人群的狂欢。\n\n平台调性参考 (Platform DNA)：\n- 舆论/情绪场：微博（情绪/吃瓜）、抖音/快手（视觉/传播快）、B站（年轻/玩梗）\n- 理性/专业场：知乎（深度/批判）、雪球（投资/财经）、IT之家/36氪（科技/商业）\n- 资讯/分发场：今日头条（社会/民生）、百度热搜（综合/搜索量）\n\n分析\"平台温差\"时，请结合平台调性。例如：某话题在微博火但在知乎冷，可能说明该话题\"情绪价值大于逻辑价值\"或\"缺乏深度讨论点\"。\n\n## 输出格式规范（严格遵守）\n\n你将以 JSON 格式输出分析结果。每个字段的值是纯文本字符串。\n\n换行规则：\n- 用 \\n 表示换行（JSON 字符串内标准换行符）\n- 段落之间用 \\n\\n 分隔\n\n结构标签规则（【】仅用于分段）：\n- 【】仅用于板块内的结构性分段标签，如【宏观主线】、【跨平台共振】\n- 标签后只跟冒号或直接换行（×【宏观主线】两大叙事交织：→ ○【宏观主线】：）\n- 标签前用 \\n 与前段分隔\n- 【】内只允许固定的分段名称，禁止放入话题名、新闻标题等动态内容\n- 同一标签下仅有1条内容时不加序号，2条及以上才使用序号\n\n话题引用规则（「」用于行内引用）：\n- 提及具体话题、新闻标题、事件名称时，使用「」角引号（×【黄仁勋暴论】→ ○「黄仁勋暴论」）\n- 「」是行内标记，不触发换行，不加冒号\n\n序号规则：\n- 列举时用 1. 2. 3. 数字序号\n- 每个序号独占一行（前面用 \\n 换行）\n- 序号行内禁止使用【】标签\n\n绝对禁止：\n- 禁止使用 Markdown（如 **加粗**、## 标题、- 列表）\n- 禁止使用 emoji 或特殊装饰符号\n\n## 分析板块说明（6个板块）\n\n### 1. core_trends — 核心热点态势（200字以内）\n整合\"趋势概述\"、\"热度走势\"、\"跨平台关联\"。\n任务：提炼共性与定性。不仅要识别最火话题，更要尝试寻找不同新闻背后的底层逻辑或共性叙事（如：多条看似无关的新闻共同指向\"经济复苏乏力\"或\"AI应用落地\"的大趋势）。\n重点：判断热度性质（全网霸屏vs圈层自嗨）以及话题间的潜在关联。\n写法：拒绝流水账。用\"宏观主线+微观佐证\"的结构，将散点信息串联成逻辑链条。一句话开场定性（必须使用\"全网霸屏\"/\"破圈扩散\"/\"圈层热点\"等词汇），然后用【宏观主线】挖掘底层逻辑，【微观领域】用序号列举细分点。\n\n### 2. sentiment_controversy — 舆论风向争议（100字以内）\n任务：绘制情绪光谱。拒绝简单的\"褒/贬\"二元对立。要识别\"舆论断层\"（如：专家担忧风险而大众狂欢，或媒体冷处理而民间热议）。\n核心：寻找观点冲突点。哪里有争吵，哪里就有价值。识别是\"利益之争\"（钱包问题）还是\"认知之争\"（观念问题）。\n写法：【情绪光谱】识别\"主流声音\"与\"潜流暗涌\"的反差，【核心矛盾】用序号列举冲突点。\n\n### 3. signals — 异动与弱信号（150字以内）\n任务：捕捉时间轴（轨迹）和空间轴（跨平台）上的异常波动。拒绝平铺直叙的单点罗列。\n关注维度：\n- 跨平台共振：某话题在A平台爆发后，是否迅速引发B平台关注？（对应\"破圈扩散\"）\n- 平台温差：某话题在微博霸榜但在知乎无人问津（对应\"圈层热点\"）\n- 轨迹突变：排名骤升（急升）、死而不僵（僵尸）、反转复活（回榜）\n写法：必须结合跨平台特征分析，拒绝只列举单个平台的涨跌。用【标签】分段（不用序号），从【跨平台共振/温差】【轨迹突变】【弱信号捕捉】等维度至少覆盖2点。\n\n### 4. rss_insights — RSS深度洞察（100字以内）\n任务：寻找信息增量。RSS 源通常比大众热榜更垂直、更专业。\n策略：\n- 去重：果断忽略与热榜大众新闻高度雷同的内容\n- 互补：挖掘热榜未覆盖的硬核细节（如技术参数、深度行研）或长尾话题\n- 前瞻：识别可能尚未引爆但极具价值的早期行业信号\n写法：【认知纠偏】专业视角如何修正大众热搜的误区或盲目，【硬核增量】补充热榜缺失的关键技术参数、行业内幕或深度数据。无RSS数据时填\"暂无RSS数据\"。\n\n### 5. outlook_strategy — 研判策略建议\n任务：预测与推演。不仅总结过去，更要预测未来。\n核心：\n- 后续推演：预测事件的下一阶段（如：是否会反转？监管是否介入？热度是否可持续？）\n- 行动指南：给出具体、有针对性的建议。严禁使用\"建议持续关注\"等无意义的正确的废话。\n写法：格式为 1. 投资者：xxx 2. 品牌方：xxx 3. 公众：xxx，序号后直接跟角色名加冒号，不使用【】标签。\n\n### 6. standalone_summaries — 独立展示区概括（每源100字以内）\n仅当数据中包含独立展示区数据时返回。对象类型，key 为数据中每个源的 ### 标题方括号内的名称，value 为 100 字以内的概括。有几个源就写几个 key。\n核心原则：去重补盲 + 轨迹洞察。\n1. 去重：果断忽略前5板块已充分分析的话题，优先提取前5板块未覆盖的独有内容。若某话题虽在前5板块提及但在该平台有独特表现（如排名走势截然不同），可简要补充差异点。\n2. 轨迹洞察：若数据中包含轨迹信息，按上述\"### 2. 轨迹量化分析\"的规则解读排名走势，识别该平台的急升/衰退/回榜等趋势特征。若数据中无轨迹信息，则基于排名和出现次数做简要判断即可。\n写法：先用一句话点明该平台当前的整体趋势动向（基于轨迹数据判断），再列举前5板块未提及的重要话题（附带排名走势）。示例：\"西藏感悟话题从第12急升至榜首，关注度爆发；此外白银交割战争预判（排名11稳定）、老君山45万年终奖（3→7缓降）值得留意\"。禁止空泛总结。\n\n[user]\n请分析以下热点新闻数据：\n\n## 数据概览\n- 报告模式：{report_mode} ({report_type})\n- 分析时间：{current_time}\n- 数据量：{news_count}条热榜 + {rss_count}条RSS\n- 来源：{platforms}\n\n## 匹配关键词\n{keywords}\n\n## 热榜新闻\n{news_content}\n\n## RSS 订阅\n{rss_content}\n\n## 独立展示区\n以下为独立展示的完整热榜/RSS 数据（不受关键词过滤），请按板块说明中 standalone_summaries 的要求处理。\n{standalone_content}\n\n---\n\n请基于上述数据撰写分析报告。以 JSON 格式返回，所有字段均为可选（缺少任何字段不会报错）：\n\n```json\n{\n  \"core_trends\": \"（按上述板块说明写法输出）\",\n  \"sentiment_controversy\": \"（按上述板块说明写法输出）\",\n  \"signals\": \"（按上述板块说明写法输出）\",\n  \"rss_insights\": \"（按上述板块说明写法输出）\",\n  \"outlook_strategy\": \"（按上述板块说明写法输出）\",\n  \"standalone_summaries\": {\"知乎\": \"100字概括，优先列前5板块未提及的话题及排名走势\", \"Hacker News\": \"100字概括...\"}\n}\n```\n\n要求：\n- 使用 {language} 输出，语言简练专业\n- 6个板块内容不重叠不冗余\n- 若某板块无明显内容，可简写\"暂无显著异常\"\n"
  },
  {
    "path": "config/ai_filter/extract_prompt.txt",
    "content": "[system]\n你是一个兴趣标签提取专家。你的任务是从用户的兴趣描述中提取出结构化的新闻分类标签。\n\n提取规则：\n1. 每个标签简洁（2-6个字），同时配一句描述说明该标签涵盖哪些话题和关键词\n2. 标签之间尽量不重叠\n3. 标签数量控制在 5~20 个，优先保留细分标签，只有语义高度重叠时才合并\n4. 描述要具体，包含具体的人名、公司名、产品名等关键词，方便后续分类\n5. 返回顺序必须尽量遵循用户兴趣描述中的先后顺序，越靠前代表优先级越高\n\n[user]\n用户的兴趣描述如下：\n\n{interests_content}\n\n请从中提取出新闻分类标签。\n\n返回严格的 JSON 格式（不要添加任何其他内容）：\n```json\n{\n  \"tags\": [\n    {\"tag\": \"标签名\", \"description\": \"该标签涵盖的话题、关键词描述\"}\n  ]\n}\n```\n"
  },
  {
    "path": "config/ai_filter/prompt.txt",
    "content": "[system]\n你是一个高效的新闻分类专家。根据给定的标签列表，快速判断每条新闻标题最适合哪个标签。\n\n分类规则：\n1. 每条新闻只归入一个最相关的标签（选相关度最高的那个）\n2. 不匹配任何标签的新闻不要输出（不要返回空 tags）\n3. 给出 0.0-1.0 的相关度分数（1.0=完全相关，0.5=部分相关）\n4. 只根据标题判断，不要过度推测\n5. 严格遵循用户偏好中的额外过滤要求（如有）\n6. 如果两类标签相关度接近，优先选择排序更靠前的标签（前面的标签优先级更高）\n\n[user]\n## 用户偏好\n\n{interests_content}\n\n## 分类标签\n\n{tags_list}\n\n## 新闻列表（共 {news_count} 条）\n\n{news_list}\n\n请对每条新闻进行分类。返回严格的 JSON 数组（不要添加任何其他内容）：\n```json\n[\n  {\"id\": 1, \"tag_id\": 1, \"score\": 0.9},\n  {\"id\": 5, \"tag_id\": 2, \"score\": 0.8}\n]\n```\n只返回有匹配的新闻，无匹配的不要包含在结果中。\n"
  },
  {
    "path": "config/ai_filter/update_tags_prompt.txt",
    "content": "[system]\n你是一个标签管理专家。用户修改了兴趣描述后，你需要对比旧标签集和新的兴趣描述，给出标签更新方案。\n\n核心原则：\n1. 语义等价的标签视为同一个标签（如\"AI/大模型\"和\"AI与大模型\"是同一个标签），优先保留旧标签名\n2. 只有用户明确不再关注的方向才标记移除\n3. 新增的兴趣方向才需要新增标签\n4. 标签名简洁（2-10个字），描述要具体，包含关键词、人名、公司名、产品名\n5. 标签总数控制在 20 个以内，优先保留细分标签，只有语义高度重叠时再合并\n6. keep 和 add 的输出顺序应尽量遵循用户兴趣描述中的先后顺序（越靠前优先级越高）\n\nchange_ratio 评估标准：\n- 0.0 = 兴趣几乎没变（只是措辞调整、补充细节）\n- 0.1~0.3 = 小幅调整（新增或移除了 1-2 个方向）\n- 0.4~0.6 = 中等变化（多个方向有调整）\n- 0.7~1.0 = 大幅改变（兴趣方向基本重写）\n\n[user]\n## 当前标签集\n\n{old_tags_json}\n\n## 新的兴趣描述\n\n{interests_content}\n\n## 任务\n\n对比当前标签集和新的兴趣描述，判断每个旧标签是保留还是移除，以及是否需要新增标签。\n\n返回严格的 JSON 格式（不要添加任何其他内容）：\n```json\n{\n  \"keep\": [\n    {\"tag\": \"旧标签名\", \"description\": \"根据新兴趣更新后的描述\"}\n  ],\n  \"add\": [\n    {\"tag\": \"新标签名\", \"description\": \"该标签涵盖的话题、关键词描述\"}\n  ],\n  \"remove\": [\"要废弃的旧标签名\"],\n  \"change_ratio\": 0.2\n}\n```\n"
  },
  {
    "path": "config/ai_interests.txt",
    "content": "# ═══════════════════════════════════════════════════════════════\n#                    TrendRadar AI 兴趣描述文件\n#                         Version: 1.1.0\n# ═══════════════════════════════════════════════════════════════\n# 用自然语言描述你关注的话题，AI 会自动提取标签并对新闻进行分类\n# 修改此文件后，下次运行时自动生效（旧分类会被标记废弃，重新分类）\n\n\n下面是我要关注的内容：\n# 重要性排序说明：从上到下优先级递减，越靠前越重要。\n# 如果一条新闻同时可能匹配多个方向，请优先归入更靠前的方向。\n\n1. 中国科技与互联网公司：重点关注 DeepSeek、华为、腾讯、字节跳动、京东及相关核心人物和业务线（含鸿蒙、海思、昇腾、抖音、微信等）的战略、组织调整、产品节奏、资本动作与监管影响。\n2. 大模型与 AI 产品：关注 OpenAI、Claude、ChatGPT、Sora、DALL-E、Qwen、MiniMax、GLM 等模型和产品的能力演进、开源闭源策略与生态竞争。\n3. AI 基础设施与云算力：关注英伟达、AMD、华为算力体系、CUDA、Azure、Google Cloud 相关的算力供给、推理成本、训练效率与供应链变化。\n4. 芯片与半导体制造：关注芯片、半导体、光刻机、先进封装、国产替代、关键材料设备与供应安全。\n5. 智能汽车与自动驾驶：关注比亚迪、特斯拉、FSD、无人驾驶、智驾、刀片电池、云辇等技术路线，以及销量、定价与出海变化。\n6. 机器人与具身智能：关注宇树、智元、众擎、大疆在机器人、机械狗、四足、人形、具身智能方向的产品发布、量产和场景落地。\n7. 全球科技巨头：关注苹果、微软、谷歌、Anthropic、OpenAI 的财报、发布会、产品路线、合作与竞争格局。\n8. 地缘政治与国际关系（独立于金融）：重点关注中美欧日印及俄罗斯相关的关税、制裁、外交、冲突、产业脱钩和关键供应链博弈。\n9. 金融市场与宏观政策：关注美联储利率路径、汇率波动、通胀、就业、股债商品表现及全球流动性变化。\n10. 能源与电力系统：关注光伏、太阳能、水电（含雅鲁藏布江项目）、核能和新型电力系统建设。\n11. 航天与深空探索：关注 SpaceX、登月、火星、飞船、卫星、空间站、商业航天的技术节点与产业化进展。\n12. 前沿科学技术：关注量子、脑机接口、基因工程等前沿方向的重要科研突破与产业应用。\n13. 文化 IP 与内容产业：关注黑神话悟空、影之刃零、三体、流浪地球、申奥相关内容，以及游戏工业化和文化出海。\n14. 零售与消费品牌：关注胖东来等零售标杆在组织效率、供应链管理、门店运营和消费趋势方面的信号。\n15. 国家与区域观察：关注中国、美国、加拿大、日本、韩国、朝鲜、英国、法国、印度、俄罗斯相关的政策、科技、产业与社会议题（作为背景参考，不高于上述核心方向）。\n\n\n# 标题质量要求（即使匹配了上面的标签，符合以下特征的标题也请跳过）\n# 可自由增删改，按你的偏好来\n- 不要标题党/震惊体（如\"震惊！\"、\"太可怕了！\"、\"竟然...\"、\"刚刚！\"）\n- 不要营销软文、广告推广类标题\n"
  },
  {
    "path": "config/ai_translation_prompt.txt",
    "content": "# ═══════════════════════════════════════════════════════════════\n#                    TrendRadar AI 翻译提示词配置\n#                      Version: 1.2.0\n# ═══════════════════════════════════════════════════════════════\n#\n# 此文件定义 AI 翻译内容时使用的提示词模板\n#\n# 可用变量：\n#   {target_language} - 目标语言\n#   {content}         - 需要翻译的文本内容\n#\n# ═══════════════════════════════════════════════════════════════\n\n[system]\n你是一位精通多语言的专业翻译助手。你的任务是将新闻内容翻译成目标语言，保持新闻的专业性、准确性和简洁性。\n\n要求：\n1. 准确传达原文含义，不要遗漏关键信息。\n2. 保持新闻标题的吸引力，但不要做标题党。\n3. 专有名词（人名、地名、机构名）若有通用译名请使用通用译名，否则保留原文或在括号内备注。\n4. 输出格式必须严格遵循要求，不要输出任何多余的解释性文字。\n5. ⚠️重点：输入可能包含混合语言列表。请务必逐行检查每一条内容。如果某条内容不是 {target_language}，**必须**将其翻译为 {target_language}。严禁保留非 {target_language} 的原文（除非是纯专有名词）。即使列表中 99% 已经是目标语言，也绝对不能忽略剩下的 1%。\n6. 格式严格限制：输出结果中**只允许包含目标语言**的文本。绝对禁止“原文 + 译文”的形式。如果进行了翻译，直接用译文替换原文，不要在后面括号备注原文，也不要保留原文。\n\n[user]\n请将以下内容翻译成 {target_language}：\n\n{content}\n\n请直接输出翻译结果。\n"
  },
  {
    "path": "config/config.yaml",
    "content": "# ═══════════════════════════════════════════════════════════════\n#                    TrendRadar 配置文件\n#                      Version: 2.2.0\n# ═══════════════════════════════════════════════════════════════\n\n\n# 可视化配置编辑器地址: https://sansan0.github.io/TrendRadar/\n\n\n# ===============================================================\n# 1. 基础设置\n# ===============================================================\napp:\n  # 时区配置（影响所有时间显示、调度系统判断、数据存储）\n  # 常用时区：\n  #   - Asia/Shanghai (北京时间 UTC+8)\n  #   - America/New_York (美东时间 UTC-5/-4)\n  #   - Europe/London (伦敦时间 UTC+0/+1)\n  # 完整时区列表: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones\n  timezone: \"Asia/Shanghai\"\n  show_version_update: true           # 显示版本更新提示\n\n\n# ===============================================================\n# 1.5 调度系统 —— 什么时间做什么事\n#\n# 通过 timeline.yaml 里定义的时间段来自动决定：\n#   - 什么时候推送通知\n#   - 什么时候做 AI 分析\n#   - 用什么报告模式\n#\n# 快速上手：选一个预设模板，改 preset 的值就行\n#\n#   always_on       → 全天候，有新增即推送\n#   morning_evening → 全天推送 + 晚间当日汇总（推荐）\n#   office_hours    → 工作日三段式（到岗→午间→收工），周末增量自由推\n#   night_owl       → 午后速览 + 深夜全天汇总\n#   custom          → 完全自定义，详见 timeline.yaml\n#\n# 详细时间线图请查看 config/timeline.yaml\n# ===============================================================\nschedule:\n  enabled: true                         # 是否启用调度系统\n  preset: \"morning_evening\"             # 预设模板名称（见上方说明）\n\n\n# ===============================================================\n# 2. 数据源 - 热榜平台\n#\n# enabled: 是否启用热榜抓取（总开关）\n# sources: 平台列表\n#   - id: 平台唯一标识（勿修改）\n#   - name: 显示名称（可自定义，修改后不影响运行）\n# ===============================================================\nplatforms:\n  enabled: true                         # 是否启用热榜平台抓取\n  sources:\n    - id: \"toutiao\"\n      name: \"今日头条\"\n    - id: \"baidu\"\n      name: \"百度热搜\"\n    - id: \"wallstreetcn-hot\"\n      name: \"华尔街见闻\"\n    - id: \"thepaper\"\n      name: \"澎湃新闻\"\n    - id: \"bilibili-hot-search\"\n      name: \"bilibili 热搜\"\n    - id: \"cls-hot\"\n      name: \"财联社热门\"\n    - id: \"ifeng\"\n      name: \"凤凰网\"\n    - id: \"tieba\"\n      name: \"贴吧\"\n    - id: \"weibo\"\n      name: \"微博\"\n    - id: \"douyin\"\n      name: \"抖音\"\n    - id: \"zhihu\"\n      name: \"知乎\"\n\n\n\n# ===============================================================\n# 3. 数据源 - RSS 订阅\n#\n# 与热榜数据分开存储，按时间流展示\n# 每个源配置：id(唯一标识)、name(显示名称)、url(订阅地址)\n# enabled: 可选，默认 true\n# max_age_days: 可选，覆盖全局 freshness_filter.max_age_days\n# ===============================================================\nrss:\n  enabled: true                       # 是否启用 RSS 抓取\n\n  # 文章新鲜度过滤配置（全局默认值）\n  # 过滤掉发布时间超过指定天数的旧文章，避免同一篇文章重复出现在推送中\n  #\n  # 过滤逻辑：\n  #   - 文章发布时间距当前时间（app.timezone 时区）超过 N 天则不推送\n  #   - 无发布时间的文章会被保留（不过滤）\n  #\n  # ⚠️ 过滤时机：在推送阶段过滤\n  #    - 所有文章都会存入数据库（MCP Server 的 AI 查询仍可访问）\n  #    - 只有新鲜的文章会被推送到通知渠道\n  freshness_filter:\n    enabled: true                     # 是否启用新鲜度过滤（默认启用）\n\n    max_age_days: 1                   # 最大文章年龄（天）\n                                      # - 正整数：只推送 N 天内的文章\n                                      # - 0：禁用过滤，推送所有文章\n\n  # 单个 feed 可配置 max_age_days 覆盖全局设置：\n  # - 不配置：使用全局 freshness_filter.max_age_days（默认 3 天）\n  # - 正整数：覆盖全局设置，只推送此天数内的文章\n  # - 0：禁用此频道的新鲜度过滤，推送所有文章\n  feeds:\n    - id: \"hacker-news\"\n      name: \"Hacker News\"\n      url: \"https://hnrss.org/frontpage\"\n\n    - id: \"ruanyifeng\"\n      name: \"阮一峰的网络日志\"\n      url: \"http://www.ruanyifeng.com/blog/atom.xml\"\n      enabled: false                  # 禁用\n      # max_age_days: 3               # 示例：推送 3 天内的文章（更新较慢的博客）\n     \n    - id: \"yahoo-finance\"\n      name: \"雅虎财经\"\n      url: \"https://finance.yahoo.com/news/rssindex\"\n\n    # 自定义源示例\n    # - id: \"custom-feed\"\n    #   name: \"自定义源\"\n    #   url: \"https://example.com/feed.xml\"\n    #   enabled: false\n    #   max_age_days: 0               # 示例：禁用过滤，推送所有文章\n\n\n# ===============================================================\n# 4. 报告模式\n#\n# 新手 5 行：\n# 1) 先选 mode：daily(当日汇总) / current(当前榜单) / incremental(仅新增)\n# 2) 再选 display_mode：keyword(按词/标签) / platform(按平台)\n# 3) 如果你开了 schedule，这里的 mode 只是默认值，会被 timeline 时段覆盖\n# 4) sort_by_position_first 只影响 keyword 模式排序\n# 5) rank_threshold 和 max_news_per_keyword 只影响展示，不影响抓取\n#\n# 进阶说明：\n# - daily：信息最全，但重复最多\n# - current：适合盯当前热度\n# - incremental：最少打扰，只看新增\n# ===============================================================\nreport:\n  mode: \"current\"                     # daily | current | incremental（schedule 开启时作为默认值）\n\n  display_mode: \"keyword\"             # 分组维度: keyword | platform\n                                      # keyword: 按关键词分组显示（默认）\n                                      # platform: 按平台/来源分组显示\n\n  # 关键词模式分组排序方式（仅 keyword 模式生效）\n  # true: 按 frequency_words.txt 的定义顺序排列\n  # false: 按匹配到的热点条数排序（条数多的在前）\n  sort_by_position_first: false\n\n  rank_threshold: 5                   # 排名高亮阈值（影响展示强调，不改变抓取范围）\n\n  max_news_per_keyword: 0             # 每个关键词/标签最大显示数量（0=不限制，仅影响展示裁剪）\n\n\n# ===============================================================\n# 4.5 筛选策略\n#\n# 新手 5 行：\n# 1) 先选 method：keyword（关键词）或 ai（兴趣分类）\n# 2) keyword 模式：看 config/frequency_words.txt\n# 3) ai 模式：看 config/ai_interests.txt + 下方 ai_filter 配置\n# 4) priority_sort_enabled 只影响 ai 模式标签排序\n# 5) 这里决定“筛选路径”，不决定 AI 模型（模型在 ai 段）\n# ===============================================================\nfilter:\n  method: \"ai\"                     # 可选: keyword | ai\n\n  # AI 模式标签排序开关（仅 ai 模式生效）\n  # true: 按标签优先级排序（来自兴趣描述提取顺序）\n  # false: 按匹配条数排序（条数多的在前）\n  priority_sort_enabled: true\n\n\n# ===============================================================\n# 4.6 AI 智能筛选配置（当 filter.method=ai 时生效）\n#\n# 新手 5 行：\n# 1) 先调 min_score（推荐 0.5~0.7）\n# 2) 再调 reclassify_threshold（大改兴趣建议更低）\n# 3) 批量参数只影响速度/限流，不影响分类逻辑\n# 4) interests_file 不填就用 config/ai_interests.txt\n# 5) prompt_file 系列属于进阶项，默认一般不用改\n#\n# 进阶说明：\n# - min_score 越高，结果越“准”但会漏召回\n# - reclassify_threshold 越低，越倾向全量重分类（更耗 token）\n# - 模型配置统一在下方 ai 段\n# ===============================================================\nai_filter:\n  batch_size: 200                         # 每批发送给 AI 的标题数（控制单次 API 调用量）\n                                          # 新闻超过此数量时自动分批处理\n  batch_interval: 2                       # 分批处理时，每批之间的等待时间（秒）\n                                          # 避免频繁调用 API 触发限流，设为 0 则不等待\n\n  min_score: 0.7                          # 推送最低分数阈值（0.0 ~ 1.0）\n                                          # 0 = 不过滤；值越高越严格（推荐先用 0.5~0.7）\n\n  # 兴趣描述文件\n  # 默认使用 config/ai_interests.txt，无需在此配置\n  # 这里设置的是“全局默认”，可被 timeline.yaml 时段内的 interests_file 覆盖\n  # 如需使用自定义文件，将文件放入 config/custom/ai/ 目录，然后指定文件名：\n  # interests_file: \"finance.txt\"    # → 加载 config/custom/ai/finance.txt\n\n  # 全量重分类触发阈值（0~1）\n  # change_ratio >= 此值：全量重分类；否则增量更新\n  # 0.0 最准确最费；1.0 最省但可能陈旧；0.6 是平衡点\n  reclassify_threshold: 0.6\n\n  # 以下提示词模板一般无需修改（不建议动）\n\n  # 分类提示词模板\n  prompt_file: \"prompt.txt\"\n\n  # 标签提取提示词模板（首次运行时使用）\n  extract_prompt_file: \"extract_prompt.txt\"\n\n  # 标签更新提示词模板（兴趣变更时 AI 对比新旧标签）\n  update_tags_prompt_file: \"update_tags_prompt.txt\"\n\n\n# ===============================================================\n# 5. 推送内容控制\n#\n# 统一管理推送消息中显示哪些区域及其排列顺序\n# ===============================================================\ndisplay:\n  # 📋 区域显示顺序\n  # 列表从上到下的顺序 = 推送消息中从上到下的显示顺序\n  # 想调整顺序？直接剪切粘贴整行即可，例如把 ai_analysis 移到最前面：\n  #   region_order:\n  #     - ai_analysis    ← 移到第一行，AI 分析就会显示在最顶部\n  #     - new_items\n  #     - hotlist\n  #     - ...\n  # 注意：区域需同时满足两个条件才会显示：\n  #   1. 在此列表中\n  #   2. 下方 regions 中对应开关为 true\n  region_order:\n    - new_items                           # 1️⃣ 新增热点区域\n    - hotlist                             # 2️⃣ 热榜区域（关键词匹配 / AI 智能筛选）\n    - rss                                 # 3️⃣ RSS 订阅区域\n    - standalone                          # 4️⃣ 独立展示区\n    - ai_analysis                         # 5️⃣ AI 分析区域\n\n  # 推送区域开关\n  # 控制各区域是否启用（配合 region_order 使用）\n  regions:\n    hotlist: true                     # 热榜区域（关键词匹配 / AI 智能筛选）\n    new_items: false                   # 新增热点区域（含热榜新增 + RSS 新增）\n                                      # 注：热点词汇统计中的新增标记🆕不受此配置影响\n\n    rss: true                         # RSS 订阅区域\n                                      # 开启后将对 RSS 进行关键词分析并在通知中展示\n                                      # 关闭后跳过分析，但独立展示区不受影响\n\n    standalone: false                 # 独立展示区（完整热榜/RSS，不受关键词过滤）\n    ai_analysis: true                 # AI 分析区域\n\n  # 📋 独立展示区配置\n  # 用途：将指定平台的完整热榜/RSS 数据独立提取，不受关键词过滤影响\n  # 两个独立用途：\n  #   - 推送展示：由 regions.standalone 开关控制，在推送中单独展示完整热榜\n  #   - AI 分析：由 ai_analysis.include_standalone 开关控制，将完整数据送入 AI 做深度分析\n  # 两者共享此处的平台/RSS 配置，但开关互相独立（可只开 AI 分析、不推送展示）\n  standalone:\n    platforms: [\"zhihu\", \"wallstreetcn-hot\"]     # 热榜平台 ID 列表（如 [\"zhihu\", \"weibo\"]）\n    rss_feeds: []                     # RSS 源 ID 列表（如 [\"hacker-news\"]）\n    max_items: 20                     # 每个源最多展示条数（0=不限制）\n\n\n# ===============================================================\n# 6. 推送通知\n#\n# ⚠️ 重要安全警告 ⚠️\n#\n# 🔴 请务必妥善保管好 webhooks，不要公开!!!\n# 🔴 如果你以 fork 的方式部署在 GitHub 上，请勿在此填写\n# 🔴 而是将 webhooks 填入 GitHub Secrets\n#    (Settings → Secrets and variables → Actions)\n# 🔴 否则：\n#    - 轻则：手机上收到大量垃圾广告推送\n#    - 重则：webhook 被滥用造成严重安全隐患\n#\n# 📌 多账号支持说明\n#\n# • 使用分号(;)分隔多个账号，如：\"url1;url2;url3\"\n# • 需要配对的配置（如 Telegram 的 token 和 chat_id）数量必须一致\n# • 每个渠道最多支持 max_accounts_per_channel 个账号\n# • 邮箱已支持多收件人（逗号分隔）\n#\n# 新手建议：\n# • 第一次先只配置 1 个渠道（建议 ntfy 或 telegram）验证通路\n# • 跑通后再增加多渠道和多账号，排障成本最低\n# ===============================================================\nnotification:\n  enabled: true                       # 是否启用通知功能（总开关）\n                                      # ⚠️ 开启调度系统后，此项仍为总开关：\n                                      #   false → 永远不推送（无论调度怎么设置）\n                                      #   true  → 由调度的 push 字段控制何时推送\n\n  # 推送渠道配置\n  channels:\n    feishu:\n      webhook_url: \"\"                 # 飞书机器人 webhook URL\n\n    dingtalk:\n      webhook_url: \"\"                 # 钉钉机器人 webhook URL\n\n    wework:\n      webhook_url: \"\"                 # 企业微信机器人 webhook URL\n      msg_type: \"markdown\"            # 消息类型：markdown(群机器人) | text(个人微信应用)\n\n    telegram:\n      bot_token: \"\"                   # Telegram Bot Token\n      chat_id: \"\"                     # Telegram Chat ID\n\n    email:\n      from: \"\"                        # 发件人邮箱地址\n      password: \"\"                    # 发件人邮箱密码或授权码\n      to: \"\"                          # 收件人邮箱，多个用逗号分隔\n      smtp_server: \"\"                 # SMTP 服务器（可选，留空自动识别）\n      smtp_port: \"\"                   # SMTP 端口（可选，留空自动识别）\n\n    ntfy:\n      server_url: \"https://ntfy.sh\"   # ntfy 服务器地址（可改为自托管）\n      topic: \"\"                       # ntfy 主题名称\n      token: \"\"                       # ntfy 访问令牌（可选，用于私有主题）\n\n    bark:\n      url: \"\"                         # Bark 推送 URL（格式：https://api.day.app/your_device_key）\n\n    slack:\n      webhook_url: \"\"                 # Slack Incoming Webhook URL\n\n    generic_webhook:\n      webhook_url: \"\"                 # 通用 Webhook URL（支持 Discord、Matrix、IFTTT 等）\n      payload_template: \"\"            # JSON 模板，支持 {title} 和 {content} 占位符\n                                      # 示例：{\"content\": \"{content}\"}\n                                      # 留空则使用默认格式：{\"title\": \"{title}\", \"content\": \"{content}\"}\n\n\n# ===============================================================\n# 7. 存储配置\n# ===============================================================\nstorage:\n  # 存储后端选择\n  # - auto: 自动选择（GitHub Actions 且配置了远程存储 → remote，否则 → local）\n  # - local: 本地 SQLite + TXT/HTML 文件\n  # - remote: 远程云存储（S3 兼容协议，支持 R2/OSS/COS 等）\n  backend: \"auto\"\n\n  # 数据格式选项\n  formats:\n    sqlite: true                      # 主存储（必须启用）\n    txt: false                        # 是否生成 TXT 快照\n    html: true                       # 是否生成 HTML 报告（⚠️ 邮件推送或者需要看网页版报告必须设为 true）\n\n  # 本地存储配置\n  local:\n    data_dir: \"output\"                # 数据目录\n    retention_days: 0                 # 保留天数（0=永久保留）\n\n  # 远程存储配置（S3 兼容协议）\n  # 支持: Cloudflare R2, 阿里云 OSS, 腾讯云 COS, AWS S3, MinIO 等\n  # 建议将敏感信息配置在 GitHub Secrets 或环境变量中\n  remote:\n    retention_days: 0                 # 保留天数（0=永久保留）\n\n    # S3 兼容配置（或使用环境变量 S3_ENDPOINT_URL 等）\n    endpoint_url: \"\"                  # 服务端点\n                                      # Cloudflare R2: https://<account_id>.r2.cloudflarestorage.com\n                                      # 阿里云 OSS: https://oss-cn-hangzhou.aliyuncs.com\n                                      # 腾讯云 COS: https://cos.ap-guangzhou.myqcloud.com\n    bucket_name: \"\"                   # 存储桶名称\n    access_key_id: \"\"                 # 访问密钥 ID\n    secret_access_key: \"\"             # 访问密钥\n    region: \"\"                        # 区域（可选，部分服务商需要）\n\n  # 数据拉取配置（从远程同步到本地）\n  # 用于 MCP Server 等场景：爬虫存到远程，MCP 拉取到本地分析\n  pull:\n    enabled: false                    # 是否启用启动时自动拉取\n    days: 7                           # 拉取最近 N 天的数据\n\n\n# ===============================================================\n# 8. AI 模型配置（共享）\n#\n# ai_analysis / ai_translation / ai_filter 共用此模型配置\n# 基于 LiteLLM 统一接口，支持 100+ AI 提供商\n# ===============================================================\nai:\n  # LiteLLM 模型格式: provider/model_name\n  # 示例:\n  #   - deepseek/deepseek-chat (DeepSeek)\n  #   - openai/gpt-4o (OpenAI)\n  #   - gemini/gemini-2.5-flash (Google Gemini)\n  #   - anthropic/claude-3-5-sonnet (Anthropic)\n  #   - ollama/llama3 (本地 Ollama)\n  # 完整列表: https://docs.litellm.ai/docs/providers\n  # 如果你对于看英文文档比较头疼，那么可以点击页面右下角的 【Ask AI】 ,用中文询问怎么配置 \n  \n  model: \"deepseek/deepseek-chat\"\n\n  api_key: \"\"                       # API Key（建议使用环境变量 AI_API_KEY）\n  \n  api_base: \"\"                      # 自定义 API 端点（可选，大多数情况留空）\n                                    # 示例: https://api.openai.com/v1（自建代理或兼容接口）\n                                    #\n                                    # 💡 超级重要：连接任意兼容 OpenAI 协议的模型商\n                                    # 如果你使用的模型商不在上述支持列表中，但提供了兼容 OpenAI 的接口：\n                                    #\n                                    # 1. api_base 填写: 服务商提供的接口地址\n                                    #    例如: https://api.example.com/v1\n                                    #\n                                    # 2. model 填写: \"openai/\" + 实际模型名称\n                                    #    例如: openai/deepseek-ai/DeepSeek-V3\n                                    #    (原理：前缀 openai/ 强制 LiteLLM 使用 OpenAI 协议格式进行通信)\n\n\n  timeout: 120                      # 请求超时（秒）\n\n  temperature: 1.0                  # 采样温度 (0.0-2.0)\n                                    # 注意：部分模型(如 gpt-5)可能要求必须为 1.0，否则会报错\n  \n  max_tokens: 5000                  # 最大生成 token 数\n                                    # 注意：如果 API 不支持此参数(报 HTTP 400)，请设为 0 以禁用发送\n  # 高级选项\n  num_retries: 1                    # 失败重试次数\n  fallback_models: []               # 备用模型列表（可选）\n                                    # 示例: [\"openai/gpt-4o-mini\", \"openai/deepseek-ai/DeepSeek-V3\"]\n\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  # 额外参数 (高级选项，一般无需修改)\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  # LiteLLM 会自动将通用参数转换为各提供商格式，无需手动适配。\n  # 仅在需要传递特殊参数时启用此项。\n  #\n  # 提示：你可以根据模型 API 文档自行添加任何支持的字段。\n  # 操作：如需启用，请删掉该行最前方的 \"# \"（井号和空格）。\n  # 注意：如果这几行都带着井号，则代表不使用额外参数（推荐做法）。\n  # -------------------------------------------------------------\n  # extra_params:\n  #   top_p: 1.0              # 核采样（通用）\n  #   presence_penalty: 0.0   # 话题多样性（OpenAI/DeepSeek）\n  #   stop: [\"END\"]           # 停止词列表（通用）\n\n\n# ===============================================================\n# 9. AI 分析功能\n#\n# 使用 AI 大模型对推送内容进行深度分析\n# 模型配置见上方 ai 配置段\n# ===============================================================\nai_analysis:\n  enabled: true                     # 是否启用 AI 分析（总开关）\n                                    # ⚠️ 开启调度系统后，此项仍为总开关：\n                                    #   false → 永远不分析（无论调度怎么设置）\n                                    #   true  → 由调度的 analyze 字段控制何时分析\n\n  # 分析报告输出语言\n  # 格式：自然语言描述\n  # 示例: \"English\", \"Korean\", \"法语\"\n  language: \"Chinese\"\n\n  # 提示词配置文件路径（相对于 config 目录）\n  prompt_file: \"ai_analysis_prompt.txt\"\n\n  # AI 分析模式（独立于推送报告模式）\n  # 可选值:\n  #   - \"follow_report\": 跟随 report.mode 的设置（默认）\n  #   - \"daily\": 强制使用当日汇总模式（分析当天所有新闻）\n  #   - \"current\": 强制使用当前榜单模式（只分析当前在榜新闻）\n  #   - \"incremental\": 强制使用增量模式（只分析新增新闻）\n  #\n  # 使用场景：\n  #   - 推送 incremental（避免重复），AI 分析 current（看当前榜单变化）\n  #   - 推送 current（实时热点），AI 分析 daily（全天总结）\n  #\n  mode: \"follow_report\"\n\n  # 分析内容配置\n  max_news_for_analysis: 150        # 热榜+RSS 合计参与分析的新闻数量上限（控制成本关键项）\n                                    # 热榜优先占用配额，RSS 使用剩余配额；独立展示区不受此限制\n                                    # 推送消息顶部会显示实际的 AI 分析数供参考\n\n                                    # api 成本估算 (仅供参考)\n                                      # 按默认模型(deepseek)\n                                      # max_news_for_analysis 为 【50】 条\n                                      # include_rank_timeline 为 【false】\n                                    # 则\n                                      # GitHub Action 部署默认推送约 20 次（每小时推送一次）， 约 0.1 元/天\n                                      # Docker 部署默认推送 48 次(每半小时推送一次)， 约 0.2 元/天\n\n  include_rss: false                # 是否包含 RSS 内容进行分析\n  \n  include_standalone: true          # 是否将独立展示区数据纳入 AI 分析\n                                    # 数据源列表来自 display.standalone.platforms / display.standalone.rss_feeds\n\n  include_rank_timeline: true       # 是否传递完整排名时间线\n                                    # false: 使用简化格式（排名范围+时间范围+出现次数）\n                                    # true: 传递完整排名变化轨迹（如 1(09:30)→2(10:00)→0(11:00)）\n                                    # 启用后 AI 能更精确分析热度趋势，但会额外增加 token 消耗（0.5 倍到 1 倍）\n\n\n\n\n# ===============================================================\n# 10. AI 翻译功能\n#\n# 对推送内容进行多语言翻译，不包含 ai_analysis 分析的内容\n# 模型配置见上方 ai 配置段\n# ===============================================================\nai_translation:\n  enabled: true                    # 是否启用翻译功能\n\n  # 翻译目标语言\n  # 格式：自然语言描述\n  # 示例: \"Chinese\", \"Korean\", \"法语\"\n  language: \"中文\"\n\n  # 提示词配置文件路径（相对于 config 目录）\n  prompt_file: \"ai_translation_prompt.txt\"\n\n  # 翻译范围\n  # 控制哪些区域的标题会被翻译\n  # hotlist: 热榜标题 + 新增热点\n  # rss: RSS 统计 + RSS 新增\n  # standalone: 独立展示区（热榜平台 + RSS 源）\n  # 如果 display.regions 关闭了显示，那么这边即使开启了也不会翻译\n  scope:\n    hotlist: false                  # 热榜区域\n    rss: true                      # RSS 区域\n    standalone: true               # 独立展示区\n\n\n# ===============================================================\n# 11. 高级设置（一般无需修改）\n# ===============================================================\nadvanced:\n  # 调试模式\n  debug: false\n\n  # 版本检查\n  version_check_url: \"https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/version\"\n  mcp_version_check_url: \"https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/version_mcp\"\n  configs_version_check_url: \"https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/version_configs\"\n\n  # 热榜爬虫技术参数\n  crawler:\n    request_interval: 2000            # 请求间隔（毫秒）\n    use_proxy: false                  # 是否启用代理\n    default_proxy: \"http://127.0.0.1:10801\"\n\n  # RSS 设置\n  rss:\n    request_interval: 1000            # 请求间隔（毫秒）\n    timeout: 15                       # 请求超时（秒）\n    use_proxy: false                  # 是否使用代理\n    proxy_url: \"\"                     # RSS 专属代理（留空则使用 crawler.default_proxy）\n\n  # 排序权重（用于重新排序不同平台的热搜）\n  # 合起来等于 1\n  weight:\n    rank: 0.6                         # 排名权重\n    frequency: 0.3                    # 频次权重\n    hotness: 0.1                      # 热度权重\n\n  # 多账号限制\n  max_accounts_per_channel: 3         # 每个渠道最大账号数量\n\n  # 以下为内部参数（一般无需修改）\n  # 消息分批大小（字节）- 内部配置，请勿修改\n  batch_size:\n    default: 4000\n    dingtalk: 20000\n    feishu: 30000\n    bark: 4000\n    slack: 4000\n  batch_send_interval: 3              # 批次发送间隔（秒）\n  feishu_message_separator: \"━━━━━━━━━━━━━━━━\"\n"
  },
  {
    "path": "config/custom/ai/.gitkeep",
    "content": ""
  },
  {
    "path": "config/custom/keyword/.gitkeep",
    "content": ""
  },
  {
    "path": "config/frequency_words.txt",
    "content": "# ═══════════════════════════════════════════════════════════════\n#                    TrendRadar 频率词配置文件\n#                         Version: 1.1.0\n# ═══════════════════════════════════════════════════════════════\n\n# 可视化配置编辑器地址: https://sansan0.github.io/TrendRadar/\n#\n# 凡是左侧有 # 的都是仅供阅读的说明性文字\n#\n# 这个文件用来设置你想关注的新闻关键词。\n# 系统会自动抓取包含这些关键词的热榜新闻推送给你。\n#\n# 文件分为两个区域：\n#   [GLOBAL_FILTER]  - 全局过滤区：排除不想看的内容\n#   [WORD_GROUPS]    - 词组定义区：设置想关注的关键词\n#\n# ═══════════════════════════════════════════════════════════════\n\n\n# ───────────────────────────────────────────────────────────────\n#                        全局过滤区\n# ───────────────────────────────────────────────────────────────\n# 在这里写入你不想看到的词，每行一个。\n# 包含这些词的新闻会被自动排除，不会出现在推送中。\n#\n# 使用方法：\n#   震惊              直接写词，包含\"震惊\"的新闻会被过滤\n#   /赌博|博彩/       用 /.../ 包裹可以匹配多个词（用 | 分隔）\n\n[GLOBAL_FILTER]\n# 过滤标题党\n震惊\n\n\n\n# ───────────────────────────────────────────────────────────────\n#                        词组定义区\n# ───────────────────────────────────────────────────────────────\n# 在这里写入你想关注的关键词。\n# 每个词组用空行分隔，同一词组内的关键词是\"或\"的关系。\n#\n# ┌─────────────────────────────────────────────────────────────┐\n# │                      语法总览（快速参考）                      │\n# └─────────────────────────────────────────────────────────────┘\n#\n# 关键词语法：\n#   关键词            普通关键词，标题包含即匹配\n#   /正则/            正则表达式匹配（自动忽略大小写）\n#   关键词 => 别名    给关键词指定显示别名\n#   [组别名]          词组第一行，给整组指定别名\n#   +关键词           必须词，所有必须词都要匹配才算匹配\n#   !关键词           过滤词，匹配则排除该条新闻（仅限当前词组）\n#   @数字             限制该词组最多显示多少条\n#\n# 显示名称优先级：\n#   1. 有组别名 → 显示组别名\n#   2. 没有组别名 → 显示各行别名拼接（用 \" / \" 连接）\n#   3. 没有别名 → 显示关键词本身\n#\n#\n# ┌─────────────────────────────────────────────────────────────┐\n# │                      基础用法（推荐新手）                      │\n# └─────────────────────────────────────────────────────────────┘\n#\n# 1. 最简单：直接写关键词\n#    ────────────────────\n#    华为\n#\n#    效果：匹配所有包含\"华为\"的新闻\n#\n#\n# 2. 多个关键词归为一组\n#    ────────────────────\n#    华为\n#    鸿蒙\n#    任正非\n#\n#    效果：匹配包含\"华为\"或\"鸿蒙\"或\"任正非\"的新闻，统一显示为\"华为 / 鸿蒙 / 任正非\"\n#\n#\n# 3. 给词组起个名字（推荐）\n#    ────────────────────\n#    [华为]\n#    华为\n#    鸿蒙\n#    任正非\n#\n#    效果：同上，但显示名称为\"华为\"（更简洁）\n#\n#\n# ┌─────────────────────────────────────────────────────────────┐\n# │                      进阶用法（可选）                         │\n# └─────────────────────────────────────────────────────────────┘\n#\n# 4. 用正则表达式匹配多个词（一行搞定）\n#    ────────────────────\n#    /华为|鸿蒙|任正非/ => 华为\n#\n#    效果：匹配包含\"华为\"或\"鸿蒙\"或\"任正非\"的新闻，显示为\"华为\"\n#    说明：/.../ 里用 | 分隔多个词，=> 后面是显示名称\n#\n#    💡 不懂正则？问 AI：\n#       \"帮我写一个正则表达式，匹配包含'华为'或'鸿蒙'或'任正非'的文本，\n#        格式要求：/正则/ => 显示名称\"\n#\n#\n# 5. 精确匹配英文单词（避免误匹配）\n#    ────────────────────\n#    /\\bAI\\b/i => AI\n#\n#    说明：\\b 表示单词边界，避免匹配到 \"MAIL\" 中的 \"AI\"\n#          /i 表示忽略大小写，\"ai\"、\"AI\"、\"Ai\" 都能匹配\n#\n#    💡 不懂正则？问 AI：\n#       \"帮我写一个正则表达式，精确匹配英文单词'AI'（不匹配 MAIL 中的 AI），\n#        忽略大小写，格式要求：/正则/i => 显示名称\"\n#\n#\n# 6. 排除特定内容\n#    ────────────────────\n#    [苹果公司]\n#    苹果\n#    !水果\n#    !果园\n#\n#    效果：匹配\"苹果\"但排除包含\"水果\"或\"果园\"的新闻\n#    说明：! 开头的词表示\"排除\"\n#\n#\n# 7. 限制显示条数\n#    ────────────────────\n#    [科技新闻]\n#    科技\n#    @5\n#\n#    效果：最多显示 5 条匹配的新闻\n#    说明：@数字 表示限制条数\n#\n#\n# 8. 必须同时包含多个词\n#    ────────────────────\n#    +苹果\n#    +发布会\n#\n#    效果：必须同时包含\"苹果\"和\"发布会\"才匹配\n#    说明：+ 开头的词表示\"必须包含\"\n#\n# ───────────────────────────────────────────────────────────────\n\n[WORD_GROUPS]\n\n# ═══════════════════════════════════════════════════════════════\n#                         企业与品牌\n# ═══════════════════════════════════════════════════════════════\n\n/胖东来|于东来/ => 胖东来\n\n/深度求索|幻方量化|梁文锋|\\bDeepSeek\\b/ => DeepSeek\n\n/华为|任正非|余承东|鸿蒙|海思|昇腾|鲲鹏|\\bHUAWEI\\b|\\bHarmonyOS\\b|\\bHiSilicon\\b/ => 华为\n\n/比亚迪|王传福|方程豹|腾势|仰望|弗迪|刀片电池|云辇|\\bBYD\\b|\\bDenza\\b|\\bYangwang\\b/ => 比亚迪\n\n/大疆|汪滔|灵眸|如影|\\bDJI\\b|\\bRoboMaster\\b|\\bMavic\\b|\\bZenmuse\\b/ => 大疆\n\n/宇树|王兴兴|\\bUnitree\\b/ => 宇树机器人\n\n/智元|灵犀|稚晖君|彭志辉|AgiBot/ => 智元机器人\n/众擎|EngineAI|赵同阳/ => 众擎机器人\n\n/黑神话|冯骥/ => 黑神话悟空\n\n/影之刃零|梁其伟/ => 影之刃零\n\n/三体|流浪地球|刘慈欣|郭帆/ => 三体/流浪地球\n\n申奥\n\n/京东|刘强东|\\bJD\\b|\\bJingdong\\b/ => 京东\n\n/字节|张一鸣|梁汝波|抖音|\\bByteDance\\b|\\bTikTok\\b|\\bDouyin\\b|\\bLark\\b|\\bCapCut\\b/ => 字节跳动\n\n/腾讯|鹅厂|马化腾|微信|QQ|天美|阅文集团|微众银行|\\bTencent\\b|\\bPony Ma\\b|\\bWeChat\\b|\\bLightSpeed\\b|\\bWeBank\\b/ => 腾讯\n\n/qwen|minimax|glm/ => 国产开源模型\n\n/特斯拉|马斯克|\\bTesla\\b|\\bElon Musk\\b|\\bCybertruck\\b|\\bModel 3\\b|\\bModel Y\\b|\\bModel S\\b|\\bModel X\\b|\\bFSD\\b/ => 特斯拉\n\n/英伟达|黄仁勋|\\bNVIDIA\\b|\\bGeForce\\b|\\bRTX\\b|\\bCUDA\\b|\\bJensen Huang\\b/ => 英伟达\n/苏姿丰|锐龙|霄龙|\\bAMD\\b|\\bRyzen\\b|\\bEPYC\\b|\\bRadeon\\b|\\bLisa Su\\b/ => AMD\n\n/微软|\\bMicrosoft\\b|\\bWindows\\b|\\bAzure\\b|\\bSatya Nadella\\b|\\bCopilot\\b/ => 微软\n/谷歌|皮查伊|安卓|油管|\\bGoogle\\b|\\bAlphabet\\b|\\bAndroid\\b|\\bChrome\\b|\\bYouTube\\b|\\bGemini\\b|\\bDeepMind\\b|\\bWaymo\\b/ => 谷歌\n/库克|\\biPhone\\b|\\biPad\\b|\\bMacBook\\b|\\biOS\\b|\\bVision Pro\\b|\\bAirPods\\b|\\bApple\\b|\\bTim Cook\\b/ => 苹果\n\n/\\bOpenAI\\b|\\bChatGPT\\b|\\bSora\\b|\\bDALL-E\\b|\\bSam Altman\\b|\\bGreg Brockman\\b/ => OpenAI\n/\\bAnthropic\\b|\\bClaude\\b|\\bDario Amodei\\b/ => Claude\n\n\n# ═══════════════════════════════════════════════════════════════\n#                         国家与地区\n# ═══════════════════════════════════════════════════════════════\n\n[中国]\n国产\n中国\n\n[东亚]\n日本\n朝鲜\n韩国\n\n[北美]\n美国\n加拿大\n\n[西欧]\n法国\n英国\n\n/俄罗斯|俄国/ => 俄罗斯\n\n印度\n\n\n# ═══════════════════════════════════════════════════════════════\n#                         科技领域\n# ═══════════════════════════════════════════════════════════════\n\n[AI 相关]\n/(?<![a-zA-Z])ai(?![a-zA-Z])/\n人工智能\n\n[芯片]\n芯片\n光刻机\n半导体\n\n/水电|雅鲁藏布江/ => 水电\n/光伏|太阳能/ => 光伏\n核能\n能源\n\n/自动驾驶|无人驾驶|智驾/ => 自动驾驶\n\n机器人\n/机械狗|四足/ => 机器狗\n具身智能\n\n/月球|登月|火星|宇宙|飞船|航天|空间站|卫星/ => 航天\n\n# 前沿科技\n量子\n脑机\n基因\n\n# 产业政策\n生产力\n"
  },
  {
    "path": "config/timeline.yaml",
    "content": "# ═══════════════════════════════════════════════════════════════\n#                   TrendRadar 时间线配置\n#                      Version: 1.2.0\n# ═══════════════════════════════════════════════════════════════\n#\n# 这个文件控制「什么时间做什么事」。\n#\n# 大多数人不需要编辑这个文件。\n# 只需在 config.yaml 中选择一个预设模板即可：\n#\n#   schedule:\n#     preset: \"morning_evening\"    ← 改这里就行\n#\n#\n# 可视化配置编辑器地址: https://sansan0.github.io/TrendRadar/\n#\n#\n# ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─\n# 📖 基本概念（帮助你理解后面的配置）\n# ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─\n#\n#\n# 🔁 程序是怎么运行的？\n#\n#   TrendRadar 不是一直在后台运行的，而是被「定时闹钟」周期性唤醒：\n#\n#     GitHub Actions 用户 → 由 .github/workflows/crawler.yml 中的 cron 定时触发\n#                           默认每小时运行一次（如每小时第 33 分钟）\n#\n#     Docker 用户         → 由 docker/.env 中的 CRON_SCHEDULE 定时触发\n#                           默认每 30 分钟运行一次\n#\n#   每次被唤醒后，程序按以下三个阶段依次执行：\n#\n#     1️⃣ 采集（collect）\n#        爬取各热榜平台 + RSS 订阅源的最新数据，存入数据库\n#\n#                  ⬇\n#\n#     2️⃣ 分析（analyze）\n#        调用 AI 大模型对采集到的新闻进行深度分析（可选，需配置 API Key）\n#\n#                  ⬇\n#\n#     3️⃣ 推送（push）\n#        将整理好的热点新闻 + AI 分析结果发送到你的通知渠道\n#        （飞书、钉钉、Telegram、邮件等）\n#\n#   这三个阶段都可以独立开关。本文件的作用就是控制：\n#   「在什么时间段，开启/关闭哪些阶段」。\n#\n#\n# 🔌 config.yaml 总开关 与 timeline 时间段开关 的关系\n#\n#   config.yaml 里有几个「总开关」，它们的优先级高于本文件：\n#\n#     platforms.enabled: false   → 永远不爬热榜（无论 timeline 怎么设置）\n#     rss.enabled: false         → 永远不爬 RSS（同上）\n#     notification.enabled: false → 永远不推送（同上）\n#     ai_analysis.enabled: false  → 永远不分析（同上）\n#\n#   只有当总开关为 true 时，timeline 的时间段开关才会生效。\n#   换句话说：总开关决定「能不能做」，timeline 决定「什么时候做」。\n#\n#\n# ⏰ 什么是「时间段」和「静默期」？\n#\n#   你可以把一天想象成一条时间线，上面划分了若干个「时间段」。\n#   每个时间段有自己的行为开关（是否采集、是否分析、是否推送）。\n#\n#   而不在任何时间段内的时间，就叫「静默期」（走 default 默认配置）。\n#   静默期通常必须要采集，这样数据一直在积累，\n#   等到推送时，就能汇总出完整的报告。\n#\n#\n#   💡 静默期越长，积累的数据越丰富（排名变化轨迹、上榜/下榜时间等），\n#   最终提交给 AI 分析的上下文也越完整，分析质量更高。\n#   相比 MCP Server，该方案的全天数据能呈现更完整的热度趋势和变化脉络。\n#\n#\n# ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─\n# 📋 预设模板一览（选一个就行）\n# ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─\n#\n#   1️⃣ always_on        全天候，有新增就推（默认）\n#   2️⃣ morning_evening  全天推送 + 晚间汇总（推荐大多数人）\n#   3️⃣ office_hours     工作日三段式：到岗速览→午间热点→收工汇总\n#   4️⃣ night_owl        午后速览 + 深夜全天汇总\n#   5️⃣ custom           完全自定义（需要编辑本文件底部的 custom 段）\n#\n# 想自定义？两种方式：\n#   1. 直接翻到本文件底部的「自定义模式」部分\n#   2. 在下方 presets 里新增你自己的预设模板\n#      （只要 key 不重复，然后在 config.yaml 里填你的模板名即可）\n#\n# ⚠️ 关于时间段设计的建议：\n#   GitHub Actions： 建议定时任务间隔 ≥ 2 小时。由于系统触发存在随机延迟，间隔过短可能导致任务漏运行。\n#   Docker 用户：cron 定时是准时的，无此限制，按需设置即可。\n#\n#\n# ═══════════════════════════════════════════════════════════════\n\n\n# ───────────────────────────────────────────────────────────────\n# 预设模板\n# ───────────────────────────────────────────────────────────────\npresets:\n\n  # ───────────────────────────────────────────────────────────\n  # 1️⃣ always_on - 全天候监控\n  #\n  # 最简单的模式：全天候采集 + 推送，有新增就通知你。\n  # 不划分时间段，全天使用同一套配置。\n  # 适合：重度用户、实时舆情监控\n  #\n  # 全天：推送 ✓ | AI分析 ✗ | 不限推送次数\n  # ───────────────────────────────────────────────────────────\n  always_on:\n    name: \"全天监控\"\n    description: \"全天候监控，有新增立即推送。适合重度用户。\"\n\n    # 默认配置 ── 不在任何时间段内时，使用这组开关\n    # 因为这个模式没有划分时间段，所以 default 就是全天的行为\n    default:\n      collect: true                # 采集数据（爬取热榜 + RSS）\n      analyze: false               # 不做 AI 分析（节省 API 费用）\n      ai_mode: \"current\"           # AI 分析当前榜单\n      push: true                   # 有新内容就推送\n      report_mode: \"incremental\"   # 只推送新增内容，避免重复\n      once:                        # 限制每个时间段内只执行一次\n        analyze: false             #   不限制分析次数\n        push: false                #   不限制推送次数\n\n    # 没有定义任何时间段，全天都走 default\n    #\n    # 语法提示：{} 是 YAML 的「空字典」写法，表示里面没有任何内容。\n    # 等价于写成多行但什么都不填。后面的 [] 同理，表示「空列表」。\n    periods: {}\n    day_plans:\n      all_day:\n        periods: []                   # 空列表 = 这天不启用任何时间段\n    week_map:\n      1: \"all_day\"                 # 周一\n      2: \"all_day\"                 # 周二\n      3: \"all_day\"                 # 周三\n      4: \"all_day\"                 # 周四\n      5: \"all_day\"                 # 周五\n      6: \"all_day\"                 # 周六\n      7: \"all_day\"                 # 周日\n\n\n  # ───────────────────────────────────────────────────────────\n  # 2️⃣ morning_evening - 早晚汇总（推荐）\n  #\n  # 全天推送当前热点 + 晚间做一次当日全天汇总。\n  # 适合：大多数人\n  #\n  # 默认（全天）：推送 ✓ | AI分析 ✓ | 不限推送次数\n  # 晚间汇总：推送 ✓ | AI分析 ✓ | 只推/分析一次\n  # ───────────────────────────────────────────────────────────\n  morning_evening:\n    name: \"早晚汇总\"\n    description: \"全天推送 + 晚间当日汇总。适合大多数人。\"\n\n    # 默认配置 ── 不命中任何时间段时的行为\n    default:\n      collect: true                # 始终采集\n      analyze: true                # AI 分析当前榜单\n      ai_mode: \"current\"           # AI 分析当前榜单\n      push: true                   # 每次推送当前在榜热点\n      report_mode: \"current\"       # 当前在榜的新闻\n      # frequency_file: \"xxx.txt\"               # 关键词文件（可选，位于 config/custom/keyword/）\n      # interests_file: \"xxx.txt\"                # AI 兴趣文件（可选，位于 config/custom/ai/）\n      # filter_method: \"keyword\"                # 筛选策略（可选: keyword | ai，不填用全局 filter.method）\n      once:\n        analyze: false             # 不限制分析次数\n        push: false                # 不限制推送次数\n\n    # 时间段定义 ── 只有晚间汇总需要特殊处理\n    periods:\n      evening_summary:\n        name: \"晚间汇总\"\n        start: \"20:00\"\n        end: \"22:00\"\n        # frequency_file: \"xxx.txt\"               # 关键词文件（可选，位于 config/custom/keyword/）\n        # interests_file: \"xxx.txt\"                # AI 兴趣文件（可选，位于 config/custom/ai/）\n        # filter_method: \"keyword\"                # 筛选策略（可选: keyword | ai，不填用全局 filter.method）\n        analyze: true              # 晚间做 AI 分析\n        ai_mode: \"daily\"           # AI 也汇总全天内容\n        report_mode: \"daily\"       # 切换为当日全部新闻汇总\n        once:\n          analyze: true            # 窗口内只分析一次\n          push: true               # 窗口内只推送一次\n\n    # 日计划 ── 把时间段组装成一天的安排\n    day_plans:\n      all_day:\n        periods: [\"evening_summary\"]\n\n    # 周映射 ── 每天用哪个日计划（1=周一 ... 7=周日）\n    week_map:\n      1: \"all_day\"\n      2: \"all_day\"\n      3: \"all_day\"\n      4: \"all_day\"\n      5: \"all_day\"\n      6: \"all_day\"\n      7: \"all_day\"\n\n\n  # ───────────────────────────────────────────────────────────\n  # 3️⃣ office_hours - 办公时间推送\n  #\n  # 工作日三段式推送，周末增量自由推。\n  # 适合：上班族、企业用户\n  #\n  # 默认（静默期）：推送 ✗ | AI分析 ✗\n  # 到岗速览：推送 ✓ | AI分析 ✓ | 只推一次\n  # 午间热点：推送 ✓ | AI分析 ✗ | 只推一次\n  # 收工汇总：推送 ✓ | AI分析 ✓ | 只推一次\n  # 周末自由：推送 ✓ | AI分析 ✗ | 不限推送次数\n  # ───────────────────────────────────────────────────────────\n  office_hours:\n    name: \"办公时间\"\n    description: \"工作日三段式推送（到岗→午间→收工），周末增量自由推送。\"\n\n    default:\n      collect: true\n      analyze: false\n      ai_mode: \"current\"\n      push: false                  # 默认不推送\n      report_mode: \"current\"\n      once:\n        analyze: true              # 每个时段只分析一次\n        push: true                 # 每个时段只推送一次\n\n    periods:\n      morning_briefing:\n        name: \"到岗速览\"\n        start: \"09:00\"\n        end: \"11:00\"\n        analyze: true              # AI 分析当前热点\n        ai_mode: \"current\"         # AI 分析当前榜单\n        push: true                 # 到岗后看当前热点\n        report_mode: \"current\"     # 当前在榜的新闻\n        # once 继承 default（analyze: true, push: true）→ 只推/分析一次\n\n      noon_update:\n        name: \"午间热点\"\n        start: \"13:00\"\n        end: \"15:00\"\n        push: true                 # 午间推送当前在榜热点\n        report_mode: \"current\"     # 当前在榜的新闻\n        # analyze 继承 default: false → 午间不做 AI 分析，节省 API\n        # once 继承 default（push: true）→ 只推一次\n\n      closing_summary:\n        name: \"收工汇总\"\n        start: \"17:00\"\n        end: \"19:00\"\n        analyze: true              # AI 做全天汇总分析\n        ai_mode: \"daily\"           # AI 也分析全天内容\n        push: true                 # 下班前推送当日完整汇总\n        report_mode: \"daily\"       # 当日全部新闻汇总\n        # once 继承 default（analyze: true, push: true）→ 只推/分析一次\n\n      weekend_free:\n        name: \"周末自由\"\n        start: \"08:00\"\n        end: \"23:00\"\n        ai_mode: \"current\"         # AI 分析当前榜单\n        push: true                 # 有新增就推送\n        report_mode: \"incremental\" # 增量模式：有新增才推，没有就安静\n        once:\n          analyze: false           # 不限制分析次数\n          push: false              # 不限制推送次数\n\n    # 工作日使用三段式推送；周末使用增量自由模式\n    day_plans:\n      workday:\n        periods: [\"morning_briefing\", \"noon_update\", \"closing_summary\"]\n      weekend:\n        periods: [\"weekend_free\"]  # 周末：有新增就推，不打扰睡眠\n\n    week_map:\n      1: \"workday\"                 # 周一 → 工作日计划\n      2: \"workday\"\n      3: \"workday\"\n      4: \"workday\"\n      5: \"workday\"\n      6: \"weekend\"                 # 周六 → 周末计划\n      7: \"weekend\"                 # 周日 → 周末计划\n\n\n  # ───────────────────────────────────────────────────────────\n  # 4️⃣ night_owl - 夜猫子模式\n  #\n  # 白天安静，午后和深夜各推一次。\n  # 适合：夜间工作者、海外时差用户、自由职业者\n  #\n  # 默认（白天静默）：推送 ✗ | AI分析 ✗\n  # 午后速览：推送 ✓ | AI分析 ✓ | 只推一次\n  # 深夜汇总：推送 ✓ | AI分析 ✓ | 只推一次\n  # ───────────────────────────────────────────────────────────\n  night_owl:\n    name: \"夜猫子模式\"\n    description: \"午后速览 + 深夜全天汇总。适合夜间工作者、海外时差用户。\"\n\n    default:\n      collect: true\n      analyze: false\n      ai_mode: \"current\"\n      push: false\n      report_mode: \"current\"\n      once:\n        analyze: true              # 每个时段只分析一次\n        push: true                 # 每个时段只推送一次\n\n    periods:\n      afternoon_peek:\n        name: \"午后速览\"\n        start: \"15:00\"\n        end: \"17:00\"\n        analyze: true              # AI 分析当前热点\n        ai_mode: \"current\"         # AI 分析当前榜单\n        push: true                 # 午后看当前热点\n        report_mode: \"current\"     # 当前在榜的新闻\n        # once 继承 default（analyze: true, push: true）→ 只推/分析一次\n\n      late_night:\n        name: \"深夜汇总\"\n        start: \"22:00\"\n        end: \"01:00\"               # start > end → 自动识别为跨日\n        analyze: true              # AI 做全天汇总分析\n        ai_mode: \"daily\"           # AI 也分析全天内容\n        push: true                 # 深夜推送当日完整汇总\n        report_mode: \"daily\"       # 当日全部新闻汇总\n        # once 继承 default（analyze: true, push: true）→ 只推/分析一次\n\n    day_plans:\n      all_day:\n        periods: [\"afternoon_peek\", \"late_night\"]\n    week_map:\n      1: \"all_day\"\n      2: \"all_day\"\n      3: \"all_day\"\n      4: \"all_day\"\n      5: \"all_day\"\n      6: \"all_day\"\n      7: \"all_day\"\n\n\n# ═══════════════════════════════════════════════════════════════\n#\n# 5️⃣ 自定义模式\n#\n# 当 config.yaml 中设置 schedule.preset: \"custom\" 时，\n# 系统会读取下面这段配置。\n#\n# 如果上面的预设模板无法满足你的需求，可以在这里自由定义。\n#\n# ═══════════════════════════════════════════════════════════════\n#\n# 自定义配置的思路很简单，就像搭积木：\n#\n#   第 1 步：定义「积木块」（periods）\n#            每块积木 = 一个时间段 + 这段时间要做什么\n#            例如：早间 08-10 推送、晚间 19-21 汇总\n#\n#   第 2 步：拼成「一天的安排」（day_plans）\n#            把积木块组合起来，形成一天的日程\n#            例如：工作日用 [早间, 晚间]，周末用 [晚间]\n#\n#   第 3 步：指定「每天用哪个安排」（week_map）\n#            周一到周日，分别对应哪个日计划\n#            例如：周一~周五用 workday，周六周日用 weekend\n#\n#   另外还有一个「默认配置」（default），\n#   当某个时刻不在任何积木块内时，就用默认配置。\n#   积木块里没写的字段，也会自动回退到默认配置。\n#\n#\n# 下面是一个完整的自定义示例，工作日和周末使用不同的时间段安排：\n#\n#   工作日时间段:\n#     深夜静默 23:00-06:00（跨日）：采集 ✓ | 分析 ✓ | 推送 ✗\n#     工作日早间 08:00-10:00：推送 ✓ | incremental\n#     晚间汇总 19:00-21:00：推送 ✓ | 分析 ✓ | daily\n#     其余时间走默认配置（静默采集）\n#\n#   周末时间段:\n#     深夜静默 23:00-06:00（跨日）：采集 ✓ | 分析 ✓ | 推送 ✗\n#     周末早间 10:00-12:00：推送 ✓ | daily\n#     晚间汇总 19:00-21:00：推送 ✓ | 分析 ✓ | daily\n#     其余时间走默认配置（静默采集）\n\ncustom:\n  name: \"自定义\"\n  description: \"完全自由定义时间段、日计划和周映射。\"\n\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  # 默认配置\n  #\n  # 当前时刻不在任何时间段（积木块）内时，使用这组开关。\n  # 时间段中没有写的字段，也会回退到这里。\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  default:\n    collect: true                  # 是否采集数据（爬取热榜 + RSS）\n    analyze: false                 # 是否执行 AI 分析\n    ai_mode: \"current\"            # AI 分析模式:\n                                   #   follow_report → 跟随 report_mode\n                                   #   daily         → 强制全天汇总\n                                   #   current       → 强制当前榜单\n                                   #   incremental   → 强制增量模式\n    push: false                    # 是否发送推送通知\n    report_mode: \"current\"         # 报告模式:\n                                   #   daily       → 当日所有新闻的汇总\n                                   #   current     → 当前在榜的新闻\n                                   #   incremental → 只推送新增内容\n\n                                   \n    # frequency_file: \"general.txt\"\n                                   # 关键词文件（可选，位于 config/custom/keyword/）\n                                   # 不填则使用默认的 config/frequency_words.txt\n                                   # 时间段（period）中也可以设置此字段来覆盖默认值\n                                   # 例如晚间汇总用科技词库：\n                                   #   frequency_file: \"tech.txt\"\n                                   # 注意：仅在 filter_method 为 keyword 时生效\n                                   \n    # interests_file: \"finance.txt\"\n                                   # AI 兴趣描述文件（可选，位于 config/custom/ai/）\n                                   # 不填则使用默认的 config/ai_interests.txt\n                                   # 时间段（period）中也可以设置此字段来覆盖默认值\n                                   # 例如晚间汇总用金融兴趣：\n                                   #   interests_file: \"finance.txt\"\n                                   # 注意：仅在 filter_method 为 ai 时生效\n\n    # filter_method: \"keyword\"     # 筛选策略（可选: keyword | ai）\n                                   # 不填则使用全局 config.yaml 的 filter.method\n                                   # 时间段（period）中也可以设置此字段来覆盖\n                                   # 例如晚间汇总用 AI 筛选：\n                                   #   filter_method: \"ai\"\n    once:\n      analyze: true                # 该时间段内只分析一次（省 API）\n      push: true                   # 该时间段内只推送一次（省打扰）\n\n\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  # 第 1 步：定义积木块（时间段）\n  #\n  # 每个时间段有一个唯一的 key（如 deep_quiet），\n  # 以及 start / end 表示生效的时间范围。\n  #\n  # 只需要写「和 default 不同的字段」，其余自动继承 default。\n  # 例如 weekday_morning 没写 collect，就会继承 default 的 collect: true。\n  #\n  # 提示：如果 start > end（如 22:00 → 07:00），\n  #       系统会自动识别为跨越午夜的时间段。\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  periods:\n\n    deep_quiet:\n      name: \"深夜静默\"\n      start: \"23:00\"\n      end: \"06:00\"                 # 23:00 → 次日 06:00（跨日时间段）\n      # frequency_file: \"xxx.txt\"               # 关键词文件（可选，位于 config/custom/keyword/）\n      # interests_file: \"xxx.txt\"                # AI 兴趣文件（可选，位于 config/custom/ai/）\n      # filter_method: \"keyword\"                # 筛选策略（可选: keyword | ai，不填用全局 filter.method）\n      collect: true                # 夜间继续采集数据\n      analyze: true                # 夜间可以跑 AI 分析（反正不推送）\n      push: false                  # 深夜不推送，避免打扰\n\n    weekday_morning:\n      name: \"工作日早间\"\n      start: \"08:00\"\n      end: \"10:00\"                 # 跨度 2h，留足触发裕量\n      # frequency_file: \"xxx.txt\"               # 关键词文件（可选，位于 config/custom/keyword/）\n      # interests_file: \"xxx.txt\"                # AI 兴趣文件（可选，位于 config/custom/ai/）\n      # filter_method: \"keyword\"                # 筛选策略（可选: keyword | ai，不填用全局 filter.method）\n      push: true                   # 早上推送一次\n      report_mode: \"incremental\"   # 只推新增内容\n      # once 继承 default（push: true）→ 窗口内只推一次\n\n    weekend_morning:\n      name: \"周末早间\"\n      start: \"10:00\"\n      end: \"12:00\"                 # 跨度 2h\n      # frequency_file: \"xxx.txt\"               # 关键词文件（可选，位于 config/custom/keyword/）\n      # interests_file: \"xxx.txt\"                # AI 兴趣文件（可选，位于 config/custom/ai/）\n      # filter_method: \"keyword\"                # 筛选策略（可选: keyword | ai，不填用全局 filter.method）\n      push: true\n      report_mode: \"daily\"         # 周末看全天汇总\n      # once 继承 default（push: true）→ 窗口内只推一次\n\n    evening_summary:\n      name: \"晚间汇总\"\n      start: \"19:00\"\n      end: \"21:00\"\n      # frequency_file: \"xxx.txt\"               # 关键词文件（可选，位于 config/custom/keyword/）\n      # interests_file: \"xxx.txt\"                # AI 兴趣文件（可选，位于 config/custom/ai/）\n      # filter_method: \"keyword\"                # 筛选策略（可选: keyword | ai，不填用全局 filter.method）\n      analyze: true                # 晚间做 AI 分析\n      ai_mode: \"daily\"             # AI 也分析全天内容\n      push: true                   # 晚间推送\n      report_mode: \"daily\"         # 当日全部新闻汇总\n      # once 继承 default（analyze: true, push: true）→ 只分析/推送一次\n\n\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  # 第 2 步：把积木块拼成日计划\n  #\n  # 把上面定义的时间段组合成一天的安排。\n  # 你可以定义多个日计划（如 workday 和 weekend），\n  # 然后在第 3 步的 week_map 中分配给不同的星期。\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  day_plans:\n    workday:                       # 工作日计划\n      periods: [\"deep_quiet\", \"weekday_morning\", \"evening_summary\"]\n    weekend:                       # 周末计划（用 weekend_morning 替换 weekday_morning）\n      periods: [\"deep_quiet\", \"weekend_morning\", \"evening_summary\"]\n\n\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  # 第 3 步：指定每天用哪个日计划\n  #\n  # 1=周一  2=周二  3=周三  4=周四  5=周五  6=周六  7=周日\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  week_map:\n    1: \"workday\"                   # 周一 → 工作日计划\n    2: \"workday\"                   # 周二\n    3: \"workday\"                   # 周三\n    4: \"workday\"                   # 周四\n    5: \"workday\"                   # 周五\n    6: \"weekend\"                   # 周六 → 周末计划\n    7: \"weekend\"                   # 周日\n\n\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  # 冲突策略（一般不用改）\n  #\n  # 什么是「冲突」？\n  #   如果你的两个时间段有重叠（比如 A 是 08:00-12:00，B 是 10:00-14:00），\n  #   那么 10:00-12:00 这段时间就同时属于 A 和 B，产生了冲突。\n  #   此时程序需要知道：到底听谁的？\n  #\n  # 两种处理方式：\n  #\n  #   error_on_overlap（推荐）\n  #     直接报错，提醒你去修改配置。\n  #     适合大多数人 —— 时间段重叠通常是写错了，报错能及时发现。\n  #\n  #   last_wins\n  #     day_plans 的 periods 列表中，写在后面的优先。\n  #     比如 periods: [\"A\", \"B\"]，重叠时 B 生效。\n  #     适合场景：你想用一个大范围时间段打底，再用后面的小范围覆盖。\n  #\n  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n  overlap:\n    policy: \"error_on_overlap\"\n"
  },
  {
    "path": "docker/Dockerfile",
    "content": "FROM python:3.12-slim-bookworm\n\nWORKDIR /app\n\n# Latest releases available at https://github.com/aptible/supercronic/releases\nARG TARGETARCH\nENV SUPERCRONIC_VERSION=v0.2.39\n\nRUN set -ex && \\\n    apt-get update && \\\n    apt-get install -y --no-install-recommends curl ca-certificates && \\\n    case ${TARGETARCH} in \\\n    amd64) \\\n    export SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-amd64; \\\n    export SUPERCRONIC_SHA1SUM=c98bbf82c5f648aaac8708c182cc83046fe48423; \\\n    export SUPERCRONIC=supercronic-linux-amd64; \\\n    ;; \\\n    arm64) \\\n    export SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-arm64; \\\n    export SUPERCRONIC_SHA1SUM=5ef4ccc3d43f12d0f6c3763758bc37cc4e5af76e; \\\n    export SUPERCRONIC=supercronic-linux-arm64; \\\n    ;; \\\n    *) \\\n    echo \"Unsupported architecture: ${TARGETARCH}\"; \\\n    exit 1; \\\n    ;; \\\n    esac && \\\n    echo \"Downloading supercronic for ${TARGETARCH} from ${SUPERCRONIC_URL}\" && \\\n    # 重试机制：最多3次，每次超时30秒\n    for i in 1 2 3; do \\\n        echo \"Download attempt $i/3\"; \\\n        if curl -fsSL --connect-timeout 30 --max-time 60 -o \"$SUPERCRONIC\" \"$SUPERCRONIC_URL\"; then \\\n            echo \"Download successful\"; \\\n            break; \\\n        else \\\n            echo \"Download attempt $i failed\"; \\\n            if [ $i -eq 3 ]; then \\\n                echo \"All download attempts failed\"; \\\n                exit 1; \\\n            fi; \\\n            sleep 2; \\\n        fi; \\\n    done && \\\n    echo \"${SUPERCRONIC_SHA1SUM}  ${SUPERCRONIC}\" | sha1sum -c - && \\\n    chmod +x \"$SUPERCRONIC\" && \\\n    mv \"$SUPERCRONIC\" \"/usr/local/bin/${SUPERCRONIC}\" && \\\n    ln -s \"/usr/local/bin/${SUPERCRONIC}\" /usr/local/bin/supercronic && \\\n    supercronic -version && \\\n    apt-get remove -y curl && \\\n    apt-get clean && \\\n    rm -rf /var/lib/apt/lists/*\n\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\n\nCOPY docker/manage.py .\nCOPY trendradar/ ./trendradar/\n\n# 复制 entrypoint.sh 并强制转换为 LF 格式\nCOPY docker/entrypoint.sh /entrypoint.sh.tmp\nRUN sed -i 's/\\r$//' /entrypoint.sh.tmp && \\\n    mv /entrypoint.sh.tmp /entrypoint.sh && \\\n    chmod +x /entrypoint.sh && \\\n    chmod +x manage.py && \\\n    mkdir -p /app/config /app/output\n\nENV PYTHONUNBUFFERED=1 \\\n    CONFIG_PATH=/app/config/config.yaml \\\n    FREQUENCY_WORDS_PATH=/app/config/frequency_words.txt\n\nENTRYPOINT [\"/entrypoint.sh\"]"
  },
  {
    "path": "docker/Dockerfile.mcp",
    "content": "FROM python:3.12-slim-bookworm\n\nWORKDIR /app\n\n# 安装依赖\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\n\n# 复制 MCP 服务器代码\nCOPY mcp_server/ ./mcp_server/\n# 复制 trendradar 模块（MCP 服务需要读取 SQLite 数据）\nCOPY trendradar/ ./trendradar/\n\n# 创建必要目录\nRUN mkdir -p /app/config /app/output\n\nENV PYTHONUNBUFFERED=1 \\\n    CONFIG_PATH=/app/config/config.yaml \\\n    FREQUENCY_WORDS_PATH=/app/config/frequency_words.txt\n\n# MCP HTTP 服务端口\nEXPOSE 3333\n\n# 启动 MCP 服务器（HTTP 模式）\nCMD [\"python\", \"-m\", \"mcp_server.server\", \"--transport\", \"http\", \"--host\", \"0.0.0.0\", \"--port\", \"3333\"]\n"
  },
  {
    "path": "docker/docker-compose-build.yml",
    "content": "services:\n  trendradar:\n    build:\n      context: ..\n      dockerfile: docker/Dockerfile\n    container_name: trendradar\n    restart: unless-stopped\n\n    ports:\n      - \"127.0.0.1:${WEBSERVER_PORT:-8080}:${WEBSERVER_PORT:-8080}\"\n\n    volumes:\n      - ../config:/app/config:ro\n      - ../output:/app/output\n\n    environment:\n      - TZ=Asia/Shanghai\n      # Web 服务器\n      - ENABLE_WEBSERVER=${ENABLE_WEBSERVER:-false}\n      - WEBSERVER_PORT=${WEBSERVER_PORT:-8080}\n      - WEBSERVER_WATCHDOG=${WEBSERVER_WATCHDOG:-true}\n      - WEBSERVER_WATCHDOG_INTERVAL=${WEBSERVER_WATCHDOG_INTERVAL:-60}\n      # 通知渠道\n      - FEISHU_WEBHOOK_URL=${FEISHU_WEBHOOK_URL:-}\n      - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}\n      - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}\n      - DINGTALK_WEBHOOK_URL=${DINGTALK_WEBHOOK_URL:-}\n      - WEWORK_WEBHOOK_URL=${WEWORK_WEBHOOK_URL:-}\n      - WEWORK_MSG_TYPE=${WEWORK_MSG_TYPE:-}\n      # 邮件配置\n      - EMAIL_FROM=${EMAIL_FROM:-}\n      - EMAIL_PASSWORD=${EMAIL_PASSWORD:-}\n      - EMAIL_TO=${EMAIL_TO:-}\n      - EMAIL_SMTP_SERVER=${EMAIL_SMTP_SERVER:-}\n      - EMAIL_SMTP_PORT=${EMAIL_SMTP_PORT:-}\n      # ntfy配置\n      - NTFY_SERVER_URL=${NTFY_SERVER_URL:-https://ntfy.sh}\n      - NTFY_TOPIC=${NTFY_TOPIC:-}\n      - NTFY_TOKEN=${NTFY_TOKEN:-}\n      # Bark配置\n      - BARK_URL=${BARK_URL:-}\n      # Slack配置\n      - SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL:-}\n      # 通用Webhook配置\n      - GENERIC_WEBHOOK_URL=${GENERIC_WEBHOOK_URL:-}\n      - GENERIC_WEBHOOK_TEMPLATE=${GENERIC_WEBHOOK_TEMPLATE:-}\n      # AI 配置（ai_analysis 和 ai_translation 共享模型配置）\n      - AI_ANALYSIS_ENABLED=${AI_ANALYSIS_ENABLED:-}\n      - AI_API_KEY=${AI_API_KEY:-}\n      - AI_MODEL=${AI_MODEL:-}\n      - AI_API_BASE=${AI_API_BASE:-}\n      # 远程存储配置（S3 兼容协议）\n      - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-}\n      - S3_BUCKET_NAME=${S3_BUCKET_NAME:-}\n      - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID:-}\n      - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY:-}\n      - S3_REGION=${S3_REGION:-}\n      # 运行模式\n      - CRON_SCHEDULE=${CRON_SCHEDULE:-*/30 * * * *}\n      - RUN_MODE=${RUN_MODE:-cron}\n      - IMMEDIATE_RUN=${IMMEDIATE_RUN:-true}\n\n  trendradar-mcp:\n    build:\n      context: ..\n      dockerfile: docker/Dockerfile.mcp\n    container_name: trendradar-mcp\n    restart: unless-stopped\n\n    ports:\n      - \"127.0.0.1:3333:3333\"\n\n    volumes:\n      - ../config:/app/config:ro\n      - ../output:/app/output\n\n    environment:\n      - TZ=Asia/Shanghai\n"
  },
  {
    "path": "docker/docker-compose.yml",
    "content": "services:\n  trendradar:\n    image: wantcat/trendradar:latest\n    container_name: trendradar\n    restart: unless-stopped\n\n    ports:\n      - \"127.0.0.1:${WEBSERVER_PORT:-8080}:${WEBSERVER_PORT:-8080}\"\n\n    volumes:\n      - ../config:/app/config:ro\n      - ../output:/app/output\n\n    environment:\n      - TZ=Asia/Shanghai\n      # Web 服务器\n      - ENABLE_WEBSERVER=${ENABLE_WEBSERVER:-false}\n      - WEBSERVER_PORT=${WEBSERVER_PORT:-8080}\n      - WEBSERVER_WATCHDOG=${WEBSERVER_WATCHDOG:-true}\n      - WEBSERVER_WATCHDOG_INTERVAL=${WEBSERVER_WATCHDOG_INTERVAL:-60}\n      # 通知渠道\n      - FEISHU_WEBHOOK_URL=${FEISHU_WEBHOOK_URL:-}\n      - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}\n      - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}\n      - DINGTALK_WEBHOOK_URL=${DINGTALK_WEBHOOK_URL:-}\n      - WEWORK_WEBHOOK_URL=${WEWORK_WEBHOOK_URL:-}\n      - WEWORK_MSG_TYPE=${WEWORK_MSG_TYPE:-}\n      # 邮件配置\n      - EMAIL_FROM=${EMAIL_FROM:-}\n      - EMAIL_PASSWORD=${EMAIL_PASSWORD:-}\n      - EMAIL_TO=${EMAIL_TO:-}\n      - EMAIL_SMTP_SERVER=${EMAIL_SMTP_SERVER:-}\n      - EMAIL_SMTP_PORT=${EMAIL_SMTP_PORT:-}\n      # ntfy配置\n      - NTFY_SERVER_URL=${NTFY_SERVER_URL:-https://ntfy.sh}\n      - NTFY_TOPIC=${NTFY_TOPIC:-}\n      - NTFY_TOKEN=${NTFY_TOKEN:-}\n      # Bark配置\n      - BARK_URL=${BARK_URL:-}\n      # Slack配置\n      - SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL:-}\n      # 通用Webhook配置\n      - GENERIC_WEBHOOK_URL=${GENERIC_WEBHOOK_URL:-}\n      - GENERIC_WEBHOOK_TEMPLATE=${GENERIC_WEBHOOK_TEMPLATE:-}\n      # AI 配置（ai_analysis 和 ai_translation 共享模型配置）\n      - AI_ANALYSIS_ENABLED=${AI_ANALYSIS_ENABLED:-}\n      - AI_API_KEY=${AI_API_KEY:-}\n      - AI_MODEL=${AI_MODEL:-}\n      - AI_API_BASE=${AI_API_BASE:-}\n      # 远程存储配置（S3 兼容协议）\n      - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-}\n      - S3_BUCKET_NAME=${S3_BUCKET_NAME:-}\n      - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID:-}\n      - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY:-}\n      - S3_REGION=${S3_REGION:-}\n      # 运行模式\n      - CRON_SCHEDULE=${CRON_SCHEDULE:-*/30 * * * *}\n      - RUN_MODE=${RUN_MODE:-cron}\n      - IMMEDIATE_RUN=${IMMEDIATE_RUN:-true}\n\n  trendradar-mcp:\n    image: wantcat/trendradar-mcp:latest\n    container_name: trendradar-mcp\n    restart: unless-stopped\n\n    ports:\n      - \"127.0.0.1:3333:3333\"\n\n    volumes:\n      - ../config:/app/config:ro\n      - ../output:/app/output\n\n    environment:\n      - TZ=Asia/Shanghai\n"
  },
  {
    "path": "docker/entrypoint.sh",
    "content": "#!/bin/bash\nset -e\n\n# 检查配置文件\nif [ ! -f \"/app/config/config.yaml\" ] || [ ! -f \"/app/config/frequency_words.txt\" ]; then\n    echo \"❌ 配置文件缺失\"\n    exit 1\nfi\n\n# 保存环境变量\nenv >> /etc/environment\n\ncase \"${RUN_MODE:-cron}\" in\n\"once\")\n    echo \"🔄 单次执行\"\n    exec /usr/local/bin/python -m trendradar\n    ;;\n\"cron\")\n    # 生成 crontab\n    echo \"${CRON_SCHEDULE:-*/30 * * * *} cd /app && /usr/local/bin/python -m trendradar\" > /tmp/crontab\n    \n    echo \"📅 生成的crontab内容:\"\n    cat /tmp/crontab\n\n    if ! /usr/local/bin/supercronic -test /tmp/crontab; then\n        echo \"❌ crontab格式验证失败\"\n        exit 1\n    fi\n\n    # 立即执行一次（如果配置了）\n    if [ \"${IMMEDIATE_RUN:-false}\" = \"true\" ]; then\n        echo \"▶️ 立即执行一次\"\n        /usr/local/bin/python -m trendradar\n    fi\n\n    # 启动 Web 服务器（如果配置了）\n    if [ \"${ENABLE_WEBSERVER:-false}\" = \"true\" ]; then\n        echo \"🌐 启动 Web 服务器...\"\n        /usr/local/bin/python manage.py start_webserver\n\n        WEBSERVER_WATCHDOG_ENABLED=$(echo \"${WEBSERVER_WATCHDOG:-true}\" | tr '[:upper:]' '[:lower:]')\n        WEBSERVER_WATCHDOG_INTERVAL=${WEBSERVER_WATCHDOG_INTERVAL:-60}\n        if [ \"$WEBSERVER_WATCHDOG_ENABLED\" = \"true\" ] || [ \"$WEBSERVER_WATCHDOG_ENABLED\" = \"1\" ] || [ \"$WEBSERVER_WATCHDOG_ENABLED\" = \"yes\" ] || [ \"$WEBSERVER_WATCHDOG_ENABLED\" = \"on\" ]; then\n            # 启动后台 watchdog 定期检查 Web 服务器健康状态\n            echo \"🔄 启动 Web 服务器 watchdog (间隔: ${WEBSERVER_WATCHDOG_INTERVAL}s)...\"\n            (\n                while true; do\n                    sleep \"$WEBSERVER_WATCHDOG_INTERVAL\"\n                    /usr/local/bin/python manage.py webserver_autofix\n                done\n            ) &\n            WEBSERVER_WATCHDOG_PID=$!\n            echo \"  ✅ watchdog 已启动 (PID: $WEBSERVER_WATCHDOG_PID)\"\n        else\n            echo \"⏸️ Web 服务器 watchdog 已禁用\"\n        fi\n    fi\n\n    echo \"⏰ 启动supercronic: ${CRON_SCHEDULE:-*/30 * * * *}\"\n    echo \"🎯 supercronic 将作为 PID 1 运行\"\n\n    exec /usr/local/bin/supercronic -passthrough-logs /tmp/crontab\n    ;;\n*)\n    exec \"$@\"\n    ;;\nesac\n"
  },
  {
    "path": "docker/manage.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\"\"\"\n新闻爬虫容器管理工具 - supercronic\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport time\nimport signal\nfrom pathlib import Path\nfrom datetime import datetime\n\n# Web 服务器配置\nWEBSERVER_PORT = int(os.environ.get(\"WEBSERVER_PORT\", \"8080\"))\nWEBSERVER_DIR = \"/app/output\"\nWEBSERVER_PID_FILE = \"/tmp/webserver.pid\"\nWEBSERVER_MANUAL_STOP_FILE = \"/tmp/webserver.manual_stop\"\n\n\ndef _env_bool(name: str, default: bool) -> bool:\n    \"\"\"读取布尔环境变量，兼容 true/1/yes/on。\"\"\"\n    value = os.environ.get(name)\n    if value is None:\n        return default\n    return value.strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n\n\nWEBSERVER_AUTOFIX_LOG_HEALTHY = _env_bool(\"WEBSERVER_AUTOFIX_LOG_HEALTHY\", False)\n\n\ndef get_timestamp():\n    \"\"\"获取当前时间戳字符串\"\"\"\n    return datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n\ndef run_command(cmd, shell=True, capture_output=True):\n    \"\"\"执行系统命令\"\"\"\n    try:\n        result = subprocess.run(\n            cmd, shell=shell, capture_output=capture_output, text=True\n        )\n        return result.returncode == 0, result.stdout, result.stderr\n    except Exception as e:\n        return False, \"\", str(e)\n\n\ndef manual_run():\n    \"\"\"手动执行一次爬虫\"\"\"\n    print(\"🔄 手动执行爬虫...\")\n    try:\n        result = subprocess.run(\n            [\"python\", \"-m\", \"trendradar\"], cwd=\"/app\", capture_output=False, text=True\n        )\n        if result.returncode == 0:\n            print(\"✅ 执行完成\")\n        else:\n            print(f\"❌ 执行失败，退出码: {result.returncode}\")\n    except Exception as e:\n        print(f\"❌ 执行出错: {e}\")\n\n\ndef parse_cron_schedule(cron_expr):\n    \"\"\"解析cron表达式并返回人类可读的描述\"\"\"\n    if not cron_expr or cron_expr == \"未设置\":\n        return \"未设置\"\n    \n    try:\n        parts = cron_expr.strip().split()\n        if len(parts) != 5:\n            return f\"原始表达式: {cron_expr}\"\n        \n        minute, hour, day, month, weekday = parts\n        \n        # 分析分钟\n        if minute == \"*\":\n            minute_desc = \"每分钟\"\n        elif minute.startswith(\"*/\"):\n            interval = minute[2:]\n            minute_desc = f\"每{interval}分钟\"\n        elif \",\" in minute:\n            minute_desc = f\"在第{minute}分钟\"\n        else:\n            minute_desc = f\"在第{minute}分钟\"\n        \n        # 分析小时\n        if hour == \"*\":\n            hour_desc = \"每小时\"\n        elif hour.startswith(\"*/\"):\n            interval = hour[2:]\n            hour_desc = f\"每{interval}小时\"\n        elif \",\" in hour:\n            hour_desc = f\"在{hour}点\"\n        else:\n            hour_desc = f\"在{hour}点\"\n        \n        # 分析日期\n        if day == \"*\":\n            day_desc = \"每天\"\n        elif day.startswith(\"*/\"):\n            interval = day[2:]\n            day_desc = f\"每{interval}天\"\n        else:\n            day_desc = f\"每月{day}号\"\n        \n        # 分析月份\n        if month == \"*\":\n            month_desc = \"每月\"\n        else:\n            month_desc = f\"在{month}月\"\n        \n        # 分析星期\n        weekday_names = {\n            \"0\": \"周日\", \"1\": \"周一\", \"2\": \"周二\", \"3\": \"周三\", \n            \"4\": \"周四\", \"5\": \"周五\", \"6\": \"周六\", \"7\": \"周日\"\n        }\n        if weekday == \"*\":\n            weekday_desc = \"\"\n        else:\n            weekday_desc = f\"在{weekday_names.get(weekday, weekday)}\"\n        \n        # 组合描述\n        if minute.startswith(\"*/\") and hour == \"*\" and day == \"*\" and month == \"*\" and weekday == \"*\":\n            # 简单的间隔模式，如 */30 * * * *\n            return f\"每{minute[2:]}分钟执行一次\"\n        elif hour != \"*\" and minute != \"*\" and day == \"*\" and month == \"*\" and weekday == \"*\":\n            # 每天特定时间，如 0 9 * * *\n            return f\"每天{hour}:{minute.zfill(2)}执行\"\n        elif weekday != \"*\" and day == \"*\":\n            # 每周特定时间\n            return f\"{weekday_desc}{hour}:{minute.zfill(2)}执行\"\n        else:\n            # 复杂模式，显示详细信息\n            desc_parts = [part for part in [month_desc, day_desc, weekday_desc, hour_desc, minute_desc] if part and part != \"每月\" and part != \"每天\" and part != \"每小时\"]\n            if desc_parts:\n                return \" \".join(desc_parts) + \"执行\"\n            else:\n                return f\"复杂表达式: {cron_expr}\"\n    \n    except Exception as e:\n        return f\"解析失败: {cron_expr}\"\n\n\ndef show_status():\n    \"\"\"显示容器状态\"\"\"\n    print(\"📊 容器状态:\")\n\n    # 检查 PID 1 状态\n    supercronic_is_pid1 = False\n    pid1_cmdline = \"\"\n    try:\n        with open('/proc/1/cmdline', 'r') as f:\n            pid1_cmdline = f.read().replace('\\x00', ' ').strip()\n        print(f\"  🔍 PID 1 进程: {pid1_cmdline}\")\n        \n        if \"supercronic\" in pid1_cmdline.lower():\n            print(\"  ✅ supercronic 正确运行为 PID 1\")\n            supercronic_is_pid1 = True\n        else:\n            print(\"  ❌ PID 1 不是 supercronic\")\n            print(f\"  📋 实际的 PID 1: {pid1_cmdline}\")\n    except Exception as e:\n        print(f\"  ❌ 无法读取 PID 1 信息: {e}\")\n\n    # 检查环境变量\n    cron_schedule = os.environ.get(\"CRON_SCHEDULE\", \"未设置\")\n    run_mode = os.environ.get(\"RUN_MODE\", \"未设置\")\n    immediate_run = os.environ.get(\"IMMEDIATE_RUN\", \"未设置\")\n    \n    print(f\"  ⚙️ 运行配置:\")\n    print(f\"    CRON_SCHEDULE: {cron_schedule}\")\n    \n    # 解析并显示cron表达式的含义\n    cron_description = parse_cron_schedule(cron_schedule)\n    print(f\"    ⏰ 执行频率: {cron_description}\")\n    \n    print(f\"    RUN_MODE: {run_mode}\")\n    print(f\"    IMMEDIATE_RUN: {immediate_run}\")\n\n    # 检查配置文件\n    config_files = [\"/app/config/config.yaml\", \"/app/config/frequency_words.txt\"]\n    print(\"  📁 配置文件:\")\n    for file_path in config_files:\n        if Path(file_path).exists():\n            print(f\"    ✅ {Path(file_path).name}\")\n        else:\n            print(f\"    ❌ {Path(file_path).name} 缺失\")\n\n    # 检查关键文件\n    key_files = [\n        (\"/usr/local/bin/supercronic-linux-amd64\", \"supercronic二进制文件\"),\n        (\"/usr/local/bin/supercronic\", \"supercronic软链接\"),\n        (\"/tmp/crontab\", \"crontab文件\"),\n        (\"/entrypoint.sh\", \"启动脚本\")\n    ]\n    \n    print(\"  📂 关键文件检查:\")\n    for file_path, description in key_files:\n        if Path(file_path).exists():\n            print(f\"    ✅ {description}: 存在\")\n            # 对于crontab文件，显示内容\n            if file_path == \"/tmp/crontab\":\n                try:\n                    with open(file_path, 'r') as f:\n                        crontab_content = f.read().strip()\n                        print(f\"         内容: {crontab_content}\")\n                except:\n                    pass\n        else:\n            print(f\"    ❌ {description}: 不存在\")\n\n    # 检查容器运行时间\n    print(\"  ⏱️ 容器时间信息:\")\n    try:\n        # 检查 PID 1 的启动时间\n        with open('/proc/1/stat', 'r') as f:\n            stat_content = f.read().strip().split()\n            if len(stat_content) >= 22:\n                # starttime 是第22个字段（索引21）\n                starttime_ticks = int(stat_content[21])\n                \n                # 读取系统启动时间\n                with open('/proc/stat', 'r') as stat_f:\n                    for line in stat_f:\n                        if line.startswith('btime'):\n                            boot_time = int(line.split()[1])\n                            break\n                    else:\n                        boot_time = 0\n                \n                # 读取系统时钟频率\n                clock_ticks = os.sysconf(os.sysconf_names['SC_CLK_TCK'])\n                \n                if boot_time > 0:\n                    pid1_start_time = boot_time + (starttime_ticks / clock_ticks)\n                    current_time = time.time()\n                    uptime_seconds = int(current_time - pid1_start_time)\n                    uptime_minutes = uptime_seconds // 60\n                    uptime_hours = uptime_minutes // 60\n                    \n                    if uptime_hours > 0:\n                        print(f\"    PID 1 运行时间: {uptime_hours} 小时 {uptime_minutes % 60} 分钟\")\n                    else:\n                        print(f\"    PID 1 运行时间: {uptime_minutes} 分钟 ({uptime_seconds} 秒)\")\n                else:\n                    print(f\"    PID 1 运行时间: 无法精确计算\")\n            else:\n                print(\"    ❌ 无法解析 PID 1 统计信息\")\n    except Exception as e:\n        print(f\"    ❌ 时间检查失败: {e}\")\n\n    # 状态总结和建议\n    print(\"  📊 状态总结:\")\n    if supercronic_is_pid1:\n        print(\"    ✅ supercronic 正确运行为 PID 1\")\n        print(\"    ✅ 定时任务应该正常工作\")\n        \n        # 显示当前的调度信息\n        if cron_schedule != \"未设置\":\n            print(f\"    ⏰ 当前调度: {cron_description}\")\n            \n            # 提供一些常见的调度建议\n            if \"分钟\" in cron_description and \"每30分钟\" not in cron_description and \"每60分钟\" not in cron_description:\n                print(\"    💡 频繁执行模式，适合实时监控\")\n            elif \"小时\" in cron_description:\n                print(\"    💡 按小时执行模式，适合定期汇总\")\n            elif \"天\" in cron_description:\n                print(\"    💡 每日执行模式，适合日报生成\")\n        \n        print(\"    💡 如果定时任务不执行，检查:\")\n        print(\"       • crontab 格式是否正确\")\n        print(\"       • 时区设置是否正确\")\n        print(\"       • 应用程序是否有错误\")\n    else:\n        print(\"    ❌ supercronic 状态异常\")\n        if pid1_cmdline:\n            print(f\"    📋 当前 PID 1: {pid1_cmdline}\")\n        print(\"    💡 建议操作:\")\n        print(\"       • 重启容器: docker restart trendradar\")\n        print(\"       • 检查容器日志: docker logs trendradar\")\n\n    # 显示日志检查建议\n    print(\"  📋 运行状态检查:\")\n    print(\"    • 查看完整容器日志: docker logs trendradar\")\n    print(\"    • 查看实时日志: docker logs -f trendradar\")\n    print(\"    • 手动执行测试: python manage.py run\")\n    print(\"    • 重启容器服务: docker restart trendradar\")\n\n\ndef show_config():\n    \"\"\"显示当前配置\"\"\"\n    print(\"⚙️ 当前配置:\")\n\n    env_vars = [\n        # 运行配置\n        \"CRON_SCHEDULE\",\n        \"RUN_MODE\",\n        \"IMMEDIATE_RUN\",\n        # 通知渠道\n        \"FEISHU_WEBHOOK_URL\",\n        \"DINGTALK_WEBHOOK_URL\",\n        \"WEWORK_WEBHOOK_URL\",\n        \"WEWORK_MSG_TYPE\",\n        \"TELEGRAM_BOT_TOKEN\",\n        \"TELEGRAM_CHAT_ID\",\n        \"NTFY_SERVER_URL\",\n        \"NTFY_TOPIC\",\n        \"NTFY_TOKEN\",\n        \"BARK_URL\",\n        \"SLACK_WEBHOOK_URL\",\n        # AI 分析配置\n        \"AI_ANALYSIS_ENABLED\",\n        \"AI_API_KEY\",\n        \"AI_PROVIDER\",\n        \"AI_MODEL\",\n        \"AI_BASE_URL\",\n        # 远程存储配置\n        \"S3_BUCKET_NAME\",\n        \"S3_ACCESS_KEY_ID\",\n        \"S3_ENDPOINT_URL\",\n        \"S3_REGION\",\n    ]\n\n    for var in env_vars:\n        value = os.environ.get(var, \"未设置\")\n        # 隐藏敏感信息\n        if any(sensitive in var for sensitive in [\"WEBHOOK\", \"TOKEN\", \"KEY\", \"SECRET\"]):\n            if value and value != \"未设置\":\n                masked_value = value[:10] + \"***\" if len(value) > 10 else \"***\"\n                print(f\"  {var}: {masked_value}\")\n            else:\n                print(f\"  {var}: {value}\")\n        else:\n            print(f\"  {var}: {value}\")\n\n    crontab_file = \"/tmp/crontab\"\n    if Path(crontab_file).exists():\n        print(\"  📅 Crontab内容:\")\n        try:\n            with open(crontab_file, \"r\") as f:\n                content = f.read().strip()\n                print(f\"    {content}\")\n        except Exception as e:\n            print(f\"    读取失败: {e}\")\n    else:\n        print(\"  📅 Crontab文件不存在\")\n\n\ndef show_files():\n    \"\"\"显示输出文件\"\"\"\n    print(\"📁 输出文件:\")\n\n    output_dir = Path(\"/app/output\")\n    if not output_dir.exists():\n        print(\"  📭 输出目录不存在\")\n        return\n\n    # 新结构：扁平化目录\n    # - output/news/*.db\n    # - output/rss/*.db\n    # - output/txt/{date}/*.txt\n    # - output/html/{date}/*.html\n\n    # 检查 news 数据库\n    news_dir = output_dir / \"news\"\n    if news_dir.exists():\n        db_files = sorted(news_dir.glob(\"*.db\"), key=lambda x: x.name, reverse=True)\n        if db_files:\n            print(f\"  💾 热榜数据库 (news/): {len(db_files)} 个\")\n            for db_file in db_files[:5]:\n                mtime = time.ctime(db_file.stat().st_mtime)\n                size_kb = db_file.stat().st_size // 1024\n                print(f\"    📀 {db_file.name} ({size_kb}KB, {mtime.split()[3][:5]})\")\n            if len(db_files) > 5:\n                print(f\"    ... 还有 {len(db_files) - 5} 个\")\n\n    # 检查 RSS 数据库\n    rss_dir = output_dir / \"rss\"\n    if rss_dir.exists():\n        db_files = sorted(rss_dir.glob(\"*.db\"), key=lambda x: x.name, reverse=True)\n        if db_files:\n            print(f\"  📰 RSS 数据库 (rss/): {len(db_files)} 个\")\n            for db_file in db_files[:5]:\n                mtime = time.ctime(db_file.stat().st_mtime)\n                size_kb = db_file.stat().st_size // 1024\n                print(f\"    📀 {db_file.name} ({size_kb}KB, {mtime.split()[3][:5]})\")\n            if len(db_files) > 5:\n                print(f\"    ... 还有 {len(db_files) - 5} 个\")\n\n    # 检查 TXT 快照目录\n    txt_dir = output_dir / \"txt\"\n    if txt_dir.exists():\n        date_dirs = sorted([d for d in txt_dir.iterdir() if d.is_dir()], reverse=True)\n        if date_dirs:\n            print(f\"  📄 TXT 快照 (txt/): {len(date_dirs)} 天\")\n            for date_dir in date_dirs[:3]:\n                txt_files = list(date_dir.glob(\"*.txt\"))\n                if txt_files:\n                    recent = sorted(txt_files, key=lambda x: x.stat().st_mtime, reverse=True)[0]\n                    mtime = time.ctime(recent.stat().st_mtime)\n                    print(f\"    📅 {date_dir.name}: {len(txt_files)} 个文件 (最新: {mtime.split()[3][:5]})\")\n\n    # 检查 HTML 报告目录\n    html_dir = output_dir / \"html\"\n    if html_dir.exists():\n        date_dirs = sorted([d for d in html_dir.iterdir() if d.is_dir()], reverse=True)\n        if date_dirs:\n            print(f\"  🌐 HTML 报告 (html/): {len(date_dirs)} 天\")\n            for date_dir in date_dirs[:3]:\n                html_files = list(date_dir.glob(\"*.html\"))\n                if html_files:\n                    recent = sorted(html_files, key=lambda x: x.stat().st_mtime, reverse=True)[0]\n                    mtime = time.ctime(recent.stat().st_mtime)\n                    print(f\"    📅 {date_dir.name}: {len(html_files)} 个文件 (最新: {mtime.split()[3][:5]})\")\n\n\ndef show_logs():\n    \"\"\"显示实时日志\"\"\"\n    print(\"📋 实时日志 (按 Ctrl+C 退出):\")\n    print(\"💡 提示: 这将显示 PID 1 进程的输出\")\n    try:\n        # 尝试多种方法查看日志\n        log_files = [\n            \"/proc/1/fd/1\",  # PID 1 的标准输出\n            \"/proc/1/fd/2\",  # PID 1 的标准错误\n        ]\n        \n        for log_file in log_files:\n            if Path(log_file).exists():\n                print(f\"📄 尝试读取: {log_file}\")\n                subprocess.run([\"tail\", \"-f\", log_file], check=True)\n                break\n        else:\n            print(\"📋 无法找到标准日志文件，建议使用: docker logs trendradar\")\n            \n    except KeyboardInterrupt:\n        print(\"\\n👋 退出日志查看\")\n    except Exception as e:\n        print(f\"❌ 查看日志失败: {e}\")\n        print(\"💡 建议使用: docker logs trendradar\")\n\n\ndef restart_supercronic():\n    \"\"\"重启supercronic进程\"\"\"\n    print(\"🔄 重启supercronic...\")\n    print(\"⚠️ 注意: supercronic 是 PID 1，无法直接重启\")\n\n    # 检查当前 PID 1\n    try:\n        with open('/proc/1/cmdline', 'r') as f:\n            pid1_cmdline = f.read().replace('\\x00', ' ').strip()\n        print(f\"  🔍 当前 PID 1: {pid1_cmdline}\")\n\n        if \"supercronic\" in pid1_cmdline.lower():\n            print(\"  ✅ PID 1 是 supercronic\")\n            print(\"  💡 要重启 supercronic，需要重启整个容器:\")\n            print(\"    docker restart trendradar\")\n        else:\n            print(\"  ❌ PID 1 不是 supercronic，这是异常状态\")\n            print(\"  💡 建议重启容器以修复问题:\")\n            print(\"    docker restart trendradar\")\n    except Exception as e:\n        print(f\"  ❌ 无法检查 PID 1: {e}\")\n        print(\"  💡 建议重启容器: docker restart trendradar\")\n\n\ndef _read_proc_cmdline(pid: int) -> str:\n    \"\"\"读取进程 cmdline，失败时返回空字符串。\"\"\"\n    proc_cmdline = Path(f\"/proc/{pid}/cmdline\")\n    if not proc_cmdline.exists():\n        return \"\"\n    try:\n        with open(proc_cmdline, \"rb\") as f:\n            return f.read().replace(b\"\\x00\", b\" \").decode(\"utf-8\", errors=\"ignore\").strip()\n    except Exception:\n        return \"\"\n\n\ndef _is_expected_webserver_process(pid: int) -> bool:\n    \"\"\"检查 pid 是否是当前端口的 http.server 进程。\"\"\"\n    cmdline = _read_proc_cmdline(pid)\n    if not cmdline:\n        return False\n    return \"http.server\" in cmdline and str(WEBSERVER_PORT) in cmdline\n\n\ndef _is_manual_stop_requested() -> bool:\n    \"\"\"是否处于手动停服状态。\"\"\"\n    return Path(WEBSERVER_MANUAL_STOP_FILE).exists()\n\n\ndef _set_manual_stop_marker():\n    \"\"\"写入手动停服标记，防止 watchdog 自动拉起。\"\"\"\n    try:\n        with open(WEBSERVER_MANUAL_STOP_FILE, \"w\", encoding=\"utf-8\") as f:\n            f.write(get_timestamp())\n    except Exception:\n        pass\n\n\ndef _clear_manual_stop_marker():\n    \"\"\"清理手动停服标记。\"\"\"\n    try:\n        if Path(WEBSERVER_MANUAL_STOP_FILE).exists():\n            os.remove(WEBSERVER_MANUAL_STOP_FILE)\n    except Exception:\n        pass\n\n\ndef _terminate_webserver_process(pid: int, require_expected: bool = True) -> bool:\n    \"\"\"尝试终止 Web 服务器进程。\n\n    require_expected=True 时，仅终止确认是 http.server 的进程，避免误杀。\n    \"\"\"\n    try:\n        os.kill(pid, 0)\n    except OSError:\n        return True\n\n    if require_expected and not _is_expected_webserver_process(pid):\n        print(f\"  ⚠️ PID {pid} 存在但并非 Web 服务器进程，跳过终止\")\n        return False\n\n    try:\n        os.kill(pid, signal.SIGTERM)\n        time.sleep(0.5)\n        try:\n            os.kill(pid, 0)\n            os.kill(pid, signal.SIGKILL)\n            print(f\"  ⚠️ 强制停止 Web 服务器 (PID: {pid})\")\n        except OSError:\n            print(f\"  ✅ Web 服务器已停止 (PID: {pid})\")\n        return True\n    except OSError:\n        return True\n\n\ndef _is_webserver_running(pid: int) -> bool:\n    \"\"\"检查 Web 服务器进程是否真正在运行。\"\"\"\n    try:\n        os.kill(pid, 0)\n    except OSError:\n        return False\n\n    if not _is_expected_webserver_process(pid):\n        return False\n\n    try:\n        import urllib.request\n        req = urllib.request.Request(f\"http://127.0.0.1:{WEBSERVER_PORT}/\", method=\"HEAD\")\n        urllib.request.urlopen(req, timeout=3)\n        return True\n    except Exception:\n        try:\n            time.sleep(1)\n            import urllib.request\n            req = urllib.request.Request(f\"http://127.0.0.1:{WEBSERVER_PORT}/\", method=\"HEAD\")\n            urllib.request.urlopen(req, timeout=3)\n            return True\n        except Exception:\n            return False\n\n\ndef _cleanup_stale_pid():\n    \"\"\"清理失效的 PID 文件\"\"\"\n    if not Path(WEBSERVER_PID_FILE).exists():\n        return False\n\n    try:\n        with open(WEBSERVER_PID_FILE, 'r') as f:\n            old_pid = int(f.read().strip())\n        os.remove(WEBSERVER_PID_FILE)\n        print(f\"  🧹 清理失效 PID 文件 (PID: {old_pid})\")\n        return True\n    except Exception:\n        return False\n\n\ndef start_webserver(force: bool = False):\n    \"\"\"启动 Web 服务器托管 output 目录\"\"\"\n    print(f\"🌐 启动 Web 服务器 (端口: {WEBSERVER_PORT})...\")\n    print(f\"  🔒 安全提示：仅提供静态文件访问，限制在 {WEBSERVER_DIR} 目录\")\n\n    if force:\n        _clear_manual_stop_marker()\n    elif _is_manual_stop_requested():\n        print(\"  ℹ️ 检测到手动停服标记，跳过自动启动\")\n        return\n\n    # 检查是否已经运行\n    if Path(WEBSERVER_PID_FILE).exists():\n        try:\n            with open(WEBSERVER_PID_FILE, 'r') as f:\n                old_pid = int(f.read().strip())\n\n            # 使用增强的进程检查\n            if _is_webserver_running(old_pid):\n                print(f\"  ⚠️ Web 服务器已在运行 (PID: {old_pid})\")\n                print(f\"  💡 访问: http://localhost:{WEBSERVER_PORT}\")\n                print(\"  💡 停止服务: python manage.py stop_webserver\")\n                return\n\n            # 进程异常时优先尝试终止旧进程，避免端口占用导致重启失败\n            _terminate_webserver_process(old_pid, require_expected=True)\n            _cleanup_stale_pid()\n            print(f\"  ℹ️ 检测到失效的 PID 文件，已清理\")\n\n        except Exception as e:\n            print(f\"  ⚠️ 清理旧的 PID 文件: {e}\")\n            _cleanup_stale_pid()\n\n    # 检查目录是否存在\n    if not Path(WEBSERVER_DIR).exists():\n        print(f\"  ❌ 目录不存在: {WEBSERVER_DIR}\")\n        return\n\n    try:\n        # 启动 HTTP 服务器\n        # 使用 --bind 绑定到 0.0.0.0 使容器内部可访问\n        # 工作目录限制在 WEBSERVER_DIR，防止访问其他目录\n        process = subprocess.Popen(\n            [sys.executable, '-m', 'http.server', str(WEBSERVER_PORT), '--bind', '0.0.0.0'],\n            cwd=WEBSERVER_DIR,\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n            start_new_session=True\n        )\n\n        # 等待一下确保服务器启动\n        time.sleep(1)\n\n        # 检查进程是否还在运行\n        if process.poll() is None:\n            # 保存 PID\n            with open(WEBSERVER_PID_FILE, 'w') as f:\n                f.write(str(process.pid))\n            _clear_manual_stop_marker()\n\n            print(f\"  ✅ Web 服务器已启动 (PID: {process.pid})\")\n            print(f\"  📁 服务目录: {WEBSERVER_DIR} (只读，仅静态文件)\")\n            print(f\"  🌐 访问地址: http://localhost:{WEBSERVER_PORT}\")\n            print(f\"  📄 首页: http://localhost:{WEBSERVER_PORT}/index.html\")\n            print(\"  💡 停止服务: python manage.py stop_webserver\")\n        else:\n            print(f\"  ❌ Web 服务器启动失败\")\n    except Exception as e:\n        print(f\"  ❌ 启动失败: {e}\")\n\n\ndef stop_webserver():\n    \"\"\"停止 Web 服务器\"\"\"\n    print(\"🛑 停止 Web 服务器...\")\n    _set_manual_stop_marker()\n\n    if not Path(WEBSERVER_PID_FILE).exists():\n        print(\"  ℹ️ Web 服务器未运行\")\n        print(\"  ℹ️ 已写入手动停服标记，watchdog 不会自动拉起\")\n        return\n\n    try:\n        with open(WEBSERVER_PID_FILE, 'r') as f:\n            pid = int(f.read().strip())\n        _terminate_webserver_process(pid, require_expected=True)\n        if Path(WEBSERVER_PID_FILE).exists():\n            os.remove(WEBSERVER_PID_FILE)\n        print(\"  ℹ️ 已写入手动停服标记，watchdog 不会自动拉起\")\n    except Exception as e:\n        print(f\"  ❌ 停止失败: {e}\")\n        # 尝试清理 PID 文件\n        try:\n            os.remove(WEBSERVER_PID_FILE)\n        except:\n            pass\n\n\ndef webserver_status():\n    \"\"\"查看 Web 服务器状态\"\"\"\n    print(\"🌐 Web 服务器状态:\")\n\n    if not Path(WEBSERVER_PID_FILE).exists():\n        print(\"  ⭕ 未运行\")\n        if _is_manual_stop_requested():\n            print(\"  ℹ️ 当前为手动停服状态，watchdog 不会自动拉起\")\n        print(f\"  💡 启动服务: python manage.py start_webserver\")\n        return\n\n    try:\n        with open(WEBSERVER_PID_FILE, 'r') as f:\n            pid = int(f.read().strip())\n\n        # 使用增强的进程检查\n        if _is_webserver_running(pid):\n            print(f\"  ✅ 运行中 (PID: {pid})\")\n            print(f\"  📁 服务目录: {WEBSERVER_DIR}\")\n            print(f\"  🌐 访问地址: http://localhost:{WEBSERVER_PORT}\")\n            print(f\"  📄 首页: http://localhost:{WEBSERVER_PORT}/index.html\")\n            print(\"  💡 停止服务: python manage.py stop_webserver\")\n        else:\n            print(f\"  ⭕ 未运行 (PID 文件存在但进程不可用)\")\n            _cleanup_stale_pid()\n            print(\"  💡 启动服务: python manage.py start_webserver\")\n    except Exception as e:\n        print(f\"  ❌ 状态检查失败: {e}\")\n\n\ndef webserver_autofix():\n    \"\"\"Web 服务器健康检查和自动修复\n\n    供 watchdog/定时任务调用，检查服务状态并在需要时自动重启。\n    输出日志格式便于外部监控系统解析。\n    \"\"\"\n    if _is_manual_stop_requested():\n        if WEBSERVER_AUTOFIX_LOG_HEALTHY:\n            print(f\"[{get_timestamp()}] ℹ️ 手动停服状态，跳过自动修复\")\n        return\n\n    if not Path(WEBSERVER_PID_FILE).exists():\n        print(f\"[{get_timestamp()}] ℹ️ Web 服务器未运行，启动中...\")\n        start_webserver(force=False)\n        return\n\n    try:\n        with open(WEBSERVER_PID_FILE, 'r') as f:\n            pid = int(f.read().strip())\n\n        # 使用增强检查\n        if not _is_webserver_running(pid):\n            print(f\"[{get_timestamp()}] ⚠️ Web 服务器不可用 (PID: {pid})，尝试重启...\")\n            _terminate_webserver_process(pid, require_expected=True)\n            _cleanup_stale_pid()\n            start_webserver(force=False)\n            return\n\n        if WEBSERVER_AUTOFIX_LOG_HEALTHY:\n            print(f\"[{get_timestamp()}] ✅ Web 服务器健康 (PID: {pid})\")\n\n    except Exception as e:\n        print(f\"[{get_timestamp()}] ❌ 健康检查异常: {e}\")\n        _cleanup_stale_pid()\n        start_webserver(force=False)\n\n\ndef show_help():\n    \"\"\"显示帮助信息\"\"\"\n    help_text = \"\"\"\n🐳 TrendRadar 容器管理工具\n\n📋 命令列表:\n  run              - 手动执行一次爬虫\n  status           - 显示容器运行状态\n  config           - 显示当前配置\n  files            - 显示输出文件\n  logs             - 实时查看日志\n  restart          - 重启说明\n  start_webserver  - 启动 Web 服务器托管 output 目录\n  stop_webserver   - 停止 Web 服务器\n  webserver_status - 查看 Web 服务器状态\n  help             - 显示此帮助\n\n📖 使用示例:\n  # 在容器中执行\n  python manage.py run\n  python manage.py status\n  python manage.py logs\n  python manage.py start_webserver\n\n  # 在宿主机执行\n  docker exec -it trendradar python manage.py run\n  docker exec -it trendradar python manage.py status\n  docker exec -it trendradar python manage.py start_webserver\n  docker logs trendradar\n\n💡 常用操作指南:\n  1. 检查运行状态: status\n     - 查看 supercronic 是否为 PID 1\n     - 检查配置文件和关键文件\n     - 查看 cron 调度设置\n\n  2. 手动执行测试: run\n     - 立即执行一次新闻爬取\n     - 测试程序是否正常工作\n\n  3. 查看日志: logs\n     - 实时监控运行情况\n     - 也可使用: docker logs trendradar\n\n  4. 重启服务: restart\n     - 由于 supercronic 是 PID 1，需要重启整个容器\n     - 使用: docker restart trendradar\n\n  5. Web 服务器管理:\n     - 启动: start_webserver\n     - 停止: stop_webserver（写入手动停服标记，watchdog 不自动拉起）\n     - 状态: webserver_status\n     - 访问: http://localhost:8080\n\"\"\"\n    print(help_text)\n\n\ndef main():\n    if len(sys.argv) < 2:\n        show_help()\n        return\n\n    command = sys.argv[1]\n    commands = {\n        \"run\": manual_run,\n        \"status\": show_status,\n        \"config\": show_config,\n        \"files\": show_files,\n        \"logs\": show_logs,\n        \"restart\": restart_supercronic,\n        \"start_webserver\": lambda: start_webserver(force=True),\n        \"stop_webserver\": stop_webserver,\n        \"webserver_status\": webserver_status,\n        \"webserver_autofix\": webserver_autofix,\n        \"help\": show_help,\n    }\n\n    if command in commands:\n        try:\n            commands[command]()\n        except KeyboardInterrupt:\n            print(\"\\n👋 操作已取消\")\n        except Exception as e:\n            print(f\"❌ 执行出错: {e}\")\n    else:\n        print(f\"❌ 未知命令: {command}\")\n        print(\"运行 'python manage.py help' 查看可用命令\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "docs/assets/script.js",
    "content": "/**\n * TrendRadar 配置文件编辑器核心逻辑\n * 特点：确保原始 YAML 的注释和格式 100% 保留\n */\n\n// ==========================================\n// 0. 注释高亮功能\n// ==========================================\n\n/**\n * 对文本应用高亮，# 后的内容显示为灰色\n */\nfunction applyHighlight(text) {\n    const escape = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n    return text.split('\\n').map(line => {\n        const idx = line.indexOf('#');\n        if (idx === -1) return escape(line);\n        return escape(line.slice(0, idx)) + '<span class=\"syntax-comment\">' + escape(line.slice(idx)) + '</span>';\n    }).join('\\n');\n}\n\n/**\n * 更新高亮层\n */\nfunction updateBackdrop(textareaId, backdropId) {\n    const ta = document.getElementById(textareaId);\n    const bd = document.getElementById(backdropId);\n    if (ta && bd) bd.innerHTML = applyHighlight(ta.value) + '\\n';\n}\n\n/**\n * 同步滚动\n */\nfunction syncScroll(textareaId, backdropId) {\n    const ta = document.getElementById(textareaId);\n    const bd = document.getElementById(backdropId);\n    if (ta && bd) {\n        bd.scrollTop = ta.scrollTop;\n        bd.scrollLeft = ta.scrollLeft;\n    }\n}\n\n// ==========================================\n// 12. 二维码放大弹窗逻辑\n// ==========================================\n\nconst QR_MODAL_DATA = {\n    weixin: {\n        icon: '<i class=\"fa-brands fa-weixin text-green-600\"></i>',\n        iconBg: 'bg-green-100',\n        title: '不迷路',\n        subtitle: '第一时间获取更新通知',\n        img: './assets/weixin.webp',\n        alt: '微信公众号',\n        hint: '微信扫码关注公众号'\n    },\n    donate: {\n        icon: '<i class=\"fa-solid fa-hand-holding-heart text-emerald-600\"></i>',\n        iconBg: 'bg-emerald-100',\n        title: '随心赞赏',\n        subtitle: '金额随意，1 元也是鼓励 (´▽`ʃ♡ƪ)',\n        img: 'https://cdn-1258574687.cos.ap-shanghai.myqcloud.com/img/%2F2026%2F01%2F18ecce7c224ce0ea4c59394c29e408f8-e0d1db45.webp',\n        alt: '微信支付',\n        hint: '微信扫码 · 丰俭由人'\n    }\n};\n\nfunction openQrModal(type) {\n    const data = QR_MODAL_DATA[type];\n    if (!data) return;\n    const modal = document.getElementById('qr-modal');\n    document.getElementById('qr-modal-icon').className = 'w-10 h-10 rounded-xl flex items-center justify-center text-lg ' + data.iconBg;\n    document.getElementById('qr-modal-icon').innerHTML = data.icon;\n    document.getElementById('qr-modal-title').textContent = data.title;\n    document.getElementById('qr-modal-subtitle').textContent = data.subtitle;\n    document.getElementById('qr-modal-img').src = data.img;\n    document.getElementById('qr-modal-img').alt = data.alt;\n    document.getElementById('qr-modal-hint').textContent = data.hint;\n    modal.classList.remove('hidden');\n}\n\nfunction closeQrModal() {\n    const modal = document.getElementById('qr-modal');\n    if (modal) modal.classList.add('hidden');\n}\n\nwindow.openQrModal = openQrModal;\nwindow.closeQrModal = closeQrModal;\nconst MODULE_DEFS = [\n    { id: 1, name: \"1. 基础设置\", key: \"app\", editable: false },\n    { id: 2, name: \"2. 数据源 - 热榜平台\", key: \"platforms\", editable: true },\n    { id: 3, name: \"3. 数据源 - RSS 订阅\", key: \"rss\", editable: true },\n    { id: 4, name: \"4. 报告模式\", key: \"report\", editable: true },\n    { id: \"4.5\", name: \"4.5 筛选策略\", key: \"filter\", editable: true },\n    { id: \"4.6\", name: \"4.6 AI 智能筛选\", key: \"ai_filter\", editable: true },\n    { id: 5, name: \"5. 推送内容控制\", key: \"display\", editable: true },\n    { id: 6, name: \"6. 推送通知\", key: \"notification\", editable: true, partial: true },\n    { id: 7, name: \"7. 存储配置\", key: \"storage\", editable: false },\n    { id: 8, name: \"8. AI 模型配置\", key: \"ai\", editable: true },\n    { id: 9, name: \"9. AI 分析功能\", key: \"ai_analysis\", editable: true },\n    { id: 10, name: \"10. AI 翻译功能\", key: \"ai_translation\", editable: true },\n    { id: 11, name: \"11. 高级设置\", key: \"advanced\", editable: false }\n];\n\n// 初始默认内容 (用于空状态) - 只显示提示文本\nconst INITIAL_YAML = `# 在此粘贴你的 config.yaml...\n# 或拖拽文件到编辑器区域\n# 或点击右上角\"加载官网最新配置\"`;\n\n// LocalStorage 键名\nconst STORAGE_KEY_CONFIG = 'trendradar_config_yaml';\nconst STORAGE_KEY_FREQUENCY = 'trendradar_frequency_txt';\nconst STORAGE_KEY_TIMELINE = 'trendradar_timeline_yaml';\nconst STORAGE_KEY_CONFIG_TIME = 'trendradar_config_time';\nconst STORAGE_KEY_FREQUENCY_TIME = 'trendradar_frequency_time';\nconst STORAGE_KEY_TIMELINE_TIME = 'trendradar_timeline_time';\n\n// 官网配置文件 URL\nconst REMOTE_CONFIG_URL = 'https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/config/config.yaml';\nconst REMOTE_FREQUENCY_URL = 'https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/config/frequency_words.txt';\nconst REMOTE_TIMELINE_URL = 'https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/config/timeline.yaml';\nconst REMOTE_VERSION_URL = 'https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/version_configs';\n\nlet currentYaml = \"\";\nlet currentFrequency = \"\";\nlet currentTimeline = \"\";\nlet currentFrequencyData = null;  // 缓存解析后的数据，避免重复解析导致索引错位\nlet currentTab = \"config\";\n\n// ==========================================\n// 2. 初始化与事件绑定\n// ==========================================\n// 防抖定时器\nlet configSaveTimer = null;\nlet frequencySaveTimer = null;\nlet timelineSaveTimer = null;\n\ndocument.addEventListener('DOMContentLoaded', () => {\n    const yamlEditor = document.getElementById('yaml-editor');\n    const frequencyEditor = document.getElementById('frequency-editor');\n\n    // 尝试从 LocalStorage 恢复配置\n    const savedConfig = localStorage.getItem(STORAGE_KEY_CONFIG);\n    const savedFrequency = localStorage.getItem(STORAGE_KEY_FREQUENCY);\n\n    // 初始化编辑器\n    if (savedConfig && savedConfig.trim() && savedConfig !== INITIAL_YAML) {\n        yamlEditor.value = savedConfig;\n        currentYaml = savedConfig;\n        showToast('已恢复上次保存的配置', 'info');\n    } else {\n        yamlEditor.value = INITIAL_YAML;\n        currentYaml = INITIAL_YAML;\n    }\n\n    if (savedFrequency && savedFrequency.trim()) {\n        frequencyEditor.value = savedFrequency;\n        currentFrequency = savedFrequency;\n    } else {\n        frequencyEditor.value = \"# 在此粘贴你的 frequency_words.txt 内容...\\n# 或拖拽文件到编辑器区域\\n\\n[GLOBAL_FILTER]\\n\\n[WORD_GROUPS]\\n\";\n        currentFrequency = frequencyEditor.value;\n    }\n\n    // 初始化 Timeline 编辑器\n    const timelineEditor = document.getElementById('timeline-editor');\n    const savedTimeline = localStorage.getItem(STORAGE_KEY_TIMELINE);\n\n    const INITIAL_TIMELINE = `# 在此粘贴你的 timeline.yaml...\\n# 或拖拽文件到编辑器区域\\n# 或点击右上角\"加载官网最新配置\"`;\n\n    if (savedTimeline && savedTimeline.trim() && savedTimeline !== INITIAL_TIMELINE) {\n        timelineEditor.value = savedTimeline;\n        currentTimeline = savedTimeline;\n    } else {\n        timelineEditor.value = INITIAL_TIMELINE;\n        currentTimeline = INITIAL_TIMELINE;\n    }\n\n    // 渲染右侧模块列表\n    renderModules();\n\n    // 监听编辑器输入（实时同步到 UI + 防抖保存）\n    yamlEditor.addEventListener('input', (e) => {\n        currentYaml = e.target.value;\n        updateBackdrop('yaml-editor', 'yaml-backdrop');\n        syncYamlToUI();\n        debounceSaveConfig();\n    });\n\n    frequencyEditor.addEventListener('input', (e) => {\n        currentFrequency = e.target.value;\n        updateBackdrop('frequency-editor', 'frequency-backdrop');\n        currentFrequencyData = null;\n        syncFrequencyToUI();\n        debounceSaveFrequency();\n    });\n\n    timelineEditor.addEventListener('input', (e) => {\n        currentTimeline = e.target.value;\n        updateBackdrop('timeline-editor', 'timeline-backdrop');\n        syncTimelineToUI();\n        debounceSaveTimeline();\n    });\n\n    // 同步滚动\n    yamlEditor.addEventListener('scroll', () => syncScroll('yaml-editor', 'yaml-backdrop'));\n    frequencyEditor.addEventListener('scroll', () => syncScroll('frequency-editor', 'frequency-backdrop'));\n    timelineEditor.addEventListener('scroll', () => syncScroll('timeline-editor', 'timeline-backdrop'));\n\n    // 初始化拖拽上传功能\n    initDragAndDrop(yamlEditor, 'config');\n    initDragAndDrop(frequencyEditor, 'frequency');\n    initDragAndDrop(timelineEditor, 'timeline');\n\n    // 页面关闭/刷新时立即保存\n    window.addEventListener('beforeunload', saveAllToLocalStorage);\n\n    document.addEventListener('keydown', function(e) {\n        if ((e.ctrlKey || e.metaKey) && e.key === 's') {\n            e.preventDefault();\n            saveAllToLocalStorage();\n            showToast('已手动保存配置', 'success');\n        }\n    });\n\n    syncYamlToUI();\n\n    updateBackdrop('yaml-editor', 'yaml-backdrop');\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n\n    updateSaveTimeDisplay();\n});\n\n// 防抖保存 config.yaml\nfunction debounceSaveConfig() {\n    if (configSaveTimer) clearTimeout(configSaveTimer);\n    configSaveTimer = setTimeout(() => {\n        saveConfigToLocalStorage();\n    }, 1000);\n}\n\n// 防抖保存 frequency_words.txt\nfunction debounceSaveFrequency() {\n    if (frequencySaveTimer) clearTimeout(frequencySaveTimer);\n    frequencySaveTimer = setTimeout(() => {\n        saveFrequencyToLocalStorage();\n    }, 1000);\n}\n\n// 防抖保存 timeline.yaml\nfunction debounceSaveTimeline() {\n    if (timelineSaveTimer) clearTimeout(timelineSaveTimer);\n    timelineSaveTimer = setTimeout(() => {\n        saveTimelineToLocalStorage();\n    }, 1000);\n}\n\n// ==========================================\n// 2.1 拖拽上传功能\n// ==========================================\nfunction initDragAndDrop(editor, type) {\n    const container = editor.parentElement;\n\n    const dropOverlay = document.createElement('div');\n    dropOverlay.className = 'drop-overlay hidden';\n    dropOverlay.innerHTML = `\n        <div class=\"drop-overlay-content\">\n            <i class=\"fa-solid fa-cloud-arrow-up text-4xl mb-2\"></i>\n            <div class=\"text-sm font-bold\">释放以加载文件</div>\n            <div class=\"text-xs opacity-75\">${type === 'config' ? 'config.yaml' : type === 'timeline' ? 'timeline.yaml' : 'frequency_words.txt'}</div>\n        </div>\n    `;\n    container.style.position = 'relative';\n    container.appendChild(dropOverlay);\n\n    editor.addEventListener('dragover', (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        dropOverlay.classList.remove('hidden');\n    });\n\n    editor.addEventListener('dragleave', (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        if (!container.contains(e.relatedTarget)) {\n            dropOverlay.classList.add('hidden');\n        }\n    });\n\n    dropOverlay.addEventListener('dragleave', (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        if (!container.contains(e.relatedTarget)) {\n            dropOverlay.classList.add('hidden');\n        }\n    });\n\n    dropOverlay.addEventListener('dragover', (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n    });\n\n    dropOverlay.addEventListener('drop', (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        dropOverlay.classList.add('hidden');\n        handleFileDrop(e, type);\n    });\n\n    editor.addEventListener('drop', (e) => {\n        e.preventDefault();\n        e.stopPropagation();\n        dropOverlay.classList.add('hidden');\n        handleFileDrop(e, type);\n    });\n}\n\nfunction handleFileDrop(e, type) {\n    const files = e.dataTransfer.files;\n    if (files.length === 0) return;\n\n    const file = files[0];\n\n    const validExtensions = type === 'config'\n        ? ['.yaml', '.yml', '.txt']\n        : type === 'timeline'\n        ? ['.yaml', '.yml']\n        : ['.txt', '.yaml', '.yml'];\n\n    const fileName = file.name.toLowerCase();\n    const isValid = validExtensions.some(ext => fileName.endsWith(ext));\n\n    if (!isValid) {\n        showToast(`请拖入 ${type === 'config' || type === 'timeline' ? 'YAML' : 'TXT'} 文件`, 'error');\n        return;\n    }\n\n    const reader = new FileReader();\n    reader.onload = (event) => {\n        const content = event.target.result;\n\n        if (type === 'config') {\n            try {\n                jsyaml.load(content);\n                document.getElementById('yaml-editor').value = content;\n                currentYaml = content;\n                syncYamlToUI();\n                showToast(`已加载: ${file.name}`, 'success');\n            } catch (err) {\n                showToast(`YAML 语法错误: ${err.message}`, 'error');\n                // 仍然加载，让用户修复\n                document.getElementById('yaml-editor').value = content;\n                currentYaml = content;\n            }\n        } else if (type === 'timeline') {\n            try {\n                jsyaml.load(content);\n                document.getElementById('timeline-editor').value = content;\n                currentTimeline = content;\n                updateBackdrop('timeline-editor', 'timeline-backdrop');\n                syncTimelineToUI();\n                showToast(`已加载: ${file.name}`, 'success');\n            } catch (err) {\n                showToast(`YAML 语法错误: ${err.message}`, 'error');\n                document.getElementById('timeline-editor').value = content;\n                currentTimeline = content;\n            }\n        } else {\n            document.getElementById('frequency-editor').value = content;\n            currentFrequency = content;\n            syncFrequencyToUI();\n            showToast(`已加载: ${file.name}`, 'success');\n        }\n    };\n\n    reader.onerror = () => {\n        showToast('文件读取失败', 'error');\n    };\n\n    reader.readAsText(file);\n}\n\n// ==========================================\n// 2.2 LocalStorage 保存与恢复\n// ==========================================\n\n// 保存 config.yaml\nfunction saveConfigToLocalStorage() {\n    try {\n        if (currentYaml && currentYaml.trim().length > 10) {\n            const now = new Date().toISOString();\n            localStorage.setItem(STORAGE_KEY_CONFIG, currentYaml);\n            localStorage.setItem(STORAGE_KEY_CONFIG_TIME, now);\n            updateSaveTimeDisplay();\n        }\n    } catch (e) {\n        console.warn('LocalStorage 保存 config 失败:', e);\n    }\n}\n\n// 保存 frequency_words.txt\nfunction saveFrequencyToLocalStorage() {\n    try {\n        if (currentFrequency && currentFrequency.trim().length > 10) {\n            const now = new Date().toISOString();\n            localStorage.setItem(STORAGE_KEY_FREQUENCY, currentFrequency);\n            localStorage.setItem(STORAGE_KEY_FREQUENCY_TIME, now);\n            updateSaveTimeDisplay();\n        }\n    } catch (e) {\n        console.warn('LocalStorage 保存 frequency 失败:', e);\n    }\n}\n\n// 保存 timeline.yaml\nfunction saveTimelineToLocalStorage() {\n    try {\n        if (currentTimeline && currentTimeline.trim().length > 10) {\n            const now = new Date().toISOString();\n            localStorage.setItem(STORAGE_KEY_TIMELINE, currentTimeline);\n            localStorage.setItem(STORAGE_KEY_TIMELINE_TIME, now);\n            updateSaveTimeDisplay();\n        }\n    } catch (e) {\n        console.warn('LocalStorage 保存 timeline 失败:', e);\n    }\n}\n\n// 保存全部（页面关闭时调用）\nfunction saveAllToLocalStorage() {\n    saveConfigToLocalStorage();\n    saveFrequencyToLocalStorage();\n    saveTimelineToLocalStorage();\n}\n\n// 兼容旧调用\nfunction saveToLocalStorage() {\n    saveAllToLocalStorage();\n}\n\n// 格式化时间显示\nfunction formatSaveTime(isoString) {\n    if (!isoString) return '未保存';\n    const date = new Date(isoString);\n    const now = new Date();\n    const diffMs = now - date;\n    const diffMins = Math.floor(diffMs / 60000);\n    const diffHours = Math.floor(diffMs / 3600000);\n    const diffDays = Math.floor(diffMs / 86400000);\n\n    if (diffMins < 1) return '刚刚';\n    if (diffMins < 60) return `${diffMins} 分钟前`;\n    if (diffHours < 24) return `${diffHours} 小时前`;\n    if (diffDays < 7) return `${diffDays} 天前`;\n\n    return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });\n}\n\n// 更新保存时间显示\nfunction updateSaveTimeDisplay() {\n    const configTime = localStorage.getItem(STORAGE_KEY_CONFIG_TIME);\n    const frequencyTime = localStorage.getItem(STORAGE_KEY_FREQUENCY_TIME);\n\n    // 更新 config.yaml 的时间显示\n    const configTimeEl = document.getElementById('config-save-time');\n    const configLabelEl = document.getElementById('config-save-label');\n    if (configTimeEl) {\n        configTimeEl.textContent = formatSaveTime(configTime);\n        configTimeEl.title = configTime ? new Date(configTime).toLocaleString('zh-CN') : '未保存';\n        if (configLabelEl) {\n            if (configTime) {\n                configLabelEl.classList.remove('hidden');\n            } else {\n                configLabelEl.classList.add('hidden');\n            }\n        }\n    }\n\n    // 更新 frequency_words.txt 的时间显示\n    const frequencyTimeEl = document.getElementById('frequency-save-time');\n    const frequencyLabelEl = document.getElementById('frequency-save-label');\n    if (frequencyTimeEl) {\n        frequencyTimeEl.textContent = formatSaveTime(frequencyTime);\n        frequencyTimeEl.title = frequencyTime ? new Date(frequencyTime).toLocaleString('zh-CN') : '未保存';\n        if (frequencyLabelEl) {\n            if (frequencyTime) {\n                frequencyLabelEl.classList.remove('hidden');\n            } else {\n                frequencyLabelEl.classList.add('hidden');\n            }\n        }\n    }\n\n    // 更新 timeline.yaml 的时间显示\n    const timelineTime = localStorage.getItem(STORAGE_KEY_TIMELINE_TIME);\n    const timelineTimeEl = document.getElementById('timeline-save-time');\n    const timelineLabelEl = document.getElementById('timeline-save-label');\n    if (timelineTimeEl) {\n        timelineTimeEl.textContent = formatSaveTime(timelineTime);\n        timelineTimeEl.title = timelineTime ? new Date(timelineTime).toLocaleString('zh-CN') : '未保存';\n        if (timelineLabelEl) {\n            if (timelineTime) {\n                timelineLabelEl.classList.remove('hidden');\n            } else {\n                timelineLabelEl.classList.add('hidden');\n            }\n        }\n    }\n}\n\n// ==========================================\n// 2.3 加载官网最新配置\n// ==========================================\nwindow.openLoadConfigModal = function() {\n    // 创建选择弹窗\n    const modal = document.createElement('div');\n    modal.id = 'load-config-modal';\n    modal.className = 'modal-overlay';\n    modal.innerHTML = `\n        <div class=\"modal-content\" style=\"max-width: 420px;\">\n            <div class=\"flex items-center justify-between mb-4\">\n                <h3 class=\"text-lg font-bold text-gray-800\"><i class=\"fa-solid fa-cloud-arrow-down mr-2 text-blue-500\"></i>加载官网最新配置</h3>\n                <button onclick=\"closeLoadConfigModal()\" class=\"text-gray-400 hover:text-gray-600\"><i class=\"fa-solid fa-times text-xl\"></i></button>\n            </div>\n            <div class=\"text-sm text-gray-600 mb-4\">\n                选择要从 GitHub 加载的配置文件：\n            </div>\n            <div class=\"space-y-3\">\n                <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\">\n                    <input type=\"checkbox\" id=\"load-config-yaml\" checked class=\"w-4 h-4 text-blue-600 rounded\">\n                    <div class=\"flex-1\">\n                        <div class=\"font-medium text-gray-800\">config.yaml</div>\n                        <div class=\"text-xs text-gray-500\">系统配置、平台、AI、通知等</div>\n                    </div>\n                    <i class=\"fa-solid fa-file-code text-blue-400\"></i>\n                </label>\n                <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\">\n                    <input type=\"checkbox\" id=\"load-frequency-txt\" checked class=\"w-4 h-4 text-blue-600 rounded\">\n                    <div class=\"flex-1\">\n                        <div class=\"font-medium text-gray-800\">frequency_words.txt</div>\n                        <div class=\"text-xs text-gray-500\">关键词组、过滤规则、正则逻辑</div>\n                    </div>\n                    <i class=\"fa-solid fa-filter text-orange-400\"></i>\n                </label>\n                <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\">\n                    <input type=\"checkbox\" id=\"load-timeline-yaml\" checked class=\"w-4 h-4 text-blue-600 rounded\">\n                    <div class=\"flex-1\">\n                        <div class=\"font-medium text-gray-800\">timeline.yaml</div>\n                        <div class=\"text-xs text-gray-500\">调度时间线、预设模板、自定义时间段</div>\n                    </div>\n                    <i class=\"fa-solid fa-calendar-week text-purple-400\"></i>\n                </label>\n            </div>\n            <div class=\"text-xs text-gray-400 mt-3 p-2 bg-gray-50 rounded\">\n                <i class=\"fa-solid fa-info-circle mr-1\"></i>\n                数据来源：<a href=\"https://github.com/sansan0/TrendRadar\" target=\"_blank\" class=\"text-blue-500 hover:underline\">sansan0/TrendRadar</a>\n            </div>\n            <div class=\"flex justify-end gap-2 mt-4\">\n                <button onclick=\"closeLoadConfigModal()\" class=\"px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg\">取消</button>\n                <button onclick=\"confirmLoadConfig()\" class=\"px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700\">\n                    <i class=\"fa-solid fa-download mr-1\"></i>加载选中\n                </button>\n            </div>\n        </div>\n    `;\n    document.body.appendChild(modal);\n}\n\nwindow.closeLoadConfigModal = function() {\n    const modal = document.getElementById('load-config-modal');\n    if (modal) modal.remove();\n}\n\nwindow.confirmLoadConfig = async function() {\n    const loadConfig = document.getElementById('load-config-yaml')?.checked;\n    const loadFrequency = document.getElementById('load-frequency-txt')?.checked;\n    const loadTimeline = document.getElementById('load-timeline-yaml')?.checked;\n\n    if (!loadConfig && !loadFrequency && !loadTimeline) {\n        showToast('请至少选择一个文件', 'warning');\n        return;\n    }\n\n    closeLoadConfigModal();\n    showToast('正在从 GitHub 加载...', 'info');\n\n    try {\n        const promises = [];\n        if (loadConfig) promises.push(fetch(REMOTE_CONFIG_URL).then(r => ({ type: 'config', res: r })));\n        if (loadFrequency) promises.push(fetch(REMOTE_FREQUENCY_URL).then(r => ({ type: 'frequency', res: r })));\n        if (loadTimeline) promises.push(fetch(REMOTE_TIMELINE_URL).then(r => ({ type: 'timeline', res: r })));\n\n        const results = await Promise.all(promises);\n\n        for (const { type, res } of results) {\n            if (!res.ok) {\n                const names = { config: 'config.yaml', frequency: 'frequency_words.txt', timeline: 'timeline.yaml' };\n                throw new Error(`${names[type]} 加载失败: ${res.status}`);\n            }\n\n            const text = await res.text();\n\n            if (type === 'config') {\n                try {\n                    jsyaml.load(text);\n                } catch (yamlErr) {\n                    showToast(`YAML 语法错误: ${yamlErr.message}`, 'error');\n                    continue;\n                }\n                document.getElementById('yaml-editor').value = text;\n                currentYaml = text;\n                updateBackdrop('yaml-editor', 'yaml-backdrop');\n                syncYamlToUI();\n            } else if (type === 'timeline') {\n                try {\n                    jsyaml.load(text);\n                } catch (yamlErr) {\n                    showToast(`YAML 语法错误: ${yamlErr.message}`, 'error');\n                    continue;\n                }\n                document.getElementById('timeline-editor').value = text;\n                currentTimeline = text;\n                updateBackdrop('timeline-editor', 'timeline-backdrop');\n                syncTimelineToUI();\n            } else {\n                document.getElementById('frequency-editor').value = text;\n                currentFrequency = text;\n                currentFrequencyData = null;\n                updateBackdrop('frequency-editor', 'frequency-backdrop');\n                syncFrequencyToUI();\n            }\n        }\n\n        saveToLocalStorage();\n\n        const loadedFiles = [];\n        if (loadConfig) loadedFiles.push('config.yaml');\n        if (loadFrequency) loadedFiles.push('frequency_words.txt');\n        if (loadTimeline) loadedFiles.push('timeline.yaml');\n        showToast(`已加载: ${loadedFiles.join(', ')}`, 'success');\n\n    } catch (err) {\n        console.error('加载远程配置失败:', err);\n        showToast(`加载失败: ${err.message}`, 'error');\n    }\n}\n\n// ==========================================\n// 2.4 Toast 提示\n// ==========================================\nfunction showToast(message, type = 'info') {\n    // 移除已有的 toast\n    const existingToast = document.querySelector('.toast-notification');\n    if (existingToast) existingToast.remove();\n\n    const toast = document.createElement('div');\n    toast.className = `toast-notification toast-${type}`;\n\n    const icons = {\n        success: 'fa-check-circle',\n        error: 'fa-times-circle',\n        info: 'fa-info-circle',\n        warning: 'fa-exclamation-triangle'\n    };\n\n    toast.innerHTML = `\n        <i class=\"fa-solid ${icons[type] || icons.info}\"></i>\n        <span>${message}</span>\n    `;\n\n    document.body.appendChild(toast);\n\n    // 动画入场\n    requestAnimationFrame(() => {\n        toast.classList.add('show');\n    });\n\n    // 自动消失\n    setTimeout(() => {\n        toast.classList.remove('show');\n        setTimeout(() => toast.remove(), 300);\n    }, 3000);\n}\n\n// ==========================================\n// 3. 渲染逻辑\n// ==========================================\nfunction renderModules() {\n    const container = document.getElementById('config-panel');\n    container.innerHTML = '';\n\n    renderModuleNav();\n\n    MODULE_DEFS.forEach(mod => {\n        const card = document.createElement('div');\n        card.className = `module-card ${mod.editable ? 'active' : 'disabled'}`;\n        card.id = `module-${mod.key}`;\n\n        const header = `\n            <div class=\"module-header px-4 py-3 flex items-center justify-between cursor-pointer\" onclick=\"scrollToModuleInEditor('${mod.key}')\">\n                <div class=\"flex items-center\">\n                    <span class=\"text-sm font-bold\">${mod.name}</span>\n                    <i class=\"fa-solid fa-arrow-up-right-from-square text-blue-400 text-[10px] ml-2 opacity-0 group-hover:opacity-100\" title=\"跳转到左侧编辑器\"></i>\n                </div>\n                ${!mod.editable ?\n                    '<span class=\"locked-badge text-[10px] text-gray-400 border border-gray-200 px-1.5 py-0.5 rounded\">只读 (请在左侧编辑)</span>' :\n                    '<i class=\"fa-solid fa-chevron-down text-gray-400 text-xs\"></i>'}\n            </div>\n        `;\n\n        const body = mod.editable ? `<div class=\"module-body p-5 border-t border-gray-50 space-y-4\" id=\"controls-${mod.key}\"></div>` : '';\n\n        card.innerHTML = header + body;\n        container.appendChild(card);\n\n        if (mod.editable) {\n            renderControls(mod);\n        }\n    });\n}\n\n// 渲染模块导航栏\nfunction renderModuleNav() {\n    const nav = document.getElementById('module-nav');\n    if (!nav) return;\n\n    nav.innerHTML = MODULE_DEFS.map(mod => `\n        <button onclick=\"scrollToModuleInEditor('${mod.key}')\"\n                class=\"module-nav-btn text-[10px] px-2 py-1 rounded ${mod.editable ? 'bg-blue-100 text-blue-700 hover:bg-blue-200' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'} transition-colors\"\n                title=\"跳转到模块 ${mod.id}\">\n            ${mod.id}\n        </button>\n    `).join('');\n}\n\n// 切换组名编辑状态\nwindow.toggleGroupNameEdit = function(btn) {\n    const container = btn.parentNode;\n    const span = container.querySelector('span.text-sm');\n    const input = container.querySelector('input[type=\"text\"]');\n\n    if (input.classList.contains('hidden')) {\n        // 进入编辑模式\n        span.classList.add('hidden');\n        input.classList.remove('hidden');\n        input.focus();\n        btn.innerHTML = '<i class=\"fa-solid fa-check text-green-600\"></i>';\n    } else {\n        // 退出编辑模式\n        span.classList.remove('hidden');\n        input.classList.add('hidden');\n        btn.innerHTML = '<i class=\"fa-solid fa-pen\"></i>';\n\n        // 如果内容变化，已经通过 onchange 触发更新\n        span.textContent = input.value;\n    }\n}\n\n// 跳转到左侧编辑器中对应词组的位置\nwindow.scrollToWordGroupInEditor = function(groupIndex) {\n    const editor = document.getElementById('frequency-editor');\n    // 重新解析以确保行号准确\n    const data = parseFrequencyText(editor.value);\n\n    if (!data.wordGroups[groupIndex]) return;\n\n    const targetLineIndex = data.wordGroups[groupIndex].startLine;\n    if (targetLineIndex === undefined || targetLineIndex === -1) return;\n\n    const lines = editor.value.split('\\n');\n    const lineHeight = 19.5;\n    const scrollPosition = targetLineIndex * lineHeight;\n\n    // 设置光标选区\n    let charCount = 0;\n    for (let i = 0; i < targetLineIndex; i++) {\n        charCount += lines[i].length + 1; // +1 for newline\n    }\n\n    editor.focus();\n    editor.setSelectionRange(charCount, charCount + lines[targetLineIndex].length);\n    editor.scrollTop = scrollPosition - 50;\n\n    // 高亮效果\n    editor.style.transition = 'background-color 0.3s';\n    const originalBg = editor.style.backgroundColor;\n    editor.style.backgroundColor = '#2d4a7c';\n    setTimeout(() => {\n        editor.style.backgroundColor = originalBg;\n    }, 300);\n}\n\n// 跳转到左侧编辑器中对应模块的位置\nwindow.scrollToModuleInEditor = function(modKey) {\n    const editor = document.getElementById('yaml-editor');\n    const yaml = editor.value;\n    const lines = yaml.split('\\n');\n\n    // 查找模块标题注释行（# N. 模块名）\n    let targetLineIndex = -1;\n    const mod = MODULE_DEFS.find(m => m.key === modKey);\n    if (!mod) return;\n\n    // 直接匹配包含模块编号的标题行，兼容 \"4.\" 和 \"4.5\" 两种编号格式\n    const escapedId = String(mod.id).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n    const moduleTitlePattern = new RegExp(`^#\\\\s*${escapedId}(?:\\\\.)?\\\\s+`, 'i');\n\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i];\n        // 匹配模块标题行（包含编号的注释行）\n        if (moduleTitlePattern.test(line)) {\n            targetLineIndex = i;\n            break;\n        }\n    }\n\n    // 如果没找到标题行，尝试查找模块键名（如 platforms:）\n    if (targetLineIndex === -1) {\n        for (let i = 0; i < lines.length; i++) {\n            if (lines[i].match(new RegExp(`^${modKey}:\\\\s*`))) {\n                targetLineIndex = i;\n                break;\n            }\n        }\n    }\n\n    if (targetLineIndex === -1) return;\n\n    // 计算目标位置并滚动\n    const lineHeight = 19.5;\n    const scrollPosition = targetLineIndex * lineHeight;\n\n    // 设置光标位置\n    const textBeforeTarget = lines.slice(0, targetLineIndex).join('\\n').length + (targetLineIndex > 0 ? 1 : 0);\n    editor.focus();\n    editor.setSelectionRange(textBeforeTarget, textBeforeTarget + lines[targetLineIndex].length);\n\n    editor.scrollTop = scrollPosition - 5;\n\n    // 高亮提示（闪烁效果）\n    editor.style.transition = 'background-color 0.3s';\n    const originalBg = editor.style.backgroundColor;\n    editor.style.backgroundColor = '#2d4a7c';\n    setTimeout(() => {\n        editor.style.backgroundColor = originalBg;\n    }, 300);\n}\n\nfunction renderControls(mod) {\n    const body = document.getElementById(`controls-${mod.key}`);\n\n    // 根据模块 key 定义不同的 UI 控件\n    let html = \"\";\n\n    switch(mod.key) {\n        case \"platforms\":\n            html = createToggleControl(mod.key, \"enabled\", \"启用热榜抓取\");\n            html += `<div class=\"mt-4 mb-2 text-xs font-bold text-gray-700\">平台列表 <span class=\"text-gray-400 font-normal\">(可拖拽排序)</span></div>`;\n            html += `<div id=\"platforms-list\" class=\"space-y-2\"></div>`;\n            html += `<div class=\"flex items-center gap-2 mt-3\">\n                        <button onclick=\"openPlatformModal()\" class=\"text-xs bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 transition-colors\">\n                            <i class=\"fa-solid fa-plus mr-1\"></i>添加平台\n                        </button>\n                        <a href=\"https://github.com/sansan0/TrendRadar?tab=readme-ov-file#%E9%85%8D%E7%BD%AE%E8%AF%A6%E8%A7%A3\" target=\"_blank\" class=\"text-xs bg-gray-100 text-gray-600 px-3 py-1.5 rounded hover:bg-gray-200 transition-colors border border-gray-200 flex items-center gap-1 no-underline\">\n                            <i class=\"fa-solid fa-circle-question text-gray-400\"></i>添加其它平台\n                        </a>\n                     </div>`;\n            break;\n        case \"rss\":\n            html = createToggleControl(mod.key, \"enabled\", \"启用 RSS 抓取\");\n            html += `<div class=\"mt-3 mb-2 text-xs font-bold text-gray-700\">新鲜度过滤</div>`;\n            html += createToggleControl(mod.key, \"freshness_filter.enabled\", \"启用新鲜度过滤\");\n            html += createNumberControl(mod.key, \"freshness_filter.max_age_days\", \"最大文章年龄 (天)\");\n            html += `<div class=\"mt-4 mb-2 text-xs font-bold text-gray-700\">RSS 源列表</div>`;\n            html += `<div id=\"rss-feeds-list\" class=\"space-y-2\"></div>`;\n            html += `<div class=\"flex items-center gap-2 mt-3\">\n                        <button onclick=\"openRssModal()\" class=\"text-xs bg-green-600 text-white px-3 py-1.5 rounded hover:bg-green-700 transition-colors\">\n                            <i class=\"fa-solid fa-plus mr-1\"></i>添加 RSS 源\n                        </button>\n                        <div class=\"text-xs text-gray-500 italic\">\n                            (内附 RSS 源参考库)\n                        </div>\n                     </div>`;\n            html += `<div class=\"text-xs text-orange-600 mt-2 p-2 bg-orange-50 rounded border border-orange-200\">\n                        <i class=\"fa-solid fa-triangle-exclamation mr-1\"></i>\n                        <strong>注意：</strong>部分海外媒体内容可能涉及敏感话题，AI 模型可能拒绝翻译或分析，建议根据实际需求筛选订阅源。\n                     </div>`;\n            break;\n        case \"report\":\n            html = createSelectControl(mod.key, \"mode\", \"报告模式\", [\"current\", \"daily\", \"incremental\"]);\n            html += createSelectControl(mod.key, \"display_mode\", \"分组维度\", [\"keyword\", \"platform\"]);\n            html += createToggleControl(mod.key, \"sort_by_position_first\", \"按定义顺序排序\");\n            html += createNumberControl(mod.key, \"rank_threshold\", \"排名高亮阈值\");\n            html += createNumberControl(mod.key, \"max_news_per_keyword\", \"每个关键词最大显示数量\");\n            break;\n        case \"filter\":\n            html = createSelectControl(mod.key, \"method\", \"筛选方法\", [\"keyword\", \"ai\"]);\n            html += createToggleControl(mod.key, \"priority_sort_enabled\", \"AI 模式按标签优先级排序\");\n            html += `<div class=\"text-xs text-gray-500 mt-2 p-2 bg-blue-50 rounded border border-blue-200\">\n                        <i class=\"fa-solid fa-info-circle mr-1 text-blue-500\"></i>\n                        <strong>说明：</strong><code>method=keyword</code> 使用 <code>frequency_words.txt</code>；\n                        <code>method=ai</code> 使用 <code>ai_interests.txt</code> + AI 筛选配置。<br>\n                        <code>priority_sort_enabled</code> 仅在 <code>method=ai</code> 时生效。\n                     </div>`;\n            break;\n        case \"ai_filter\":\n            html = `<div class=\"text-xs text-gray-500 mb-3 p-2 bg-blue-50 rounded border border-blue-200\">\n                        <i class=\"fa-solid fa-info-circle mr-1 text-blue-500\"></i>\n                        仅当 <strong>filter.method=ai</strong> 时生效。\n                    </div>`;\n            html += createNumberControl(mod.key, \"batch_size\", \"每批标题数量\");\n            html += createNumberControl(mod.key, \"batch_interval\", \"分批间隔 (秒)\");\n            html += createNumberControl(mod.key, \"min_score\", \"最低分数阈值 (0~1)\");\n            html += createInputControl(mod.key, \"interests_file\", \"兴趣描述文件 (可选)\");\n            html += `<div class=\"text-xs text-amber-700 mt-1 mb-3 p-2 bg-amber-50 rounded border border-amber-200\">\n                        <i class=\"fa-solid fa-folder-tree mr-1\"></i>\n                        留空时使用 <code>config/ai_interests.txt</code>；填写后仅从\n                        <code>config/custom/ai/</code> 查找该文件名。\n                     </div>`;\n            html += createNumberControl(mod.key, \"reclassify_threshold\", \"全量重分类阈值 (0~1)\");\n            html += createInputControl(mod.key, \"prompt_file\", \"分类提示词文件\");\n            html += createInputControl(mod.key, \"extract_prompt_file\", \"标签提取提示词文件\");\n            html += createInputControl(mod.key, \"update_tags_prompt_file\", \"标签更新提示词文件\");\n            break;\n        case \"display\":\n            html = `<div class=\"text-xs font-bold text-gray-700 mb-2\">推送内容控制 <span class=\"text-gray-400 font-normal\">(可拖拽排序)</span></div>`;\n            html += `<div id=\"display-regions-list\" class=\"space-y-2\"></div>`;\n            html += `<div class=\"text-xs text-gray-500 mt-2 mb-6\">\n                        <i class=\"fa-solid fa-lightbulb mr-1\"></i>\n                        提示：列表顺序决定了报告中的显示顺序\n                     </div>`;\n\n            // Standalone Configuration Section\n            html += `<div class=\"border-t border-gray-200 pt-4 mt-4\">`;\n            html += `<div class=\"text-xs font-bold text-gray-700 mb-3\">独立展示区配置 <span class=\"text-gray-400 font-normal\">(推送展示由上方开关控制，AI 分析由 AI 模块的开关独立控制)</span></div>`;\n\n            html += createNumberControl(mod.key, \"standalone.max_items\", \"每个源最多展示条数\");\n\n            html += `<div class=\"mt-3 mb-2 text-xs font-medium text-gray-700\">选择要展示的热榜平台</div>`;\n            html += `<div id=\"standalone-platforms-list\" class=\"max-h-40 overflow-y-auto border border-gray-200 rounded p-2 bg-gray-50 grid grid-cols-2 gap-2\"></div>`;\n\n            html += `<div class=\"mt-3 mb-2 text-xs font-medium text-gray-700\">选择要展示的 RSS 源</div>`;\n            html += `<div id=\"standalone-rss-list\" class=\"max-h-40 overflow-y-auto border border-gray-200 rounded p-2 bg-gray-50 grid grid-cols-1 gap-2\"></div>`;\n\n            html += `</div>`;\n\n            setTimeout(() => {\n                renderDisplayRegionsList();\n                renderStandaloneLists();\n            }, 0);\n            break;\n        case \"notification\":\n            html = `<div class=\"text-xs text-gray-500 mb-2 p-2 bg-blue-50 rounded border border-blue-200\">\n                        <i class=\"fa-solid fa-info-circle mr-1 text-blue-500\"></i>\n                        推送时间由 <strong>timeline.yaml</strong> 控制，切换到 timeline.yaml 标签页可可视化编辑调度规则。<br>\n                        此处仅配置通知渠道（Telegram / 企业微信等），请在左侧编辑器中修改。\n                    </div>`;\n            break;\n        case \"ai\":\n            html = createInputControl(mod.key, \"model\", \"模型名称\");\n            html += createInputControl(mod.key, \"api_key\", \"API Key\", \"password\");\n            html += createInputControl(mod.key, \"api_base\", \"API Base URL (可选)\");\n            html += createNumberControl(mod.key, \"timeout\", \"请求超时 (秒)\");\n            html += createNumberControl(mod.key, \"temperature\", \"采样温度 (0.0-2.0)\");\n            html += createNumberControl(mod.key, \"max_tokens\", \"最大生成 Token 数\");\n            break;\n        case \"ai_analysis\":\n            html = createToggleControl(mod.key, \"enabled\", \"开启 AI 分析报告\");\n\n            // 提示：分析时间窗口已迁移到 timeline.yaml\n            html += `<div class=\"text-xs text-gray-500 mt-3 mb-3 p-2 bg-blue-50 rounded border border-blue-200\">\n                        <i class=\"fa-solid fa-info-circle mr-1 text-blue-500\"></i>\n                        AI 分析的执行时间已由 <strong>timeline.yaml</strong> 统一控制。\n                    </div>`;\n\n            // 其他 AI 分析配置\n            html += `<div class=\"text-xs font-bold text-blue-600 mb-2\">分析内容配置</div>`;\n            html += createInputControl(mod.key, \"language\", \"输出语言\");\n            html += createInputControl(mod.key, \"prompt_file\", \"提示词配置文件\");\n            html += createSelectControl(mod.key, \"mode\", \"AI 分析模式\", [\"follow_report\", \"daily\", \"current\", \"incremental\"]);\n            html += createNumberControl(mod.key, \"max_news_for_analysis\", \"最大分析条数\");\n            html += createToggleControl(mod.key, \"include_rss\", \"包含 RSS 内容\");\n            html += createToggleControl(mod.key, \"include_standalone\", \"包含独立展示区数据\");\n            html += createToggleControl(mod.key, \"include_rank_timeline\", \"传递完整排名时间线\");\n            break;\n        case \"ai_translation\":\n            html = createToggleControl(mod.key, \"enabled\", \"开启 AI 自动翻译\");\n            html += createInputControl(mod.key, \"language\", \"目标语言\");\n            html += createInputControl(mod.key, \"prompt_file\", \"提示词配置文件\");\n            break;\n    }\n\n    body.innerHTML = html;\n\n    // 绑定事件\n    body.querySelectorAll('input, select').forEach(el => {\n        el.addEventListener('change', (e) => {\n            updateYamlFromUI(mod.key, e.target.dataset.path, e.target);\n        });\n    });\n}\n\n// ==========================================\n// 4. 同步逻辑 (YAML -> UI)\n// ==========================================\nfunction syncYamlToUI() {\n    try {\n        const doc = jsyaml.load(currentYaml);\n        if (!doc) return;\n\n        MODULE_DEFS.filter(m => m.editable).forEach(mod => {\n            const modData = doc[mod.key];\n            if (!modData) return;\n\n            const controls = document.querySelectorAll(`#controls-${mod.key} [data-path]`);\n            controls.forEach(ctrl => {\n                const path = ctrl.dataset.path.split('.');\n                let val = modData;\n                for (const part of path) {\n                    val = val ? val[part] : undefined;\n                }\n\n                if (ctrl.type === 'checkbox') {\n                    ctrl.checked = !!val;\n                } else {\n                    ctrl.value = val !== undefined ? val : \"\";\n                }\n            });\n        });\n\n        renderPlatformsList();\n        renderRssFeedsList();\n        renderStandaloneLists(); \n    } catch (e) {\n        // 解析失败时不更新 UI，保持原有状态\n    }\n}\n\n// ==========================================\n// 5. 更新逻辑 (UI -> YAML) - 核心难点：正则保留注释\n// ==========================================\nfunction updateYamlFromUI(modKey, path, el) {\n    let newVal = el.type === 'checkbox' ? el.checked : el.value;\n\n    // 如果是数字类型\n    if (el.type === 'number') {\n        newVal = parseFloat(newVal);\n        if (isNaN(newVal)) newVal = 0;\n    }\n\n    const editor = document.getElementById('yaml-editor');\n    let yaml = editor.value;\n    const lines = yaml.split('\\n');\n    const pathParts = path.split('.');\n\n    // 找到模块的起始行\n    let moduleStartLine = -1;\n    let moduleEndLine = lines.length;\n\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i];\n        // 匹配模块开始（非缩进的 key:）\n        const moduleMatch = line.match(/^([a-z_]+):/);\n        if (moduleMatch) {\n            if (moduleMatch[1] === modKey) {\n                moduleStartLine = i;\n            } else if (moduleStartLine >= 0) {\n                // 找到下一个模块，记录当前模块结束位置\n                moduleEndLine = i;\n                break;\n            }\n        }\n    }\n\n    if (moduleStartLine < 0) return;\n\n    // 在模块内查找目标路径\n    let targetLine = -1;\n    let currentIndent = 0;\n    let searchKey = pathParts[pathParts.length - 1];\n\n    for (let i = moduleStartLine + 1; i < moduleEndLine; i++) {\n        const line = lines[i];\n        if (line.trim() === '' || line.trim().startsWith('#')) continue;\n\n        // 检查是否匹配目标键\n        const indent = line.search(/\\S/);\n        const keyMatch = line.match(/^\\s*([a-z_]+):\\s*(.*)/i);\n\n        if (keyMatch && keyMatch[1] === searchKey) {\n            // 如果是嵌套路径，需要检查缩进层级是否正确\n            if (pathParts.length > 1) {\n                // 简化处理：对于嵌套路径，确保在正确的父级下\n                let valid = true;\n                for (let j = 0; j < pathParts.length - 1; j++) {\n                    let found = false;\n                    for (let k = moduleStartLine + 1; k < i; k++) {\n                        const parentMatch = lines[k].match(/^\\s*([a-z_]+):/i);\n                        if (parentMatch && parentMatch[1] === pathParts[j]) {\n                            found = true;\n                            break;\n                        }\n                    }\n                    if (!found) {\n                        valid = false;\n                        break;\n                    }\n                }\n                if (!valid) continue;\n            }\n\n            targetLine = i;\n            break;\n        }\n    }\n\n    if (targetLine < 0) {\n        // 允许为模块新增一级字段（例如默认被注释掉的 ai_filter.interests_file）\n        if (pathParts.length === 1) {\n            let formattedVal = newVal;\n            if (typeof newVal === 'string') {\n                formattedVal = `\"${newVal.replace(/\"/g, '\\\\\"')}\"`;\n            }\n\n            lines.splice(moduleEndLine, 0, `  ${searchKey}: ${formattedVal}`);\n            editor.value = lines.join('\\n');\n            currentYaml = editor.value;\n            updateBackdrop('yaml-editor', 'yaml-backdrop');\n            debounceSaveConfig();\n        }\n        return;\n    }\n\n    // 更新该行，保留注释\n    const originalLine = lines[targetLine];\n    const match = originalLine.match(/^(\\s*[a-z_]+:\\s*)(.*)$/i);\n\n    if (match) {\n        const prefix = match[1];\n        const rest = match[2];\n\n        // 提取原有注释\n        const commentMatch = rest.match(/(\\s*#.*)$/);\n        const comment = commentMatch ? commentMatch[1] : '';\n\n        // 格式化新值\n        let formattedVal = newVal;\n        if (typeof newVal === 'string') {\n            // 获取原值部分（去除注释后的部分）\n            const valPart = rest.slice(0, rest.length - comment.length).trim();\n            // 检查原值是否带有引号\n            const isOriginalQuoted = (valPart.startsWith('\"') && valPart.endsWith('\"')) ||\n                                     (valPart.startsWith(\"'\") && valPart.endsWith(\"'\"));\n\n            // 如果原值有引号，或者新值包含特殊字符（空格、冒号、井号、引号）或者是空字符串，则添加双引号\n            if (isOriginalQuoted || newVal.includes(':') || newVal.includes('#') ||\n                newVal.includes('\"') || newVal.includes(' ') || newVal === \"\") {\n                formattedVal = `\"${newVal.replace(/\"/g, '\\\\\"')}\"`;\n            }\n        }\n\n        // 构建新行\n        lines[targetLine] = `${prefix}${formattedVal}${comment}`;\n    }\n\n    // 更新编辑器\n    editor.value = lines.join('\\n');\n    currentYaml = editor.value;\n    updateBackdrop('yaml-editor', 'yaml-backdrop');\n    debounceSaveConfig();\n}\n\n// ==========================================\n// 6. UI 组件工厂\n// ==========================================\nfunction createToggleControl(mod, path, label) {\n    const id = `toggle-${mod}-${path.replace('.', '-')}`;\n    return `\n        <div class=\"flex items-center justify-between\">\n            <label for=\"${id}\" class=\"text-xs font-medium text-gray-700\">${label}</label>\n            <div class=\"relative inline-block w-10 mr-2 align-middle select-none\">\n                <input type=\"checkbox\" id=\"${id}\" data-path=\"${path}\" class=\"toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer transition-all duration-200 ease-in-out\"/>\n                <label for=\"${id}\" class=\"toggle-label block overflow-hidden h-5 rounded-full bg-gray-300 cursor-pointer\"></label>\n            </div>\n        </div>\n    `;\n}\n\nfunction createInputControl(mod, path, label, type = \"text\") {\n    return `\n        <div>\n            <label class=\"block text-[10px] uppercase tracking-wider font-bold text-gray-400 mb-1\">${label}</label>\n            <input type=\"${type}\" data-path=\"${path}\" class=\"bg-white border-gray-300 focus:border-blue-500\" placeholder=\"未设置\">\n        </div>\n    `;\n}\n\nfunction createNumberControl(mod, path, label) {\n    return `\n        <div class=\"flex items-center justify-between\">\n            <label class=\"text-xs font-medium text-gray-700\">${label}</label>\n            <input type=\"number\" data-path=\"${path}\" class=\"w-20 text-right bg-white border-gray-300\" style=\"width: 80px\">\n        </div>\n    `;\n}\n\nfunction createSelectControl(mod, path, label, options) {\n    const optionsHtml = options.map(opt => `<option value=\"${opt}\">${opt}</option>`).join('');\n    return `\n        <div>\n            <label class=\"block text-[10px] uppercase tracking-wider font-bold text-gray-400 mb-1\">${label}</label>\n            <select data-path=\"${path}\" class=\"bg-white border-gray-300\">\n                ${optionsHtml}\n            </select>\n        </div>\n    `;\n}\n\n// ==========================================\n// 7. 工具函数\n// ==========================================\n\nwindow.copyResult = function() {\n    const yamlEditor = document.getElementById('yaml-editor');\n    const frequencyEditor = document.getElementById('frequency-editor');\n    const timelineEditor = document.getElementById('timeline-editor');\n    const editor = currentTab === 'config' ? yamlEditor : currentTab === 'timeline' ? timelineEditor : frequencyEditor;\n\n    editor.select();\n    document.execCommand('copy');\n\n    const btn = document.querySelector('button[onclick=\"copyResult()\"]');\n    const original = btn.innerHTML;\n    btn.innerHTML = '<i class=\"fa-solid fa-check mr-1.5\"></i>已复制!';\n    setTimeout(() => btn.innerHTML = original, 2000);\n}\n\nwindow.resetToDefault = function() {\n    if (confirm('确定要重置为初始状态吗？未保存的修改将丢失。')) {\n        if (currentTab === 'config') {\n            const yamlEditor = document.getElementById('yaml-editor');\n            yamlEditor.value = INITIAL_YAML;\n            currentYaml = INITIAL_YAML;\n            updateBackdrop('yaml-editor', 'yaml-backdrop');\n            localStorage.removeItem(STORAGE_KEY_CONFIG);\n            localStorage.removeItem(STORAGE_KEY_CONFIG_TIME);\n            renderModules();\n            syncYamlToUI();\n            updateSaveTimeDisplay();\n        } else if (currentTab === 'timeline') {\n            const timelineEditor = document.getElementById('timeline-editor');\n            const initialTimeline = `# 在此粘贴你的 timeline.yaml...\\n# 或拖拽文件到编辑器区域\\n# 或点击右上角\"加载官网最新配置\"`;\n            timelineEditor.value = initialTimeline;\n            currentTimeline = initialTimeline;\n            updateBackdrop('timeline-editor', 'timeline-backdrop');\n            localStorage.removeItem(STORAGE_KEY_TIMELINE);\n            localStorage.removeItem(STORAGE_KEY_TIMELINE_TIME);\n            syncTimelineToUI();\n            updateSaveTimeDisplay();\n        } else {\n            const frequencyEditor = document.getElementById('frequency-editor');\n            frequencyEditor.value = \"# 在此粘贴你的 frequency_words.txt 内容...\\n\\n[GLOBAL_FILTER]\\n\\n[WORD_GROUPS]\\n\";\n            currentFrequency = frequencyEditor.value;\n            updateBackdrop('frequency-editor', 'frequency-backdrop');\n            localStorage.removeItem(STORAGE_KEY_FREQUENCY);\n            localStorage.removeItem(STORAGE_KEY_FREQUENCY_TIME);\n            syncFrequencyToUI();\n            updateSaveTimeDisplay();\n        }\n        showToast('已重置为初始状态', 'success');\n    }\n}\n\n// ==========================================\n// 8. Tab 切换功能\n// ==========================================\nwindow.switchTab = function(tab) {\n    currentTab = tab;\n\n    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\";\n    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\";\n\n    // 更新 Tab 按钮状态\n    const configBtn = document.getElementById('tab-config');\n    const freqBtn = document.getElementById('tab-frequency');\n    const timelineBtn = document.getElementById('tab-timeline');\n\n    configBtn.className = tab === 'config' ? activeClass : inactiveClass;\n    freqBtn.className = tab === 'frequency' ? activeClass : inactiveClass;\n    timelineBtn.className = tab === 'timeline' ? activeClass : inactiveClass;\n\n    // 更新编辑器显示\n    document.getElementById('yaml-editor-wrap').classList.toggle('hidden', tab !== 'config');\n    document.getElementById('frequency-editor-wrap').classList.toggle('hidden', tab !== 'frequency');\n    document.getElementById('timeline-editor-wrap').classList.toggle('hidden', tab !== 'timeline');\n\n    // 更新右侧面板\n    document.getElementById('config-panel').classList.toggle('hidden', tab !== 'config');\n    document.getElementById('frequency-panel').classList.toggle('hidden', tab !== 'frequency');\n    document.getElementById('timeline-panel').classList.toggle('hidden', tab !== 'timeline');\n\n    // 更新模块导航栏显示状态：只在 config 模式下显示\n    const moduleNav = document.getElementById('module-nav');\n    if (moduleNav) {\n        moduleNav.classList.toggle('hidden', tab !== 'config');\n    }\n\n    // 更新保存时间显示\n    const saveTimeConfig = document.getElementById('save-time-config');\n    const saveTimeFrequency = document.getElementById('save-time-frequency');\n    const saveTimeTimeline = document.getElementById('save-time-timeline');\n    if (saveTimeConfig) saveTimeConfig.classList.toggle('hidden', tab !== 'config');\n    if (saveTimeFrequency) saveTimeFrequency.classList.toggle('hidden', tab !== 'frequency');\n    if (saveTimeTimeline) saveTimeTimeline.classList.toggle('hidden', tab !== 'timeline');\n\n    // 更新右侧标题\n    const versionBtn = document.getElementById('version-check-btn');\n    if (tab === 'config') {\n        document.getElementById('right-panel-title').textContent = '配置模块';\n        if (versionBtn) { versionBtn.style.display = ''; versionBtn.title = \"检测 config.yaml 版本\"; }\n    } else if (tab === 'frequency') {\n        document.getElementById('right-panel-title').textContent = '频率词编辑';\n        if (versionBtn) { versionBtn.style.display = ''; versionBtn.title = \"检测 frequency_words.txt 版本\"; }\n    } else {\n        document.getElementById('right-panel-title').textContent = '时间线调度';\n        if (versionBtn) versionBtn.style.display = 'none';\n    }\n\n    if (tab === 'frequency') {\n        renderFrequencyPanel();\n    }\n    if (tab === 'timeline') {\n        syncTimelineToUI();\n    }\n}\n\n// ==========================================\n// 9. Frequency 编辑器功能\n// ==========================================\nfunction parseFrequencyText(text) {\n    const result = {\n        globalFilter: [],\n        wordGroups: [],\n        originalText: text  // 保存原始文本\n    };\n\n    const lines = text.split('\\n');\n    let currentSection = null;\n    let currentGroup = null;\n    let lastLineWasAlias = false;  // 追踪上一行是否为别名行\n    let relatedGroupsBuffer = [];  // 缓存连续的相关组\n    let pendingComments = [];  // 缓存待分配的注释行\n\n    // 辅助函数：保存缓存的相关组\n    function flushRelatedGroups() {\n        if (relatedGroupsBuffer.length > 0) {\n            // 如果有多个连续的组，标记它们为相关组\n            if (relatedGroupsBuffer.length > 1) {\n                relatedGroupsBuffer.forEach((group, idx) => {\n                    group.isRelatedGroup = true;\n                    group.relatedGroupIndex = idx;\n                    group.relatedGroupTotal = relatedGroupsBuffer.length;\n                });\n            }\n            result.wordGroups.push(...relatedGroupsBuffer);\n            relatedGroupsBuffer = [];\n        }\n    }\n\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i];\n        const trimmed = line.trim();\n\n        // 收集注释行（在 [WORD_GROUPS] 区域内）\n        if (trimmed.startsWith('#') && currentSection === 'groups') {\n            pendingComments.push(line);\n            continue;\n        }\n\n        // 跳过注释（非 [WORD_GROUPS] 区域）\n        if (trimmed.startsWith('#')) continue;\n\n        // 空行：结束当前词组和相关组缓存\n        if (!trimmed) {\n            if (currentGroup) {\n                // 保存当前词组到缓存\n                relatedGroupsBuffer.push(currentGroup);\n                currentGroup = null;\n            }\n            // 空行表示相关组结束，刷新缓存\n            flushRelatedGroups();\n            lastLineWasAlias = false;\n            // 在 [WORD_GROUPS] 区域内，空行加入待分配注释（保留空行结构）\n            if (currentSection === 'groups') {\n                pendingComments.push('');\n            }\n            continue;\n        }\n\n        // 检测区域标记\n        if (trimmed === '[GLOBAL_FILTER]') {\n            currentSection = 'global';\n            continue;\n        }\n        if (trimmed === '[WORD_GROUPS]') {\n            currentSection = 'groups';\n            continue;\n        }\n\n        // 处理内容\n        if (currentSection === 'global') {\n            result.globalFilter.push(trimmed);\n        } else if (currentSection === 'groups') {\n            // 检测组别名 [组名]\n            const groupNameMatch = trimmed.match(/^\\[([^\\]]+)\\]$/);\n            if (groupNameMatch && !['GLOBAL_FILTER', 'WORD_GROUPS'].includes(groupNameMatch[1])) {\n                // 保存当前词组到缓存\n                if (currentGroup) {\n                    relatedGroupsBuffer.push(currentGroup);\n                }\n                // 刷新缓存（组别名独立成组）\n                flushRelatedGroups();\n                // 创建组别名类型\n                currentGroup = {\n                    type: 'group-name',\n                    name: groupNameMatch[1],\n                    keywords: [],\n                    startLine: i,\n                    precedingComments: pendingComments.length > 0 ? [...pendingComments] : []\n                };\n                pendingComments = [];\n                lastLineWasAlias = false;\n            } else {\n                // 检测 => 别名语法（允许右侧为空）\n                const aliasMatch = trimmed.match(/^(.+?)\\s*=>\\s*(.*)$/);\n                if (aliasMatch) {\n                    const keyword = aliasMatch[1].trim();\n                    const alias = aliasMatch[2].trim();\n\n                    // 关键逻辑：如果上一行也是别名行（无空行分隔），则归入连续别名组\n                    if (lastLineWasAlias && currentGroup && (currentGroup.type === 'alias' || currentGroup.type === 'alias-group')) {\n                        // 如果当前是单个别名，升级为别名组\n                        if (currentGroup.type === 'alias') {\n                            currentGroup.type = 'alias-group';\n                        }\n                        // 添加到别名组\n                        currentGroup.items.push({ keyword, alias });\n                    } else {\n                        // 新的单个别名（可能会升级为别名组）\n                        if (currentGroup) {\n                            // 保存当前词组到缓存（而不是直接添加到结果）\n                            relatedGroupsBuffer.push(currentGroup);\n                        }\n                        currentGroup = {\n                            type: 'alias',\n                            items: [{ keyword, alias }],\n                            startLine: i,\n                            precedingComments: pendingComments.length > 0 ? [...pendingComments] : []\n                        };\n                        pendingComments = [];\n                    }\n                    lastLineWasAlias = true;\n                } else {\n                    // 普通关键词\n                    if (!currentGroup || currentGroup.type === 'alias' || currentGroup.type === 'alias-group') {\n                        // 如果当前是别名类型，需要先保存到缓存\n                        if (currentGroup) {\n                            relatedGroupsBuffer.push(currentGroup);\n                        }\n                        // 创建新的普通词组\n                        currentGroup = {\n                            type: 'plain',\n                            keywords: [],\n                            startLine: i,\n                            precedingComments: pendingComments.length > 0 ? [...pendingComments] : []\n                        };\n                        pendingComments = [];\n                    }\n                    currentGroup.keywords.push(trimmed);\n                    lastLineWasAlias = false;\n                }\n            }\n        }\n    }\n\n    // 添加最后一个组\n    if (currentGroup) {\n        relatedGroupsBuffer.push(currentGroup);\n    }\n    flushRelatedGroups();\n\n    return result;\n}\n\nfunction buildFrequencyText(data) {\n    // 如果有原始文本，尝试保留注释\n    if (data.originalText) {\n        const lines = data.originalText.split('\\n');\n        let result = [];\n\n        // 第一步：保留文件头部的注释\n        let i = 0;\n        while (i < lines.length) {\n            const line = lines[i];\n            const trimmed = line.trim();\n\n            if (trimmed === '[GLOBAL_FILTER]') {\n                break;\n            }\n            result.push(line);\n            i++;\n        }\n\n        // 第二步：重建 [GLOBAL_FILTER] 区域\n        result.push('[GLOBAL_FILTER]');\n\n        // 保留 [GLOBAL_FILTER] 后面的注释（直到第一个非注释非空行）\n        i++;\n        while (i < lines.length) {\n            const line = lines[i];\n            const trimmed = line.trim();\n            if (trimmed.startsWith('#') || trimmed === '') {\n                result.push(line);\n                i++;\n            } else {\n                break;\n            }\n        }\n\n        // 添加全局过滤词\n        data.globalFilter.forEach(filter => {\n            result.push(filter);\n        });\n\n        // 跳过原始文件中的 [GLOBAL_FILTER] 内容（非注释行），保留空行和注释直到 [WORD_GROUPS]\n        while (i < lines.length) {\n            const line = lines[i];\n            const trimmed = line.trim();\n            if (trimmed === '[WORD_GROUPS]') {\n                break;\n            }\n            // 保留注释和空行\n            if (trimmed.startsWith('#') || trimmed === '') {\n                result.push(line);\n            }\n            i++;\n        }\n\n        // 第三步：重建 [WORD_GROUPS] 区域\n        result.push('[WORD_GROUPS]');\n\n        // 添加词组（注释已保存在每个词组的 precedingComments 中）\n        data.wordGroups.forEach((group, index) => {\n            // 先输出词组前的注释\n            if (group.precedingComments && group.precedingComments.length > 0) {\n                group.precedingComments.forEach(comment => {\n                    result.push(comment);\n                });\n            }\n\n            if (group.type === 'group-name') {\n                // 组别名类型：[组名] + 关键词\n                if (group.name) {\n                    result.push(`[${group.name}]`);\n                }\n                group.keywords.forEach(kw => {\n                    result.push(kw);\n                });\n            } else if (group.type === 'alias' || group.type === 'alias-group') {\n                // 别名类型：keyword => alias\n                group.items.forEach(item => {\n                    result.push(`${item.keyword} => ${item.alias}`);\n                });\n            } else if (group.type === 'plain') {\n                // 普通词组\n                group.keywords.forEach(kw => {\n                    result.push(kw);\n                });\n            }\n\n            // 空行处理逻辑：\n            // 1. 如果当前词组和下一个词组都是相关组，则不添加空行\n            // 2. 否则，在词组之间添加空行\n            const isLastGroup = index === data.wordGroups.length - 1;\n            const nextGroup = !isLastGroup ? data.wordGroups[index + 1] : null;\n\n            // 简化判断：只要当前和下一个都是相关组，就不添加空行\n            const bothAreRelatedGroups = group.isRelatedGroup && nextGroup && nextGroup.isRelatedGroup;\n\n            // 如果下一个词组有前置注释，不需要额外添加空行（注释中已包含空行）\n            const nextHasComments = nextGroup && nextGroup.precedingComments && nextGroup.precedingComments.length > 0;\n\n            if (bothAreRelatedGroups) {\n                // 相关组内部不添加空行\n                // 不添加任何内容\n            } else if (!isLastGroup && !nextHasComments) {\n                // 词组之间添加空行（如果下一个没有前置注释）\n                result.push('');\n            } else if (isLastGroup) {\n                // 最后一个词组后也保留一个空行\n                result.push('');\n            }\n        });\n\n        return result.join('\\n');\n    }\n\n    // 如果没有原始文本，使用默认模板\n    let text = '# ═══════════════════════════════════════════════════════════════\\n';\n    text += '#                    TrendRadar 频率词配置文件\\n';\n    text += '# ═══════════════════════════════════════════════════════════════\\n\\n';\n\n    text += '[GLOBAL_FILTER]\\n';\n    data.globalFilter.forEach(filter => {\n        text += filter + '\\n';\n    });\n    text += '\\n\\n';\n\n    text += '[WORD_GROUPS]\\n\\n';\n    data.wordGroups.forEach((group, index) => {\n        // 先输出词组前的注释\n        if (group.precedingComments && group.precedingComments.length > 0) {\n            group.precedingComments.forEach(comment => {\n                text += comment + '\\n';\n            });\n        }\n\n        if (group.type === 'group-name') {\n            if (group.name) {\n                text += `[${group.name}]\\n`;\n            }\n            group.keywords.forEach(kw => {\n                text += kw + '\\n';\n            });\n        } else if (group.type === 'alias' || group.type === 'alias-group') {\n            group.items.forEach(item => {\n                text += `${item.keyword} => ${item.alias}\\n`;\n            });\n        } else if (group.type === 'plain') {\n            group.keywords.forEach(kw => {\n                text += kw + '\\n';\n            });\n        }\n\n        // 空行处理逻辑：与上面保持一致\n        const isLastGroup = index === data.wordGroups.length - 1;\n        const nextGroup = !isLastGroup ? data.wordGroups[index + 1] : null;\n\n        const bothAreRelatedGroups = group.isRelatedGroup && nextGroup && nextGroup.isRelatedGroup;\n\n        // 如果下一个词组有前置注释，不需要额外添加空行\n        const nextHasComments = nextGroup && nextGroup.precedingComments && nextGroup.precedingComments.length > 0;\n\n        if (bothAreRelatedGroups) {\n            // 相关组内部不添加空行\n        } else if (!isLastGroup && !nextHasComments) {\n            text += '\\n';  // 词组之间用空行分隔\n        } else if (isLastGroup) {\n            text += '\\n';  // 最后一个词组后也保留一个空行\n        }\n    });\n\n    return text;\n}\n\nfunction syncFrequencyToUI() {\n    const data = parseFrequencyText(currentFrequency);\n    currentFrequencyData = data;\n    renderFrequencyPanel(data);\n}\n\nfunction renderFrequencyPanel(data) {\n    if (!data) {\n        data = parseFrequencyText(currentFrequency);\n    }\n\n    const panel = document.getElementById('frequency-panel');\n\n    // 辅助函数：根据关键词类型返回样式类\n    function getKeywordClass(keyword) {\n        if (keyword.startsWith('+')) return 'bg-green-500';\n        if (keyword.startsWith('!')) return 'bg-red-500';\n        if (keyword.startsWith('@')) return 'bg-purple-500';\n        if (keyword.startsWith('/') || keyword.includes('=>')) return 'bg-indigo-500';\n        return 'bg-blue-500';\n    }\n\n    // 辅助函数：为关键词添加标签\n    function getKeywordLabel(keyword) {\n        if (keyword.startsWith('+')) return '必须';\n        if (keyword.startsWith('!')) return '排除';\n        if (keyword.startsWith('@')) return '限制';\n        if (keyword.startsWith('/')) return '正则';\n        if (keyword.includes('=>')) return '别名';\n        return '';\n    }\n\n    // 渲染词组卡片\n    function renderGroupCard(group, idx) {\n        const jumpIcon = `<i class=\"fa-solid fa-grip-vertical text-gray-400 text-xs mr-2\" title=\"拖动调整顺序\"></i>`;\n\n        // 序号标记\n        const indexBadge = `<span class=\"text-xs bg-gray-700 text-white px-2.5 py-1 rounded-full font-bold mr-2\" title=\"词组序号\">#${idx + 1}</span>`;\n\n        // 相关组标记\n        const relatedGroupBadge = group.isRelatedGroup\n            ? `<span class=\"text-[10px] bg-gradient-to-r from-blue-500 to-indigo-500 text-white px-2 py-0.5 rounded font-bold ml-2\" title=\"此组与相邻组相关（无空行分隔）\">\n                <i class=\"fa-solid fa-link mr-1\"></i>相关组 ${group.relatedGroupIndex + 1}/${group.relatedGroupTotal}\n               </span>`\n            : '';\n\n        // 相关组边框样式\n        const relatedGroupStyle = group.isRelatedGroup\n            ? 'border-l-4 border-l-blue-500 shadow-lg'\n            : '';\n\n        if (group.type === 'group-name') {\n            // 组别名类型\n            return `\n                <div class=\"word-group-card border-2 border-orange-200 bg-orange-50 group ${relatedGroupStyle} cursor-move\" data-group-index=\"${idx}\" onclick=\"scrollToWordGroupInEditor(${idx})\">\n                    <div class=\"flex items-center justify-between mb-3\">\n                        <div class=\"flex items-center flex-1 gap-2\">\n                            ${jumpIcon}\n                            ${indexBadge}\n                            <span class=\"text-[10px] bg-orange-500 text-white px-2 py-0.5 rounded font-bold\">组别名</span>\n                            ${relatedGroupBadge}\n                            <input type=\"text\" value=\"${group.name || ''}\" placeholder=\"组别名（如：东亚）\"\n                                   class=\"text-sm font-bold border-0 border-b-2 border-orange-300 focus:border-orange-500 outline-none px-2 py-1 flex-1 bg-transparent\"\n                                   onclick=\"event.stopPropagation()\"\n                                   onchange=\"updateGroupName(${idx}, this.value)\">\n                        </div>\n                        <button onclick=\"event.stopPropagation(); removeWordGroup(${idx})\" class=\"text-red-500 hover:text-red-700 text-xs ml-2\">\n                            <i class=\"fa-solid fa-trash\"></i>\n                        </button>\n                    </div>\n                    <div class=\"bg-white rounded p-3 border border-orange-200 editable-area\" onclick=\"event.stopPropagation()\">\n                        <div class=\"text-xs text-gray-600 mb-2 font-bold\">关键词列表：</div>\n                        <div class=\"tag-input-container\">\n                            ${group.keywords.map(kw => {\n                                const label = getKeywordLabel(kw);\n                                const escapedKw = kw.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n                                return `\n                                    <span class=\"tag-item ${getKeywordClass(kw)} relative break-all cursor-pointer\" data-keyword=\"${escapedKw}\" onclick=\"editKeyword(${idx}, this.dataset.keyword, this)\">\n                                        ${label ? `<span class=\"text-[9px] opacity-75 mr-1\">[${label}]</span>` : ''}\n                                        ${escapedKw}\n                                        <button data-keyword=\"${escapedKw}\" onclick=\"event.stopPropagation(); removeKeyword(${idx}, this.dataset.keyword)\">×</button>\n                                    </span>\n                                `;\n                            }).join('')}\n                            <input type=\"text\" class=\"tag-input\" placeholder=\"输入关键词后按回车...\"\n                                   onkeydown=\"handleKeywordInput(event, ${idx})\">\n                        </div>\n                        <div class=\"flex items-center justify-between mt-2\">\n                            <button onclick=\"openDeepSeekAI('group', ${idx})\" class=\"text-xs text-blue-600 hover:text-blue-700 flex items-center gap-1\">\n                                <i class=\"fa-solid fa-wand-magic-sparkles\"></i>AI 写正则\n                            </button>\n                            <div class=\"text-[10px] text-gray-400\">${group.keywords.length} 个关键词</div>\n                        </div>\n                    </div>\n                </div>\n            `;\n        } else if (group.type === 'alias') {\n            // 单个别名类型\n            const item = group.items[0];\n            return `\n                <div class=\"word-group-card border-2 border-teal-200 bg-teal-50 group ${relatedGroupStyle} cursor-move\" data-group-index=\"${idx}\" onclick=\"scrollToWordGroupInEditor(${idx})\">\n                    <div class=\"flex items-center justify-between mb-3\">\n                        <div class=\"flex items-center flex-1 gap-2\">\n                            ${jumpIcon}\n                            ${indexBadge}\n                            <span class=\"text-[10px] bg-teal-500 text-white px-2 py-0.5 rounded font-bold\">单个别名</span>\n                            ${relatedGroupBadge}\n                        </div>\n                        <button onclick=\"event.stopPropagation(); removeWordGroup(${idx})\" class=\"text-red-500 hover:text-red-700 text-xs\">\n                            <i class=\"fa-solid fa-trash\"></i>\n                        </button>\n                    </div>\n                    <div class=\"bg-white rounded p-3 border border-teal-200 editable-area\" onclick=\"event.stopPropagation()\">\n                        <div class=\"flex items-center gap-2\">\n                            <input type=\"text\" value=\"${item.keyword || ''}\" placeholder=\"/正则/ 或 关键词\"\n                                   class=\"flex-1 px-3 py-2 border border-gray-300 rounded focus:border-teal-500 outline-none text-sm font-mono\"\n                                   onblur=\"updateAliasItem(${idx}, 0, 'keyword', this.value)\">\n                            <span class=\"text-teal-600 font-bold\">=></span>\n                            <input type=\"text\" value=\"${item.alias || ''}\" placeholder=\"别名\"\n                                   class=\"flex-1 px-3 py-2 border border-gray-300 rounded focus:border-teal-500 outline-none text-sm\"\n                                   onblur=\"updateAliasItem(${idx}, 0, 'alias', this.value)\">\n                        </div>\n                        <div class=\"flex items-center justify-between mt-2\">\n                            <button onclick=\"openDeepSeekAI('group', ${idx})\" class=\"text-xs text-blue-600 hover:text-blue-700 flex items-center gap-1\">\n                                <i class=\"fa-solid fa-wand-magic-sparkles\"></i>AI 写正则\n                            </button>\n                            <div class=\"text-[10px] text-gray-500\">\n                                <i class=\"fa-solid fa-lightbulb mr-1\"></i>示例：/胖东来|于东来/ => 胖东来\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            `;\n        } else if (group.type === 'alias-group') {\n            // 连续别名组类型\n            return `\n                <div class=\"word-group-card border-2 border-purple-200 bg-purple-50 group ${relatedGroupStyle} cursor-move\" data-group-index=\"${idx}\" onclick=\"scrollToWordGroupInEditor(${idx})\">\n                    <div class=\"flex items-center justify-between mb-3\">\n                        <div class=\"flex items-center flex-1 gap-2\">\n                            ${jumpIcon}\n                            ${indexBadge}\n                            <span class=\"text-[10px] bg-purple-500 text-white px-2 py-0.5 rounded font-bold\">连续别名组</span>\n                            ${relatedGroupBadge}\n                        </div>\n                        <button onclick=\"event.stopPropagation(); removeWordGroup(${idx})\" class=\"text-red-500 hover:text-red-700 text-xs\">\n                            <i class=\"fa-solid fa-trash\"></i>\n                        </button>\n                    </div>\n                    <div class=\"bg-white rounded p-3 border border-purple-200 space-y-2 editable-area\" onclick=\"event.stopPropagation()\">\n                        <div class=\"text-xs text-gray-600 mb-2 font-bold\">\n                            别名列表（无空行分隔）：\n                        </div>\n                        ${group.items.map((item, itemIdx) => `\n                            <div class=\"flex items-center gap-2\">\n                                <input type=\"text\" value=\"${item.keyword || ''}\" placeholder=\"/正则/ 或 关键词\"\n                                       class=\"flex-1 px-3 py-2 border border-gray-300 rounded focus:border-purple-500 outline-none text-sm font-mono\"\n                                       onblur=\"updateAliasItem(${idx}, ${itemIdx}, 'keyword', this.value)\">\n                                <span class=\"text-purple-600 font-bold\">=></span>\n                                <input type=\"text\" value=\"${item.alias || ''}\" placeholder=\"别名\"\n                                       class=\"flex-1 px-3 py-2 border border-gray-300 rounded focus:border-purple-500 outline-none text-sm\"\n                                       onblur=\"updateAliasItem(${idx}, ${itemIdx}, 'alias', this.value)\">\n                                <button onclick=\"removeAliasItem(${idx}, ${itemIdx})\" class=\"text-red-500 hover:text-red-700 text-xs\">\n                                    <i class=\"fa-solid fa-trash\"></i>\n                                </button>\n                            </div>\n                        `).join('')}\n                        <div class=\"flex items-center justify-between mt-2\">\n                            <button onclick=\"openDeepSeekAI('group', ${idx})\" class=\"text-xs text-blue-600 hover:text-blue-700 flex items-center gap-1\">\n                                <i class=\"fa-solid fa-wand-magic-sparkles\"></i>AI 写正则\n                            </button>\n                            <div class=\"text-[10px] text-gray-500\">\n                                <i class=\"fa-solid fa-info-circle mr-1\"></i>这些别名行在配置文件中无空行分隔，属于同一组\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            `;\n        } else if (group.type === 'plain') {\n            // 普通词组类型\n            return `\n                <div class=\"word-group-card border-2 border-gray-200 bg-gray-50 group ${relatedGroupStyle} cursor-move\" data-group-index=\"${idx}\" onclick=\"scrollToWordGroupInEditor(${idx})\">\n                    <div class=\"flex items-center justify-between mb-3\">\n                        <div class=\"flex items-center flex-1 gap-2\">\n                            ${jumpIcon}\n                            ${indexBadge}\n                            <span class=\"text-[10px] bg-gray-500 text-white px-2 py-0.5 rounded font-bold\">普通词组</span>\n                            ${relatedGroupBadge}\n                        </div>\n                        <button onclick=\"event.stopPropagation(); removeWordGroup(${idx})\" class=\"text-red-500 hover:text-red-700 text-xs\">\n                            <i class=\"fa-solid fa-trash\"></i>\n                        </button>\n                    </div>\n                    <div class=\"bg-white rounded p-3 border border-gray-200 editable-area\" onclick=\"event.stopPropagation()\">\n                        <div class=\"tag-input-container\">\n                            ${group.keywords.map(kw => {\n                                const label = getKeywordLabel(kw);\n                                const escapedKw = kw.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\n                                return `\n                                    <span class=\"tag-item ${getKeywordClass(kw)} relative break-all cursor-pointer\" data-keyword=\"${escapedKw}\" onclick=\"editKeyword(${idx}, this.dataset.keyword, this)\">\n                                        ${label ? `<span class=\"text-[9px] opacity-75 mr-1\">[${label}]</span>` : ''}\n                                        ${escapedKw}\n                                        <button data-keyword=\"${escapedKw}\" onclick=\"event.stopPropagation(); removeKeyword(${idx}, this.dataset.keyword)\">×</button>\n                                    </span>\n                                `;\n                            }).join('')}\n                            <input type=\"text\" class=\"tag-input\" placeholder=\"输入关键词后按回车...\"\n                                   onkeydown=\"handleKeywordInput(event, ${idx})\">\n                        </div>\n                        <div class=\"flex items-center justify-between mt-2\">\n                            <button onclick=\"openDeepSeekAI('group', ${idx})\" class=\"text-xs text-blue-600 hover:text-blue-700 flex items-center gap-1\">\n                                <i class=\"fa-solid fa-wand-magic-sparkles\"></i>AI 写正则\n                            </button>\n                            <div class=\"text-[10px] text-gray-400\">${group.keywords.length} 个关键词</div>\n                        </div>\n                    </div>\n                </div>\n            `;\n        }\n        return '';\n    }\n\n    panel.innerHTML = `\n        <!-- 规则说明区域 -->\n        <div class=\"bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg border border-blue-200 p-4 mb-4\">\n            <div class=\"flex items-start gap-3\">\n                <i class=\"fa-solid fa-book text-blue-600 text-lg mt-0.5\"></i>\n                <div class=\"flex-1\">\n                    <h3 class=\"text-sm font-bold text-gray-800 mb-2\">四种词组类型说明</h3>\n                    <div class=\"grid grid-cols-2 gap-3 text-xs\">\n                        <div class=\"bg-white rounded p-2 border-l-4 border-orange-500\">\n                            <div class=\"font-bold text-orange-700 mb-1\">组别名</div>\n                            <div class=\"text-gray-600 font-mono text-[10px] mb-1\">[东亚]<br>日本<br>韩国</div>\n                            <div class=\"text-gray-500 text-[10px]\">多个关键词，统一显示为组名</div>\n                        </div>\n                        <div class=\"bg-white rounded p-2 border-l-4 border-teal-500\">\n                            <div class=\"font-bold text-teal-700 mb-1\">单个别名</div>\n                            <div class=\"text-gray-600 font-mono text-[10px] mb-1\">/胖东来|于东来/ => 胖东来</div>\n                            <div class=\"text-gray-500 text-[10px]\">正则匹配，显示为别名</div>\n                        </div>\n                        <div class=\"bg-white rounded p-2 border-l-4 border-purple-500\">\n                            <div class=\"font-bold text-purple-700 mb-1\">连续别名组</div>\n                            <div class=\"text-gray-600 font-mono text-[10px] mb-1\">/智元|稚晖君/ => 智元<br>/众擎|EngineAI/ => 众擎</div>\n                            <div class=\"text-gray-500 text-[10px]\">多个别名无空行分隔</div>\n                        </div>\n                        <div class=\"bg-white rounded p-2 border-l-4 border-gray-500\">\n                            <div class=\"font-bold text-gray-700 mb-1\">普通词组</div>\n                            <div class=\"text-gray-600 font-mono text-[10px] mb-1\">申奥</div>\n                            <div class=\"text-gray-500 text-[10px]\">普通关键词</div>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n\n        <!-- Global Filter 区域 -->\n        <div class=\"bg-white rounded-lg border border-gray-200 p-5\">\n            <div class=\"flex items-center justify-between mb-3\">\n                <h3 class=\"text-sm font-bold text-gray-700\">\n                    <i class=\"fa-solid fa-filter mr-2\"></i>全局过滤词\n                </h3>\n                <button onclick=\"openDeepSeekAI('global')\" class=\"text-xs text-blue-600 hover:text-blue-700 flex items-center gap-1\">\n                    <i class=\"fa-solid fa-wand-magic-sparkles\"></i>AI 写正则\n                </button>\n            </div>\n            <div id=\"global-filter-tags\" class=\"tag-input-container\">\n                ${data.globalFilter.map(f => `\n                    <span class=\"tag-item ${getKeywordClass(f)}\">\n                        ${f}\n                        <button onclick=\"removeGlobalFilter('${f.replace(/'/g, \"\\\\'\")}')\">×</button>\n                    </span>\n                `).join('')}\n                <input type=\"text\" class=\"tag-input\" placeholder=\"输入过滤词后按回车...\" onkeydown=\"handleGlobalFilterInput(event)\">\n            </div>\n            <div class=\"text-xs text-gray-500 mt-2\">\n                <i class=\"fa-solid fa-lightbulb mr-1\"></i>提示：支持正则表达式（用 /.../ 包裹）\n            </div>\n        </div>\n\n        <!-- Word Groups 区域 -->\n        <div class=\"bg-white rounded-lg border border-gray-200 p-5\">\n            <div class=\"flex items-center justify-between mb-3\">\n                <h3 class=\"text-sm font-bold text-gray-700\">\n                    <i class=\"fa-solid fa-layer-group mr-2\"></i>关键词组 <span class=\"text-xs text-gray-400 font-normal\">(${data.wordGroups.length} 个词组)</span>\n                </h3>\n                <button onclick=\"addWordGroup('top')\" class=\"text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700\">\n                    <i class=\"fa-solid fa-plus mr-1\"></i>添加词组\n                </button>\n            </div>\n            <div id=\"word-groups-container\" class=\"space-y-3\">\n                ${data.wordGroups.map((group, idx) => {\n                    const card = renderGroupCard(group, idx);\n                    // 在每个词组后添加插入区域（最后一个除外）\n                    if (idx < data.wordGroups.length - 1) {\n                        return card + `\n                            <div class=\"insert-zone group/insert\" data-insert-index=\"${idx + 1}\">\n                                <button onclick=\"insertWordGroupAt(${idx + 1})\" class=\"insert-button\">\n                                    <i class=\"fa-solid fa-plus\"></i>\n                                </button>\n                            </div>\n                        `;\n                    }\n                    return card;\n                }).join('')}\n            </div>\n\n            <!-- 底部添加按钮 -->\n            <div class=\"mt-4 flex justify-center\">\n                <button onclick=\"addWordGroup('bottom')\" class=\"text-sm bg-gradient-to-r from-blue-500 to-blue-600 text-white px-6 py-2 rounded-lg hover:from-blue-600 hover:to-blue-700 shadow-sm transition-all flex items-center gap-2\">\n                    <i class=\"fa-solid fa-plus-circle\"></i>\n                    <span>在底部添加词组</span>\n                </button>\n            </div>\n        </div>\n    `;\n\n    // 初始化拖拽排序功能\n    setTimeout(() => {\n        const container = document.getElementById('word-groups-container');\n        if (container && typeof Sortable !== 'undefined') {\n            // 销毁之前的实例（如果存在）\n            if (container.sortableInstance) {\n                container.sortableInstance.destroy();\n            }\n\n            // 创建新的 Sortable 实例\n            container.sortableInstance = new Sortable(container, {\n                animation: 150,\n                filter: '.editable-area, input, button, select, textarea',  // 排除编辑区域\n                preventOnFilter: false,  // 允许在过滤区域正常交互\n                ghostClass: 'sortable-ghost',\n                chosenClass: 'sortable-chosen',\n                dragClass: 'sortable-drag',\n                onEnd: function(evt) {\n                    // 获取所有词组卡片的当前顺序\n                    const cards = Array.from(container.querySelectorAll('.word-group-card'));\n                    const newOrder = cards.map(card => parseInt(card.getAttribute('data-group-index')));\n\n                    // 检查顺序是否改变\n                    const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n                    const oldOrder = data.wordGroups.map((_, idx) => idx);\n\n                    if (JSON.stringify(newOrder) !== JSON.stringify(oldOrder)) {\n                        // 根据新顺序重新排列数据\n                        const reorderedGroups = newOrder.map(idx => data.wordGroups[idx]);\n                        data.wordGroups = reorderedGroups;\n\n                        // 重新构建文本\n                        currentFrequency = buildFrequencyText(data);\n                        currentFrequencyData = parseFrequencyText(currentFrequency);\n                        document.getElementById('frequency-editor').value = currentFrequency;\n                        updateBackdrop('frequency-editor', 'frequency-backdrop');\n\n                        // 重新渲染\n                        renderFrequencyPanel(currentFrequencyData);\n                    }\n                }\n            });\n        }\n    }, 0);\n}\n\n// Global Filter 操作\nwindow.handleGlobalFilterInput = function(event) {\n    if (event.key === 'Enter' && event.target.value.trim()) {\n        const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n        data.globalFilter.push(event.target.value.trim());\n        currentFrequency = buildFrequencyText(data);\n        currentFrequencyData = data;\n        document.getElementById('frequency-editor').value = currentFrequency;\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n        renderFrequencyPanel(data);\n    }\n}\n\nwindow.removeGlobalFilter = function(filter) {\n    const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n    data.globalFilter = data.globalFilter.filter(f => f !== filter);\n    currentFrequency = buildFrequencyText(data);\n    currentFrequencyData = data;\n    document.getElementById('frequency-editor').value = currentFrequency;\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n    renderFrequencyPanel(data);\n}\n\n// Word Groups 操作\nlet pendingWordGroupPosition = 'top';  // 记录添加位置：'top', 'bottom', 或数字索引\n\nwindow.addWordGroup = function(position = 'top') {\n    pendingWordGroupPosition = position;\n    document.getElementById('wordgroup-type-modal').classList.remove('hidden');\n}\n\n// 在指定位置插入词组\nwindow.insertWordGroupAt = function(index) {\n    pendingWordGroupPosition = index;  // 记录插入位置（数字索引）\n    document.getElementById('wordgroup-type-modal').classList.remove('hidden');\n}\n\nwindow.closeWordGroupTypeModal = function() {\n    document.getElementById('wordgroup-type-modal').classList.add('hidden');\n}\n\nwindow.confirmAddWordGroup = function(type) {\n    const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n    let newGroup;\n\n    if (type === 'group') {\n        // 组别名类型\n        newGroup = { type: 'group-name', name: '', keywords: [] };\n    } else if (type === 'alias') {\n        // 单个别名类型\n        newGroup = { type: 'alias', items: [{ keyword: '', alias: '' }] };\n    } else if (type === 'multi-alias') {\n        // 连续别名类型（多个别名行）\n        newGroup = { type: 'alias-group', items: [{ keyword: '', alias: '' }, { keyword: '', alias: '' }] };\n    } else if (type === 'plain') {\n        // 普通词组类型\n        newGroup = { type: 'plain', keywords: [] };\n    }\n\n    // 根据位置插入\n    if (pendingWordGroupPosition === 'bottom') {\n        data.wordGroups.push(newGroup);\n    } else if (pendingWordGroupPosition === 'top') {\n        data.wordGroups.unshift(newGroup);\n    } else if (typeof pendingWordGroupPosition === 'number') {\n        // 在指定索引位置插入\n        data.wordGroups.splice(pendingWordGroupPosition, 0, newGroup);\n    }\n\n    currentFrequency = buildFrequencyText(data);\n    currentFrequencyData = data;\n    document.getElementById('frequency-editor').value = currentFrequency;\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n    renderFrequencyPanel(data);\n\n    closeWordGroupTypeModal();\n\n    // 滚动到新添加的词组\n    setTimeout(() => {\n        const container = document.getElementById('word-groups-container');\n        if (pendingWordGroupPosition === 'bottom') {\n            container.scrollTop = container.scrollHeight;\n        } else if (pendingWordGroupPosition === 'top') {\n            container.scrollTop = 0;\n        } else if (typeof pendingWordGroupPosition === 'number') {\n            // 滚动到插入的位置\n            const cards = container.querySelectorAll('.word-group-card');\n            if (cards[pendingWordGroupPosition]) {\n                cards[pendingWordGroupPosition].scrollIntoView({ behavior: 'smooth', block: 'center' });\n            }\n        }\n    }, 100);\n}\n\nwindow.removeWordGroup = function(index) {\n    const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n    data.wordGroups.splice(index, 1);\n    currentFrequency = buildFrequencyText(data);\n    // 重新解析以更新相关组信息\n    currentFrequencyData = parseFrequencyText(currentFrequency);\n    document.getElementById('frequency-editor').value = currentFrequency;\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n    renderFrequencyPanel(currentFrequencyData);\n}\n\nwindow.updateGroupName = function(index, name) {\n    const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n    const group = data.wordGroups[index];\n\n    // 只有 group-name 类型才有 name 字段\n    if (group.type === 'group-name') {\n        group.name = name;\n    }\n\n    currentFrequency = buildFrequencyText(data);\n    // 重新解析以更新相关组信息\n    currentFrequencyData = parseFrequencyText(currentFrequency);\n    document.getElementById('frequency-editor').value = currentFrequency;\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n    renderFrequencyPanel(currentFrequencyData);\n}\n\nwindow.editKeyword = function(groupIndex, oldKeyword, spanElement) {\n    const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n    const group = data.wordGroups[groupIndex];\n\n    // 只有 group-name 和 plain 类型才有 keywords 字段\n    if (group.type !== 'group-name' && group.type !== 'plain') {\n        return;\n    }\n\n    const originalKeyword = group.keywords.find(kw => kw === oldKeyword) || oldKeyword;\n\n    const input = document.createElement('input');\n    input.type = 'text';\n    input.value = originalKeyword;\n    input.className = 'tag-input inline-block px-2 py-1 text-xs border border-blue-500 rounded';\n    input.style.minWidth = '100px';\n\n    const saveEdit = () => {\n        const newKeyword = input.value.trim();\n        if (newKeyword && newKeyword !== originalKeyword) {\n            const kwIndex = group.keywords.indexOf(originalKeyword);\n            if (kwIndex !== -1) {\n                group.keywords[kwIndex] = newKeyword;\n            }\n            currentFrequency = buildFrequencyText(data);\n            // 重新解析以更新相关组信息\n            currentFrequencyData = parseFrequencyText(currentFrequency);\n            document.getElementById('frequency-editor').value = currentFrequency;\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n            renderFrequencyPanel(currentFrequencyData);\n        } else {\n            spanElement.style.display = '';\n            input.remove();\n        }\n    };\n\n    input.onblur = saveEdit;\n    input.onkeydown = (e) => {\n        if (e.key === 'Enter') {\n            saveEdit();\n        } else if (e.key === 'Escape') {\n            spanElement.style.display = '';\n            input.remove();\n        }\n    };\n\n    spanElement.style.display = 'none';\n    spanElement.parentNode.insertBefore(input, spanElement);\n    input.focus();\n    input.select();\n}\n\nwindow.handleKeywordInput = function(event, groupIndex) {\n    if (event.key === 'Enter' && event.target.value.trim()) {\n        const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n        const group = data.wordGroups[groupIndex];\n\n        // 只有 group-name 和 plain 类型才能添加关键词\n        if (group.type === 'group-name' || group.type === 'plain') {\n            group.keywords.push(event.target.value.trim());\n            event.target.value = '';\n\n            currentFrequency = buildFrequencyText(data);\n            // 重新解析以更新相关组信息\n            currentFrequencyData = parseFrequencyText(currentFrequency);\n            document.getElementById('frequency-editor').value = currentFrequency;\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n            renderFrequencyPanel(currentFrequencyData);\n        }\n    }\n}\n\nwindow.removeKeyword = function(groupIndex, keyword) {\n    const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n    const group = data.wordGroups[groupIndex];\n\n    // 只有 group-name 和 plain 类型才能删除关键词\n    if (group.type === 'group-name' || group.type === 'plain') {\n        group.keywords = group.keywords.filter(k => k !== keyword);\n\n        // 如果词组变空，删除整个词组\n        if (group.keywords.length === 0) {\n            data.wordGroups.splice(groupIndex, 1);\n        }\n\n        currentFrequency = buildFrequencyText(data);\n        // 重新解析以更新相关组信息\n        currentFrequencyData = parseFrequencyText(currentFrequency);\n        document.getElementById('frequency-editor').value = currentFrequency;\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n        renderFrequencyPanel(currentFrequencyData);\n    }\n}\n\n// 更新别名项\nwindow.updateAliasItem = function(groupIndex, itemIndex, field, value) {\n    const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n    const group = data.wordGroups[groupIndex];\n\n    // 只有 alias 和 alias-group 类型才有 items 字段\n    if (group.type === 'alias' || group.type === 'alias-group') {\n        if (group.items[itemIndex]) {\n            group.items[itemIndex][field] = value;\n\n            currentFrequency = buildFrequencyText(data);\n            currentFrequencyData = parseFrequencyText(currentFrequency);\n            document.getElementById('frequency-editor').value = currentFrequency;\n            updateBackdrop('frequency-editor', 'frequency-backdrop');\n            renderFrequencyPanel(currentFrequencyData);\n        }\n    }\n}\n\n// 添加别名项\nwindow.addAliasItem = function(groupIndex) {\n    const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n    const group = data.wordGroups[groupIndex];\n\n    // 只有 alias-group 类型才能添加别名项\n    if (group.type === 'alias-group') {\n        group.items.push({ keyword: '', alias: '' });\n\n        currentFrequency = buildFrequencyText(data);\n        // 重新解析以更新相关组信息\n        currentFrequencyData = parseFrequencyText(currentFrequency);\n        document.getElementById('frequency-editor').value = currentFrequency;\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n        renderFrequencyPanel(currentFrequencyData);\n    } else if (group.type === 'alias') {\n        // 如果是单个别名，升级为别名组\n        group.type = 'alias-group';\n        group.items.push({ keyword: '', alias: '' });\n\n        currentFrequency = buildFrequencyText(data);\n        // 重新解析以更新相关组信息\n        currentFrequencyData = parseFrequencyText(currentFrequency);\n        document.getElementById('frequency-editor').value = currentFrequency;\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n        renderFrequencyPanel(currentFrequencyData);\n    }\n}\n\n// 删除别名项\nwindow.removeAliasItem = function(groupIndex, itemIndex) {\n    const data = currentFrequencyData || parseFrequencyText(currentFrequency);\n    const group = data.wordGroups[groupIndex];\n\n    // 只有 alias-group 类型才能删除别名项\n    if (group.type === 'alias-group') {\n        group.items.splice(itemIndex, 1);\n\n        // 如果没有别名项了，删除整个词组\n        if (group.items.length === 0) {\n            data.wordGroups.splice(groupIndex, 1);\n        }\n        // 如果只剩一个别名项，降级为单个别名\n        else if (group.items.length === 1) {\n            group.type = 'alias';\n        }\n\n        currentFrequency = buildFrequencyText(data);\n        currentFrequencyData = parseFrequencyText(currentFrequency);\n        document.getElementById('frequency-editor').value = currentFrequency;\n    updateBackdrop('frequency-editor', 'frequency-backdrop');\n        renderFrequencyPanel(currentFrequencyData);\n    }\n}\n\n// DeepSeek AI 辅助\nwindow.openDeepSeekAI = function(type, groupIndex) {\n    const userInput = window.prompt('请输入核心关键词（例如：华为）：');\n    if (!userInput) return;\n\n    const promptText = `我正在配置一个新闻聚合系统，需要通过 Python 正则表达式 抓取关于【${userInput}】的新闻。\n\n请帮我完成以下步骤，并最终只输出一个正则表达式字符串：\n\n第一步：【精准关键词筛选】\n请列出与【${userInput}】强绑定的核心词汇：\n1. 核心品牌：包括中文全称、简称、股票代码、别名。\n2. 核心人物：仅限最高决策层或极具代表性的创始人。\n3. 独家产品：必须是具有极高辨识度的独家产品名。\n4. 核心工作室/子品牌：强相关的下属机构。\n\n第二步：【严格清洗与过滤】（请严格执行）\n1. 包含关系去重（最短匹配原则）：\n   - 中文：如果列表里已经有了核心短词（如“腾讯”），请删除所有包含该短词的长词（如“腾讯云”、“腾讯视频”统统不要，因为它们会被短词命中）。\n   - 英文：如果有了 \\\\bKeyword\\\\b，就不要再出现 Keyword。\n2. 彻底排除无关公司：\n   - 绝对不要包含：该品牌的竞争对手、合作伙伴（如京东、美团、字节跳动等非隶属公司）。\n3. 彻底排除通用黑话：\n   - 绝对不要包含：行业通用词（如“互联网”、“大厂”、“新质生产力”、“人工智能”、“元宇宙”、“金融科技”等）。\n\n第三步：【构建 Python 正则】\n将清洗后的词汇合并，格式要求如下：\n1. 英文处理：所有英文单词必须前后加 \\\\b（例如 \\\\bWord\\\\b），严禁出现没有边界符的英文单词。\n2. 连接符：用 | 连接。\n\n最终输出示例格式：\n/词A|词B|\\\\bEnglishWord\\\\b/ => ${userInput}\n\n输出要求：\n- 只要这一行正则表达式，不要任何解释，不要代码块。`;\n\n    const textArea = document.createElement(\"textarea\");\n    textArea.value = promptText;\n\n    textArea.style.position = \"fixed\";\n    textArea.style.left = \"-9999px\";\n    textArea.style.top = \"0\";\n    document.body.appendChild(textArea);\n\n    textArea.focus();\n    textArea.select();\n\n    let copySuccess = false;\n    try {\n        copySuccess = document.execCommand('copy');\n    } catch (err) {\n        console.error('复制失败:', err);\n    }\n\n    document.body.removeChild(textArea);\n\n    if (copySuccess) {\n        if (confirm(`提示词已复制到剪贴板！\\n\\n关键词：${userInput}\\n\\n点击【确定】跳转 DeepSeek 官网，直接粘贴 (Ctrl+V) 即可。`)) {\n            window.open('https://chat.deepseek.com/', '_blank');\n        }\n    } else {\n        prompt('自动复制失败，请手动复制以下内容，然后自行打开 DeepSeek:', promptText);\n        window.open('https://chat.deepseek.com/', '_blank');\n    }\n}\n\n// ==========================================\n// 10. 平台管理功能\n// ==========================================\n\n// 解析当前配置中的平台列表\nfunction parsePlatformsFromYaml() {\n    try {\n        const doc = jsyaml.load(currentYaml);\n        if (doc && doc.platforms && doc.platforms.sources) {\n            return doc.platforms.sources;\n        }\n    } catch (e) {}\n    return [];\n}\n\n// 渲染平台列表\nfunction renderPlatformsList() {\n    const container = document.getElementById('platforms-list');\n    if (!container) return;\n\n    const platforms = parsePlatformsFromYaml();\n\n    if (platforms.length === 0) {\n        container.innerHTML = `<div class=\"text-xs text-gray-400 italic\">暂无平台，请添加</div>`;\n        return;\n    }\n\n    container.innerHTML = platforms.map((p, idx) => `\n        <div class=\"platform-item flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 border border-gray-200 hover:border-blue-300 transition-colors\" data-index=\"${idx}\">\n            <div class=\"flex items-center gap-2\">\n                <i class=\"fa-solid fa-grip-vertical text-gray-300 cursor-move\"></i>\n                <span class=\"text-xs font-medium text-gray-700\">${p.name}</span>\n                <span class=\"text-[10px] text-gray-400\">(${p.id})</span>\n            </div>\n            <button onclick=\"removePlatform(${idx})\" class=\"text-red-400 hover:text-red-600 text-xs\" title=\"删除\">\n                <i class=\"fa-solid fa-trash\"></i>\n            </button>\n        </div>\n    `).join('');\n\n    // 初始化拖拽排序\n    if (typeof Sortable !== 'undefined') {\n        new Sortable(container, {\n            animation: 150,\n            handle: '.fa-grip-vertical',\n            onEnd: function(evt) {\n                reorderPlatforms(evt.oldIndex, evt.newIndex);\n            }\n        });\n    }\n}\n\n// 删除平台\nwindow.removePlatform = function(index) {\n    const platforms = parsePlatformsFromYaml();\n    if (index < 0 || index >= platforms.length) return;\n\n    const platformName = platforms[index].name;\n    if (!confirm(`确定要删除平台 \"${platformName}\" 吗？`)) return;\n\n    platforms.splice(index, 1);\n    updatePlatformsInYaml(platforms);\n}\n\n// 重新排序平台\nfunction reorderPlatforms(oldIndex, newIndex) {\n    const platforms = parsePlatformsFromYaml();\n    const [removed] = platforms.splice(oldIndex, 1);\n    platforms.splice(newIndex, 0, removed);\n    updatePlatformsInYaml(platforms);\n}\n\n// 更新 YAML 中的平台配置（保留注释）\nfunction updatePlatformsInYaml(platforms) {\n    const editor = document.getElementById('yaml-editor');\n    let yaml = editor.value;\n    const lines = yaml.split('\\n');\n\n    // 找到 platforms.sources 的位置\n    let sourcesStart = -1;\n    let sourcesEnd = -1;\n    let inPlatforms = false;\n    let inSources = false;\n    let baseIndent = 0;\n    let lastDataLineIndex = -1; // 记录最后一个数据行的位置\n\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i];\n        const trimmed = line.trim();\n\n        if (line.match(/^platforms:/)) {\n            inPlatforms = true;\n            continue;\n        }\n\n        if (inPlatforms && !inSources && trimmed.startsWith('sources:')) {\n            sourcesStart = i + 1;\n            inSources = true;\n            baseIndent = line.search(/\\S/) + 2; // sources 下一级的缩进\n            continue;\n        }\n\n        if (inSources) {\n            const currentIndent = line.search(/\\S/);\n\n            // 如果是数据行（以 - 开头或是数据项的属性）\n            if (trimmed.startsWith('-')) {\n                lastDataLineIndex = i;\n            } else if (trimmed && !trimmed.startsWith('#') && currentIndent >= baseIndent) {\n                // 数据项的属性行（如 name:, id:）\n                lastDataLineIndex = i;\n            } else if (trimmed && !trimmed.startsWith('#') && currentIndent < baseIndent) {\n                // 遇到缩进更小的非注释行，说明离开了 sources 区域\n                sourcesEnd = lastDataLineIndex + 1;\n                break;\n            }\n        }\n\n        // 检查是否进入下一个顶级模块\n        if (inPlatforms && line.match(/^[a-z_]+:/) && !line.match(/^platforms:/)) {\n            if (lastDataLineIndex >= 0) {\n                sourcesEnd = lastDataLineIndex + 1;\n            } else {\n                sourcesEnd = i;\n            }\n            break;\n        }\n    }\n\n    // 如果没有找到结束位置，使用最后一个数据行的下一行\n    if (sourcesEnd === -1) {\n        sourcesEnd = lastDataLineIndex >= 0 ? lastDataLineIndex + 1 : lines.length;\n    }\n\n    // 提取区域内的注释（保留在开头的注释）\n    const regionLines = lines.slice(sourcesStart, sourcesEnd);\n    const leadingComments = [];\n    for (const line of regionLines) {\n        const trimmed = line.trim();\n        if (trimmed.startsWith('#')) {\n            leadingComments.push(line);\n        } else if (trimmed.startsWith('-') || (trimmed && !trimmed.startsWith('#'))) {\n            // 遇到第一个数据项，停止收集注释\n            break;\n        } else if (trimmed === '') {\n            // 空行也保留\n            leadingComments.push(line);\n        }\n    }\n\n    const indent = '    '; // 4 空格缩进\n    const newSourcesLines = platforms.map(p =>\n        `${indent}- id: \"${p.id}\"\\n${indent}  name: \"${p.name}\"`\n    ).join('\\n');\n\n    const beforeSources = lines.slice(0, sourcesStart);\n    const afterSources = lines.slice(sourcesEnd);\n\n    // 组合：前面内容 + 开头注释 + 新数据 + 后面内容\n    const newYaml = [\n        ...beforeSources,\n        ...(leadingComments.length > 0 ? leadingComments : []),\n        newSourcesLines,\n        ...afterSources\n    ].join('\\n');\n\n    editor.value = newYaml;\n    currentYaml = newYaml;\n    updateBackdrop('yaml-editor', 'yaml-backdrop');\n    debounceSaveConfig();\n    renderPlatformsList();\n    renderStandaloneLists(); // 同步更新独立展示区的平台选择列表\n}\n\n// ==========================================\n// 12. Display Regions 排序与管理功能\n// ==========================================\n\nconst DISPLAY_REGIONS_DEF = [\n    { key: \"hotlist\", label: \"热榜区域\" },\n    { key: \"new_items\", label: \"新增热点区域\" },\n    { key: \"rss\", label: \"RSS 订阅区域\" },\n    { key: \"standalone\", label: \"独立展示区\" },\n    { key: \"ai_analysis\", label: \"AI 分析区域\" }\n];\n\n// 从 YAML 解析 display.regions，严格按照 region_order 定义顺序\nfunction parseDisplayRegionsFromYaml() {\n    try {\n        const doc = jsyaml.load(currentYaml);\n        if (doc && doc.display) {\n            const regionOrder = doc.display.region_order || [];\n            const regionStates = doc.display.regions || {};\n\n            // 严格按 region_order 顺序构建列表\n            if (regionOrder.length > 0) {\n                return regionOrder.map(key => {\n                    const normalizedKey = key === 'new_item' ? 'new_items' : key;\n                    const def = DISPLAY_REGIONS_DEF.find(d => d.key === normalizedKey);\n                    return {\n                        key: normalizedKey,\n                        label: def ? def.label : normalizedKey,\n                        enabled: regionStates[normalizedKey] !== undefined ? regionStates[normalizedKey] : false\n                    };\n                });\n            }\n\n            // 后备方案：如果没有 region_order，使用 regions 对象的顺序\n            const regions = [];\n            for (const key in regionStates) {\n                const normalizedKey = key === 'new_item' ? 'new_items' : key;\n                const def = DISPLAY_REGIONS_DEF.find(d => d.key === normalizedKey);\n                if (def) {\n                    regions.push({\n                        key: normalizedKey,\n                        label: def.label,\n                        enabled: regionStates[key]\n                    });\n                }\n            }\n            return regions;\n        }\n    } catch (e) {}\n\n    // 默认返回所有区域（禁用状态）\n    return DISPLAY_REGIONS_DEF.map(def => ({\n        key: def.key,\n        label: def.label,\n        enabled: false\n    }));\n}\n\n// 渲染 Display Regions 列表\nfunction renderDisplayRegionsList() {\n    const container = document.getElementById('display-regions-list');\n    if (!container) return;\n\n    const regions = parseDisplayRegionsFromYaml();\n\n    container.innerHTML = regions.map((r, idx) => `\n        <div class=\"display-region-item flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 border border-gray-200 hover:border-blue-300 transition-colors\" data-key=\"${r.key}\">\n            <div class=\"flex items-center gap-2\">\n                <i class=\"fa-solid fa-grip-vertical text-gray-300 cursor-move\"></i>\n                <span class=\"text-xs font-medium ${r.enabled ? 'text-gray-700' : 'text-gray-400'}\">${r.label}</span>\n                <span class=\"text-[10px] text-gray-400\">(${r.key})</span>\n            </div>\n            <div class=\"relative inline-block w-10 align-middle select-none\">\n                <input type=\"checkbox\" id=\"toggle-region-${r.key}\"\n                       ${r.enabled ? 'checked' : ''}\n                       onchange=\"toggleDisplayRegion('${r.key}')\"\n                       class=\"toggle-checkbox absolute block w-4 h-4 mt-0.5 ml-0.5 rounded-full bg-white border-4 appearance-none cursor-pointer transition-all duration-200 ease-in-out\"/>\n                <label for=\"toggle-region-${r.key}\" class=\"toggle-label block overflow-hidden h-5 rounded-full bg-gray-300 cursor-pointer\"></label>\n            </div>\n        </div>\n    `).join('');\n\n    // 初始化拖拽排序\n    if (typeof Sortable !== 'undefined') {\n        new Sortable(container, {\n            animation: 150,\n            handle: '.fa-grip-vertical',\n            onEnd: function(evt) {\n                reorderDisplayRegions();\n            }\n        });\n    }\n}\n\n// 切换区域启用状态\nwindow.toggleDisplayRegion = function(key) {\n    const regions = parseDisplayRegionsFromYaml();\n    const target = regions.find(r => r.key === key);\n    if (target) {\n        target.enabled = !target.enabled;\n        updateDisplayRegionsInYaml(regions);\n    }\n}\n\n// 重新排序区域\nwindow.reorderDisplayRegions = function() {\n    const container = document.getElementById('display-regions-list');\n    const items = container.querySelectorAll('.display-region-item');\n    const newOrderKeys = Array.from(items).map(item => item.dataset.key);\n\n    const currentRegions = parseDisplayRegionsFromYaml();\n\n    const newRegions = newOrderKeys.map(key => {\n        return currentRegions.find(r => r.key === key);\n    }).filter(r => r); // 过滤掉可能的 undefined\n\n    updateDisplayRegionsInYaml(newRegions);\n}\n\n// 更新 YAML 中的 display.regions 和 display.region_order\nfunction updateDisplayRegionsInYaml(regions) {\n    const editor = document.getElementById('yaml-editor');\n    let yaml = editor.value;\n    const lines = yaml.split('\\n');\n\n    let regionOrderStart = -1;\n    let regionOrderEnd = -1;\n    let regionsStart = -1;\n    let regionsEnd = -1;\n    let inDisplay = false;\n    let regionOrderIndent = 0;\n    let regionsIndent = 0;\n\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i];\n        const trimmed = line.trim();\n\n        if (line.match(/^display:/)) {\n            inDisplay = true;\n            continue;\n        }\n\n        if (!inDisplay) continue;\n\n        // 查找 region_order 数组\n        if (trimmed.startsWith('region_order:')) {\n            regionOrderStart = i + 1;\n            regionOrderIndent = line.search(/\\S/) + 2;\n            // 找到 region_order 的结束位置\n            for (let j = i + 1; j < lines.length; j++) {\n                const nextLine = lines[j];\n                const nextTrimmed = nextLine.trim();\n                if (nextTrimmed && !nextTrimmed.startsWith('#') && !nextTrimmed.startsWith('-')) {\n                    const nextIndent = nextLine.search(/\\S/);\n                    if (nextIndent < regionOrderIndent) {\n                        regionOrderEnd = j;\n                        break;\n                    }\n                }\n            }\n            if (regionOrderEnd === -1) regionOrderEnd = lines.length;\n            continue;\n        }\n\n        // 查找 regions 对象\n        if (trimmed.startsWith('regions:')) {\n            regionsStart = i + 1;\n            regionsIndent = line.search(/\\S/) + 2;\n            // 找到 regions 的结束位置（遇到同级或更高级的键）\n            for (let j = i + 1; j < lines.length; j++) {\n                const nextLine = lines[j];\n                const nextTrimmed = nextLine.trim();\n                if (nextTrimmed && !nextTrimmed.startsWith('#')) {\n                    const nextIndent = nextLine.search(/\\S/);\n                    // 检查是否是同级或更高级的键（如 standalone:）\n                    if (nextIndent <= line.search(/\\S/)) {\n                        regionsEnd = j;\n                        break;\n                    }\n                }\n            }\n            if (regionsEnd === -1) regionsEnd = lines.length;\n            break;\n        }\n\n        // 检查是否离开 display 模块\n        if (line.match(/^[a-z_]+:/) && !line.match(/^display:/)) {\n            break;\n        }\n    }\n\n    // 更新 region_order 数组（保留注释）\n    if (regionOrderStart > 0 && regionOrderEnd > regionOrderStart) {\n        const indentStr = ' '.repeat(regionOrderIndent);\n\n        // 提取原有行的注释映射\n        const originalRegionOrderBlock = lines.slice(regionOrderStart, regionOrderEnd);\n        const commentMap = {};\n\n        originalRegionOrderBlock.forEach(line => {\n            // 匹配 \"- key  # 注释\" 格式\n            const match = line.match(/^\\s*-\\s*([a-z_]+)\\s*(#.*)?$/);\n            if (match) {\n                const key = match[1];\n                const comment = match[2] || '';\n                if (key) commentMap[key] = comment;\n            }\n        });\n\n        // 生成新的行，保留注释\n        const newRegionOrderLines = regions.map(r => {\n            const comment = commentMap[r.key] || '';\n            return `${indentStr}- ${r.key}${comment ? '                       ' + comment : ''}`;\n        });\n\n        lines.splice(regionOrderStart, regionOrderEnd - regionOrderStart, ...newRegionOrderLines);\n\n        // 调整 regionsStart 和 regionsEnd\n        const lineDiff = newRegionOrderLines.length - (regionOrderEnd - regionOrderStart);\n        if (regionsStart > regionOrderEnd) {\n            regionsStart += lineDiff;\n            regionsEnd += lineDiff;\n        }\n    }\n\n    // 更新 regions 对象\n    if (regionsStart > 0 && regionsEnd > regionsStart) {\n        const originalRegionsBlock = lines.slice(regionsStart, regionsEnd);\n        const commentMap = {};\n\n        originalRegionsBlock.forEach(line => {\n            const match = line.match(/^\\s*([a-z_]+):\\s*[^#]*(#.*)?$/);\n            if (match) {\n                const key = match[1];\n                const comment = match[2] || '';\n                if (key) commentMap[key] = comment;\n            }\n        });\n\n        const indentStr = ' '.repeat(regionsIndent);\n        const newRegionsLines = regions.map(r => {\n            const comment = commentMap[r.key] || '';\n            return `${indentStr}${r.key}: ${r.enabled}${comment ? ' ' + comment.trim() : ''}`;\n        });\n\n        lines.splice(regionsStart, regionsEnd - regionsStart, ...newRegionsLines);\n    }\n\n    editor.value = lines.join('\\n');\n    currentYaml = lines.join('\\n');\n    updateBackdrop('yaml-editor', 'yaml-backdrop');\n    debounceSaveConfig();\n\n    renderDisplayRegionsList();\n}\n\n// 解析当前配置中的 RSS 源列表\nfunction parseRssFeedsFromYaml() {\n    try {\n        const doc = jsyaml.load(currentYaml);\n        if (doc && doc.rss && doc.rss.feeds) {\n            return doc.rss.feeds;\n        }\n    } catch (e) {}\n    return [];\n}\n\n// 渲染 RSS 源列表\nfunction renderRssFeedsList() {\n    const container = document.getElementById('rss-feeds-list');\n    if (!container) return;\n\n    const feeds = parseRssFeedsFromYaml();\n\n    if (feeds.length === 0) {\n        container.innerHTML = `<div class=\"text-xs text-gray-400 italic\">暂无 RSS 源，请添加</div>`;\n        return;\n    }\n\n    container.innerHTML = feeds.map((f, idx) => `\n        <div class=\"rss-feed-item bg-gray-50 rounded-lg px-3 py-2 border border-gray-200 hover:border-blue-300 transition-colors\" data-index=\"${idx}\">\n            <div class=\"flex items-center justify-between\">\n                <div class=\"flex items-center gap-2 flex-1 min-w-0\">\n                    <i class=\"fa-solid fa-rss text-orange-400\"></i>\n                    <span class=\"text-xs font-medium text-gray-700 truncate\">${f.name}</span>\n                    <span class=\"text-[10px] text-gray-400\">(${f.id})</span>\n                    ${f.enabled === false ? '<span class=\"text-[9px] bg-gray-200 text-gray-500 px-1 rounded\">已禁用</span>' : ''}\n                </div>\n                <div class=\"flex items-center gap-1\">\n                    <button onclick=\"editRssFeed(${idx})\" class=\"text-blue-400 hover:text-blue-600 text-xs px-1\" title=\"编辑\">\n                        <i class=\"fa-solid fa-pen\"></i>\n                    </button>\n                    <button onclick=\"toggleRssFeed(${idx})\" class=\"text-gray-400 hover:text-gray-600 text-xs px-1\" title=\"${f.enabled === false ? '启用' : '禁用'}\">\n                        <i class=\"fa-solid fa-${f.enabled === false ? 'eye' : 'eye-slash'}\"></i>\n                    </button>\n                    <button onclick=\"removeRssFeed(${idx})\" class=\"text-red-400 hover:text-red-600 text-xs px-1\" title=\"删除\">\n                        <i class=\"fa-solid fa-trash\"></i>\n                    </button>\n                </div>\n            </div>\n            <div class=\"text-[10px] text-gray-400 mt-1 truncate\" title=\"${f.url}\">${f.url}</div>\n        </div>\n    `).join('');\n}\n\n// 删除 RSS 源\nwindow.removeRssFeed = function(index) {\n    const feeds = parseRssFeedsFromYaml();\n    if (index < 0 || index >= feeds.length) return;\n\n    const feedName = feeds[index].name;\n    if (!confirm(`确定要删除 RSS 源 \"${feedName}\" 吗？`)) return;\n\n    feeds.splice(index, 1);\n    updateRssFeedsInYaml(feeds);\n}\n\n// 切换 RSS 源启用状态\nwindow.toggleRssFeed = function(index) {\n    const feeds = parseRssFeedsFromYaml();\n    if (index < 0 || index >= feeds.length) return;\n\n    feeds[index].enabled = feeds[index].enabled === false ? true : false;\n    updateRssFeedsInYaml(feeds);\n}\n\n// 编辑 RSS 源\nwindow.editRssFeed = function(index) {\n    const feeds = parseRssFeedsFromYaml();\n    if (index < 0 || index >= feeds.length) return;\n\n    const feed = feeds[index];\n\n    openRssModalWithData(feed, index);\n}\n\n// 更新 YAML 中的 RSS 配置（保留注释）\nfunction updateRssFeedsInYaml(feeds) {\n    const editor = document.getElementById('yaml-editor');\n    let yaml = editor.value;\n    const lines = yaml.split('\\n');\n\n    // 找到 rss.feeds 的位置\n    let feedsStart = -1;\n    let feedsEnd = -1;\n    let inRss = false;\n    let inFeeds = false;\n    let lastDataLineIndex = -1; // 记录最后一个数据行的位置\n\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i];\n        const trimmed = line.trim();\n\n        if (line.match(/^rss:/)) {\n            inRss = true;\n            continue;\n        }\n\n        if (inRss && !inFeeds && trimmed.startsWith('feeds:')) {\n            feedsStart = i + 1;\n            inFeeds = true;\n            continue;\n        }\n\n        if (inFeeds) {\n            const indent = line.search(/\\S/);\n\n            // 如果是数据行（以 - 开头或是数据项的属性）\n            if (trimmed.startsWith('-')) {\n                lastDataLineIndex = i;\n            } else if (trimmed && !trimmed.startsWith('#') && indent > 2) {\n                // 数据项的属性行（如 name:, id:, url:）\n                lastDataLineIndex = i;\n            } else if (trimmed && !trimmed.startsWith('#') && indent <= 2 && indent >= 0) {\n                // 遇到缩进更小的非注释行，说明离开了 feeds 区域\n                feedsEnd = lastDataLineIndex + 1;\n                break;\n            }\n        }\n\n        // 检查是否进入下一个顶级模块\n        if (inRss && line.match(/^[a-z_]+:/) && !line.match(/^rss:/)) {\n            if (lastDataLineIndex >= 0) {\n                feedsEnd = lastDataLineIndex + 1;\n            } else {\n                feedsEnd = i;\n            }\n            break;\n        }\n    }\n\n    // 如果没有找到结束位置，使用最后一个数据行的下一行\n    if (feedsEnd === -1) {\n        feedsEnd = lastDataLineIndex >= 0 ? lastDataLineIndex + 1 : lines.length;\n    }\n\n    // 提取区域内的注释（保留在开头的注释）\n    const regionLines = lines.slice(feedsStart, feedsEnd);\n    const leadingComments = [];\n    for (const line of regionLines) {\n        const trimmed = line.trim();\n        if (trimmed.startsWith('#')) {\n            leadingComments.push(line);\n        } else if (trimmed.startsWith('-') || (trimmed && !trimmed.startsWith('#'))) {\n            // 遇到第一个数据项，停止收集注释\n            break;\n        } else if (trimmed === '') {\n            // 空行也保留\n            leadingComments.push(line);\n        }\n    }\n\n    // 构建新的 feeds 内容\n    const indent = '    '; // 4 空格缩进\n    const newFeedsLines = feeds.map(f => {\n        let feedYaml = `${indent}- id: \"${f.id}\"\\n${indent}  name: \"${f.name}\"\\n${indent}  url: \"${f.url}\"`;\n        if (f.enabled === false) {\n            feedYaml += `\\n${indent}  enabled: false`;\n        }\n        if (f.max_age_days !== undefined && f.max_age_days !== '') {\n            feedYaml += `\\n${indent}  max_age_days: ${f.max_age_days}`;\n        }\n        return feedYaml;\n    }).join('\\n\\n');\n\n    const beforeFeeds = lines.slice(0, feedsStart);\n    const afterFeeds = lines.slice(feedsEnd);\n\n    // 组合：前面内容 + 开头注释 + 新数据 + 空行 + 后面内容\n    const newYaml = [\n        ...beforeFeeds,\n        ...(leadingComments.length > 0 ? leadingComments : []),\n        newFeedsLines,\n        '',\n        ...afterFeeds\n    ].join('\\n');\n\n    editor.value = newYaml;\n    currentYaml = newYaml;\n    updateBackdrop('yaml-editor', 'yaml-backdrop');\n    debounceSaveConfig();\n    renderRssFeedsList();\n    renderStandaloneLists(); // 同步更新独立展示区的 RSS 选择列表\n}\n\n// 打开 RSS 添加/编辑弹窗\nwindow.openRssModal = function() {\n    openRssModalWithData(null, -1);\n}\n\nfunction openRssModalWithData(feed, editIndex) {\n    const modal = document.getElementById('rss-modal');\n\n    document.getElementById('rss-id').value = feed ? feed.id : '';\n    document.getElementById('rss-name').value = feed ? feed.name : '';\n    document.getElementById('rss-url').value = feed ? feed.url : '';\n    document.getElementById('rss-max-age').value = feed && feed.max_age_days !== undefined ? feed.max_age_days : '';\n\n    modal.dataset.editIndex = editIndex;\n\n    const title = modal.querySelector('h3');\n    if (title) {\n        title.innerHTML = editIndex >= 0 ?\n            '<i class=\"fa-solid fa-rss mr-2 text-orange-500\"></i>编辑 RSS 源' :\n            '<i class=\"fa-solid fa-rss mr-2 text-orange-500\"></i>添加 RSS 源';\n    }\n\n    modal.classList.remove('hidden');\n}\n\n// 关闭 RSS 弹窗\nwindow.closeRssModal = function() {\n    const modal = document.getElementById('rss-modal');\n    modal.classList.add('hidden');\n    modal.dataset.editIndex = '-1';\n\n    document.getElementById('rss-id').value = '';\n    document.getElementById('rss-name').value = '';\n    document.getElementById('rss-url').value = '';\n    document.getElementById('rss-max-age').value = '';\n}\n\n// 确认添加/编辑 RSS\nwindow.confirmAddRss = function() {\n    const modal = document.getElementById('rss-modal');\n    const editIndex = parseInt(modal.dataset.editIndex || '-1');\n\n    const id = document.getElementById('rss-id').value.trim();\n    const name = document.getElementById('rss-name').value.trim();\n    const url = document.getElementById('rss-url').value.trim();\n    const maxAge = document.getElementById('rss-max-age').value.trim();\n\n    if (!id || !name || !url) {\n        alert('请填写完整信息：ID、名称和 URL 都是必填项');\n        return;\n    }\n\n    const feeds = parseRssFeedsFromYaml();\n\n    const newFeed = { id, name, url };\n    if (maxAge) {\n        newFeed.max_age_days = parseInt(maxAge);\n    }\n\n    if (editIndex >= 0) {\n        feeds[editIndex] = newFeed;\n    } else {\n        feeds.push(newFeed);\n    }\n\n    updateRssFeedsInYaml(feeds);\n    closeRssModal();\n}\n\n// ==========================================\n// 14. 独立展示区 (Standalone) 管理功能\n// ==========================================\n\nfunction parseStandaloneConfigFromYaml() {\n    try {\n        const doc = jsyaml.load(currentYaml);\n        if (doc && doc.display && doc.display.standalone) {\n            return {\n                platforms: doc.display.standalone.platforms || [],\n                rss_feeds: doc.display.standalone.rss_feeds || []\n            };\n        }\n    } catch (e) {}\n    return { platforms: [], rss_feeds: [] };\n}\n\nfunction renderStandaloneLists() {\n    const platformsContainer = document.getElementById('standalone-platforms-list');\n    const rssContainer = document.getElementById('standalone-rss-list');\n\n    if (!platformsContainer || !rssContainer) return;\n\n    const standaloneConfig = parseStandaloneConfigFromYaml();\n    const availablePlatforms = parsePlatformsFromYaml();\n    const availableRss = parseRssFeedsFromYaml();\n\n    // Render Platforms\n    if (availablePlatforms.length === 0) {\n        platformsContainer.innerHTML = `<div class=\"col-span-2 text-xs text-gray-400 italic\">暂无可用平台</div>`;\n    } else {\n        platformsContainer.innerHTML = availablePlatforms.map(p => {\n            const isChecked = standaloneConfig.platforms.includes(p.id);\n            return `\n                <label class=\"flex items-center gap-2 p-1.5 rounded hover:bg-white transition-colors cursor-pointer\">\n                    <input type=\"checkbox\" onchange=\"toggleStandaloneItem('platforms', '${p.id}')\"\n                           ${isChecked ? 'checked' : ''} class=\"rounded border-gray-300 text-blue-600 focus:ring-blue-500\">\n                    <div class=\"min-w-0\">\n                        <div class=\"text-xs font-medium text-gray-700 truncate\">${p.name}</div>\n                        <div class=\"text-[9px] text-gray-400 truncate\">${p.id}</div>\n                    </div>\n                </label>\n            `;\n        }).join('');\n    }\n\n    // Render RSS\n    if (availableRss.length === 0) {\n        rssContainer.innerHTML = `<div class=\"text-xs text-gray-400 italic\">暂无可用 RSS 源</div>`;\n    } else {\n        rssContainer.innerHTML = availableRss.map(f => {\n            const isChecked = standaloneConfig.rss_feeds.includes(f.id);\n            return `\n                <label class=\"flex items-center gap-2 p-1.5 rounded hover:bg-white transition-colors cursor-pointer\">\n                    <input type=\"checkbox\" onchange=\"toggleStandaloneItem('rss_feeds', '${f.id}')\"\n                           ${isChecked ? 'checked' : ''} class=\"rounded border-gray-300 text-blue-600 focus:ring-blue-500\">\n                    <div class=\"min-w-0 flex-1\">\n                        <div class=\"flex items-center justify-between\">\n                            <span class=\"text-xs font-medium text-gray-700 truncate\">${f.name}</span>\n                            <span class=\"text-[9px] text-gray-400 ml-2\">${f.id}</span>\n                        </div>\n                        <div class=\"text-[9px] text-gray-400 truncate\">${f.url}</div>\n                    </div>\n                </label>\n            `;\n        }).join('');\n    }\n}\n\nwindow.toggleStandaloneItem = function(type, id) {\n    const config = parseStandaloneConfigFromYaml();\n    const list = config[type];\n\n    const index = list.indexOf(id);\n    if (index === -1) {\n        list.push(id);\n    } else {\n        list.splice(index, 1);\n    }\n\n    updateStandaloneConfigInYaml(type, list);\n}\n\nfunction updateStandaloneConfigInYaml(type, list) {\n    const editor = document.getElementById('yaml-editor');\n    let yaml = editor.value;\n    const lines = yaml.split('\\n');\n\n    // 找到 display -> standalone -> [type]\n    let inDisplay = false;\n    let inStandalone = false;\n    let targetLineIndex = -1;\n    let indent = '';\n\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i];\n        if (line.match(/^display:/)) {\n            inDisplay = true;\n            continue;\n        }\n        if (inDisplay && line.trim().startsWith('standalone:')) {\n            inStandalone = true;\n            continue;\n        }\n        if (inStandalone) {\n            // 检查是否离开 standalone (遇到缩进更少或相同的非注释行)\n            const currentIndent = line.search(/\\S/);\n            // standalone 下一级的缩进\n            if (line.match(new RegExp(`^\\\\s*${type}:`))) {\n                targetLineIndex = i;\n                indent = line.substring(0, line.indexOf(type));\n                break;\n            }\n            // 如果遇到下一个模块，停止\n            if (line.match(/^[a-z_]+:/) && !line.match(/^display:/)) break;\n        }\n    }\n\n    if (targetLineIndex !== -1) {\n        // 构建新的数组字符串 [\"item1\", \"item2\"]\n        const jsonStr = JSON.stringify(list);\n        // 保留原有注释\n        const originalLine = lines[targetLineIndex];\n        const commentMatch = originalLine.match(/#.*$/);\n        const comment = commentMatch ? commentMatch[0] : '';\n\n        lines[targetLineIndex] = `${indent}${type}: ${jsonStr}${comment ? ' ' + comment : ''}`;\n\n        const newYaml = lines.join('\\n');\n        editor.value = newYaml;\n        currentYaml = newYaml;\n        updateBackdrop('yaml-editor', 'yaml-backdrop');\n        debounceSaveConfig();\n\n        // 不需要重新渲染整个列表，因为是 checkbox 点击触发的\n        // 但如果需要保持一致性，可以重新渲染\n    }\n}\n\n\n// 从文本中提取版本号\nfunction extractVersion(text) {\n    // 匹配 Version: v5.3.0 或 Version: 5.3.0 格式\n    const versionMatch = text.match(/Version:\\s*v?(\\d+\\.\\d+\\.\\d+)/i);\n    if (versionMatch) {\n        return versionMatch[1]; // 返回不带 v 的版本号\n    }\n    return null;\n}\n\n// 比较版本号 (返回 1: v1 > v2, -1: v1 < v2, 0: v1 == v2)\nfunction compareVersions(v1, v2) {\n    if (!v1 || !v2) return 0;\n\n    const parts1 = v1.split('.').map(Number);\n    const parts2 = v2.split('.').map(Number);\n\n    for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {\n        const num1 = parts1[i] || 0;\n        const num2 = parts2[i] || 0;\n\n        if (num1 > num2) return 1;\n        if (num1 < num2) return -1;\n    }\n\n    return 0;\n}\n\n// 版本检测主函数\nwindow.checkVersion = async function() {\n    const btn = document.getElementById('version-check-btn');\n    const originalHTML = btn.innerHTML;\n\n    btn.innerHTML = '<i class=\"fa-solid fa-spinner fa-spin\"></i><span>检测中...</span>';\n    btn.disabled = true;\n\n    try {\n        const versionRes = await fetch(REMOTE_VERSION_URL);\n        if (!versionRes.ok) {\n            throw new Error(`版本信息获取失败: ${versionRes.status}`);\n        }\n\n        const versionConfigText = await versionRes.text();\n        const versionMap = {};\n        versionConfigText.split('\\n').forEach(line => {\n            const parts = line.trim().split('=');\n            if (parts.length >= 2) {\n                versionMap[parts[0].trim()] = parts[1].trim();\n            }\n        });\n\n        const currentTab = getCurrentTab();\n        let currentVersion = null;\n        let fileName = '';\n\n        if (currentTab === 'config') {\n            currentVersion = extractVersion(currentYaml);\n            fileName = 'config.yaml';\n        } else {\n            currentVersion = extractVersion(currentFrequency);\n            fileName = 'frequency_words.txt';\n        }\n\n        const latestVersion = versionMap[fileName];\n\n        if (!latestVersion) {\n             throw new Error(`未在远程版本清单中找到 ${fileName}`);\n        }\n\n        showVersionComparisonModal(fileName, currentVersion, latestVersion);\n\n    } catch (err) {\n        console.error('版本检测失败:', err);\n        showToast(`版本检测失败: ${err.message}`, 'error');\n    } finally {\n        btn.innerHTML = originalHTML;\n        btn.disabled = false;\n    }\n}\n\n// 获取当前 Tab\nfunction getCurrentTab() {\n    return currentTab; \n}\n\n// 显示版本对比弹窗\nfunction showVersionComparisonModal(fileName, currentVersion, latestVersion) {\n    const existingModal = document.getElementById('version-comparison-modal');\n    if (existingModal) existingModal.remove();\n\n    const comparison = compareVersions(currentVersion, latestVersion);\n    let statusIcon = '';\n    let statusText = '';\n    let statusColor = '';\n    let actionButtons = '';\n\n    if (!currentVersion) {\n        statusIcon = '<i class=\"fa-solid fa-question-circle text-gray-500 text-3xl\"></i>';\n        statusText = '未检测到版本信息';\n        statusColor = 'text-gray-600';\n        actionButtons = `\n            <button onclick=\"closeVersionModal()\" class=\"px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg\">关闭</button>\n            <button onclick=\"updateToLatest()\" class=\"px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700\">\n                <i class=\"fa-solid fa-download mr-1\"></i>更新到最新版本\n            </button>\n        `;\n    } else if (comparison < 0) {\n        statusIcon = '<i class=\"fa-solid fa-arrow-up text-orange-500 text-3xl\"></i>';\n        statusText = '发现新版本';\n        statusColor = 'text-orange-600';\n        actionButtons = `\n            <button onclick=\"closeVersionModal()\" class=\"px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg\">稍后更新</button>\n            <button onclick=\"updateToLatest()\" class=\"px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700\">\n                <i class=\"fa-solid fa-download mr-1\"></i>立即更新\n            </button>\n        `;\n    } else if (comparison > 0) {\n        statusIcon = '<i class=\"fa-solid fa-flask text-purple-500 text-3xl\"></i>';\n        statusText = '当前版本较新（开发版本？）';\n        statusColor = 'text-purple-600';\n        actionButtons = `\n            <button onclick=\"closeVersionModal()\" class=\"px-4 py-2 bg-gray-100 text-gray-600 hover:bg-gray-200 rounded-lg\">关闭</button>\n        `;\n    } else {\n        statusIcon = '<i class=\"fa-solid fa-check-circle text-green-500 text-3xl\"></i>';\n        statusText = '已是最新版本';\n        statusColor = 'text-green-600';\n        actionButtons = `\n            <button onclick=\"closeVersionModal()\" class=\"px-4 py-2 bg-gray-100 text-gray-600 hover:bg-gray-200 rounded-lg\">关闭</button>\n        `;\n    }\n\n    const modal = document.createElement('div');\n    modal.id = 'version-comparison-modal';\n    modal.className = 'modal-overlay';\n    modal.innerHTML = `\n        <div class=\"modal-content\" style=\"max-width: 480px;\">\n            <div class=\"flex items-center justify-between mb-4\">\n                <h3 class=\"text-lg font-bold text-gray-800\">\n                    <i class=\"fa-solid fa-code-compare mr-2 text-blue-500\"></i>版本检测结果\n                </h3>\n                <button onclick=\"closeVersionModal()\" class=\"text-gray-400 hover:text-gray-600\">\n                    <i class=\"fa-solid fa-times text-xl\"></i>\n                </button>\n            </div>\n\n            <div class=\"text-center py-6\">\n                ${statusIcon}\n                <div class=\"text-xl font-bold ${statusColor} mt-3\">${statusText}</div>\n            </div>\n\n            <div class=\"bg-gray-50 rounded-lg p-4 space-y-3 mb-4\">\n                <div class=\"flex items-center justify-between text-sm\">\n                    <span class=\"text-gray-600\">配置文件</span>\n                    <span class=\"font-mono font-bold text-gray-800\">${fileName}</span>\n                </div>\n                <div class=\"border-t border-gray-200\"></div>\n                <div class=\"flex items-center justify-between text-sm\">\n                    <span class=\"text-gray-600\">当前版本</span>\n                    <span class=\"font-mono font-bold ${currentVersion ? 'text-blue-600' : 'text-gray-400'}\">\n                        ${currentVersion ? 'v' + currentVersion : '未知'}\n                    </span>\n                </div>\n                <div class=\"flex items-center justify-between text-sm\">\n                    <span class=\"text-gray-600\">最新版本</span>\n                    <span class=\"font-mono font-bold text-green-600\">v${latestVersion}</span>\n                </div>\n            </div>\n\n            ${comparison < 0 || !currentVersion ? `\n                <div class=\"text-xs text-gray-500 bg-yellow-50 border border-yellow-200 rounded p-3 mb-4\">\n                    <i class=\"fa-solid fa-lightbulb mr-1 text-yellow-600\"></i>\n                    <strong>提示：</strong>更新将从 GitHub 加载最新的 ${fileName}，你当前的修改将被覆盖。建议先复制保存你的自定义配置。\n                </div>\n            ` : ''}\n\n            <div class=\"flex justify-end gap-2\">\n                ${actionButtons}\n            </div>\n        </div>\n    `;\n\n    document.body.appendChild(modal);\n}\n\nwindow.closeVersionModal = function() {\n    const modal = document.getElementById('version-comparison-modal');\n    if (modal) modal.remove();\n}\n\n// ==========================================\n// 13. 平台添加弹窗逻辑\n// ==========================================\n\n// 预定义可用平台列表 (仅包含官方默认支持的平台)\nconst PRESET_PLATFORMS = [\n    { key: 'toutiao', name: '今日头条' },\n    { key: 'baidu', name: '百度热搜' },\n    { key: 'wallstreetcn-hot', name: '华尔街见闻' },\n    { key: 'thepaper', name: '澎湃新闻' },\n    { key: 'bilibili-hot-search', name: 'bilibili 热搜' },\n    { key: 'cls-hot', name: '财联社热门' },\n    { key: 'ifeng', name: '凤凰网' },\n    { key: 'tieba', name: '贴吧' },\n    { key: 'weibo', name: '微博' },\n    { key: 'douyin', name: '抖音' },\n    { key: 'zhihu', name: '知乎' }\n];\n\n/**\n * 打开平台添加弹窗\n */\nwindow.openPlatformModal = function() {\n    const modal = document.getElementById('platform-modal');\n    if (modal) {\n        modal.classList.remove('hidden');\n        if (typeof switchPlatformTab === 'function') {\n            switchPlatformTab('select');\n        }\n        renderAvailablePlatforms();\n    }\n}\n\n/**\n * 关闭平台添加弹窗\n */\nwindow.closePlatformModal = function() {\n    const modal = document.getElementById('platform-modal');\n    if (modal) {\n        modal.classList.add('hidden');\n    }\n}\n\n/**\n * 切换平台添加标签页\n */\nwindow.switchPlatformTab = function(tab) {\n    currentPlatformTab = tab;\n\n    // 更新 Tab 样式\n    const tabSelect = document.getElementById('tab-platform-select');\n    const tabCustom = document.getElementById('tab-platform-custom');\n\n    if (tab === 'select') {\n        if (tabSelect) {\n            tabSelect.classList.add('text-blue-600', 'border-blue-600');\n            tabSelect.classList.remove('text-gray-500', 'border-transparent');\n        }\n        if (tabCustom) {\n            tabCustom.classList.remove('text-blue-600', 'border-blue-600');\n            tabCustom.classList.add('text-gray-500', 'border-transparent');\n        }\n\n        const selectPanel = document.getElementById('platform-select-panel');\n        const customPanel = document.getElementById('platform-custom-panel');\n        if (selectPanel) selectPanel.classList.remove('hidden');\n        if (customPanel) customPanel.classList.add('hidden');\n    } else {\n        if (tabCustom) {\n            tabCustom.classList.add('text-blue-600', 'border-blue-600');\n            tabCustom.classList.remove('text-gray-500', 'border-transparent');\n        }\n        if (tabSelect) {\n            tabSelect.classList.remove('text-blue-600', 'border-blue-600');\n            tabSelect.classList.add('text-gray-500', 'border-transparent');\n        }\n\n        const selectPanel = document.getElementById('platform-select-panel');\n        const customPanel = document.getElementById('platform-custom-panel');\n        if (selectPanel) selectPanel.classList.add('hidden');\n        if (customPanel) customPanel.classList.remove('hidden');\n    }\n}\n\n/**\n * 渲染可用平台列表（排除已添加的）\n */\nfunction renderAvailablePlatforms() {\n    const container = document.getElementById('available-platforms-list');\n    const tip = document.getElementById('no-platforms-tip');\n    if (!container) return;\n    container.innerHTML = '';\n\n    const currentPlatforms = parsePlatformsFromYaml();\n    const existingKeys = currentPlatforms.map(p => p.id); \n\n    const available = PRESET_PLATFORMS.filter(p => !existingKeys.includes(p.key));\n\n    if (available.length === 0) {\n        if (tip) {\n            tip.classList.remove('hidden');\n            tip.innerHTML = `<i class=\"fa-solid fa-check-circle text-green-500 mr-2\"></i>所有预设平台已添加`;\n        }\n    } else {\n        if (tip) tip.classList.add('hidden');\n\n        available.forEach(p => {\n            const div = document.createElement('div');\n            div.className = 'flex items-center justify-between p-3 border border-gray-100 rounded hover:bg-blue-50 cursor-pointer transition-colors group';\n            div.onclick = () => confirmAddPlatform(p.key, p.name);\n            div.innerHTML = `\n                <div class=\"flex items-center gap-3\">\n                    <div class=\"w-8 h-8 rounded bg-gray-100 flex items-center justify-center text-gray-500 group-hover:bg-white group-hover:text-blue-600\">\n                        <i class=\"fa-solid fa-cube\"></i>\n                    </div>\n                    <div>\n                        <div class=\"font-bold text-gray-800 text-sm\">${p.name}</div>\n                        <div class=\"text-xs text-gray-400 font-mono\">${p.key}</div>\n                    </div>\n                </div>\n                <button class=\"text-gray-300 group-hover:text-blue-600\">\n                    <i class=\"fa-solid fa-plus-circle text-lg\"></i>\n                </button>\n            `;\n            container.appendChild(div);\n        });\n    }\n}\n\n/**\n * 确认添加平台\n */\nwindow.confirmAddPlatform = function(key, name) {\n    let platformKey = key;\n    let platformName = name;\n\n    // 如果是手动输入模式 (且未传入 key)\n    if (currentPlatformTab === 'custom' && !key) {\n        const keyInput = document.getElementById('custom-platform-key');\n        const nameInput = document.getElementById('custom-platform-name');\n\n        if (keyInput) platformKey = keyInput.value.trim();\n        if (nameInput) platformName = nameInput.value.trim();\n\n        if (!platformKey) {\n            alert('请输入平台 Key');\n            return;\n        }\n        if (!platformName) {\n            platformName = platformKey;\n        }\n    } else if (currentPlatformTab === 'select' && !key) {\n        alert('请直接点击上方列表中的平台进行添加');\n        return;\n    }\n\n    // 检查是否已存在\n    const currentPlatforms = parsePlatformsFromYaml();\n    if (currentPlatforms.find(p => p.id === platformKey)) {\n        alert(`平台 ${platformKey} 已存在！`);\n        return;\n    }\n\n    // 添加到 YAML (注意字段是 id 和 name)\n    const newPlatform = {\n        id: platformKey,\n        name: platformName,\n        enabled: true\n    };\n\n    // 重新构建 YAML\n    currentPlatforms.push(newPlatform);\n    updatePlatformsInYaml(currentPlatforms);\n\n    closePlatformModal();\n\n    const keyInput = document.getElementById('custom-platform-key');\n    const nameInput = document.getElementById('custom-platform-name');\n    if (keyInput) keyInput.value = '';\n    if (nameInput) nameInput.value = '';\n\n    renderPlatformsList();\n\n    showToast(`平台 ${platformName} 已添加`, 'success');\n}\n\n// 绑定到全局\nwindow.updateToLatest = async function() {\n    closeVersionModal();\n\n    const currentTab = getCurrentTab();\n    const fileName = currentTab === 'config' ? 'config.yaml' : 'frequency_words.txt';\n\n    if (!confirm(`确定要从 GitHub 更新 ${fileName} 到最新版本吗？\\n\\n你当前的自定义配置将被覆盖，建议先复制保存。`)) {\n        return;\n    }\n\n    showToast('正在从 GitHub 加载最新版本...', 'info');\n\n    try {\n        const url = currentTab === 'config' ? REMOTE_CONFIG_URL : REMOTE_FREQUENCY_URL;\n        const res = await fetch(url);\n\n        if (!res.ok) {\n            throw new Error(`加载失败: ${res.status}`);\n        }\n\n        const text = await res.text();\n\n        if (currentTab === 'config') {\n            try {\n                jsyaml.load(text);\n            } catch (yamlErr) {\n                showToast(`YAML 语法错误: ${yamlErr.message}`, 'error');\n                return;\n            }\n            document.getElementById('yaml-editor').value = text;\n            currentYaml = text;\n            syncYamlToUI();\n        } else {\n            document.getElementById('frequency-editor').value = text;\n            currentFrequency = text;\n            syncFrequencyToUI();\n        }\n\n        saveToLocalStorage();\n\n        showToast(`已更新到最新版本`, 'success');\n\n    } catch (err) {\n        console.error('更新失败:', err);\n        showToast(`更新失败: ${err.message}`, 'error');\n    }\n}\n\n// ==========================================\n// RSS 辅助功能\n// ==========================================\n\nfunction toggleRssTips() {\n    const panel = document.getElementById('rss-tips-panel');\n    const icon = document.getElementById('rss-tips-icon');\n    if (panel) {\n        panel.classList.toggle('hidden');\n        if (icon) {\n            icon.style.transform = panel.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(180deg)';\n        }\n    }\n}\n\nfunction fillRssUrl(url) {\n    const input = document.getElementById('rss-url');\n    if (input) {\n        input.value = url;\n        // 视觉反馈\n        input.classList.add('ring-2', 'ring-blue-500', 'bg-blue-50');\n        setTimeout(() => {\n            input.classList.remove('ring-2', 'ring-blue-500', 'bg-blue-50');\n        }, 500);\n    }\n}\n\n// ==========================================\n// 13. Timeline 编辑器功能\n// ==========================================\n\nconst PRESET_META = {\n    morning_evening: { icon: 'fa-sun', color: 'text-amber-500', bg: 'bg-amber-50', recommend: true },\n    always_on:       { icon: 'fa-bolt', color: 'text-blue-500', bg: 'bg-blue-50' },\n    office_hours:    { icon: 'fa-briefcase', color: 'text-green-500', bg: 'bg-green-50' },\n    night_owl:       { icon: 'fa-moon', color: 'text-indigo-500', bg: 'bg-indigo-50' },\n    custom:          { icon: 'fa-sliders', color: 'text-purple-500', bg: 'bg-purple-50' }\n};\n\nconst DAY_NAMES = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];\n\n/**\n * 从当前 config.yaml 中读取 schedule.preset\n */\nfunction getActivePreset() {\n    try {\n        const doc = jsyaml.load(currentYaml);\n        return doc?.schedule?.preset || 'morning_evening';\n    } catch { return 'morning_evening'; }\n}\n\n/**\n * 解析 timeline YAML，返回结构化数据\n */\nfunction parseTimelineData() {\n    try {\n        const doc = jsyaml.load(currentTimeline);\n        if (!doc) return null;\n        return doc;\n    } catch { return null; }\n}\n\n/**\n * 获取指定预设/custom 的完整配置\n */\nfunction getPresetConfig(data, presetName) {\n    if (!data) return null;\n    if (presetName === 'custom') return data.custom || null;\n    return data.presets?.[presetName] || null;\n}\n\n/**\n * 主渲染函数：解析 timeline YAML → 渲染右侧面板\n */\nfunction syncTimelineToUI() {\n    const panel = document.getElementById('timeline-panel');\n    if (!panel) return;\n\n    const data = parseTimelineData();\n    const activePreset = getActivePreset();\n\n    if (!data) {\n        panel.innerHTML = `\n            <div class=\"text-center py-12 text-gray-400\">\n                <i class=\"fa-solid fa-calendar-xmark text-4xl mb-3\"></i>\n                <p class=\"text-sm\">请在左侧粘贴 timeline.yaml 内容</p>\n                <p class=\"text-xs mt-1\">或点击右上角「加载官网最新配置」</p>\n            </div>`;\n        return;\n    }\n\n    let html = '';\n\n    // ── Layer 1: 预设模式选择卡片 ──\n    html += `<div class=\"mb-6\">\n        <div class=\"tl-section-title\"><i class=\"fa-solid fa-swatchbook\"></i>调度模式</div>\n        <div class=\"grid grid-cols-2 gap-3\" id=\"tl-preset-grid\">`;\n\n    // 收集所有预设名\n    const presetNames = Object.keys(data.presets || {});\n    // 确保 custom 在最后\n    const allModes = [...presetNames.filter(n => n !== 'custom'), ...(data.custom ? ['custom'] : [])];\n\n    allModes.forEach(name => {\n        const meta = PRESET_META[name] || { icon: 'fa-puzzle-piece', color: 'text-gray-500', bg: 'bg-gray-50' };\n        const presetCfg = getPresetConfig(data, name);\n        const label = presetCfg?.name || meta.label || name;\n        const desc = presetCfg?.description || meta.desc || '';\n        const isActive = name === activePreset;\n        const isProtected = ['morning_evening', 'always_on', 'office_hours', 'night_owl', 'custom'].includes(name);\n        html += `\n            <div class=\"tl-preset-card ${isActive ? 'selected' : ''}\" data-preset=\"${name}\">\n                ${meta.recommend ? '<div class=\"tl-recommend-badge\">推荐</div>' : ''}\n                <div class=\"flex items-center gap-3 cursor-pointer\" onclick=\"selectTimelinePreset('${name}')\">\n                    <div class=\"tl-card-icon ${meta.bg} ${meta.color}\"><i class=\"fa-solid ${meta.icon}\"></i></div>\n                    <div class=\"flex-1 min-w-0\">\n                        <div class=\"text-sm font-bold text-gray-800 truncate tl-editable\" ondblclick=\"event.stopPropagation();tlInlineEdit(this,'${name}','name','${escapeAttr(label)}')\">${label}</div>\n                        <div class=\"text-[10px] text-gray-500 truncate tl-editable\" ondblclick=\"event.stopPropagation();tlInlineEdit(this,'${name}','description','${escapeAttr(desc)}')\">${desc}</div>\n                    </div>\n                </div>\n                <div class=\"tl-card-actions\">\n                    <button onclick=\"event.stopPropagation();duplicateTlPreset('${name}')\" class=\"tl-card-action-btn\" title=\"复制\"><i class=\"fa-regular fa-copy\"></i></button>\n                    ${!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>` : ''}\n                </div>\n                ${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>' : ''}\n            </div>`;\n    });\n\n    // 新建模式卡片\n    html += `\n        <div class=\"tl-preset-card tl-new-preset-card\" onclick=\"openTlNewPresetModal()\">\n            <div class=\"flex items-center gap-3\">\n                <div class=\"tl-card-icon bg-gray-50 text-gray-400\"><i class=\"fa-solid fa-plus\"></i></div>\n                <div>\n                    <div class=\"text-sm font-bold text-gray-500\">新建模式</div>\n                    <div class=\"text-[10px] text-gray-400\">创建自定义调度方案</div>\n                </div>\n            </div>\n        </div>`;\n\n    html += `</div></div>`;\n\n    // 获取当前预设配置\n    const config = getPresetConfig(data, activePreset);\n\n    if (!config) {\n        html += `<div class=\"text-center py-6 text-gray-400 text-sm\">\n            <i class=\"fa-solid fa-triangle-exclamation text-amber-400 mr-1\"></i>\n            未找到预设「${activePreset}」的配置\n        </div>`;\n        panel.innerHTML = html;\n        return;\n    }\n\n    // ── Layer 2: 周视图时间线 ──\n    html += renderWeekView(config, activePreset);\n\n    // ── Layer 3: 时间段详情 ──\n    html += renderPeriodDetails(config, activePreset);\n\n    panel.innerHTML = html;\n\n    // 初始化日计划 Tag 拖拽排序\n    initDayPlanSortable(activePreset);\n}\n\n/**\n * 渲染周视图（7 天 × 24 小时水平条）\n */\nfunction renderWeekView(config, presetName) {\n    const periods = config.periods || {};\n    const dayPlans = config.day_plans || {};\n    const weekMap = config.week_map || {};\n\n    // 时间刻度\n    let html = `<div class=\"tl-week-view\">\n        <div class=\"tl-section-title mb-2\"><i class=\"fa-solid fa-calendar-week\"></i>周视图</div>\n        <div class=\"tl-hour-markers\">\n            <div style=\"width:2.5rem;flex-shrink:0\"></div>\n            <div style=\"flex:1;display:flex;min-width:480px\">`;\n\n    for (let h = 0; h <= 24; h += 2) {\n        html += `<div class=\"tl-hour-marker\" style=\"width:${100/12}%;${h===24?'text-align:right;margin-left:-1em':''}\">\n            ${h < 10 ? '0' : ''}${h}\n        </div>`;\n    }\n    html += `</div></div>`;\n\n    // 获取当前星期几 (1=周一...7=周日)\n    const today = new Date().getDay();\n    const todayIso = today === 0 ? 7 : today;\n\n    // 7 天的行\n    for (let d = 1; d <= 7; d++) {\n        const dayPlanName = weekMap[d] || weekMap[String(d)];\n        const dayPlan = dayPlans[dayPlanName];\n        const dayPeriodNames = dayPlan?.periods || [];\n        const isToday = d === todayIso;\n\n        html += `<div class=\"tl-week-row\">\n            <div class=\"tl-day-label ${isToday ? 'today' : ''}\">${DAY_NAMES[d-1]}</div>\n            <div class=\"tl-timeline-bar\" data-day=\"${d}\" onclick=\"onTlBarClick(event,'${presetName}',${d})\">`;\n\n        // 渲染各时间段色块\n        dayPeriodNames.forEach(pName => {\n            const p = periods[pName];\n            if (!p) return;\n\n            const merged = mergeWithDefault(p, config.default);\n            const colorClass = getBlockColorClass(merged);\n            const blocks = computeBlocks(p.start, p.end);\n\n            blocks.forEach(b => {\n                const left = (b.start / 24 * 100).toFixed(2);\n                const width = ((b.end - b.start) / 24 * 100).toFixed(2);\n                const label = p.name || pName;\n                html += `<div class=\"tl-period-block ${colorClass}\" style=\"left:${left}%;width:${width}%\"\n                              onclick=\"scrollToPeriodCard('${pName}')\"\n                              onmouseenter=\"showTlTooltip(event, '${escapeAttr(label)}', '${p.start||''}', '${p.end||''}', ${!!merged.push}, ${!!merged.analyze}, '${merged.report_mode||''}')\"\n                              onmouseleave=\"hideTlTooltip()\">\n                    <span class=\"tl-block-label\">${label}</span>\n                </div>`;\n            });\n        });\n\n        // 当前时间指示线（仅今天）\n        if (isToday) {\n            const nowTime = new Date();\n            const nowH = nowTime.getHours() + nowTime.getMinutes() / 60;\n            const nowLeftPct = (nowH / 24 * 100).toFixed(2);\n            html += `<div class=\"tl-now-line\" style=\"left:${nowLeftPct}%\" title=\"当前时间 ${String(nowTime.getHours()).padStart(2,'0')}:${String(nowTime.getMinutes()).padStart(2,'0')}\"></div>`;\n        }\n\n        html += `</div></div>`;\n    }\n\n    // 图例\n    html += `<div class=\"tl-legend\">\n        <div class=\"tl-legend-item\"><div class=\"tl-legend-color tl-block-push\"></div>推送</div>\n        <div class=\"tl-legend-item\"><div class=\"tl-legend-color tl-block-analyze\"></div>AI 分析</div>\n        <div class=\"tl-legend-item\"><div class=\"tl-legend-color tl-block-push-analyze\"></div>推送 + 分析</div>\n        <div class=\"tl-legend-item\"><div class=\"tl-legend-color tl-block-collect\"></div>仅采集</div>\n        <div class=\"tl-legend-item\"><div class=\"tl-legend-color\" style=\"background:#f1f5f9;border:1px solid #e2e8f0\"></div>默认 (default)</div>\n    </div>`;\n\n    html += `</div>`;\n    return html;\n}\n\n/**\n * 合并 period 与 default（period 字段优先）\n */\nfunction mergeWithDefault(period, defaultCfg) {\n    if (!defaultCfg) return period || {};\n    const merged = { ...defaultCfg, ...period };\n    if (period.once || defaultCfg.once) {\n        merged.once = { ...(defaultCfg.once || {}), ...(period.once || {}) };\n    }\n    return merged;\n}\n\n/**\n * 根据 push/analyze 状态确定色块 CSS 类\n */\nfunction getBlockColorClass(merged) {\n    const push = !!merged.push;\n    const analyze = !!merged.analyze;\n    if (push && analyze) return 'tl-block-push-analyze';\n    if (push) return 'tl-block-push';\n    if (analyze) return 'tl-block-analyze';\n    if (merged.collect !== false) return 'tl-block-collect';\n    return 'tl-block-silent';\n}\n\n/**\n * 计算时间段的渲染块（处理跨午夜情况）\n * 返回 [{start: 小时数, end: 小时数}, ...] 的数组\n */\nfunction computeBlocks(startStr, endStr) {\n    if (!startStr || !endStr) return [];\n    const s = parseTime(startStr);\n    const e = parseTime(endStr);\n    if (s < e) return [{ start: s, end: e }];\n    // 跨午夜\n    return [{ start: s, end: 24 }, { start: 0, end: e }];\n}\n\nfunction parseTime(str) {\n    const [h, m] = (str || '00:00').split(':').map(Number);\n    return h + (m || 0) / 60;\n}\n\nfunction escapeAttr(s) {\n    return (s || '').replace(/'/g, \"\\\\'\").replace(/\"/g, '&quot;');\n}\n\n/**\n * Tooltip 显示/隐藏\n */\nlet tlTooltipEl = null;\n\nfunction showTlTooltip(event, name, start, end, push, analyze, mode) {\n    hideTlTooltip();\n    const el = document.createElement('div');\n    el.className = 'tl-tooltip';\n    let features = [];\n    if (push) features.push('<span style=\"color:#93c5fd\">推送</span>');\n    if (analyze) features.push('<span style=\"color:#c4b5fd\">分析</span>');\n    if (!push && !analyze) features.push('<span style=\"color:#94a3b8\">仅采集</span>');\n\n    el.innerHTML = `<div style=\"font-weight:700;margin-bottom:2px\">${name}</div>\n        <div style=\"font-size:11px;color:#9ca3af\">${start} - ${end}</div>\n        <div style=\"margin-top:4px\">${features.join(' / ')}</div>\n        ${mode ? `<div style=\"font-size:10px;color:#9ca3af;margin-top:2px\">模式: ${mode}</div>` : ''}`;\n\n    document.body.appendChild(el);\n    tlTooltipEl = el;\n\n    const rect = event.target.getBoundingClientRect();\n    el.style.left = (rect.left + rect.width / 2 - el.offsetWidth / 2) + 'px';\n    el.style.top = (rect.top - el.offsetHeight - 8) + 'px';\n\n    // 确保不超出屏幕\n    const elRect = el.getBoundingClientRect();\n    if (elRect.left < 4) el.style.left = '4px';\n    if (elRect.right > window.innerWidth - 4) el.style.left = (window.innerWidth - el.offsetWidth - 4) + 'px';\n    if (elRect.top < 4) {\n        el.style.top = (rect.bottom + 8) + 'px';\n        el.style.setProperty('--arrow', 'top');\n    }\n}\n\nfunction hideTlTooltip() {\n    if (tlTooltipEl) {\n        tlTooltipEl.remove();\n        tlTooltipEl = null;\n    }\n}\n\n/**\n * 渲染时间段详情面板\n */\nfunction renderPeriodDetails(config, presetName) {\n    const isCustom = presetName === 'custom';\n    const periods = config.periods || {};\n    const dayPlans = config.day_plans || {};\n    const weekMap = config.week_map || {};\n    const defaults = config.default || {};\n\n    let html = '';\n\n    // ── Default 配置（默认展开）──\n    html += `<div class=\"tl-collapsible mt-4\">\n        <div class=\"tl-collapsible-header\" onclick=\"toggleTlCollapsible(this)\">\n            <span><i class=\"fa-solid fa-gear mr-2 text-gray-400\"></i>默认配置 (default)</span>\n            <i class=\"fa-solid fa-chevron-down text-gray-400 text-xs\"></i>\n        </div>\n        <div class=\"tl-collapsible-body\">\n            <div class=\"text-xs text-gray-500 mb-2\">不在任何时间段内时，使用以下配置：</div>\n            ${renderBehaviorToggles(defaults, presetName, 'default', defaults)}\n        </div>\n    </div>`;\n\n    // ── 时间段列表 ──\n    const periodEntries = Object.entries(periods);\n    html += `<div class=\"mt-6\">\n        <div class=\"tl-section-title flex items-center justify-between\">\n            <span><i class=\"fa-solid fa-puzzle-piece\"></i>时间段 (Periods)</span>\n            <button onclick=\"openTlNewPeriodModal('${presetName}')\" class=\"tl-add-btn\"><i class=\"fa-solid fa-plus mr-1\"></i>新增</button>\n        </div>`;\n\n    if (periodEntries.length > 0) {\n        html += `<div class=\"space-y-3\">`;\n        periodEntries.forEach(([key, p]) => {\n            const merged = mergeWithDefault(p, defaults);\n            const colorClass = getBlockColorClass(merged);\n            html += `<div class=\"tl-period-card\" id=\"tl-period-${key}\">\n                <div class=\"flex items-center justify-between mb-2\">\n                    <div class=\"flex items-center gap-2\">\n                        <div class=\"w-3 h-3 rounded ${colorClass}\"></div>\n                        <span class=\"text-sm font-bold text-gray-800 tl-editable\" ondblclick=\"tlInlineEditPeriod(this,'${presetName}','${key}','${escapeAttr(p.name || key)}')\">${p.name || key}</span>\n                        <span class=\"text-[10px] text-gray-400 font-mono\">${key}</span>\n                    </div>\n                    <div class=\"flex items-center gap-2\">\n                        <span class=\"text-xs text-gray-500 font-mono\">${p.start || '?'} - ${p.end || '?'}</span>\n                        <button onclick=\"duplicateTlPeriod('${presetName}','${key}')\" class=\"tl-inline-btn\" title=\"复制\"><i class=\"fa-regular fa-copy\"></i></button>\n                        <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>\n                    </div>\n                </div>\n                ${renderBehaviorToggles(merged, presetName, key, p)}\n            </div>`;\n        });\n        html += `</div>`;\n    } else {\n        html += `<div class=\"text-xs text-gray-400 text-center py-4\">\n            <i class=\"fa-solid fa-info-circle mr-1\"></i>此模式无自定义时间段，全天使用 default 配置\n        </div>`;\n    }\n\n    html += `</div>`;\n\n    // ── 日计划 ──\n    const dayPlanEntries = Object.entries(dayPlans);\n    html += `<div class=\"mt-6\">\n        <div class=\"tl-section-title flex items-center justify-between\">\n            <span><i class=\"fa-solid fa-list-ol\"></i>日计划 (Day Plans)</span>\n            <button onclick=\"addTlDayPlan('${presetName}')\" class=\"tl-add-btn\"><i class=\"fa-solid fa-plus mr-1\"></i>新增</button>\n        </div>`;\n\n    if (dayPlanEntries.length > 0) {\n        html += `<div class=\"space-y-2\">`;\n        dayPlanEntries.forEach(([name, plan]) => {\n            const pList = plan.periods || [];\n            // 构建可用 period 下拉（排除已添加的）\n            const availablePeriods = periodEntries.filter(([k]) => !pList.includes(k));\n            html += `<div class=\"bg-white border border-gray-200 rounded-lg px-3 py-2 tl-dayplan-card\">\n                <div class=\"flex items-center justify-between mb-1\">\n                    <span class=\"text-xs font-bold text-gray-700\">${name}</span>\n                    <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>\n                </div>\n                <div class=\"flex flex-wrap gap-1 items-center tl-dayplan-sortable\" data-plan-key=\"${name}\">\n                    ${pList.length > 0 ? pList.map(pn => {\n                        const p = periods[pn];\n                        const merged = p ? mergeWithDefault(p, defaults) : {};\n                        const cc = getBlockColorClass(merged);\n                        return `<span class=\"tl-period-tag ${cc}\" data-period-key=\"${pn}\">\n                            ${p?.name || pn}\n                            <button onclick=\"removePeriodFromDayPlanUI('${presetName}','${name}','${pn}')\" class=\"tl-tag-remove\" title=\"移除\">&times;</button>\n                        </span>`;\n                    }).join('') : '<span class=\"text-[10px] text-gray-400\">空 (全天走 default)</span>'}\n                    ${availablePeriods.length > 0 ? `\n                        <select class=\"tl-add-period-select\" onchange=\"if(this.value){addPeriodToDayPlan('${presetName}','${name}',this.value);this.value=''}\">\n                            <option value=\"\">+ 添加</option>\n                            ${availablePeriods.map(([k, p]) => `<option value=\"${k}\">${p.name || k}</option>`).join('')}\n                        </select>\n                    ` : ''}\n                </div>\n            </div>`;\n        });\n        html += `</div>`;\n    }\n\n    html += `</div>`;\n\n    // ── 周映射（下拉选择）──\n    const dayPlanKeys = Object.keys(dayPlans);\n\n    // 为不同日计划分配颜色\n    const planColorMap = {};\n    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'];\n    dayPlanKeys.forEach((k, idx) => { planColorMap[k] = planColors[idx % planColors.length]; });\n\n    html += `<div class=\"mt-6\">\n        <div class=\"tl-section-title\"><i class=\"fa-solid fa-calendar-days\"></i>周映射 (Week Map)</div>\n        <div class=\"bg-white border border-gray-200 rounded-lg px-3 py-2 space-y-1\">`;\n\n    for (let d = 1; d <= 7; d++) {\n        const plan = weekMap[d] || weekMap[String(d)] || '';\n        const rowColor = planColorMap[plan] || '';\n        const options = dayPlanKeys.map(k =>\n            `<option value=\"${k}\" ${k === plan ? 'selected' : ''}>${k}</option>`\n        ).join('');\n        html += `<div class=\"tl-dayplan-row ${rowColor} rounded px-2\">\n            <div class=\"tl-dayplan-label\">${DAY_NAMES[d-1]}</div>\n            <select class=\"tl-weekmap-select\"\n                    onchange=\"onTlWeekMap('${presetName}',${d},this.value)\">\n                ${options}\n            </select>\n        </div>`;\n    }\n\n    html += `</div>\n        <div class=\"flex gap-2 mt-2\">\n            <button onclick=\"tlWeekMapQuick('${presetName}','all_same')\" class=\"tl-quick-btn\">全周统一</button>\n            <button onclick=\"tlWeekMapQuick('${presetName}','weekday_same')\" class=\"tl-quick-btn\">工作日统一</button>\n            <button onclick=\"tlWeekMapQuick('${presetName}','weekday_weekend')\" class=\"tl-quick-btn\">工作日/周末</button>\n        </div>\n    </div>`;\n\n    // custom 专属：时间段冲突策略\n    if (isCustom) {\n        const overlapPolicy = (config.overlap && config.overlap.policy) || 'error_on_overlap';\n        html += `<div class=\"mt-6\">\n            <div class=\"tl-section-title\"><i class=\"fa-solid fa-code-branch\"></i>冲突策略 (Overlap)</div>\n            <div class=\"bg-white border border-gray-200 rounded-lg px-3 py-3\">\n                <div class=\"flex items-center gap-2\">\n                    <span class=\"text-xs text-gray-500\">policy:</span>\n                    <select class=\"text-xs border border-gray-200 rounded px-2 py-1 bg-white\"\n                            onchange=\"onTlCustomOverlapPolicy(this.value)\">\n                        <option value=\"error_on_overlap\" ${overlapPolicy === 'error_on_overlap' ? 'selected' : ''}>error_on_overlap（推荐）</option>\n                        <option value=\"last_wins\" ${overlapPolicy === 'last_wins' ? 'selected' : ''}>last_wins（后定义优先）</option>\n                    </select>\n                </div>\n                <div class=\"text-[10px] text-gray-400 mt-2\">\n                    <i class=\"fa-solid fa-info-circle mr-1\"></i>\n                    <code>error_on_overlap</code> 会在时间段重叠时直接报错；<code>last_wins</code> 会按 day_plans 中靠后的时间段覆盖。\n                </div>\n            </div>\n        </div>`;\n    }\n\n    // 提示\n    if (!isCustom) {\n        html += `<div class=\"mt-4 text-xs text-gray-400 p-3 bg-gray-50 rounded-lg border border-gray-200\">\n            <i class=\"fa-solid fa-lightbulb mr-1 text-amber-400\"></i>\n            直接在上方调整开关和下拉框，左侧 YAML 会同步更新。如需更精细的控制，可直接编辑左侧 YAML 或修改 <strong>timeline.yaml</strong>。\n        </div>`;\n    } else {\n        html += `<div class=\"mt-4 text-xs text-gray-400 p-3 bg-purple-50 rounded-lg border border-purple-200\">\n            <i class=\"fa-solid fa-pen-ruler mr-1 text-purple-400\"></i>\n            自定义模式支持完全自由编辑。可直接在上方调整控件，或在左侧编辑 YAML 文本，两边实时同步。\n        </div>`;\n    }\n\n    return html;\n}\n\n/**\n * 渲染行为开关（可交互）\n * presetName: 当前预设名（用于定位 YAML 中的位置）\n * periodKey: 'default' 或时间段 key（如 'weekday_morning'）\n */\nfunction renderBehaviorToggles(cfg, presetName, periodKey, rawCfg = null) {\n    const toggleItems = [\n        { k: 'collect', label: '采集', icon: 'fa-download' },\n        { k: 'analyze', label: '分析', icon: 'fa-brain' },\n        { k: 'push', label: '推送', icon: 'fa-bell' },\n    ];\n\n    const uid = `tl-${presetName}-${periodKey}`;\n\n    let html = '<div class=\"tl-toggle-row\">';\n    toggleItems.forEach(item => {\n        const val = cfg[item.k];\n        const on = val === true || val === 'true';\n        const toggleId = `${uid}-${item.k}`;\n        html += `<label class=\"tl-toggle-item ${on ? 'on' : 'off'}\" for=\"${toggleId}\" style=\"cursor:pointer\">\n            <div class=\"relative inline-block w-8 mr-1 align-middle select-none\">\n                <input type=\"checkbox\" id=\"${toggleId}\" ${on ? 'checked' : ''}\n                    onchange=\"onTlToggle('${presetName}','${periodKey}','${item.k}',this.checked)\"\n                    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\"/>\n                <label for=\"${toggleId}\" class=\"toggle-label block overflow-hidden h-4 rounded-full bg-gray-300 cursor-pointer\"></label>\n            </div>\n            <i class=\"fa-solid ${item.icon}\" style=\"font-size:10px\"></i>${item.label}\n        </label>`;\n    });\n    html += '</div>';\n\n    // 报告模式下拉\n    const reportModes = ['current', 'daily', 'incremental'];\n    const aiModes = ['follow_report', 'daily', 'current', 'incremental'];\n\n    html += `<div class=\"flex flex-wrap gap-2 mt-2 items-center\">`;\n\n    // report_mode\n    html += `<div class=\"flex items-center gap-1\">\n        <span class=\"text-[10px] text-gray-400\">报告:</span>\n        <select class=\"text-[10px] border border-gray-200 rounded px-1 py-0.5 bg-white\"\n                onchange=\"onTlSelect('${presetName}','${periodKey}','report_mode',this.value)\">\n            ${reportModes.map(m => `<option value=\"${m}\" ${cfg.report_mode === m ? 'selected' : ''}>${m}</option>`).join('')}\n        </select>\n    </div>`;\n\n    // ai_mode\n    html += `<div class=\"flex items-center gap-1\">\n        <span class=\"text-[10px] text-gray-400\">AI:</span>\n        <select class=\"text-[10px] border border-gray-200 rounded px-1 py-0.5 bg-white\"\n                onchange=\"onTlSelect('${presetName}','${periodKey}','ai_mode',this.value)\">\n            ${aiModes.map(m => `<option value=\"${m}\" ${(cfg.ai_mode || 'follow_report') === m ? 'selected' : ''}>${m}</option>`).join('')}\n        </select>\n    </div>`;\n\n    // once toggles\n    const onceAnalyze = cfg.once?.analyze === true;\n    const oncePush = cfg.once?.push === true;\n    html += `<label class=\"flex items-center gap-1 text-[10px] ${onceAnalyze ? 'text-blue-600' : 'text-gray-400'}\" style=\"cursor:pointer\">\n        <input type=\"checkbox\" ${onceAnalyze ? 'checked' : ''}\n               onchange=\"onTlToggle('${presetName}','${periodKey}','once.analyze',this.checked)\"\n               class=\"w-3 h-3 rounded\">仅分析一次\n    </label>`;\n    html += `<label class=\"flex items-center gap-1 text-[10px] ${oncePush ? 'text-blue-600' : 'text-gray-400'}\" style=\"cursor:pointer\">\n        <input type=\"checkbox\" ${oncePush ? 'checked' : ''}\n               onchange=\"onTlToggle('${presetName}','${periodKey}','once.push',this.checked)\"\n               class=\"w-3 h-3 rounded\">仅推送一次\n    </label>`;\n\n    html += `</div>`;\n\n    // 时间段编辑（仅非 default）\n    if (periodKey !== 'default' && (cfg.start || cfg.end)) {\n        html += `<div class=\"flex items-center gap-2 mt-2\">\n            <span class=\"text-[10px] text-gray-400\">时间:</span>\n            <input type=\"time\" value=\"${cfg.start || ''}\" class=\"text-xs border border-gray-200 rounded px-1.5 py-0.5\"\n                   onchange=\"onTlSelect('${presetName}','${periodKey}','start',this.value)\">\n            <span class=\"text-gray-300\">~</span>\n            <input type=\"time\" value=\"${cfg.end || ''}\" class=\"text-xs border border-gray-200 rounded px-1.5 py-0.5\"\n                   onchange=\"onTlSelect('${presetName}','${periodKey}','end',this.value)\">\n        </div>`;\n    }\n\n    // 可选筛选覆盖（仅显示“当前层”字段，避免把继承值误当作显式配置）\n    const baseCfg = rawCfg || {};\n    const filterMethod = baseCfg.filter_method || '';\n    const frequencyFile = baseCfg.frequency_file || '';\n    const interestsFile = baseCfg.interests_file || '';\n    const methodHint = periodKey === 'default' ? '不填则跟随全局 filter.method' : '不填则继承 default（再回退全局）';\n\n    html += `<div class=\"mt-3 pt-3 border-t border-gray-100\">\n        <div class=\"text-[10px] uppercase tracking-wider font-bold text-gray-400 mb-2\">筛选覆盖（可选）</div>\n        <div class=\"grid grid-cols-1 md:grid-cols-3 gap-2\">\n            <div>\n                <label class=\"block text-[10px] text-gray-400 mb-1\">filter_method</label>\n                <select class=\"text-[10px] w-full border border-gray-200 rounded px-1.5 py-1 bg-white\"\n                        onchange=\"onTlOptionalSelect('${presetName}','${periodKey}','filter_method',this.value)\">\n                    <option value=\"\" ${filterMethod === '' ? 'selected' : ''}>继承</option>\n                    <option value=\"keyword\" ${filterMethod === 'keyword' ? 'selected' : ''}>keyword</option>\n                    <option value=\"ai\" ${filterMethod === 'ai' ? 'selected' : ''}>ai</option>\n                </select>\n            </div>\n            <div>\n                <label class=\"block text-[10px] text-gray-400 mb-1\">frequency_file</label>\n                <input type=\"text\" value=\"${frequencyFile}\" placeholder=\"如 tech.txt\"\n                       class=\"text-[10px] w-full border border-gray-200 rounded px-1.5 py-1 bg-white\"\n                       onchange=\"onTlOptionalInput('${presetName}','${periodKey}','frequency_file',this.value)\">\n            </div>\n            <div>\n                <label class=\"block text-[10px] text-gray-400 mb-1\">interests_file</label>\n                <input type=\"text\" value=\"${interestsFile}\" placeholder=\"如 geopolitics.txt\"\n                       class=\"text-[10px] w-full border border-gray-200 rounded px-1.5 py-1 bg-white\"\n                       onchange=\"onTlOptionalInput('${presetName}','${periodKey}','interests_file',this.value)\">\n            </div>\n        </div>\n        <div class=\"text-[10px] text-gray-400 mt-2\">\n            <i class=\"fa-solid fa-lightbulb mr-1\"></i>${methodHint}。<code>frequency_file</code> 从 <code>config/custom/keyword/</code> 查找，\n            <code>interests_file</code> 从 <code>config/custom/ai/</code> 查找；留空会删除该字段并恢复继承。\n        </div>\n    </div>`;\n\n    return html;\n}\n\n/**\n * 点击周视图色块 → 滚动到对应 period 卡片并高亮\n */\nwindow.scrollToPeriodCard = function(periodKey) {\n    const card = document.getElementById('tl-period-' + periodKey);\n    if (!card) return;\n    card.scrollIntoView({ behavior: 'smooth', block: 'center' });\n    card.classList.add('tl-period-highlight');\n    setTimeout(() => card.classList.remove('tl-period-highlight'), 1500);\n}\n\n/**\n * 折叠/展开切换\n */\nwindow.toggleTlCollapsible = function(header) {\n    const body = header.nextElementSibling;\n    body.classList.toggle('collapsed');\n    header.classList.toggle('is-collapsed');\n}\n\n/**\n * 右侧开关变更 → 更新左侧 timeline YAML\n */\nwindow.onTlToggle = function(presetName, periodKey, field, value) {\n    updateTimelineField(presetName, periodKey, field, value);\n}\n\nwindow.onTlSelect = function(presetName, periodKey, field, value) {\n    updateTimelineField(presetName, periodKey, field, value);\n}\n\nwindow.onTlOptionalInput = function(presetName, periodKey, field, rawValue) {\n    const value = (rawValue || '').trim();\n    if (!value) {\n        removeTimelineField(presetName, periodKey, field);\n        return;\n    }\n    updateTimelineField(presetName, periodKey, field, value);\n}\n\nwindow.onTlOptionalSelect = function(presetName, periodKey, field, value) {\n    if (!value) {\n        removeTimelineField(presetName, periodKey, field);\n        return;\n    }\n    updateTimelineField(presetName, periodKey, field, value);\n}\n\nwindow.onTlCustomOverlapPolicy = function(value) {\n    updateTimelineSectionField('custom', 'overlap.policy', value);\n}\n\n/**\n * 周映射下拉变更 → 更新 timeline YAML 中的 week_map.N\n */\nwindow.onTlWeekMap = function(presetName, dayNum, value) {\n    const editor = document.getElementById('timeline-editor');\n    let yaml = editor.value;\n    const lines = yaml.split('\\n');\n\n    // 定位 preset section\n    const isCustom = presetName === 'custom';\n    let sectionStart = -1;\n    let sectionIndent = 0;\n\n    if (isCustom) {\n        for (let i = 0; i < lines.length; i++) {\n            if (/^custom:\\s*/.test(lines[i])) { sectionStart = i; break; }\n        }\n    } else {\n        let inPresets = false;\n        for (let i = 0; i < lines.length; i++) {\n            const line = lines[i];\n            if (/^presets:\\s*/.test(line)) { inPresets = true; continue; }\n            if (inPresets && /^\\S/.test(line) && !line.startsWith('#')) break;\n            if (inPresets) {\n                const m = line.match(/^(\\s+)(\\S+):\\s*/);\n                if (m && m[2] === presetName) { sectionStart = i; sectionIndent = m[1].length; break; }\n            }\n        }\n    }\n\n    if (sectionStart < 0) return;\n\n    let sectionEnd = lines.length;\n    for (let i = sectionStart + 1; i < lines.length; i++) {\n        const line = lines[i];\n        if (line.trim() === '' || line.trim().startsWith('#')) continue;\n        if (line.search(/\\S/) <= sectionIndent) { sectionEnd = i; break; }\n    }\n\n    // 找 week_map: 行\n    const weekMapLine = findChildKey(lines, sectionStart, sectionEnd, sectionIndent, 'week_map');\n    if (weekMapLine < 0) return;\n\n    const wmIndent = lines[weekMapLine].search(/\\S/);\n    const wmEnd = findBlockEnd(lines, weekMapLine, wmIndent, sectionEnd);\n\n    // 找 dayNum: 行\n    const dayKey = String(dayNum);\n    const dayLine = findChildKey(lines, weekMapLine, wmEnd, wmIndent, dayKey);\n\n    if (dayLine >= 0) {\n        replaceLineValue(lines, dayLine, value);\n    }\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n\n    clearTimeout(window._tlRenderTimer);\n    window._tlRenderTimer = setTimeout(() => syncTimelineToUI(), 300);\n}\n\n/**\n * 核心：修改 timeline YAML 中的指定字段，保留注释\n */\nfunction updateTimelineField(presetName, periodKey, field, value) {\n    const editor = document.getElementById('timeline-editor');\n    let yaml = editor.value;\n    const lines = yaml.split('\\n');\n\n    // 1. 定位预设/custom 的起始行\n    const isCustom = presetName === 'custom';\n    let sectionStart = -1;\n    let sectionIndent = 0;\n\n    if (isCustom) {\n        // 找 custom: 顶层 key\n        for (let i = 0; i < lines.length; i++) {\n            if (/^custom:\\s*/.test(lines[i])) {\n                sectionStart = i;\n                sectionIndent = 0;\n                break;\n            }\n        }\n    } else {\n        // 找 presets: 下的 presetName:\n        let inPresets = false;\n        for (let i = 0; i < lines.length; i++) {\n            const line = lines[i];\n            if (/^presets:\\s*/.test(line)) {\n                inPresets = true;\n                continue;\n            }\n            if (inPresets && /^\\S/.test(line) && !line.startsWith('#')) {\n                break; // left presets block\n            }\n            if (inPresets) {\n                const m = line.match(/^(\\s+)(\\S+):\\s*/);\n                if (m && m[2] === presetName) {\n                    sectionStart = i;\n                    sectionIndent = m[1].length;\n                    break;\n                }\n            }\n        }\n    }\n\n    if (sectionStart < 0) return;\n\n    // 2. 找到 section 结束行\n    let sectionEnd = lines.length;\n    for (let i = sectionStart + 1; i < lines.length; i++) {\n        const line = lines[i];\n        if (line.trim() === '' || line.trim().startsWith('#')) continue;\n        const indent = line.search(/\\S/);\n        if (indent <= sectionIndent) {\n            sectionEnd = i;\n            break;\n        }\n    }\n\n    // 3. 在 section 内定位 periodKey 子区域\n    let targetStart, targetEnd;\n    const fieldParts = field.split('.');\n\n    if (periodKey === 'default') {\n        // 找 default: 行\n        targetStart = findChildKey(lines, sectionStart, sectionEnd, sectionIndent, 'default');\n    } else {\n        // 找 periods: 下的 periodKey:\n        const periodsLine = findChildKey(lines, sectionStart, sectionEnd, sectionIndent, 'periods');\n        if (periodsLine < 0) return;\n        const periodsIndent = lines[periodsLine].search(/\\S/);\n        const periodsEnd = findBlockEnd(lines, periodsLine, periodsIndent, sectionEnd);\n        targetStart = findChildKey(lines, periodsLine, periodsEnd, periodsIndent, periodKey);\n    }\n\n    if (targetStart < 0) return;\n\n    const targetIndent = lines[targetStart].search(/\\S/);\n    targetEnd = findBlockEnd(lines, targetStart, targetIndent, sectionEnd);\n\n    // 4. 在 target 内查找 field（支持 once.analyze 嵌套）\n    let lineIdx = -1;\n\n    if (fieldParts.length === 1) {\n        lineIdx = findChildKey(lines, targetStart, targetEnd, targetIndent, fieldParts[0]);\n    } else {\n        // nested: once.analyze → find once: then analyze:\n        const parentLine = findChildKey(lines, targetStart, targetEnd, targetIndent, fieldParts[0]);\n        if (parentLine >= 0) {\n            const parentIndent = lines[parentLine].search(/\\S/);\n            const parentEnd = findBlockEnd(lines, parentLine, parentIndent, targetEnd);\n            lineIdx = findChildKey(lines, parentLine, parentEnd, parentIndent, fieldParts[1]);\n        }\n    }\n\n    if (lineIdx < 0) {\n        // 字段不存在 → 需要插入\n        insertTimelineField(lines, targetStart, targetEnd, targetIndent, field, value, fieldParts);\n    } else {\n        // 字段存在 → 原地替换值\n        replaceLineValue(lines, lineIdx, value);\n    }\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n\n    // 延迟重新渲染（避免输入中途刷新）\n    clearTimeout(window._tlRenderTimer);\n    window._tlRenderTimer = setTimeout(() => syncTimelineToUI(), 300);\n}\n\nfunction resolveTimelineSection(lines, presetName) {\n    const isCustom = presetName === 'custom';\n    let sectionStart = -1;\n    let sectionIndent = 0;\n\n    if (isCustom) {\n        for (let i = 0; i < lines.length; i++) {\n            if (/^custom:\\s*/.test(lines[i])) {\n                sectionStart = i;\n                sectionIndent = 0;\n                break;\n            }\n        }\n    } else {\n        let inPresets = false;\n        for (let i = 0; i < lines.length; i++) {\n            const line = lines[i];\n            if (/^presets:\\s*/.test(line)) {\n                inPresets = true;\n                continue;\n            }\n            if (inPresets && /^\\S/.test(line) && !line.startsWith('#')) {\n                break;\n            }\n            if (inPresets) {\n                const m = line.match(/^(\\s+)(\\S+):\\s*/);\n                if (m && m[2] === presetName) {\n                    sectionStart = i;\n                    sectionIndent = m[1].length;\n                    break;\n                }\n            }\n        }\n    }\n\n    if (sectionStart < 0) return null;\n\n    let sectionEnd = lines.length;\n    for (let i = sectionStart + 1; i < lines.length; i++) {\n        const line = lines[i];\n        if (line.trim() === '' || line.trim().startsWith('#')) continue;\n        const indent = line.search(/\\S/);\n        if (indent <= sectionIndent) {\n            sectionEnd = i;\n            break;\n        }\n    }\n\n    return { sectionStart, sectionEnd, sectionIndent };\n}\n\nfunction resolveTimelineTarget(lines, presetName, periodKey) {\n    const section = resolveTimelineSection(lines, presetName);\n    if (!section) return null;\n\n    const { sectionStart, sectionEnd, sectionIndent } = section;\n    let targetStart = -1;\n\n    if (periodKey === 'default') {\n        targetStart = findChildKey(lines, sectionStart, sectionEnd, sectionIndent, 'default');\n    } else {\n        const periodsLine = findChildKey(lines, sectionStart, sectionEnd, sectionIndent, 'periods');\n        if (periodsLine < 0) return null;\n        const periodsIndent = lines[periodsLine].search(/\\S/);\n        const periodsEnd = findBlockEnd(lines, periodsLine, periodsIndent, sectionEnd);\n        targetStart = findChildKey(lines, periodsLine, periodsEnd, periodsIndent, periodKey);\n    }\n\n    if (targetStart < 0) return null;\n\n    const targetIndent = lines[targetStart].search(/\\S/);\n    const targetEnd = findBlockEnd(lines, targetStart, targetIndent, sectionEnd);\n\n    return { sectionStart, sectionEnd, sectionIndent, targetStart, targetEnd, targetIndent };\n}\n\nfunction applyTimelineEditorChanges(editor, lines) {\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n    clearTimeout(window._tlRenderTimer);\n    window._tlRenderTimer = setTimeout(() => syncTimelineToUI(), 300);\n}\n\nfunction removeTimelineField(presetName, periodKey, field) {\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n    const target = resolveTimelineTarget(lines, presetName, periodKey);\n    if (!target) return;\n\n    const { targetStart, targetEnd, targetIndent } = target;\n    const fieldParts = field.split('.');\n\n    if (fieldParts.length === 1) {\n        const lineIdx = findChildKey(lines, targetStart, targetEnd, targetIndent, fieldParts[0]);\n        if (lineIdx < 0) return;\n        const lineIndent = lines[lineIdx].search(/\\S/);\n        const lineEnd = findBlockEnd(lines, lineIdx, lineIndent, targetEnd);\n        lines.splice(lineIdx, lineEnd - lineIdx);\n        applyTimelineEditorChanges(editor, lines);\n        return;\n    }\n\n    const parentLine = findChildKey(lines, targetStart, targetEnd, targetIndent, fieldParts[0]);\n    if (parentLine < 0) return;\n    const parentIndent = lines[parentLine].search(/\\S/);\n    const parentEnd = findBlockEnd(lines, parentLine, parentIndent, targetEnd);\n    const childLine = findChildKey(lines, parentLine, parentEnd, parentIndent, fieldParts[1]);\n    if (childLine < 0) return;\n\n    const childIndent = lines[childLine].search(/\\S/);\n    const childEnd = findBlockEnd(lines, childLine, childIndent, parentEnd);\n    lines.splice(childLine, childEnd - childLine);\n\n    const parentEndAfter = findBlockEnd(lines, parentLine, parentIndent, targetEnd);\n    let hasChild = false;\n    for (let i = parentLine + 1; i < parentEndAfter; i++) {\n        const line = lines[i];\n        if (line.trim() === '' || line.trim().startsWith('#')) continue;\n        if (line.search(/\\S/) > parentIndent) {\n            hasChild = true;\n            break;\n        }\n    }\n    if (!hasChild) {\n        lines.splice(parentLine, 1);\n    }\n\n    applyTimelineEditorChanges(editor, lines);\n}\n\nfunction updateTimelineSectionField(presetName, field, value) {\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n    const section = resolveTimelineSection(lines, presetName);\n    if (!section) return;\n\n    const { sectionStart, sectionEnd, sectionIndent } = section;\n    const fieldParts = field.split('.');\n    let lineIdx = -1;\n\n    if (fieldParts.length === 1) {\n        lineIdx = findChildKey(lines, sectionStart, sectionEnd, sectionIndent, fieldParts[0]);\n    } else {\n        const parentLine = findChildKey(lines, sectionStart, sectionEnd, sectionIndent, fieldParts[0]);\n        if (parentLine >= 0) {\n            const parentIndent = lines[parentLine].search(/\\S/);\n            const parentEnd = findBlockEnd(lines, parentLine, parentIndent, sectionEnd);\n            lineIdx = findChildKey(lines, parentLine, parentEnd, parentIndent, fieldParts[1]);\n        }\n    }\n\n    if (lineIdx < 0) {\n        insertTimelineField(lines, sectionStart, sectionEnd, sectionIndent, field, value, fieldParts);\n    } else {\n        replaceLineValue(lines, lineIdx, value);\n    }\n\n    applyTimelineEditorChanges(editor, lines);\n}\n\n/**\n * 查找子级 key 行\n */\nfunction findChildKey(lines, start, end, parentIndent, key) {\n    for (let i = start + 1; i < end; i++) {\n        const line = lines[i];\n        if (line.trim() === '' || line.trim().startsWith('#')) continue;\n        const indent = line.search(/\\S/);\n        if (indent <= parentIndent) break;\n        const m = line.match(/^\\s*(\\S+):\\s*/);\n        if (m && m[1] === key && indent === parentIndent + 2) {\n            return i;\n        }\n    }\n    return -1;\n}\n\n/**\n * 找一个 block 的结束行号（下一个同级或更低缩进的非空非注释行）\n */\nfunction findBlockEnd(lines, start, indent, maxEnd) {\n    for (let i = start + 1; i < maxEnd; i++) {\n        const line = lines[i];\n        if (line.trim() === '' || line.trim().startsWith('#')) continue;\n        const curIndent = line.search(/\\S/);\n        if (curIndent <= indent) return i;\n    }\n    return maxEnd;\n}\n\n/**\n * 替换行中的值，保留注释\n */\nfunction replaceLineValue(lines, idx, value) {\n    const original = lines[idx];\n    const match = original.match(/^(\\s*\\S+:\\s*)(.*)$/);\n    if (!match) return;\n\n    const prefix = match[1];\n    const rest = match[2];\n    const commentMatch = rest.match(/(\\s*#.*)$/);\n    const comment = commentMatch ? commentMatch[1] : '';\n\n    let formatted;\n    if (typeof value === 'boolean') {\n        formatted = value ? 'true' : 'false';\n    } else if (typeof value === 'string') {\n        // 检查原值是否带引号\n        const valPart = rest.slice(0, rest.length - comment.length).trim();\n        const isQuoted = (valPart.startsWith('\"') && valPart.endsWith('\"')) ||\n                         (valPart.startsWith(\"'\") && valPart.endsWith(\"'\"));\n        if (isQuoted || value.includes(':') || value.includes('#') || value.includes(' ')) {\n            formatted = `\"${value}\"`;\n        } else {\n            formatted = value;\n        }\n    } else {\n        formatted = String(value);\n    }\n\n    lines[idx] = `${prefix}${formatted}${comment}`;\n}\n\n/**\n * 字段不存在时，插入新行\n */\nfunction insertTimelineField(lines, targetStart, targetEnd, targetIndent, field, value, fieldParts) {\n    const indent = ' '.repeat(targetIndent + 2);\n\n    let formatted;\n    if (typeof value === 'boolean') formatted = value ? 'true' : 'false';\n    else if (typeof value === 'string') formatted = value.includes(':') ? `\"${value}\"` : value;\n    else formatted = String(value);\n\n    if (fieldParts.length === 1) {\n        // 直接在 target 的末尾插入\n        lines.splice(targetEnd, 0, `${indent}${field}: ${formatted}`);\n    } else {\n        // once.analyze → find or create once: block, then insert child\n        const parentLine = findChildKey(lines, targetStart, targetEnd, targetIndent, fieldParts[0]);\n        if (parentLine >= 0) {\n            const parentIndent = lines[parentLine].search(/\\S/);\n            const parentEnd = findBlockEnd(lines, parentLine, parentIndent, targetEnd);\n            const childIndent = ' '.repeat(parentIndent + 2);\n            lines.splice(parentEnd, 0, `${childIndent}${fieldParts[1]}: ${formatted}`);\n        } else {\n            // parent doesn't exist → create both\n            lines.splice(targetEnd, 0,\n                `${indent}${fieldParts[0]}:`,\n                `${indent}  ${fieldParts[1]}: ${formatted}`\n            );\n        }\n    }\n}\n\n/**\n * 点击预设卡片 → 更新 config.yaml 中的 schedule.preset + 滚动左侧编辑器\n */\nwindow.selectTimelinePreset = function(name) {\n    // 更新 config.yaml 中的 schedule.preset\n    const configEditor = document.getElementById('yaml-editor');\n    let yaml = configEditor.value;\n    const lines = yaml.split('\\n');\n\n    let presetLineIdx = -1;\n    let inSchedule = false;\n\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i];\n        if (/^schedule:\\s*$/.test(line.trimEnd()) || /^schedule:\\s*#/.test(line)) {\n            inSchedule = true;\n            continue;\n        }\n        if (inSchedule && /^\\S/.test(line) && !line.startsWith('#')) {\n            inSchedule = false;\n        }\n        if (inSchedule && /^\\s+preset:\\s*/.test(line)) {\n            presetLineIdx = i;\n            break;\n        }\n    }\n\n    if (presetLineIdx >= 0) {\n        const original = lines[presetLineIdx];\n        const match = original.match(/^(\\s*preset:\\s*)(.*)$/);\n        if (match) {\n            const prefix = match[1];\n            const rest = match[2];\n            const commentMatch = rest.match(/(\\s*#.*)$/);\n            const comment = commentMatch ? commentMatch[1] : '';\n            lines[presetLineIdx] = `${prefix}\"${name}\"${comment}`;\n        }\n    }\n\n    configEditor.value = lines.join('\\n');\n    currentYaml = configEditor.value;\n    updateBackdrop('yaml-editor', 'yaml-backdrop');\n    debounceSaveConfig();\n\n    // 左侧 timeline 编辑器跳转到对应预设\n    scrollTimelineEditorToPreset(name);\n\n    // 重新渲染 timeline 面板\n    syncTimelineToUI();\n    const tlData = parseTimelineData();\n    const tlCfg = getPresetConfig(tlData, name);\n    const displayName = tlCfg?.name || name;\n    showToast(`已切换至「${displayName}」模式`, 'success');\n}\n\n/**\n * 滚动左侧 timeline 编辑器到对应预设位置\n */\nfunction scrollTimelineEditorToPreset(presetName) {\n    const editor = document.getElementById('timeline-editor');\n    const text = editor.value;\n    const lines = text.split('\\n');\n\n    let targetLine = -1;\n\n    if (presetName === 'custom') {\n        // 找顶层 custom:\n        for (let i = 0; i < lines.length; i++) {\n            if (/^custom:\\s*/.test(lines[i])) {\n                targetLine = i;\n                break;\n            }\n        }\n    } else {\n        // 找 presets: 下的 presetName:\n        let inPresets = false;\n        for (let i = 0; i < lines.length; i++) {\n            const line = lines[i];\n            if (/^presets:\\s*/.test(line)) {\n                inPresets = true;\n                continue;\n            }\n            if (inPresets && /^\\S/.test(line) && !line.startsWith('#')) break;\n            if (inPresets) {\n                const m = line.match(/^\\s+(\\S+):\\s*/);\n                if (m && m[1] === presetName) {\n                    targetLine = i;\n                    break;\n                }\n            }\n        }\n    }\n\n    if (targetLine < 0) return;\n\n    const lineHeight = 19.5;\n    const scrollPosition = targetLine * lineHeight;\n\n    // 设置光标位置\n    let charCount = 0;\n    for (let i = 0; i < targetLine; i++) {\n        charCount += lines[i].length + 1;\n    }\n\n    editor.focus();\n    editor.setSelectionRange(charCount, charCount + lines[targetLine].length);\n    editor.scrollTop = scrollPosition - 50;\n\n    // 高亮闪烁（防止快速点击竞态）\n    clearTimeout(window._tlEditorFlashTimer);\n    editor.style.transition = 'background-color 0.3s';\n    editor.style.backgroundColor = '#2d4a7c';\n    window._tlEditorFlashTimer = setTimeout(() => { editor.style.backgroundColor = ''; }, 300);\n}\n\n// ==========================================\n// 14. Timeline CRUD 功能（新建模式/时间段/日计划/删除等）\n// ==========================================\n\n// ── 弹窗：新建调度模式 ──\n\nwindow.openTlNewPresetModal = function() {\n    const modal = document.getElementById('tl-new-preset-modal');\n    // 填充模板下拉\n    const sel = document.getElementById('tl-new-preset-template');\n    const data = parseTimelineData();\n    sel.innerHTML = '<option value=\"\">空白模板（仅采集，不推送不分析）</option>';\n    if (data?.presets) {\n        Object.keys(data.presets).forEach(k => {\n            const name = data.presets[k]?.name || k;\n            sel.innerHTML += `<option value=\"${k}\">${name} (${k})</option>`;\n        });\n    }\n    if (data?.custom) {\n        sel.innerHTML += `<option value=\"custom\">${data.custom.name || '自定义'} (custom)</option>`;\n    }\n    // 清空输入\n    document.getElementById('tl-new-preset-key').value = '';\n    document.getElementById('tl-new-preset-name').value = '';\n    document.getElementById('tl-new-preset-desc').value = '';\n    sel.value = '';\n    modal.classList.remove('hidden');\n}\n\nwindow.closeTlNewPresetModal = function() {\n    document.getElementById('tl-new-preset-modal').classList.add('hidden');\n}\n\nwindow.confirmTlNewPreset = function() {\n    const key = document.getElementById('tl-new-preset-key').value.trim();\n    const name = document.getElementById('tl-new-preset-name').value.trim();\n    const desc = document.getElementById('tl-new-preset-desc').value.trim();\n    const template = document.getElementById('tl-new-preset-template').value;\n\n    // 验证\n    if (!key) { showToast('请输入模式标识 (key)', 'error'); return; }\n    if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { showToast('key 仅支持英文、数字和下划线，且不能以数字开头', 'error'); return; }\n    if (!name) { showToast('请输入显示名称', 'error'); return; }\n\n    // 检查重复\n    const data = parseTimelineData();\n    if (data?.presets?.[key]) { showToast(`预设「${key}」已存在`, 'error'); return; }\n    if (key === 'custom') { showToast('不能使用 \"custom\" 作为预设名', 'error'); return; }\n\n    // 构建 YAML 文本块\n    let block;\n    if (template && data) {\n        const src = getPresetConfig(data, template);\n        if (src) {\n            block = buildPresetYamlBlock(key, { ...src, name: name, description: desc || src.description || '' });\n        } else {\n            block = buildEmptyPresetBlock(key, name, desc);\n        }\n    } else {\n        block = buildEmptyPresetBlock(key, name, desc);\n    }\n\n    // 插入到 timeline YAML 的 presets: 块末尾\n    const editor = document.getElementById('timeline-editor');\n    let yaml = editor.value;\n    const lines = yaml.split('\\n');\n\n    // 找 presets: 块的结束位置\n    let presetsStart = -1;\n    for (let i = 0; i < lines.length; i++) {\n        if (/^presets:\\s*/.test(lines[i])) { presetsStart = i; break; }\n    }\n\n    if (presetsStart < 0) {\n        // 没有 presets: 顶层 key，在文件开头插入\n        lines.unshift('presets:', ...block.split('\\n'));\n    } else {\n        // 找 presets 块结束（下一个顶层 key）\n        let presetsEnd = lines.length;\n        for (let i = presetsStart + 1; i < lines.length; i++) {\n            if (/^\\S/.test(lines[i]) && !lines[i].startsWith('#') && lines[i].trim() !== '') {\n                presetsEnd = i;\n                break;\n            }\n        }\n        // 在 presetsEnd 前插入（即 presets 块最后）\n        const blockLines = block.split('\\n');\n        lines.splice(presetsEnd, 0, ...blockLines);\n    }\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n\n    // 切换 config.yaml 中 preset 为新模式\n    selectTimelinePreset(key);\n\n    closeTlNewPresetModal();\n    showToast(`调度模式「${name}」创建成功`, 'success');\n}\n\n/**\n * 构建空白预设 YAML 文本块\n */\nfunction buildEmptyPresetBlock(key, name, desc) {\n    return [\n        `  ${key}:`,\n        `    name: \"${name}\"`,\n        `    description: \"${desc || ''}\"`,\n        `    default:`,\n        `      collect: true`,\n        `      analyze: false`,\n        `      ai_mode: follow_report`,\n        `      push: false`,\n        `      report_mode: current`,\n        `      once:`,\n        `        analyze: false`,\n        `        push: false`,\n        `    periods: {}`,\n        `    day_plans:`,\n        `      all_day:`,\n        `        periods: []`,\n        `    week_map:`,\n        `      1: all_day`,\n        `      2: all_day`,\n        `      3: all_day`,\n        `      4: all_day`,\n        `      5: all_day`,\n        `      6: all_day`,\n        `      7: all_day`,\n        ``\n    ].join('\\n');\n}\n\n/**\n * 基于已有配置构建预设 YAML 文本块\n */\nfunction buildPresetYamlBlock(key, cfg) {\n    const obj = { [key]: cfg };\n    const dumped = jsyaml.dump(obj, { indent: 2, lineWidth: -1, quotingType: '\"', forceQuotes: false });\n    return dumped.split('\\n').map(l => l ? '  ' + l : l).join('\\n');\n}\n\n// ── 弹窗：新增时间段 ──\n\nlet _tlNewPeriodTarget = '';\n\nwindow.openTlNewPeriodModal = function(presetName) {\n    _tlNewPeriodTarget = presetName;\n    document.getElementById('tl-new-period-key').value = '';\n    document.getElementById('tl-new-period-name').value = '';\n    document.getElementById('tl-new-period-start').value = '09:00';\n    document.getElementById('tl-new-period-end').value = '11:00';\n    document.getElementById('tl-new-period-modal').classList.remove('hidden');\n}\n\nwindow.closeTlNewPeriodModal = function() {\n    document.getElementById('tl-new-period-modal').classList.add('hidden');\n}\n\nwindow.confirmTlNewPeriod = function() {\n    const key = document.getElementById('tl-new-period-key').value.trim();\n    const name = document.getElementById('tl-new-period-name').value.trim();\n    const start = document.getElementById('tl-new-period-start').value;\n    const end = document.getElementById('tl-new-period-end').value;\n\n    if (!key) { showToast('请输入时间段标识 (key)', 'error'); return; }\n    if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { showToast('key 仅支持英文、数字和下划线', 'error'); return; }\n    if (!name) { showToast('请输入显示名称', 'error'); return; }\n    if (!start || !end) { showToast('请设置开始和结束时间', 'error'); return; }\n    if (start === end) { showToast('开始时间和结束时间不能相同', 'error'); return; }\n\n    const data = parseTimelineData();\n    const presetCfg = getPresetConfig(data, _tlNewPeriodTarget);\n    if (presetCfg?.periods?.[key]) { showToast(`时间段「${key}」已存在`, 'error'); return; }\n\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n\n    const sectionInfo = findPresetSection(lines, _tlNewPeriodTarget);\n    if (!sectionInfo) { showToast('未找到预设配置段', 'error'); return; }\n\n    const periodsLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'periods');\n    if (periodsLine < 0) { showToast('未找到 periods 配置段', 'error'); return; }\n\n    const periodsIndent = lines[periodsLine].search(/\\S/);\n    const periodsContent = lines[periodsLine].trim();\n    const childIndent = periodsIndent + 2;\n    const periodIndent = childIndent + 2;\n    const indent = ' '.repeat(childIndent);\n    const subIndent = ' '.repeat(periodIndent);\n\n    const newPeriodLines = [\n        `${indent}${key}:`,\n        `${subIndent}name: \"${name}\"`,\n        `${subIndent}start: \"${start}\"`,\n        `${subIndent}end: \"${end}\"`,\n        `${subIndent}collect: true`,\n        `${subIndent}analyze: false`,\n        `${subIndent}push: true`,\n        `${subIndent}report_mode: current`\n    ];\n\n    if (periodsContent === 'periods: {}' || periodsContent === 'periods:{}') {\n        lines[periodsLine] = ' '.repeat(periodsIndent) + 'periods:';\n        lines.splice(periodsLine + 1, 0, ...newPeriodLines);\n    } else {\n        const periodsEnd = findBlockEnd(lines, periodsLine, periodsIndent, sectionInfo.end);\n        lines.splice(periodsEnd, 0, ...newPeriodLines);\n    }\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n\n    closeTlNewPeriodModal();\n    syncTimelineToUI();\n    showToast(`时间段「${name}」添加成功`, 'success');\n}\n\n// ── 删除时间段 ──\n\nwindow.deleteTlPeriod = function(presetName, periodKey) {\n    const data = parseTimelineData();\n    const config = getPresetConfig(data, presetName);\n    if (!config) return;\n\n    const refs = [];\n    const dayPlans = config.day_plans || {};\n    Object.entries(dayPlans).forEach(([planName, plan]) => {\n        if ((plan.periods || []).includes(periodKey)) refs.push(planName);\n    });\n\n    const periodName = config.periods?.[periodKey]?.name || periodKey;\n    let msg = `确定删除时间段「${periodName}」？`;\n    if (refs.length > 0) {\n        msg += `\\n\\n⚠️ 该时间段被以下日计划引用，将同时移除引用：\\n${refs.map(r => '  • ' + r).join('\\n')}`;\n    }\n    if (!confirm(msg)) return;\n\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n\n    const sectionInfo = findPresetSection(lines, presetName);\n    if (!sectionInfo) return;\n\n    const periodsLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'periods');\n    if (periodsLine >= 0) {\n        const periodsIndent = lines[periodsLine].search(/\\S/);\n        const periodsEnd = findBlockEnd(lines, periodsLine, periodsIndent, sectionInfo.end);\n        const periodLine = findChildKey(lines, periodsLine, periodsEnd, periodsIndent, periodKey);\n        if (periodLine >= 0) {\n            const periodIndent = lines[periodLine].search(/\\S/);\n            const periodEnd = findBlockEnd(lines, periodLine, periodIndent, periodsEnd);\n            lines.splice(periodLine, periodEnd - periodLine);\n        }\n    }\n\n    if (refs.length > 0) {\n        const updatedSection = findPresetSection(lines, presetName);\n        if (updatedSection) removePeriodFromDayPlans(lines, updatedSection, periodKey);\n    }\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n    syncTimelineToUI();\n    showToast(`时间段「${periodName}」已删除`, 'success');\n}\n\n// ── 复制时间段 ──\n\nwindow.duplicateTlPeriod = function(presetName, periodKey) {\n    const data = parseTimelineData();\n    const config = getPresetConfig(data, presetName);\n    if (!config?.periods?.[periodKey]) return;\n\n    let newKey = periodKey + '_copy';\n    let i = 2;\n    while (config.periods[newKey]) { newKey = periodKey + '_copy' + i; i++; }\n\n    const src = config.periods[periodKey];\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n\n    const sectionInfo = findPresetSection(lines, presetName);\n    if (!sectionInfo) return;\n\n    const periodsLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'periods');\n    if (periodsLine < 0) return;\n\n    const periodsIndent = lines[periodsLine].search(/\\S/);\n    const periodsEnd = findBlockEnd(lines, periodsLine, periodsIndent, sectionInfo.end);\n    const srcLine = findChildKey(lines, periodsLine, periodsEnd, periodsIndent, periodKey);\n    if (srcLine < 0) return;\n\n    const srcIndent = lines[srcLine].search(/\\S/);\n    const srcEnd = findBlockEnd(lines, srcLine, srcIndent, periodsEnd);\n\n    const copiedLines = [];\n    for (let li = srcLine; li < srcEnd; li++) {\n        let line = lines[li];\n        if (li === srcLine) {\n            line = line.replace(periodKey, newKey);\n        }\n        copiedLines.push(line);\n    }\n    for (let li = 0; li < copiedLines.length; li++) {\n        const m = copiedLines[li].match(/^(\\s*name:\\s*).+$/);\n        if (m) {\n            const newName = (src.name || periodKey) + ' (副本)';\n            copiedLines[li] = `${m[1]}\"${newName}\"`;\n            break;\n        }\n    }\n\n    lines.splice(srcEnd, 0, ...copiedLines);\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n    syncTimelineToUI();\n    showToast(`已复制为「${newKey}」`, 'success');\n}\n\n// ── 删除预设模式 ──\n\nconst PROTECTED_PRESETS = ['morning_evening', 'always_on', 'office_hours', 'night_owl'];\n\nwindow.deleteTlPreset = function(presetName) {\n    if (PROTECTED_PRESETS.includes(presetName)) {\n        showToast('内置预设不可删除，可使用复制功能', 'warning');\n        return;\n    }\n    if (presetName === 'custom') {\n        showToast('custom 模式不可删除', 'warning');\n        return;\n    }\n\n    const data = parseTimelineData();\n    const cfg = data?.presets?.[presetName];\n    const displayName = cfg?.name || presetName;\n\n    if (!confirm(`确定删除调度模式「${displayName}」？\\n此操作不可撤销。`)) return;\n\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n\n    const sectionInfo = findPresetSection(lines, presetName);\n    if (!sectionInfo) return;\n\n    lines.splice(sectionInfo.start, sectionInfo.end - sectionInfo.start);\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n\n    if (getActivePreset() === presetName) {\n        selectTimelinePreset('morning_evening');\n    } else {\n        syncTimelineToUI();\n    }\n    showToast(`调度模式「${displayName}」已删除`, 'success');\n}\n\n// ── 复制预设模式 ──\n\nwindow.duplicateTlPreset = function(presetName) {\n    const data = parseTimelineData();\n    const src = getPresetConfig(data, presetName);\n    if (!src) return;\n\n    openTlNewPresetModal();\n    const origName = src.name || presetName;\n    document.getElementById('tl-new-preset-key').value = presetName + '_copy';\n    document.getElementById('tl-new-preset-name').value = origName + ' (副本)';\n    document.getElementById('tl-new-preset-desc').value = src.description || '';\n    document.getElementById('tl-new-preset-template').value = presetName;\n}\n\n// ── 新增日计划 ──\n\nwindow.addTlDayPlan = function(presetName) {\n    const planKey = prompt('请输入日计划标识 (key)，如 holiday：');\n    if (!planKey) return;\n    if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(planKey)) {\n        showToast('key 仅支持英文、数字和下划线', 'error');\n        return;\n    }\n\n    const data = parseTimelineData();\n    const config = getPresetConfig(data, presetName);\n    if (config?.day_plans?.[planKey]) {\n        showToast(`日计划「${planKey}」已存在`, 'error');\n        return;\n    }\n\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n\n    const sectionInfo = findPresetSection(lines, presetName);\n    if (!sectionInfo) return;\n\n    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');\n    if (dayPlansLine < 0) return;\n\n    const dpIndent = lines[dayPlansLine].search(/\\S/);\n    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionInfo.end);\n\n    const indent = ' '.repeat(dpIndent + 2);\n    const subIndent = ' '.repeat(dpIndent + 4);\n\n    lines.splice(dpEnd, 0,\n        `${indent}${planKey}:`,\n        `${subIndent}periods: []`\n    );\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n    syncTimelineToUI();\n    showToast(`日计划「${planKey}」已添加`, 'success');\n}\n\n// ── 删除日计划 ──\n\nwindow.deleteTlDayPlan = function(presetName, planKey) {\n    const data = parseTimelineData();\n    const config = getPresetConfig(data, presetName);\n    if (!config) return;\n\n    const weekMap = config.week_map || {};\n    const refs = [];\n    for (let d = 1; d <= 7; d++) {\n        const v = weekMap[d] || weekMap[String(d)];\n        if (v === planKey) refs.push(DAY_NAMES[d - 1]);\n    }\n\n    if (refs.length > 0) {\n        showToast(`无法删除：「${planKey}」正在被 ${refs.join('、')} 使用。请先修改周映射。`, 'error');\n        return;\n    }\n\n    if (!confirm(`确定删除日计划「${planKey}」？`)) return;\n\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n\n    const sectionInfo = findPresetSection(lines, presetName);\n    if (!sectionInfo) return;\n\n    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');\n    if (dayPlansLine < 0) return;\n\n    const dpIndent = lines[dayPlansLine].search(/\\S/);\n    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionInfo.end);\n    const planLine = findChildKey(lines, dayPlansLine, dpEnd, dpIndent, planKey);\n    if (planLine < 0) return;\n\n    const planIndent = lines[planLine].search(/\\S/);\n    const planEnd = findBlockEnd(lines, planLine, planIndent, dpEnd);\n\n    lines.splice(planLine, planEnd - planLine);\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n    syncTimelineToUI();\n    showToast(`日计划「${planKey}」已删除`, 'success');\n}\n\n// ── 日计划中添加/移除时间段引用 ──\n\nwindow.addPeriodToDayPlan = function(presetName, planKey, periodKey) {\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n\n    const sectionInfo = findPresetSection(lines, presetName);\n    if (!sectionInfo) return;\n\n    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');\n    if (dayPlansLine < 0) return;\n\n    const dpIndent = lines[dayPlansLine].search(/\\S/);\n    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionInfo.end);\n    const planLine = findChildKey(lines, dayPlansLine, dpEnd, dpIndent, planKey);\n    if (planLine < 0) return;\n\n    const planIndent = lines[planLine].search(/\\S/);\n    const planEnd = findBlockEnd(lines, planLine, planIndent, dpEnd);\n    const periodsLine = findChildKey(lines, planLine, planEnd, planIndent, 'periods');\n    if (periodsLine < 0) return;\n\n    const periodsContent = lines[periodsLine].trim();\n\n    if (periodsContent === 'periods: []' || periodsContent === 'periods:[]') {\n        const pIndent = ' '.repeat(lines[periodsLine].search(/\\S/));\n        lines[periodsLine] = `${pIndent}periods:`;\n        lines.splice(periodsLine + 1, 0, `${pIndent}  - ${periodKey}`);\n    } else {\n        const inlineMatch = lines[periodsLine].match(/^(\\s*periods:\\s*)\\[([^\\]]*)\\]/);\n        if (inlineMatch) {\n            const existing = inlineMatch[2].split(',').map(s => s.trim()).filter(Boolean);\n            // 保持引号风格一致\n            const hasQuotes = existing.length > 0 && existing[0].startsWith('\"');\n            existing.push(hasQuotes ? `\"${periodKey}\"` : periodKey);\n            lines[periodsLine] = `${inlineMatch[1]}[${existing.join(', ')}]`;\n        } else {\n            const pIndent = ' '.repeat(lines[periodsLine].search(/\\S/) + 2);\n            const listEnd = findBlockEnd(lines, periodsLine, lines[periodsLine].search(/\\S/), planEnd);\n            lines.splice(listEnd, 0, `${pIndent}- ${periodKey}`);\n        }\n    }\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n    syncTimelineToUI();\n}\n\nwindow.removePeriodFromDayPlanUI = function(presetName, planKey, periodKey) {\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n\n    const sectionInfo = findPresetSection(lines, presetName);\n    if (!sectionInfo) return;\n\n    removePeriodFromDayPlanInLines(lines, sectionInfo, planKey, periodKey);\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n    syncTimelineToUI();\n}\n\n// ── 周映射快捷操作 ──\n\nwindow.tlWeekMapQuick = function(presetName, mode) {\n    const data = parseTimelineData();\n    const config = getPresetConfig(data, presetName);\n    if (!config) return;\n\n    const dayPlanKeys = Object.keys(config.day_plans || {});\n    if (dayPlanKeys.length === 0) { showToast('没有可用的日计划', 'error'); return; }\n\n    let mapping = {};\n\n    if (mode === 'all_same') {\n        const plan = dayPlanKeys[0];\n        for (let d = 1; d <= 7; d++) mapping[d] = plan;\n    } else if (mode === 'weekday_same') {\n        const plan = dayPlanKeys[0];\n        for (let d = 1; d <= 5; d++) mapping[d] = plan;\n        const wm = config.week_map || {};\n        mapping[6] = wm[6] || wm['6'] || plan;\n        mapping[7] = wm[7] || wm['7'] || plan;\n    } else if (mode === 'weekday_weekend') {\n        if (dayPlanKeys.length < 2) { showToast('需要至少两个日计划来分离工作日/周末', 'warning'); return; }\n        const wd = dayPlanKeys[0];\n        const we = dayPlanKeys[1];\n        for (let d = 1; d <= 5; d++) mapping[d] = wd;\n        mapping[6] = we;\n        mapping[7] = we;\n    }\n\n    for (let d = 1; d <= 7; d++) {\n        if (mapping[d]) onTlWeekMap(presetName, d, mapping[d]);\n    }\n    showToast('周映射已更新', 'success');\n}\n\n// ── 辅助函数 ──\n\n/**\n * 定位预设配置段的起始行和结束行\n */\nfunction findPresetSection(lines, presetName) {\n    const isCustom = presetName === 'custom';\n    let start = -1;\n    let indent = 0;\n\n    if (isCustom) {\n        for (let i = 0; i < lines.length; i++) {\n            if (/^custom:\\s*/.test(lines[i])) { start = i; indent = 0; break; }\n        }\n    } else {\n        let inPresets = false;\n        for (let i = 0; i < lines.length; i++) {\n            const line = lines[i];\n            if (/^presets:\\s*/.test(line)) { inPresets = true; continue; }\n            if (inPresets && /^\\S/.test(line) && !line.startsWith('#') && line.trim() !== '') break;\n            if (inPresets) {\n                const m = line.match(/^(\\s+)(\\S+):\\s*/);\n                if (m && m[2] === presetName) { start = i; indent = m[1].length; break; }\n            }\n        }\n    }\n\n    if (start < 0) return null;\n\n    let end = lines.length;\n    for (let i = start + 1; i < lines.length; i++) {\n        const line = lines[i];\n        if (line.trim() === '' || line.trim().startsWith('#')) continue;\n        const curIndent = line.search(/\\S/);\n        if (curIndent <= indent) { end = i; break; }\n    }\n\n    return { start, end, indent };\n}\n\n/**\n * 从 day_plans 中批量移除对某 period 的引用\n */\nfunction removePeriodFromDayPlans(lines, sectionInfo, periodKey) {\n    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');\n    if (dayPlansLine < 0) return;\n\n    const dpIndent = lines[dayPlansLine].search(/\\S/);\n    const sectionEnd = findBlockEnd(lines, sectionInfo.start, sectionInfo.indent, lines.length);\n    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionEnd);\n\n    for (let i = dayPlansLine + 1; i < dpEnd; i++) {\n        const line = lines[i];\n        if (line.trim() === '' || line.trim().startsWith('#')) continue;\n        const listMatch = line.match(/^(\\s*)-\\s*(\\S+)\\s*$/);\n        if (listMatch && listMatch[2] === periodKey) {\n            lines.splice(i, 1);\n            i--;\n            continue;\n        }\n        const inlineMatch = line.match(/^(\\s*periods:\\s*)\\[([^\\]]*)\\]/);\n        if (inlineMatch) {\n            const items = inlineMatch[2].split(',').map(s => s.trim()).filter(s => {\n                const bare = s.replace(/^[\"']|[\"']$/g, '');\n                return bare && bare !== periodKey;\n            });\n            lines[i] = items.length > 0\n                ? `${inlineMatch[1]}[${items.join(', ')}]`\n                : `${inlineMatch[1]}[]`;\n        }\n    }\n}\n\n/**\n * 从指定 day_plan 中移除单个 period 引用\n */\nfunction removePeriodFromDayPlanInLines(lines, sectionInfo, planKey, periodKey) {\n    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');\n    if (dayPlansLine < 0) return;\n\n    const dpIndent = lines[dayPlansLine].search(/\\S/);\n    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionInfo.end);\n    const planLine = findChildKey(lines, dayPlansLine, dpEnd, dpIndent, planKey);\n    if (planLine < 0) return;\n\n    const planIndent = lines[planLine].search(/\\S/);\n    const planEnd = findBlockEnd(lines, planLine, planIndent, dpEnd);\n    const periodsLine = findChildKey(lines, planLine, planEnd, planIndent, 'periods');\n    if (periodsLine < 0) return;\n\n    const inlineMatch = lines[periodsLine].match(/^(\\s*periods:\\s*)\\[([^\\]]*)\\]/);\n    if (inlineMatch) {\n        const items = inlineMatch[2].split(',').map(s => s.trim()).filter(s => {\n            const bare = s.replace(/^[\"']|[\"']$/g, '');\n            return bare && bare !== periodKey;\n        });\n        lines[periodsLine] = items.length > 0\n            ? `${inlineMatch[1]}[${items.join(', ')}]`\n            : `${inlineMatch[1]}[]`;\n        return;\n    }\n\n    const pEnd = findBlockEnd(lines, periodsLine, lines[periodsLine].search(/\\S/), planEnd);\n    for (let i = periodsLine + 1; i < pEnd; i++) {\n        const m = lines[i].match(/^(\\s*)-\\s*(\\S+)\\s*$/);\n        if (m && m[2] === periodKey) {\n            lines.splice(i, 1);\n            return;\n        }\n    }\n}\n\n// ==========================================\n// 15. 后续优化功能\n// ==========================================\n\n// ── 1.3 / 3A.4 内联编辑（双击编辑文本）──\n\n/**\n * 预设卡片名称/描述内联编辑\n */\nwindow.tlInlineEdit = function(el, presetName, field, currentValue) {\n    if (el.querySelector('input')) return;\n\n    const original = currentValue;\n    const isName = field === 'name';\n    const input = document.createElement('input');\n    input.type = 'text';\n    input.value = original;\n    input.className = `tl-inline-input ${isName ? 'text-sm font-bold' : 'text-[10px]'}`;\n    input.style.width = '100%';\n\n    el.textContent = '';\n    el.appendChild(input);\n    input.focus();\n    input.select();\n\n    const commit = () => {\n        const newVal = input.value.trim();\n        if (newVal && newVal !== original) {\n            updatePresetMeta(presetName, field, newVal);\n        }\n        syncTimelineToUI();\n    };\n\n    input.addEventListener('blur', commit);\n    input.addEventListener('keydown', e => {\n        if (e.key === 'Enter') { e.preventDefault(); input.blur(); }\n        if (e.key === 'Escape') { el.textContent = original; }\n    });\n}\n\n/**\n * 更新预设顶层的 name / description 字段\n */\nfunction updatePresetMeta(presetName, field, value) {\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n\n    const sectionInfo = findPresetSection(lines, presetName);\n    if (!sectionInfo) return;\n\n    const lineIdx = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, field);\n    if (lineIdx >= 0) {\n        replaceLineValue(lines, lineIdx, value);\n    } else {\n        const indent = ' '.repeat(sectionInfo.indent + 2);\n        lines.splice(sectionInfo.start + 1, 0, `${indent}${field}: \"${value}\"`);\n    }\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n}\n\n/**\n * 时间段名称内联编辑\n */\nwindow.tlInlineEditPeriod = function(el, presetName, periodKey, currentValue) {\n    if (el.querySelector('input')) return;\n\n    const original = currentValue;\n    const input = document.createElement('input');\n    input.type = 'text';\n    input.value = original;\n    input.className = 'tl-inline-input text-sm font-bold';\n    input.style.width = Math.max(80, original.length * 14) + 'px';\n\n    el.textContent = '';\n    el.appendChild(input);\n    input.focus();\n    input.select();\n\n    const commit = () => {\n        const newVal = input.value.trim();\n        if (newVal && newVal !== original) {\n            updateTimelineField(presetName, periodKey, 'name', newVal);\n        }\n        syncTimelineToUI();\n    };\n\n    input.addEventListener('blur', commit);\n    input.addEventListener('keydown', e => {\n        if (e.key === 'Enter') { e.preventDefault(); input.blur(); }\n        if (e.key === 'Escape') { el.textContent = original; }\n    });\n}\n\n// ── 2.2 周视图空白区域点击 → 显示日计划名称 ──\n\nwindow.onTlBarClick = function(event, presetName, dayNum) {\n    if (event.target.closest('.tl-period-block')) return;\n\n    const data = parseTimelineData();\n    const config = getPresetConfig(data, presetName);\n    if (!config) return;\n\n    const weekMap = config.week_map || {};\n    const planKey = weekMap[dayNum] || weekMap[String(dayNum)] || '(未设置)';\n\n    hideTlTooltip();\n    const el = document.createElement('div');\n    el.className = 'tl-tooltip';\n    el.innerHTML = `<div style=\"font-weight:700;margin-bottom:2px\">${DAY_NAMES[dayNum - 1]}</div>\n        <div style=\"font-size:11px;color:#9ca3af\">日计划: <strong style=\"color:#374151\">${planKey}</strong></div>\n        <div style=\"font-size:10px;color:#9ca3af;margin-top:4px\">使用 default 配置</div>`;\n\n    document.body.appendChild(el);\n    tlTooltipEl = el;\n\n    const rect = event.currentTarget.getBoundingClientRect();\n    const x = event.clientX;\n    el.style.left = (x - el.offsetWidth / 2) + 'px';\n    el.style.top = (rect.top - el.offsetHeight - 8) + 'px';\n\n    const elRect = el.getBoundingClientRect();\n    if (elRect.left < 4) el.style.left = '4px';\n    if (elRect.right > window.innerWidth - 4) el.style.left = (window.innerWidth - el.offsetWidth - 4) + 'px';\n    if (elRect.top < 4) el.style.top = (rect.bottom + 8) + 'px';\n\n    setTimeout(() => { if (tlTooltipEl === el) hideTlTooltip(); }, 2000);\n}\n\n// ── 3B.5 日计划 Tag 拖拽排序 ──\n\n/**\n * 为日计划中的 period tag 容器初始化 SortableJS\n */\nfunction initDayPlanSortable(presetName) {\n    document.querySelectorAll('.tl-dayplan-sortable').forEach(container => {\n        const planKey = container.dataset.planKey;\n        if (!planKey) return;\n\n        new Sortable(container, {\n            animation: 150,\n            ghostClass: 'tl-tag-ghost',\n            dragClass: 'tl-tag-drag',\n            draggable: '.tl-period-tag',\n            filter: '.tl-add-period-select, .tl-tag-remove',\n            preventOnFilter: false,\n            onEnd: function() {\n                const items = [];\n                container.querySelectorAll('.tl-period-tag').forEach(tag => {\n                    const key = tag.dataset.periodKey;\n                    if (key) items.push(key);\n                });\n                reorderDayPlanPeriods(presetName, planKey, items);\n            }\n        });\n    });\n}\n\n/**\n * 重新排列 day_plan 中 periods 的顺序\n */\nfunction reorderDayPlanPeriods(presetName, planKey, orderedKeys) {\n    const editor = document.getElementById('timeline-editor');\n    const lines = editor.value.split('\\n');\n\n    const sectionInfo = findPresetSection(lines, presetName);\n    if (!sectionInfo) return;\n\n    const dayPlansLine = findChildKey(lines, sectionInfo.start, sectionInfo.end, sectionInfo.indent, 'day_plans');\n    if (dayPlansLine < 0) return;\n\n    const dpIndent = lines[dayPlansLine].search(/\\S/);\n    const dpEnd = findBlockEnd(lines, dayPlansLine, dpIndent, sectionInfo.end);\n    const planLine = findChildKey(lines, dayPlansLine, dpEnd, dpIndent, planKey);\n    if (planLine < 0) return;\n\n    const planIndent = lines[planLine].search(/\\S/);\n    const planEnd = findBlockEnd(lines, planLine, planIndent, dpEnd);\n    const periodsLine = findChildKey(lines, planLine, planEnd, planIndent, 'periods');\n    if (periodsLine < 0) return;\n\n    const inlineMatch = lines[periodsLine].match(/^(\\s*periods:\\s*)\\[([^\\]]*)\\]/);\n    if (inlineMatch) {\n        lines[periodsLine] = `${inlineMatch[1]}[${orderedKeys.join(', ')}]`;\n    } else {\n        const pIndent = lines[periodsLine].search(/\\S/);\n        const pEnd = findBlockEnd(lines, periodsLine, pIndent, planEnd);\n        lines.splice(periodsLine + 1, pEnd - periodsLine - 1);\n        const itemIndent = ' '.repeat(pIndent + 2);\n        const newItems = orderedKeys.map(k => `${itemIndent}- ${k}`);\n        lines.splice(periodsLine + 1, 0, ...newItems);\n    }\n\n    editor.value = lines.join('\\n');\n    currentTimeline = editor.value;\n    updateBackdrop('timeline-editor', 'timeline-backdrop');\n    debounceSaveTimeline();\n\n    clearTimeout(window._tlRenderTimer);\n    window._tlRenderTimer = setTimeout(() => syncTimelineToUI(), 500);\n}\n\n// ==========================================\n// 支持侧栏 折叠/展开\n// ==========================================\nfunction toggleSupportSidebar() {\n    const wrap = document.querySelector('.support-sidebar-wrap');\n    const btn = document.getElementById('sidebar-toggle-btn');\n    const isCollapsed = wrap.classList.toggle('collapsed');\n    btn.classList.toggle('is-collapsed', isCollapsed);\n    btn.title = isCollapsed ? '展开侧栏' : '收起侧栏';\n}\n"
  },
  {
    "path": "docs/assets/style.css",
    "content": "/* 编辑器区域滚动条 */\n#yaml-editor::-webkit-scrollbar,\n#frequency-editor::-webkit-scrollbar,\n#timeline-editor::-webkit-scrollbar,\n#yaml-backdrop::-webkit-scrollbar,\n#frequency-backdrop::-webkit-scrollbar,\n#timeline-backdrop::-webkit-scrollbar {\n    width: 10px;\n    height: 10px;\n}\n#yaml-editor::-webkit-scrollbar-track,\n#frequency-editor::-webkit-scrollbar-track,\n#timeline-editor::-webkit-scrollbar-track,\n#yaml-backdrop::-webkit-scrollbar-track,\n#frequency-backdrop::-webkit-scrollbar-track,\n#timeline-backdrop::-webkit-scrollbar-track {\n    background: #1e1e1e;\n}\n#yaml-editor::-webkit-scrollbar-thumb,\n#frequency-editor::-webkit-scrollbar-thumb,\n#timeline-editor::-webkit-scrollbar-thumb,\n#yaml-backdrop::-webkit-scrollbar-thumb,\n#frequency-backdrop::-webkit-scrollbar-thumb,\n#timeline-backdrop::-webkit-scrollbar-thumb {\n    background: #424242;\n    border-radius: 0;\n}\n#yaml-editor::-webkit-scrollbar-thumb:hover,\n#frequency-editor::-webkit-scrollbar-thumb:hover,\n#timeline-editor::-webkit-scrollbar-thumb:hover,\n#yaml-backdrop::-webkit-scrollbar-thumb:hover,\n#frequency-backdrop::-webkit-scrollbar-thumb:hover,\n#timeline-backdrop::-webkit-scrollbar-thumb:hover {\n    background: #4f4f4f;\n}\n\n/* 高亮编辑器容器 */\n.highlight-editor-wrap {\n    position: relative;\n    flex: 1;\n    display: flex;\n    overflow: hidden;\n}\n\n/* 高亮背景层 */\n.highlight-backdrop {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    padding: 1rem;\n    margin: 0;\n    border: none;\n    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n    font-size: 0.75rem;\n    line-height: 1.625;\n    white-space: pre-wrap;\n    word-wrap: break-word;\n    overflow: auto;\n    background: #1e1e1e;\n    color: #d4d4d4;\n    pointer-events: none;\n    z-index: 1;\n}\n\n/* 透明输入层 */\n.highlight-textarea {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    padding: 1rem;\n    margin: 0;\n    border: none;\n    font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;\n    font-size: 0.75rem;\n    line-height: 1.625;\n    overflow: auto;\n    background: transparent;\n    color: transparent;\n    caret-color: #d4d4d4;\n    resize: none;\n    outline: none;\n    z-index: 2;\n}\n\n/* 注释样式 - 灰色 */\n.syntax-comment {\n    color: #6a9955;\n}\n\n/* 右侧面板滚动条 */\n#modules-container::-webkit-scrollbar {\n    width: 8px;\n}\n#modules-container::-webkit-scrollbar-track {\n    background: transparent;\n}\n#modules-container::-webkit-scrollbar-thumb {\n    background: #cbd5e1;\n    border-radius: 4px;\n}\n\n/* 模块卡片样式 */\n.module-card {\n    background: white;\n    border-radius: 0.5rem; /* rounded-lg */\n    border: 1px solid #e5e7eb; /* border-gray-200 */\n    overflow: hidden;\n    transition: all 0.2s;\n}\n\n/* 激活态（可编辑） */\n.module-card.active {\n    box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);\n}\n.module-card.active:hover {\n    box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);\n    border-color: #bfdbfe; /* blue-200 */\n}\n.module-card.active .module-header {\n    background-color: #fff;\n    border-bottom: 1px solid #f3f4f6;\n    color: #111827;\n}\n\n/* 禁用态（灰色/只读） */\n.module-card.disabled {\n    background-color: #f9fafb; /* gray-50 */\n    opacity: 0.8;\n}\n.module-card.disabled .module-header {\n    background-color: #f3f4f6; /* gray-100 */\n    color: #6b7280; /* gray-500 */\n    cursor: not-allowed;\n}\n.module-card.disabled .module-body {\n    display: none;\n}\n.module-card.disabled .locked-badge {\n    display: inline-flex;\n}\n\n/* 输入控件统一 */\ninput[type=\"text\"],\ninput[type=\"password\"],\ninput[type=\"number\"],\nselect {\n    font-size: 0.875rem; /* text-sm */\n    line-height: 1.25rem;\n    padding: 0.5rem 0.75rem;\n    border-radius: 0.375rem;\n    border-width: 1px;\n    border-color: #d1d5db; /* gray-300 */\n    width: 100%;\n    outline: 2px solid transparent;\n    transition: all 0.15s;\n}\ninput:focus, select:focus {\n    border-color: #3b82f6; /* blue-500 */\n    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);\n}\n\n/* 开关样式 (Checkbox Toggle) */\n.toggle-checkbox:checked {\n    right: 0;\n    border-color: #3b82f6;\n}\n.toggle-checkbox:checked + .toggle-label {\n    background-color: #3b82f6;\n}\n\n/* 列表样式 (Platforms & RSS & Sortable) */\n.sortable-list-item {\n    background: #f8fafc;\n    border: 1px solid #e2e8f0;\n    margin-bottom: 0.5rem;\n    border-radius: 0.375rem;\n    transition: all 0.2s;\n}\n.sortable-list-item:hover {\n    border-color: #cbd5e1;\n    background: #f1f5f9;\n}\n.sortable-handle {\n    cursor: grab;\n    color: #94a3b8;\n}\n.sortable-handle:hover {\n    color: #64748b;\n}\n.sortable-ghost {\n    background: #e2e8f0;\n    opacity: 0.5;\n}\n\n/* 禁用状态的勾选框 */\ninput[type=\"checkbox\"]:disabled {\n    cursor: not-allowed;\n    opacity: 0.5;\n}\n\n/* Tab 切换样式 */\n.tab-button {\n    transition: all 0.2s;\n}\n.tab-button.active {\n    color: #d4d4d4;\n    border-color: #3b82f6;\n}\n.tab-content.hidden {\n    display: none;\n}\n\n/* 标签输入样式 */\n.tag-input-container {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.5rem;\n    padding: 0.5rem;\n    border: 1px solid #d1d5db;\n    border-radius: 0.375rem;\n    background: white;\n    min-height: 42px;\n}\n.tag-item {\n    display: inline-flex;\n    align-items: center;\n    gap: 0.25rem;\n    padding: 0.25rem 0.5rem;\n    background: #3b82f6;\n    color: white;\n    border-radius: 0.25rem;\n    font-size: 0.875rem;\n}\n.tag-item button {\n    background: none;\n    border: none;\n    color: white;\n    cursor: pointer;\n    padding: 0;\n    font-size: 1rem;\n    line-height: 1;\n}\n.tag-input {\n    flex: 1;\n    border: none;\n    outline: none;\n    min-width: 120px;\n    font-size: 0.875rem;\n}\n\n/* 词组卡片样式 */\n.word-group-card {\n    background: white;\n    border: 1px solid #e5e7eb;\n    border-radius: 0.5rem;\n    padding: 1rem;\n    transition: all 0.2s;\n}\n.word-group-card:hover {\n    border-color: #3b82f6;\n    box-shadow: 0 2px 4px rgba(0,0,0,0.1);\n}\n\n/* 插入区域样式 */\n.insert-zone {\n    position: relative;\n    height: 8px;\n    margin: 0.5rem 0;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    transition: all 0.2s;\n}\n\n.insert-zone:hover {\n    height: 32px;\n}\n\n.insert-button {\n    opacity: 0;\n    visibility: hidden;\n    width: 32px;\n    height: 32px;\n    border-radius: 50%;\n    background: linear-gradient(135deg, #3b82f6, #2563eb);\n    color: white;\n    border: 2px solid white;\n    box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    cursor: pointer;\n    transition: all 0.2s;\n    font-size: 14px;\n}\n\n.insert-zone:hover .insert-button {\n    opacity: 1;\n    visibility: visible;\n}\n\n.insert-button:hover {\n    transform: scale(1.1);\n    box-shadow: 0 4px 12px rgba(59, 130, 246, 0.6);\n    background: linear-gradient(135deg, #2563eb, #1d4ed8);\n}\n\n.insert-button:active {\n    transform: scale(0.95);\n}\n\n/* 编辑区域恢复默认鼠标样式 */\n.word-group-card .editable-area {\n    cursor: default;\n}\n.word-group-card .editable-area input {\n    cursor: text;\n}\n.word-group-card .editable-area button {\n    cursor: pointer;\n}\n.word-group-card .editable-area .tag-item {\n    cursor: pointer;\n}\n\n/* 拖拽手柄样式 */\n.drag-handle {\n    cursor: grab;\n    transition: all 0.2s;\n}\n.drag-handle:active {\n    cursor: grabbing;\n}\n\n/* SortableJS 拖拽样式 */\n.sortable-ghost {\n    opacity: 0.4;\n    background: #dbeafe;\n    border: 2px dashed #3b82f6;\n}\n.sortable-chosen {\n    background: #f0f9ff;\n    border-color: #3b82f6;\n}\n.sortable-drag {\n    opacity: 0.8;\n    box-shadow: 0 10px 20px rgba(0,0,0,0.2);\n    transform: rotate(2deg);\n}\n\n/* 独立区域复选框组 */\n.checkbox-grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));\n    gap: 0.75rem;\n}\n.checkbox-card {\n    display: flex;\n    align-items: center;\n    padding: 0.5rem;\n    border: 1px solid #e5e7eb;\n    border-radius: 0.375rem;\n    background-color: #fff;\n    cursor: pointer;\n    transition: all 0.15s;\n}\n.checkbox-card:hover {\n    border-color: #93c5fd;\n    background-color: #eff6ff;\n}\n.checkbox-card input:checked + span {\n    color: #2563eb;\n    font-weight: 500;\n}\n\n/* ==========================================\n   拖拽上传遮罩层\n   ========================================== */\n.drop-overlay {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background: rgba(59, 130, 246, 0.9);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    z-index: 100;\n    pointer-events: all;\n}\n.drop-overlay.hidden {\n    display: none;\n}\n.drop-overlay-content {\n    text-align: center;\n    color: white;\n}\n.drop-overlay-content i {\n    font-size: 3rem;\n    margin-bottom: 0.5rem;\n    animation: bounce 1s infinite;\n}\n@keyframes bounce {\n    0%, 100% { transform: translateY(0); }\n    50% { transform: translateY(-10px); }\n}\n\n/* ==========================================\n   Toast 提示\n   ========================================== */\n.toast-notification {\n    position: fixed;\n    bottom: 24px;\n    right: 24px;\n    display: flex;\n    align-items: center;\n    gap: 0.75rem;\n    padding: 0.875rem 1.25rem;\n    border-radius: 0.5rem;\n    font-size: 0.875rem;\n    font-weight: 500;\n    box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);\n    z-index: 9999;\n    opacity: 0;\n    transform: translateY(20px);\n    transition: all 0.3s ease;\n}\n.toast-notification.show {\n    opacity: 1;\n    transform: translateY(0);\n}\n.toast-notification i {\n    font-size: 1.125rem;\n}\n\n/* Toast 类型样式 */\n.toast-success {\n    background: #10b981;\n    color: white;\n}\n.toast-error {\n    background: #ef4444;\n    color: white;\n}\n.toast-info {\n    background: #3b82f6;\n    color: white;\n}\n.toast-warning {\n    background: #f59e0b;\n    color: white;\n}\n\n/* ==========================================\n   弹窗样式\n   ========================================== */\n.modal-overlay {\n    position: fixed;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background: rgba(0, 0, 0, 0.5);\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    z-index: 1000;\n}\n.modal-overlay.hidden {\n    display: none;\n}\n.modal-content {\n    background: white;\n    border-radius: 0.75rem;\n    padding: 1.5rem;\n    max-width: 450px;\n    width: 90%;\n    max-height: 90vh;\n    overflow-y: auto;\n    box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);\n}\n\n\n/* 弹簧跳动动画 */\n@keyframes spring-in {\n    0% { transform: scale(0.5); opacity: 0; }\n    60% { transform: scale(1.1); }\n    80% { transform: scale(0.95); }\n    100% { transform: scale(1); opacity: 1; }\n}\n\n.support-modal-content {\n    animation: spring-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);\n    background: #ffffff;\n    border: none;\n    border-radius: 1.5rem;\n}\n\n/* ==========================================\n   Timeline 编辑器样式\n   ========================================== */\n\n/* 预设模式选择卡片 */\n.tl-preset-card {\n    border: 2px solid #e5e7eb;\n    border-radius: 0.75rem;\n    padding: 0.875rem;\n    cursor: pointer;\n    transition: all 0.2s;\n    background: white;\n    position: relative;\n}\n.tl-preset-card:hover {\n    border-color: #93c5fd;\n    background: #f0f7ff;\n}\n.tl-preset-card.selected {\n    border-color: #3b82f6;\n    background: #eff6ff;\n    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);\n}\n.tl-preset-card .tl-card-icon {\n    width: 2rem;\n    height: 2rem;\n    border-radius: 0.5rem;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    font-size: 0.875rem;\n    flex-shrink: 0;\n}\n.tl-preset-card .tl-recommend-badge {\n    position: absolute;\n    top: -1px;\n    right: -1px;\n    background: linear-gradient(135deg, #f59e0b, #ef4444);\n    color: white;\n    font-size: 0.625rem;\n    font-weight: 700;\n    padding: 0.125rem 0.5rem;\n    border-radius: 0 0.625rem 0 0.5rem;\n}\n\n/* 周视图时间线 */\n.tl-week-view {\n    background: white;\n    border: 1px solid #e5e7eb;\n    border-radius: 0.75rem;\n    padding: 1rem;\n    overflow-x: auto;\n}\n.tl-week-row {\n    display: flex;\n    align-items: center;\n    height: 2.25rem;\n    margin-bottom: 0.25rem;\n}\n.tl-week-row:last-child {\n    margin-bottom: 0;\n}\n.tl-day-label {\n    width: 2.5rem;\n    flex-shrink: 0;\n    font-size: 0.6875rem;\n    font-weight: 600;\n    color: #6b7280;\n    text-align: right;\n    padding-right: 0.5rem;\n}\n.tl-day-label.today {\n    color: #3b82f6;\n    font-weight: 700;\n}\n.tl-timeline-bar {\n    flex: 1;\n    height: 1.75rem;\n    background: #f1f5f9;\n    border-radius: 0.25rem;\n    position: relative;\n    min-width: 480px;\n    overflow: hidden;\n}\n.tl-period-block {\n    position: absolute;\n    top: 2px;\n    bottom: 2px;\n    border-radius: 0.1875rem;\n    cursor: pointer;\n    transition: filter 0.15s, transform 0.15s;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    overflow: hidden;\n    z-index: 1;\n}\n.tl-period-block:hover {\n    filter: brightness(1.1);\n    transform: scaleY(1.15);\n    z-index: 2;\n}\n.tl-period-block .tl-block-label {\n    font-size: 0.5625rem;\n    font-weight: 600;\n    color: rgba(255,255,255,0.9);\n    white-space: nowrap;\n    text-overflow: ellipsis;\n    overflow: hidden;\n    padding: 0 0.25rem;\n    text-shadow: 0 1px 2px rgba(0,0,0,0.2);\n}\n\n/* 时间段颜色 */\n.tl-block-push { background: #3b82f6; }\n.tl-block-analyze { background: #8b5cf6; }\n.tl-block-push-analyze { background: #6366f1; }\n.tl-block-collect { background: #94a3b8; }\n.tl-block-silent { background: #cbd5e1; }\n\n/* 时间刻度 */\n.tl-hour-markers {\n    display: flex;\n    padding-left: 2.5rem;\n    margin-bottom: 0.25rem;\n}\n.tl-hour-marker {\n    font-size: 0.5625rem;\n    color: #9ca3af;\n    text-align: center;\n}\n\n/* 图例 */\n.tl-legend {\n    display: flex;\n    gap: 0.75rem;\n    flex-wrap: wrap;\n    padding-top: 0.5rem;\n    border-top: 1px solid #f3f4f6;\n    margin-top: 0.5rem;\n}\n.tl-legend-item {\n    display: flex;\n    align-items: center;\n    gap: 0.25rem;\n    font-size: 0.625rem;\n    color: #6b7280;\n}\n.tl-legend-color {\n    width: 0.75rem;\n    height: 0.5rem;\n    border-radius: 0.125rem;\n}\n\n/* 时间段 Tooltip */\n.tl-tooltip {\n    position: fixed;\n    background: #1f2937;\n    color: white;\n    padding: 0.5rem 0.75rem;\n    border-radius: 0.375rem;\n    font-size: 0.75rem;\n    z-index: 1000;\n    pointer-events: none;\n    box-shadow: 0 4px 12px rgba(0,0,0,0.2);\n    max-width: 220px;\n}\n.tl-tooltip::after {\n    content: '';\n    position: absolute;\n    bottom: -4px;\n    left: 50%;\n    transform: translateX(-50%);\n    border-left: 5px solid transparent;\n    border-right: 5px solid transparent;\n    border-top: 5px solid #1f2937;\n}\n\n/* Custom 模式编辑面板 */\n.tl-section-title {\n    font-size: 0.75rem;\n    font-weight: 700;\n    color: #374151;\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n    margin-bottom: 0.75rem;\n}\n.tl-section-title i {\n    color: #3b82f6;\n    font-size: 0.6875rem;\n}\n\n.tl-period-card {\n    background: white;\n    border: 1px solid #e5e7eb;\n    border-radius: 0.5rem;\n    padding: 0.75rem;\n    transition: all 0.2s;\n}\n.tl-period-card:hover {\n    border-color: #93c5fd;\n    box-shadow: 0 2px 4px rgba(0,0,0,0.05);\n}\n\n.tl-toggle-row {\n    display: flex;\n    align-items: center;\n    gap: 0.75rem;\n    flex-wrap: wrap;\n}\n.tl-toggle-item {\n    display: flex;\n    align-items: center;\n    gap: 0.375rem;\n    font-size: 0.6875rem;\n    color: #4b5563;\n}\n.tl-toggle-item.on { color: #2563eb; font-weight: 600; }\n.tl-toggle-item.off { color: #9ca3af; }\n\n/* Timeline 小型 toggle 开关 */\n.tl-toggle-item .toggle-checkbox {\n    width: 1rem;\n    height: 1rem;\n    border-width: 3px;\n}\n.tl-toggle-item .toggle-label {\n    height: 1rem;\n}\n.tl-toggle-item .toggle-checkbox:checked {\n    right: 0;\n    border-color: #3b82f6;\n}\n.tl-toggle-item .toggle-checkbox:checked + .toggle-label {\n    background-color: #3b82f6;\n}\n\n/* 日计划和周映射 */\n.tl-dayplan-row {\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n    padding: 0.375rem 0;\n}\n.tl-dayplan-label {\n    width: 3.5rem;\n    font-size: 0.6875rem;\n    font-weight: 600;\n    color: #374151;\n    flex-shrink: 0;\n}\n.tl-weekmap-select {\n    font-size: 0.75rem;\n    padding: 0.25rem 0.5rem;\n    border: 1px solid #d1d5db;\n    border-radius: 0.25rem;\n    background: white;\n    flex: 1;\n    max-width: 200px;\n}\n.tl-weekmap-select:focus {\n    border-color: #3b82f6;\n    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);\n    outline: none;\n}\n\n/* Default 配置折叠面板 */\n.tl-collapsible {\n    border: 1px solid #e5e7eb;\n    border-radius: 0.5rem;\n    overflow: hidden;\n}\n.tl-collapsible-header {\n    background: #f9fafb;\n    padding: 0.625rem 0.75rem;\n    cursor: pointer;\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    font-size: 0.75rem;\n    font-weight: 600;\n    color: #4b5563;\n    transition: background 0.15s;\n}\n.tl-collapsible-header:hover {\n    background: #f3f4f6;\n}\n.tl-collapsible-body {\n    padding: 0.75rem;\n    border-top: 1px solid #e5e7eb;\n}\n.tl-collapsible-body.collapsed {\n    display: none;\n}\n.tl-collapsible-header .fa-chevron-down {\n    transition: transform 0.2s;\n}\n.tl-collapsible-header.is-collapsed .fa-chevron-down {\n    transform: rotate(-90deg);\n}\n\n/* Timeline CRUD 新增样式 */\n\n/* 预设卡片操作按钮 */\n.tl-card-actions {\n    display: none;\n    position: absolute;\n    top: 0.375rem;\n    right: 0.375rem;\n    gap: 0.25rem;\n    z-index: 2;\n}\n.tl-preset-card:hover .tl-card-actions {\n    display: flex;\n}\n.tl-card-action-btn {\n    width: 1.5rem;\n    height: 1.5rem;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    border-radius: 0.375rem;\n    font-size: 0.625rem;\n    color: #9ca3af;\n    background: rgba(255,255,255,0.9);\n    border: 1px solid #e5e7eb;\n    cursor: pointer;\n    transition: all 0.15s;\n}\n.tl-card-action-btn:hover {\n    color: #3b82f6;\n    background: white;\n    border-color: #93c5fd;\n}\n.tl-card-action-btn.text-red-400:hover {\n    color: #ef4444;\n    border-color: #fca5a5;\n}\n\n/* 新建模式卡片 */\n.tl-new-preset-card {\n    border-style: dashed;\n    border-color: #d1d5db;\n    background: #fafafa;\n}\n.tl-new-preset-card:hover {\n    border-color: #a78bfa;\n    background: #faf5ff;\n}\n\n/* section 内的新增按钮 */\n.tl-add-btn {\n    font-size: 0.625rem;\n    font-weight: 600;\n    color: #3b82f6;\n    background: #eff6ff;\n    border: 1px solid #bfdbfe;\n    border-radius: 0.375rem;\n    padding: 0.125rem 0.5rem;\n    cursor: pointer;\n    transition: all 0.15s;\n}\n.tl-add-btn:hover {\n    background: #dbeafe;\n    border-color: #93c5fd;\n}\n\n/* period 卡片内联操作 */\n.tl-inline-btn {\n    width: 1.375rem;\n    height: 1.375rem;\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    border-radius: 0.25rem;\n    font-size: 0.625rem;\n    color: #9ca3af;\n    background: transparent;\n    border: none;\n    cursor: pointer;\n    transition: all 0.15s;\n    opacity: 0;\n}\n.tl-period-card:hover .tl-inline-btn,\n.tl-dayplan-card:hover .tl-inline-btn {\n    opacity: 1;\n}\n.tl-inline-btn:hover {\n    color: #3b82f6;\n    background: #eff6ff;\n}\n.tl-inline-btn.text-red-400:hover {\n    color: #ef4444;\n    background: #fef2f2;\n}\n\n/* 日计划中的 period tag */\n.tl-period-tag {\n    display: inline-flex;\n    align-items: center;\n    gap: 0.25rem;\n    font-size: 0.625rem;\n    padding: 0.125rem 0.5rem;\n    border-radius: 9999px;\n    color: white;\n    white-space: nowrap;\n}\n.tl-tag-remove {\n    font-size: 0.75rem;\n    font-weight: 700;\n    line-height: 1;\n    color: rgba(255,255,255,0.7);\n    background: none;\n    border: none;\n    cursor: pointer;\n    padding: 0;\n    margin-left: 0.125rem;\n}\n.tl-tag-remove:hover {\n    color: white;\n}\n\n/* 添加时间段到日计划的 select */\n.tl-add-period-select {\n    font-size: 0.625rem;\n    padding: 0.0625rem 0.375rem;\n    border: 1px dashed #d1d5db;\n    border-radius: 9999px;\n    background: #f9fafb;\n    color: #6b7280;\n    cursor: pointer;\n    transition: all 0.15s;\n}\n.tl-add-period-select:hover {\n    border-color: #93c5fd;\n    color: #3b82f6;\n}\n\n/* 周映射快捷按钮 */\n.tl-quick-btn {\n    font-size: 0.625rem;\n    font-weight: 500;\n    color: #6b7280;\n    background: #f3f4f6;\n    border: 1px solid #e5e7eb;\n    border-radius: 0.375rem;\n    padding: 0.25rem 0.5rem;\n    cursor: pointer;\n    transition: all 0.15s;\n}\n.tl-quick-btn:hover {\n    color: #3b82f6;\n    background: #eff6ff;\n    border-color: #93c5fd;\n}\n\n/* 当前时间指示线 */\n.tl-now-line {\n    position: absolute;\n    top: -2px;\n    bottom: -2px;\n    width: 2px;\n    background: #ef4444;\n    z-index: 5;\n    pointer-events: none;\n}\n.tl-now-line::before {\n    content: '';\n    position: absolute;\n    top: -3px;\n    left: -3px;\n    width: 8px;\n    height: 8px;\n    border-radius: 50%;\n    background: #ef4444;\n}\n\n/* 周视图色块点击态 */\n.tl-period-block {\n    cursor: pointer;\n}\n.tl-period-block:hover {\n    filter: brightness(1.1);\n    box-shadow: 0 0 0 2px rgba(255,255,255,0.6);\n}\n\n/* period 卡片高亮动画 */\n.tl-period-highlight {\n    animation: tl-highlight-pulse 1.5s ease-out;\n}\n@keyframes tl-highlight-pulse {\n    0%   { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.5); }\n    30%  { box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.3); }\n    100% { box-shadow: none; }\n}\n\n/* 内联编辑输入框 */\n.tl-inline-input {\n    background: white;\n    border: 1px solid #93c5fd;\n    border-radius: 0.25rem;\n    padding: 0 0.25rem;\n    outline: none;\n    box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);\n    color: #1f2937;\n}\n.tl-editable {\n    cursor: text;\n    border-radius: 0.25rem;\n    transition: background 0.15s;\n}\n.tl-editable:hover {\n    background: rgba(59, 130, 246, 0.06);\n}\n\n/* 日计划 Tag 拖拽排序 */\n.tl-period-tag {\n    cursor: grab;\n}\n.tl-period-tag:active {\n    cursor: grabbing;\n}\n.tl-tag-ghost {\n    opacity: 0.4;\n}\n.tl-tag-drag {\n    transform: rotate(2deg);\n    box-shadow: 0 4px 12px rgba(0,0,0,0.15);\n}\n\n/* ==========================================\n   支持侧栏\n   ========================================== */\n/* 外层容器：承担宽度和 flex 布局角色 */\n.support-sidebar-wrap {\n    width: 20%;\n    min-width: 180px;\n    max-width: 280px;\n    overflow: visible;\n    transition: width 0.3s ease, min-width 0.3s ease, max-width 0.3s ease;\n}\n.support-sidebar-wrap.collapsed {\n    width: 0;\n    min-width: 0;\n    max-width: 0;\n}\n\n/* 内层侧栏：填满 wrap */\n.support-sidebar {\n    width: 100%;\n    height: 100%;\n    overflow: hidden;\n    transition: opacity 0.3s ease;\n}\n.support-sidebar-wrap.collapsed .support-sidebar {\n    opacity: 0;\n    pointer-events: none;\n}\n\n/* 折叠/展开按钮 */\n.sidebar-toggle-btn {\n    position: absolute;\n    left: 0;\n    top: 50%;\n    transform: translate(-100%, -50%);\n    width: 20px;\n    height: 40px;\n    background: white;\n    border: 1px solid #e5e7eb;\n    border-radius: 6px 0 0 6px;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    cursor: pointer;\n    z-index: 10;\n    opacity: 0;\n    transition: opacity 0.2s ease, background 0.2s ease;\n    color: #9ca3af;\n}\n.support-sidebar-wrap:hover .sidebar-toggle-btn {\n    opacity: 1;\n}\n.sidebar-toggle-btn:hover {\n    background: #f3f4f6;\n    color: #6b7280;\n}\n/* 折叠后按钮始终可见，箭头朝左 */\n.sidebar-toggle-btn.is-collapsed {\n    opacity: 1;\n}\n.sidebar-toggle-btn.is-collapsed i {\n    transform: rotate(180deg);\n}\n\n/* 侧栏滚动条 */\n.sidebar-scroll::-webkit-scrollbar {\n    width: 4px;\n}\n.sidebar-scroll::-webkit-scrollbar-track {\n    background: transparent;\n}\n.sidebar-scroll::-webkit-scrollbar-thumb {\n    background: #e5e7eb;\n    border-radius: 2px;\n}\n.sidebar-scroll::-webkit-scrollbar-thumb:hover {\n    background: #d1d5db;\n}\n\n/* 侧栏卡片 */\n.sidebar-card {\n    background: white;\n    border: 1px solid #f3f4f6;\n    border-radius: 0.75rem;\n    padding: 0.75rem;\n    transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);\n    text-decoration: none;\n    display: block;\n    cursor: pointer;\n}\n.sidebar-card:hover {\n    border-color: #e5e7eb;\n    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);\n    transform: translateY(-2px);\n}\n\n/* 侧栏卡片图标 */\n.sidebar-card-icon {\n    width: 2rem;\n    height: 2rem;\n    border-radius: 0.5rem;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    font-size: 0.75rem;\n    flex-shrink: 0;\n    transition: all 0.2s ease;\n}\n.sidebar-card:hover .sidebar-card-icon {\n    transform: rotate(8deg) scale(1.1);\n}\n\n/* 侧栏 CTA 按钮 */\n.sidebar-cta {\n    text-align: center;\n    padding: 0.375rem 0.5rem;\n    border-radius: 0.5rem;\n    font-size: 0.625rem;\n    font-weight: 700;\n    transition: all 0.2s ease;\n    letter-spacing: 0.02em;\n}\n\n/* 侧栏二维码 */\n.sidebar-qr {\n    width: 100%;\n    max-width: 120px;\n    aspect-ratio: 1;\n    background: white;\n    border: 1px solid #f3f4f6;\n    border-radius: 0.625rem;\n    padding: 0.375rem;\n    transition: all 0.3s ease;\n}\n\n/* 链接样式重置 */\na.sidebar-card {\n    color: inherit;\n}\na.sidebar-card:hover {\n    color: inherit;\n    text-decoration: none;\n}\n\n/* 侧栏标题区引语 */\n.sidebar-quote {\n    max-height: 0;\n    overflow: hidden;\n    opacity: 0;\n    margin-top: 0;\n    transition: max-height 0.4s ease, opacity 0.3s ease, margin-top 0.3s ease;\n}\n.sidebar-header-hover:hover .sidebar-quote {\n    max-height: 3rem;\n    opacity: 1;\n    margin-top: 0.375rem;\n}\n\n/* 可点击的二维码卡片 */\n.sidebar-card-clickable {\n    cursor: pointer;\n    position: relative;\n}\n.sidebar-card-clickable:hover {\n    border-color: #d1d5db;\n    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);\n}\n\n/* 点击放大提示 */\n.sidebar-enlarge-hint {\n    position: absolute;\n    bottom: 0;\n    left: 50%;\n    transform: translateX(-50%) translateY(4px);\n    background: rgba(0, 0, 0, 0.65);\n    color: white;\n    font-size: 0.5625rem;\n    padding: 0.125rem 0.5rem;\n    border-radius: 0.25rem;\n    white-space: nowrap;\n    opacity: 0;\n    transition: all 0.2s ease;\n    pointer-events: none;\n}\n.sidebar-card-clickable:hover .sidebar-enlarge-hint {\n    opacity: 1;\n    transform: translateX(-50%) translateY(-4px);\n}\n"
  },
  {
    "path": "docs/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>TrendRadar 配置文件编辑器</title>\n    <!-- Tailwind CSS -->\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <!-- FontAwesome -->\n    <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css\">\n    <!-- js-yaml -->\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js\"></script>\n    <!-- SortableJS (拖拽排序库) -->\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js\"></script>\n    <!-- 自定义样式 -->\n    <link rel=\"stylesheet\" href=\"./assets/style.css\">\n</head>\n<body class=\"bg-gray-100 text-gray-800 font-sans h-screen flex flex-col overflow-hidden\">\n\n    <!-- 顶部导航 -->\n    <nav class=\"bg-white shadow-sm border-b border-gray-200 flex-shrink-0 z-20\">\n        <div class=\"max-w-full mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between\">\n            <a href=\"https://github.com/sansan0/TrendRadar\" target=\"_blank\" class=\"flex items-center gap-3 hover:opacity-80 transition-opacity\">\n                <i class=\"fa-solid fa-sliders text-blue-600 text-lg\"></i>\n                <span class=\"font-bold text-lg tracking-tight text-gray-900\">TrendRadar <span class=\"text-gray-500 text-xs font-normal ml-2\">可视化配置编辑器 </span></span>\n            </a>\n\n            <!-- 隐私安全提示 -->\n            <div class=\"hidden lg:flex items-center text-xs text-gray-500 bg-gray-50 px-3 py-1.5 rounded-full border border-gray-100 select-none\">\n                <i class=\"fa-solid fa-shield-halved mr-1.5 text-green-500\"></i>\n                <span>纯静态页面，数据仅保存在你的本地浏览器，请放心使用</span>\n            </div>\n\n            <div class=\"flex gap-3\">\n                <button onclick=\"openLoadConfigModal()\" class=\"text-xs text-blue-600 hover:text-blue-800 underline flex items-center gap-1\">\n                    <i class=\"fa-solid fa-cloud-arrow-down\"></i>加载官网最新配置\n                </button>\n                <button onclick=\"copyResult()\" class=\"bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 rounded text-sm font-medium transition-colors shadow-sm\">\n                    <i class=\"fa-regular fa-copy mr-1.5\"></i>复制配置\n                </button>\n            </div>\n        </div>\n    </nav>\n\n    <!-- 主界面：左右分栏 -->\n    <main class=\"flex-grow flex overflow-hidden\">\n\n        <!-- 左侧：源代码编辑器 (Source) -->\n        <div class=\"w-1/2 flex flex-col border-r border-gray-200 bg-[#1e1e1e]\">\n            <!-- Tab 切换 -->\n            <div class=\"flex items-center bg-[#252526] border-b border-[#333]\">\n                <button id=\"tab-config\" onclick=\"switchTab('config')\" class=\"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\">\n                    <i class=\"fa-solid fa-code mr-2\"></i>config.yaml\n                </button>\n                <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\">\n                    <i class=\"fa-solid fa-filter mr-2\"></i>frequency_words.txt\n                </button>\n                <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\">\n                    <i class=\"fa-solid fa-calendar-week mr-2\"></i>timeline.yaml\n                </button>\n                <div class=\"flex-grow\"></div>\n                <!-- 保存时间显示 -->\n                <div id=\"save-time-config\" class=\"save-time-badge px-3 text-[10px] text-gray-500 flex items-center gap-1\">\n                    <i class=\"fa-regular fa-clock\"></i>\n                    <span id=\"config-save-label\" class=\"hidden\">已保存: </span>\n                    <span id=\"config-save-time\" class=\"text-gray-400\" title=\"未保存\">未保存</span>\n                </div>\n                <div id=\"save-time-frequency\" class=\"save-time-badge hidden px-3 text-[10px] text-gray-500 flex items-center gap-1\">\n                    <i class=\"fa-regular fa-clock\"></i>\n                    <span id=\"frequency-save-label\" class=\"hidden\">已保存: </span>\n                    <span id=\"frequency-save-time\" class=\"text-gray-400\" title=\"未保存\">未保存</span>\n                </div>\n                <div id=\"save-time-timeline\" class=\"save-time-badge hidden px-3 text-[10px] text-gray-500 flex items-center gap-1\">\n                    <i class=\"fa-regular fa-clock\"></i>\n                    <span id=\"timeline-save-label\" class=\"hidden\">已保存: </span>\n                    <span id=\"timeline-save-time\" class=\"text-gray-400\" title=\"未保存\">未保存</span>\n                </div>\n            </div>\n\n            <!-- Config 编辑器 -->\n            <div id=\"yaml-editor-wrap\" class=\"tab-content highlight-editor-wrap flex-grow w-full h-full bg-[#1e1e1e]\">\n                <div id=\"yaml-backdrop\" class=\"highlight-backdrop\"></div>\n                <textarea id=\"yaml-editor\" class=\"highlight-textarea\" spellcheck=\"false\"></textarea>\n            </div>\n\n            <!-- Frequency 编辑器 -->\n            <div id=\"frequency-editor-wrap\" class=\"tab-content hidden highlight-editor-wrap flex-grow w-full h-full bg-[#1e1e1e]\">\n                <div id=\"frequency-backdrop\" class=\"highlight-backdrop\"></div>\n                <textarea id=\"frequency-editor\" class=\"highlight-textarea\" spellcheck=\"false\"></textarea>\n            </div>\n\n            <!-- Timeline 编辑器 -->\n            <div id=\"timeline-editor-wrap\" class=\"tab-content hidden highlight-editor-wrap flex-grow w-full h-full bg-[#1e1e1e]\">\n                <div id=\"timeline-backdrop\" class=\"highlight-backdrop\"></div>\n                <textarea id=\"timeline-editor\" class=\"highlight-textarea\" spellcheck=\"false\"></textarea>\n            </div>\n        </div>\n\n        <!-- 右侧：可视化配置 + 支持侧栏 -->\n        <div class=\"w-1/2 flex\">\n        <!-- 可视化配置 (Visual) -->\n        <div class=\"flex-1 flex flex-col bg-gray-50 min-w-0\">\n            <div class=\"flex items-center justify-between px-6 py-3 bg-white border-b border-gray-200\">\n                <div class=\"flex items-center gap-3\">\n                    <span class=\"text-sm font-bold text-gray-700\"><i class=\"fa-solid fa-list-check mr-2\"></i><span id=\"right-panel-title\">配置模块</span></span>\n                    <button id=\"version-check-btn\" onclick=\"checkVersion()\" class=\"text-xs bg-indigo-500 hover:bg-indigo-600 text-white px-3 py-1 rounded shadow-sm transition-all flex items-center gap-1.5\" title=\"检测 config.yaml 版本\">\n                        <i class=\"fa-solid fa-code-compare\"></i>\n                        <span>版本检测</span>\n                    </button>\n                    <button onclick=\"resetToDefault()\" class=\"text-xs text-gray-400 hover:text-red-500 transition-colors px-2 py-1\" title=\"重置当前内容为默认状态\">\n                        <i class=\"fa-solid fa-rotate-left\"></i>\n                    </button>\n                </div>\n            </div>\n\n            <!-- 模块导航栏 -->\n            <div id=\"module-nav\" class=\"tab-content bg-white border-b border-gray-200 px-4 py-2 flex flex-wrap gap-1\">\n            </div>\n\n            <!-- Config 可视化面板 -->\n            <div id=\"config-panel\" class=\"tab-content flex-grow overflow-y-auto p-6 space-y-6\">\n            </div>\n\n            <!-- Frequency 可视化面板 -->\n            <div id=\"frequency-panel\" class=\"tab-content hidden flex-grow overflow-y-auto p-6 space-y-6\">\n            </div>\n\n            <!-- Timeline 可视化面板 -->\n            <div id=\"timeline-panel\" class=\"tab-content hidden flex-grow overflow-y-auto p-6 space-y-6\">\n            </div>\n        </div>\n\n        <!-- 支持侧栏 (固定，不随内容滚动) -->\n        <div class=\"support-sidebar-wrap flex-shrink-0 relative\">\n            <!-- 折叠/展开按钮 (侧栏左边缘) -->\n            <button id=\"sidebar-toggle-btn\" class=\"sidebar-toggle-btn\" onclick=\"toggleSupportSidebar()\" title=\"收起侧栏\">\n                <i class=\"fa-solid fa-chevron-right text-[10px]\"></i>\n            </button>\n            <div id=\"support-sidebar\" class=\"support-sidebar border-l border-gray-200 bg-gradient-to-b from-orange-50/30 via-white to-pink-50/20 flex flex-col\">\n            <!-- 侧栏标题 -->\n            <div class=\"px-3 py-3 border-b border-gray-100 bg-white/80 sidebar-header-hover group/header\">\n                <div class=\"flex items-center gap-2\">\n                    <div class=\"w-6 h-6 bg-gradient-to-br from-orange-400 to-pink-500 rounded-lg flex items-center justify-center\">\n                        <i class=\"fa-solid fa-heart text-white text-[10px]\"></i>\n                    </div>\n                    <span class=\"text-sm font-bold text-gray-700 tracking-tight\">支持项目</span>\n                </div>\n                <p class=\"sidebar-quote text-[10px] text-gray-400 mt-1.5 leading-relaxed italic\">若 TrendRadar 曾为你捕捉价值，不妨为它注入动力，助其持续进化</p>\n            </div>\n\n            <!-- 卡片列表 -->\n            <div class=\"flex-1 p-3 space-y-3 overflow-y-auto sidebar-scroll\">\n\n                <!-- 01: 点亮 Star -->\n                <a href=\"https://github.com/sansan0/TrendRadar\" target=\"_blank\" class=\"sidebar-card group block\">\n                    <div class=\"flex items-center gap-2 mb-2.5\">\n                        <div class=\"sidebar-card-icon bg-orange-100 text-orange-500 group-hover:bg-orange-200\">\n                            <i class=\"fa-solid fa-star\"></i>\n                        </div>\n                        <div class=\"min-w-0\">\n                            <div class=\"text-xs font-bold text-gray-800 leading-tight\">点亮 Star</div>\n                            <div class=\"text-[10px] text-gray-400 leading-tight mt-0.5\">让更多人发现它</div>\n                        </div>\n                    </div>\n                    <div class=\"sidebar-cta bg-gradient-to-r from-orange-400 to-red-500 text-white group-hover:from-orange-500 group-hover:to-red-600 shadow-sm group-hover:shadow-md\">\n                        <i class=\"fa-brands fa-github mr-1\"></i>前往 GitHub\n                    </div>\n                </a>\n\n                <!-- 02: 不迷路 (微信) -->\n                <div class=\"sidebar-card sidebar-card-clickable group\" onclick=\"openQrModal('weixin')\">\n                    <div class=\"flex items-center gap-2 mb-2.5\">\n                        <div class=\"sidebar-card-icon bg-green-100 text-green-600 group-hover:bg-green-200\">\n                            <i class=\"fa-brands fa-weixin\"></i>\n                        </div>\n                        <div class=\"min-w-0\">\n                            <div class=\"text-xs font-bold text-gray-800 leading-tight\">不迷路</div>\n                            <div class=\"text-[10px] text-gray-400 leading-tight mt-0.5\">获取更新通知</div>\n                        </div>\n                    </div>\n                    <div class=\"flex justify-center relative\">\n                        <div class=\"sidebar-qr group-hover:shadow-md\">\n                            <img src=\"./assets/weixin.webp\" alt=\"微信公众号\" class=\"w-full h-full object-contain\">\n                        </div>\n                        <div class=\"sidebar-enlarge-hint\">\n                            <i class=\"fa-solid fa-expand mr-1\"></i>点击放大\n                        </div>\n                    </div>\n                    <p class=\"text-[10px] text-gray-400 text-center mt-2\">扫码关注公众号</p>\n                </div>\n\n                <!-- 03: 随心赞赏 -->\n                <div class=\"sidebar-card sidebar-card-clickable group\" onclick=\"openQrModal('donate')\">\n                    <div class=\"flex items-center gap-2 mb-2.5\">\n                        <div class=\"sidebar-card-icon bg-emerald-100 text-emerald-600 group-hover:bg-emerald-200\">\n                            <i class=\"fa-solid fa-hand-holding-heart\"></i>\n                        </div>\n                        <div class=\"min-w-0\">\n                            <div class=\"text-xs font-bold text-gray-800 leading-tight\">随心赞赏</div>\n                            <div class=\"text-[10px] text-gray-400 leading-tight mt-0.5\">1 元也是鼓励</div>\n                        </div>\n                    </div>\n                    <div class=\"flex justify-center relative\">\n                        <div class=\"sidebar-qr group-hover:shadow-md\">\n                            <img src=\"https://cdn-1258574687.cos.ap-shanghai.myqcloud.com/img/%2F2026%2F01%2F18ecce7c224ce0ea4c59394c29e408f8-e0d1db45.webp\" alt=\"微信支付\" class=\"w-full h-full object-contain\">\n                        </div>\n                        <div class=\"sidebar-enlarge-hint\">\n                            <i class=\"fa-solid fa-expand mr-1\"></i>点击放大\n                        </div>\n                    </div>\n                    <p class=\"text-[10px] text-gray-400 text-center mt-2\">微信扫码 · 丰俭由人</p>\n                </div>\n\n                <!-- 04: 探索更多 -->\n                <a href=\"https://sansan0.github.io/mao-map/\" target=\"_blank\" class=\"sidebar-card group block\">\n                    <div class=\"flex items-center gap-2 mb-2.5\">\n                        <div class=\"sidebar-card-icon bg-red-100 text-red-500 group-hover:bg-red-200\">\n                            <i class=\"fa-solid fa-map-location-dot\"></i>\n                        </div>\n                        <div class=\"min-w-0\">\n                            <div class=\"text-xs font-bold text-gray-800 leading-tight\">探索更多</div>\n                            <div class=\"text-[10px] text-gray-400 leading-tight mt-0.5\">另一个用心的作品</div>\n                        </div>\n                    </div>\n                    <div class=\"sidebar-cta bg-red-50 text-red-600 border border-red-100 group-hover:bg-red-100 group-hover:text-red-700\">\n                        <i class=\"fa-solid fa-arrow-up-right-from-square mr-1\"></i>去看看\n                    </div>\n                </a>\n            </div>\n\n            <!-- 底部寄语 -->\n            <div class=\"px-3 py-2.5 border-t border-gray-100 bg-white/60\">\n                <p class=\"text-[10px] text-gray-300 text-center italic font-serif tracking-wide\">\"开源不易，感谢支持\"</p>\n            </div>\n            </div>\n        </div>\n        </div>\n    </main>\n\n    <!-- RSS 添加弹窗 -->\n    <div id=\"rss-modal\" class=\"modal-overlay hidden\">\n        <div class=\"modal-content\">\n            <div class=\"flex items-center justify-between mb-4\">\n                <h3 class=\"text-lg font-bold text-gray-800\"><i class=\"fa-solid fa-rss mr-2 text-orange-500\"></i>添加 RSS 源</h3>\n                <button onclick=\"closeRssModal()\" class=\"text-gray-400 hover:text-gray-600\"><i class=\"fa-solid fa-times text-xl\"></i></button>\n            </div>\n            <div class=\"space-y-4\">\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">源 ID（唯一标识，英文）</label>\n                    <input type=\"text\" id=\"rss-id\" placeholder=\"例如: my-blog\" class=\"w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500\">\n                </div>\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">显示名称</label>\n                    <input type=\"text\" id=\"rss-name\" placeholder=\"例如: 我的博客\" class=\"w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500\">\n                </div>\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">RSS URL</label>\n                    <input type=\"text\" id=\"rss-url\" placeholder=\"https://example.com/feed.xml\" class=\"w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500\">\n                </div>\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">最大文章年龄（天，可选）</label>\n                    <input type=\"number\" id=\"rss-max-age\" placeholder=\"留空使用全局设置\" class=\"w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500\">\n                </div>\n            </div>\n\n            <!-- RSS 灵感折叠区 -->\n            <div class=\"mt-5 border-t border-gray-100 pt-4\">\n                <button type=\"button\" onclick=\"toggleRssTips()\" class=\"w-full flex items-center justify-between text-xs text-orange-600 hover:text-orange-700 bg-orange-50 hover:bg-orange-100 px-3 py-2 rounded-lg transition-all group\">\n                    <span class=\"font-bold flex items-center gap-1.5\">\n                        <i class=\"fa-regular fa-lightbulb\"></i> RSS 订阅灵感 & 参考库 <span class=\"font-normal opacity-70 ml-1\">(内附常用源)</span>\n                    </span>\n                    <i id=\"rss-tips-icon\" class=\"fa-solid fa-chevron-down transition-transform duration-200 text-orange-400 group-hover:text-orange-600\" style=\"transform: rotate(180deg);\"></i>\n                </button>\n\n                <div id=\"rss-tips-panel\" class=\"mt-2 space-y-3 pl-1\">\n\n                    <!-- 必应新闻 -->\n                    <div class=\"bg-white border border-gray-100 rounded-lg p-3 shadow-sm\">\n                        <div class=\"flex items-center gap-2 mb-2\">\n                            <i class=\"fa-brands fa-microsoft text-blue-500\"></i>\n                            <span class=\"font-bold text-gray-700\">Bing 新闻 (支持任意关键词)</span>\n                        </div>\n                        <div class=\"grid grid-cols-2 gap-2 mb-2\">\n                             <button onclick=\"fillRssUrl('https://www.bing.com/news/search?q=科技+编程&format=RSS')\" class=\"text-left text-[10px] border border-gray-200 hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 rounded px-2 py-1.5 transition-colors truncate\" title=\"点击填入\">\n                                🚀 科技/编程\n                            </button>\n                             <button onclick=\"fillRssUrl('https://www.bing.com/news/search?q=全球新闻&format=RSS')\" class=\"text-left text-[10px] border border-gray-200 hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 rounded px-2 py-1.5 transition-colors truncate\" title=\"点击填入\">\n                                🌍 全球新闻\n                            </button>\n                             <button onclick=\"fillRssUrl('https://www.bing.com/news/search?q=人工智能&format=RSS')\" class=\"text-left text-[10px] border border-gray-200 hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 rounded px-2 py-1.5 transition-colors truncate\" title=\"点击填入\">\n                                🤖 人工智能\n                            </button>\n                             <button onclick=\"fillRssUrl('https://www.bing.com/news/search?q=黄金价格+走势&format=RSS')\" class=\"text-left text-[10px] border border-gray-200 hover:border-blue-400 hover:bg-blue-50 hover:text-blue-600 rounded px-2 py-1.5 transition-colors truncate\" title=\"点击填入\">\n                                💰 黄金/财经\n                            </button>\n                        </div>\n                        <div class=\"text-[10px] text-gray-400\">\n                            💡 小贴士：修改 URL 中的 <code class=\"bg-gray-100 px-1 rounded text-gray-600\">q=</code> 参数即可监控任何你感兴趣的话题。\n                        </div>\n                    </div>\n\n\n                    <!-- 更多参考 -->\n                    <div class=\"bg-white border border-gray-100 rounded-lg p-3 shadow-sm\">\n                        <div class=\"flex items-center gap-2 mb-2\">\n                            <i class=\"fa-solid fa-book-open text-purple-500\"></i>\n                            <span class=\"font-bold text-gray-700\">更多 RSS 源参考</span>\n                        </div>\n                        <div class=\"flex flex-wrap gap-2 text-xs\">\n                             <a href=\"https://github.com/tuan3w/awesome-tech-rss\" target=\"_blank\" class=\"text-blue-600 hover:underline flex items-center bg-blue-50 px-2 py-1 rounded\">\n                                <i class=\"fa-brands fa-github mr-1\"></i>科技/编程\n                            </a>\n                             <a href=\"https://github.com/plenaryapp/awesome-rss-feeds\" target=\"_blank\" class=\"text-blue-600 hover:underline flex items-center bg-blue-50 px-2 py-1 rounded\">\n                                <i class=\"fa-brands fa-github mr-1\"></i>全球新闻\n                            </a>\n                        </div>\n                    </div>\n\n                    <!-- 免责声明 -->\n                    <div class=\"text-[10px] text-gray-400 italic leading-relaxed px-1\">\n                        <i class=\"fa-solid fa-shield-halved mr-1 text-gray-300\"></i>免责声明：以上 RSS 示例及第三方工具均源自互联网，开发者未一一验证其长期有效性，请你在使用前自行核实。\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"flex justify-end gap-2 mt-6\">\n                <button onclick=\"closeRssModal()\" class=\"px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg\">取消</button>\n                <button onclick=\"confirmAddRss()\" class=\"px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700\">添加</button>\n            </div>\n        </div>\n    </div>\n\n    <!-- 平台添加弹窗 -->\n    <div id=\"platform-modal\" class=\"modal-overlay hidden\">\n        <div class=\"modal-content\">\n            <div class=\"flex items-center justify-between mb-4\">\n                <h3 class=\"text-lg font-bold text-gray-800\"><i class=\"fa-solid fa-layer-group mr-2 text-green-600\"></i>添加热榜平台</h3>\n                <button onclick=\"closePlatformModal()\" class=\"text-gray-400 hover:text-gray-600\"><i class=\"fa-solid fa-times text-xl\"></i></button>\n            </div>\n\n            <!-- 标签页切换 -->\n            <div class=\"flex border-b border-gray-200 mb-4\">\n                <button onclick=\"switchPlatformTab('select')\" id=\"tab-platform-select\" class=\"flex-1 py-2 text-sm font-bold text-blue-600 border-b-2 border-blue-600 transition-colors\">\n                    <i class=\"fa-solid fa-list mr-1\"></i>选择预设\n                </button>\n                <button onclick=\"switchPlatformTab('custom')\" id=\"tab-platform-custom\" class=\"flex-1 py-2 text-sm font-bold text-gray-500 border-b-2 border-transparent hover:text-gray-700 transition-colors\">\n                    <i class=\"fa-solid fa-pen-to-square mr-1\"></i>手动输入\n                </button>\n            </div>\n\n            <!-- 1. 选择预设平台 -->\n            <div id=\"platform-select-panel\" class=\"space-y-4\">\n                <div id=\"available-platforms-list\" class=\"space-y-2 max-h-60 overflow-y-auto pr-1\">\n                    <!-- 动态生成可用平台 -->\n                </div>\n                <div id=\"no-platforms-tip\" class=\"hidden text-center py-6 text-gray-500 text-sm bg-gray-50 rounded\">\n                    <i class=\"fa-solid fa-check-circle text-green-500 mr-2\"></i>所有预设平台已添加\n                </div>\n            </div>\n\n            <!-- 2. 手动输入平台 -->\n            <div id=\"platform-custom-panel\" class=\"hidden space-y-4\">\n                <div class=\"bg-blue-50 border border-blue-100 rounded p-3 mb-3 text-xs text-blue-800\">\n                    <i class=\"fa-solid fa-info-circle mr-1\"></i>自定义平台需要后端爬虫支持，此处仅用于配置占位。\n                </div>\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">平台 Key（英文）</label>\n                    <input type=\"text\" id=\"custom-platform-key\" placeholder=\"例如: sspai\" class=\"w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500\">\n                </div>\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">显示名称</label>\n                    <input type=\"text\" id=\"custom-platform-name\" placeholder=\"例如: 少数派\" class=\"w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500\">\n                </div>\n            </div>\n\n            <div class=\"flex justify-end gap-2 mt-6\">\n                <button onclick=\"closePlatformModal()\" class=\"px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg\">取消</button>\n                <button onclick=\"confirmAddPlatform()\" class=\"px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700\">添加</button>\n            </div>\n        </div>\n    </div>\n\n    <!-- 词组类型选择弹窗 -->\n    <div id=\"wordgroup-type-modal\" class=\"modal-overlay hidden\">\n        <div class=\"modal-content max-w-2xl\">\n            <div class=\"flex items-center justify-between mb-4\">\n                <h3 class=\"text-lg font-bold text-gray-800\"><i class=\"fa-solid fa-layer-group mr-2 text-blue-500\"></i>选择词组类型</h3>\n                <button onclick=\"closeWordGroupTypeModal()\" class=\"text-gray-400 hover:text-gray-600\"><i class=\"fa-solid fa-times text-xl\"></i></button>\n            </div>\n            <div class=\"space-y-3\">\n                <!-- 组别名类型 -->\n                <div onclick=\"confirmAddWordGroup('group')\" class=\"cursor-pointer border-2 border-orange-200 bg-orange-50 rounded-lg p-4 hover:border-orange-400 hover:bg-orange-100 transition-all\">\n                    <div class=\"flex items-center gap-3\">\n                        <span class=\"text-xs bg-orange-500 text-white px-2 py-1 rounded font-bold\">组别名</span>\n                        <span class=\"font-bold text-gray-800\">多关键词词组（推荐）</span>\n                    </div>\n                    <div class=\"mt-2 text-sm text-gray-600\">\n                        <div class=\"font-mono bg-white rounded p-2 text-xs border border-orange-200\">\n                            <div class=\"text-orange-600\">[东亚]</div>\n                            <div>日本</div>\n                            <div>韩国</div>\n                            <div>朝鲜</div>\n                        </div>\n                        <div class=\"mt-2 text-xs text-gray-500\">\n                            <i class=\"fa-solid fa-check-circle text-orange-500 mr-1\"></i>适用于：多个关键词归为一组，统一显示为组名\n                        </div>\n                    </div>\n                </div>\n                <!-- 单个别名类型 -->\n                <div onclick=\"confirmAddWordGroup('alias')\" class=\"cursor-pointer border-2 border-teal-200 bg-teal-50 rounded-lg p-4 hover:border-teal-400 hover:bg-teal-100 transition-all\">\n                    <div class=\"flex items-center gap-3\">\n                        <span class=\"text-xs bg-teal-500 text-white px-2 py-1 rounded font-bold\">单个别名</span>\n                        <span class=\"font-bold text-gray-800\">正则/关键词 + 别名</span>\n                    </div>\n                    <div class=\"mt-2 text-sm text-gray-600\">\n                        <div class=\"font-mono bg-white rounded p-2 text-xs border border-teal-200\">\n                            <div>/胖东来|于东来/ <span class=\"text-teal-600\">=></span> 胖东来</div>\n                        </div>\n                        <div class=\"mt-2 text-xs text-gray-500\">\n                            <i class=\"fa-solid fa-check-circle text-teal-500 mr-1\"></i>适用于：用正则匹配多个词，显示为一个别名（前后有空行分隔）\n                        </div>\n                    </div>\n                </div>\n                <!-- 连续别名类型 -->\n                <div onclick=\"confirmAddWordGroup('multi-alias')\" class=\"cursor-pointer border-2 border-purple-200 bg-purple-50 rounded-lg p-4 hover:border-purple-400 hover:bg-purple-100 transition-all\">\n                    <div class=\"flex items-center gap-3\">\n                        <span class=\"text-xs bg-purple-500 text-white px-2 py-1 rounded font-bold\">连续别名组</span>\n                        <span class=\"font-bold text-gray-800\">多个相关品牌/词组</span>\n                    </div>\n                    <div class=\"mt-2 text-sm text-gray-600\">\n                        <div class=\"font-mono bg-white rounded p-2 text-xs border border-purple-200\">\n                            <div>/智元|灵犀|稚晖君/ <span class=\"text-purple-600\">=></span> 智元机器人</div>\n                            <div>/众擎|EngineAI/ <span class=\"text-purple-600\">=></span> 众擎机器人</div>\n                        </div>\n                        <div class=\"mt-2 text-xs text-gray-500\">\n                            <i class=\"fa-solid fa-check-circle text-purple-500 mr-1\"></i>适用于：多个相关品牌放在一起（<strong>无空行分隔</strong>）\n                        </div>\n                    </div>\n                </div>\n                <!-- 普通词组类型 -->\n                <div onclick=\"confirmAddWordGroup('plain')\" class=\"cursor-pointer border-2 border-gray-200 bg-gray-50 rounded-lg p-4 hover:border-gray-400 hover:bg-gray-100 transition-all\">\n                    <div class=\"flex items-center gap-3\">\n                        <span class=\"text-xs bg-gray-500 text-white px-2 py-1 rounded font-bold\">普通词组</span>\n                        <span class=\"font-bold text-gray-800\">简单关键词</span>\n                    </div>\n                    <div class=\"mt-2 text-sm text-gray-600\">\n                        <div class=\"font-mono bg-white rounded p-2 text-xs border border-gray-200\">\n                            <div>申奥</div>\n                        </div>\n                        <div class=\"mt-2 text-xs text-gray-500\">\n                            <i class=\"fa-solid fa-check-circle text-gray-500 mr-1\"></i>适用于：单个或少量普通关键词\n                        </div>\n                    </div>\n                </div>\n            </div>\n            <div class=\"flex justify-end gap-2 mt-6\">\n                <button onclick=\"closeWordGroupTypeModal()\" class=\"px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg\">取消</button>\n            </div>\n        </div>\n    </div>\n\n    <!-- 二维码放大弹窗 -->\n    <div id=\"qr-modal\" class=\"modal-overlay hidden\" onclick=\"if(event.target===this){closeQrModal()}\">\n        <div class=\"modal-content support-modal-content max-w-sm w-[90%] p-6 text-center\">\n            <div class=\"flex items-center justify-between mb-5\">\n                <div class=\"flex items-center gap-3\">\n                    <div id=\"qr-modal-icon\" class=\"w-10 h-10 rounded-xl flex items-center justify-center text-lg\"></div>\n                    <div class=\"text-left\">\n                        <h3 id=\"qr-modal-title\" class=\"text-lg font-bold text-gray-800\"></h3>\n                        <p id=\"qr-modal-subtitle\" class=\"text-xs text-gray-500 mt-0.5\"></p>\n                    </div>\n                </div>\n                <button onclick=\"closeQrModal()\" class=\"w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 text-gray-400 transition-colors\">\n                    <i class=\"fa-solid fa-times\"></i>\n                </button>\n            </div>\n            <div class=\"flex justify-center\">\n                <div class=\"w-56 h-56 bg-white border border-gray-100 rounded-2xl p-3 shadow-sm\">\n                    <img id=\"qr-modal-img\" src=\"\" alt=\"\" class=\"w-full h-full object-contain\">\n                </div>\n            </div>\n            <p id=\"qr-modal-hint\" class=\"text-xs text-gray-400 mt-4\"></p>\n        </div>\n    </div>\n\n    <!-- 新建调度模式弹窗 -->\n    <div id=\"tl-new-preset-modal\" class=\"modal-overlay hidden\">\n        <div class=\"modal-content\">\n            <div class=\"flex items-center justify-between mb-4\">\n                <h3 class=\"text-lg font-bold text-gray-800\"><i class=\"fa-solid fa-calendar-plus mr-2 text-purple-600\"></i>新建调度模式</h3>\n                <button onclick=\"closeTlNewPresetModal()\" class=\"text-gray-400 hover:text-gray-600\"><i class=\"fa-solid fa-times text-xl\"></i></button>\n            </div>\n            <div class=\"space-y-4\">\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">模式标识 (key)</label>\n                    <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\">\n                    <p class=\"text-[10px] text-gray-400 mt-1\">仅支持英文、数字和下划线，将作为 YAML 中的 key</p>\n                </div>\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">显示名称</label>\n                    <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\">\n                </div>\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">描述（可选）</label>\n                    <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\">\n                </div>\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">基于模板</label>\n                    <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\">\n                        <option value=\"\">空白模板（仅采集，不推送不分析）</option>\n                    </select>\n                    <p class=\"text-[10px] text-gray-400 mt-1\">复制已有模式的全部配置作为起点</p>\n                </div>\n            </div>\n            <div class=\"flex justify-end gap-2 mt-6\">\n                <button onclick=\"closeTlNewPresetModal()\" class=\"px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg\">取消</button>\n                <button onclick=\"confirmTlNewPreset()\" class=\"px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700\">创建</button>\n            </div>\n        </div>\n    </div>\n\n    <!-- 新增时间段弹窗 -->\n    <div id=\"tl-new-period-modal\" class=\"modal-overlay hidden\">\n        <div class=\"modal-content\">\n            <div class=\"flex items-center justify-between mb-4\">\n                <h3 class=\"text-lg font-bold text-gray-800\"><i class=\"fa-solid fa-clock-rotate-left mr-2 text-blue-600\"></i>新增时间段</h3>\n                <button onclick=\"closeTlNewPeriodModal()\" class=\"text-gray-400 hover:text-gray-600\"><i class=\"fa-solid fa-times text-xl\"></i></button>\n            </div>\n            <div class=\"space-y-4\">\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">时间段标识 (key)</label>\n                    <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\">\n                    <p class=\"text-[10px] text-gray-400 mt-1\">仅支持英文、数字和下划线</p>\n                </div>\n                <div>\n                    <label class=\"block text-xs font-bold text-gray-600 mb-1\">显示名称</label>\n                    <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\">\n                </div>\n                <div class=\"grid grid-cols-2 gap-4\">\n                    <div>\n                        <label class=\"block text-xs font-bold text-gray-600 mb-1\">开始时间</label>\n                        <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\">\n                    </div>\n                    <div>\n                        <label class=\"block text-xs font-bold text-gray-600 mb-1\">结束时间</label>\n                        <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\">\n                    </div>\n                </div>\n                <div class=\"bg-blue-50 border border-blue-100 rounded p-3 text-xs text-blue-700\">\n                    <i class=\"fa-solid fa-info-circle mr-1\"></i>如果开始时间 > 结束时间（如 22:00～01:00），将自动识别为跨午夜时间段。\n                </div>\n            </div>\n            <div class=\"flex justify-end gap-2 mt-6\">\n                <button onclick=\"closeTlNewPeriodModal()\" class=\"px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg\">取消</button>\n                <button onclick=\"confirmTlNewPeriod()\" class=\"px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700\">添加</button>\n            </div>\n        </div>\n    </div>\n\n    <script src=\"./assets/script.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "index.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>热点新闻分析</title>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js\" integrity=\"sha512-BNaRQnYJYiPSqHHDb58B0yaPfCu+Wgds8Gp/gU33kqBtgNS4tSPHuGibyoeqMV/TJlSKda6FXzoEyYGjTe+vXA==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n    <style>\n        * {\n            box-sizing: border-box;\n        }\n        body {\n            font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, sans-serif;\n            margin: 0;\n            padding: 16px;\n            background: #fafafa;\n            color: #333;\n            line-height: 1.5;\n        }\n\n        .container {\n            max-width: 600px;\n            margin: 0 auto;\n            background: white;\n            border-radius: 12px;\n            overflow: hidden;\n            box-shadow: 0 2px 16px rgba(0, 0, 0, 0.06);\n        }\n\n        .header {\n            background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);\n            color: white;\n            padding: 32px 24px;\n            text-align: center;\n            position: relative;\n        }\n\n        .save-buttons {\n            position: absolute;\n            top: 16px;\n            right: 16px;\n            display: flex;\n            gap: 8px;\n        }\n\n        .save-btn {\n            background: rgba(255, 255, 255, 0.2);\n            border: 1px solid rgba(255, 255, 255, 0.3);\n            color: white;\n            padding: 8px 16px;\n            border-radius: 6px;\n            cursor: pointer;\n            font-size: 13px;\n            font-weight: 500;\n            transition: all 0.2s ease;\n            backdrop-filter: blur(10px);\n            white-space: nowrap;\n        }\n\n        .save-btn:hover {\n            background: rgba(255, 255, 255, 0.3);\n            border-color: rgba(255, 255, 255, 0.5);\n            transform: translateY(-1px);\n        }\n\n        .save-btn:active {\n            transform: translateY(0);\n        }\n\n        .save-btn:disabled {\n            opacity: 0.6;\n            cursor: not-allowed;\n        }\n\n        .header-title {\n            font-size: 22px;\n            font-weight: 700;\n            margin: 0 0 20px 0;\n        }\n\n        .header-info {\n            display: grid;\n            grid-template-columns: 1fr 1fr;\n            gap: 16px;\n            font-size: 14px;\n            opacity: 0.95;\n        }\n\n        .info-item {\n            text-align: center;\n        }\n\n        .info-label {\n            display: block;\n            font-size: 12px;\n            opacity: 0.8;\n            margin-bottom: 4px;\n        }\n\n        .info-value {\n            font-weight: 600;\n            font-size: 16px;\n        }\n\n        .content {\n            padding: 24px;\n        }\n\n        .word-group {\n            margin-bottom: 40px;\n        }\n\n        .word-group:first-child {\n            margin-top: 0;\n        }\n\n        .word-header {\n            display: flex;\n            align-items: center;\n            justify-content: space-between;\n            margin-bottom: 20px;\n            padding-bottom: 8px;\n            border-bottom: 1px solid #f0f0f0;\n        }\n\n        .word-info {\n            display: flex;\n            align-items: center;\n            gap: 12px;\n        }\n\n        .word-name {\n            font-size: 17px;\n            font-weight: 600;\n            color: #1a1a1a;\n        }\n\n        .word-count {\n            color: #666;\n            font-size: 13px;\n            font-weight: 500;\n        }\n\n        .word-count.hot {\n            color: #dc2626;\n            font-weight: 600;\n        }\n\n        .word-count.warm {\n            color: #ea580c;\n            font-weight: 600;\n        }\n\n        .word-index {\n            color: #999;\n            font-size: 12px;\n        }\n\n        .news-item {\n            margin-bottom: 20px;\n            padding: 16px 0;\n            border-bottom: 1px solid #f5f5f5;\n            position: relative;\n            display: flex;\n            gap: 12px;\n            align-items: center;\n        }\n\n        .news-item:last-child {\n            border-bottom: none;\n        }\n\n        .news-number {\n            color: #999;\n            font-size: 13px;\n            font-weight: 600;\n            min-width: 20px;\n            text-align: center;\n            flex-shrink: 0;\n            background: #f8f9fa;\n            border-radius: 50%;\n            width: 24px;\n            height: 24px;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            align-self: flex-start;\n            margin-top: 8px;\n        }\n\n        .news-content {\n            flex: 1;\n            min-width: 0;\n        }\n\n        .news-header {\n            display: flex;\n            align-items: center;\n            gap: 8px;\n            margin-bottom: 8px;\n            flex-wrap: wrap;\n        }\n\n        .source-name {\n            color: #666;\n            font-size: 12px;\n            font-weight: 500;\n        }\n\n        .rank-num {\n            color: #fff;\n            background: #6b7280;\n            font-size: 10px;\n            font-weight: 700;\n            padding: 2px 6px;\n            border-radius: 10px;\n            min-width: 18px;\n            text-align: center;\n        }\n\n        .rank-num.top {\n            background: #dc2626;\n        }\n\n        .rank-num.high {\n            background: #ea580c;\n        }\n\n        .time-info {\n            color: #999;\n            font-size: 11px;\n        }\n\n        .count-info {\n            color: #059669;\n            font-size: 11px;\n            font-weight: 500;\n        }\n\n        .news-title {\n            font-size: 15px;\n            line-height: 1.4;\n            color: #1a1a1a;\n            margin: 0;\n        }\n\n        .news-link {\n            color: #2563eb;\n            text-decoration: none;\n        }\n\n        .news-link:hover {\n            text-decoration: underline;\n        }\n\n        .news-link:visited {\n            color: #7c3aed;\n        }\n\n        .footer {\n            margin-top: 32px;\n            padding: 20px 24px;\n            background: #f8f9fa;\n            border-top: 1px solid #e5e7eb;\n            text-align: center;\n        }\n\n        .footer-content {\n            font-size: 13px;\n            color: #6b7280;\n            line-height: 1.6;\n        }\n\n        .footer-link {\n            color: #4f46e5;\n            text-decoration: none;\n            font-weight: 500;\n            transition: color 0.2s ease;\n        }\n\n        .footer-link:hover {\n            color: #7c3aed;\n            text-decoration: underline;\n        }\n\n        .project-name {\n            font-weight: 600;\n            color: #374151;\n        }\n\n        @media (max-width: 480px) {\n            body {\n                padding: 12px;\n            }\n            .header {\n                padding: 24px 20px;\n            }\n            .content {\n                padding: 20px;\n            }\n            .footer {\n                padding: 16px 20px;\n            }\n            .header-info {\n                grid-template-columns: 1fr;\n                gap: 12px;\n            }\n            .news-header {\n                gap: 6px;\n            }\n            .news-item {\n                gap: 8px;\n            }\n            .news-number {\n                width: 20px;\n                height: 20px;\n                font-size: 12px;\n            }\n            .save-buttons {\n                position: static;\n                margin-bottom: 16px;\n                display: flex;\n                gap: 8px;\n                justify-content: center;\n                flex-direction: column;\n                width: 100%;\n            }\n            .save-btn {\n                width: 100%;\n            }\n        }\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        <div class=\"header\">\n            <div class=\"save-buttons\">\n                <button class=\"save-btn\" onclick=\"saveAsImage()\">保存为图片</button>\n                <button class=\"save-btn\" onclick=\"saveAsMultipleImages()\">分段保存</button>\n            </div>\n            <div class=\"header-title\">热点新闻分析</div>\n            <div class=\"header-info\">\n                <div class=\"info-item\">\n                    <span class=\"info-label\">报告类型</span>\n                    <span class=\"info-value\">当日汇总</span>\n                </div>\n                <div class=\"info-item\">\n                    <span class=\"info-label\">新闻总数</span>\n                    <span class=\"info-value\">387 条</span>\n                </div>\n                <div class=\"info-item\">\n                    <span class=\"info-label\">热点新闻</span>\n                    <span class=\"info-value\">5 条</span>\n                </div>\n                <div class=\"info-item\">\n                    <span class=\"info-label\">生成时间</span>\n                    <span class=\"info-value\">06-16 07:17</span>\n                </div>\n            </div>\n        </div>\n\n        <div class=\"content\">\n            <div class=\"word-group\">\n                <div class=\"word-header\">\n                    <div class=\"word-info\">\n                        <div class=\"word-name\">ai 人工智能</div>\n                        <div class=\"word-count hot\">3 条</div>\n                    </div>\n                    <div class=\"word-index\">1/4</div>\n                </div>\n\n                <div class=\"news-item\">\n                    <div class=\"news-number\">1</div>\n                    <div class=\"news-content\">\n                        <div class=\"news-header\">\n                            <span class=\"source-name\">财联社热门</span>\n                            <span class=\"rank-num high\">7-8</span>\n                            <span class=\"time-info\">00:23~07:17</span>\n                            <span class=\"count-info\">15次</span>\n                        </div>\n                        <div class=\"news-title\">\n                            <a href=\"https://www.cls.cn/detail/2057563\" target=\"_blank\" class=\"news-link\">上市首日暴涨140% 军用无人机公司登陆纽交所 AI打造产品核心竞争力</a>\n                        </div>\n                    </div>\n                </div>\n\n                <div class=\"news-item\">\n                    <div class=\"news-number\">2</div>\n                    <div class=\"news-content\">\n                        <div class=\"news-header\">\n                            <span class=\"source-name\">tieba</span>\n                            <span class=\"rank-num\">18-19</span>\n                            <span class=\"time-info\">00:23~07:17</span>\n                            <span class=\"count-info\">15次</span>\n                        </div>\n                        <div class=\"news-title\">\n                            <a href=\"https://tieba.baidu.com/hottopic/browse/hottopic?topic_id=28342819&topic_name=%E4%BC%8A%E6%9C%97%E7%96%91%E7%94%A8AI%E4%BC%AA%E9%80%A0%E4%BB%A5%E5%86%9BF35%E6%AE%8B%E9%AA%B8%E5%9B%BE\" target=\"_blank\" class=\"news-link\">伊朗疑用AI伪造以军F35残骸图</a>\n                        </div>\n                    </div>\n                </div>\n\n                <div class=\"news-item\">\n                    <div class=\"news-number\">3</div>\n                    <div class=\"news-content\">\n                        <div class=\"news-header\">\n                            <span class=\"source-name\">zhihu</span>\n                            <span class=\"rank-num top\">5-13</span>\n                            <span class=\"time-info\">00:23~07:17</span>\n                            <span class=\"count-info\">15次</span>\n                        </div>\n                        <div class=\"news-title\">\n                            <a href=\"https://www.zhihu.com/question/596907281\" target=\"_blank\" class=\"news-link\">罗杰·彭罗斯说无论意识是什么，都绝对不是一种计算。意思是：任何 AI 都不可能产生意识？</a>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"word-group\">\n                <div class=\"word-header\">\n                    <div class=\"word-info\">\n                        <div class=\"word-name\">DeepSeek 梁文锋</div>\n                        <div class=\"word-count\">1 条</div>\n                    </div>\n                    <div class=\"word-index\">2/4</div>\n                </div>\n\n                <div class=\"news-item\">\n                    <div class=\"news-number\">1</div>\n                    <div class=\"news-content\">\n                        <div class=\"news-header\">\n                            <span class=\"source-name\">华尔街见闻</span>\n                            <span class=\"rank-num high\">8-9</span>\n                            <span class=\"time-info\">00:23~07:17</span>\n                            <span class=\"count-info\">15次</span>\n                        </div>\n                        <div class=\"news-title\">\n                            <a href=\"https://wallstreetcn.com/articles/3749141\" target=\"_blank\" class=\"news-link\">恒生生科指数1月以来涨超60%，中国创新药的\"DeepSeek时刻\"超过了AI</a>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"word-group\">\n                <div class=\"word-header\">\n                    <div class=\"word-info\">\n                        <div class=\"word-name\">哪吒 饺子</div>\n                        <div class=\"word-count\">1 条</div>\n                    </div>\n                    <div class=\"word-index\">3/4</div>\n                </div>\n\n                <div class=\"news-item\">\n                    <div class=\"news-number\">1</div>\n                    <div class=\"news-content\">\n                        <div class=\"news-header\">\n                            <span class=\"source-name\">百度热搜</span>\n                            <span class=\"rank-num\">24-30</span>\n                            <span class=\"time-info\">00:57~06:55</span>\n                            <span class=\"count-info\">7次</span>\n                        </div>\n                        <div class=\"news-title\">\n                            <a href=\"https://www.baidu.com/s?wd=%E3%80%8A%E5%93%AA%E5%90%922%E3%80%8B%E7%89%87%E6%96%B9%E6%88%96%E5%88%86%E8%B4%A652%E4%BA%BF%E5%85%83\" target=\"_blank\" class=\"news-link\">《哪吒2》片方或分账52亿元</a>\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"word-group\">\n                <div class=\"word-header\">\n                    <div class=\"word-info\">\n                        <div class=\"word-name\">米哈游 原神 星穹铁道</div>\n                        <div class=\"word-count\">1 条</div>\n                    </div>\n                    <div class=\"word-index\">4/4</div>\n                </div>\n\n                <div class=\"news-item\">\n                    <div class=\"news-number\">1</div>\n                    <div class=\"news-content\">\n                        <div class=\"news-header\">\n                            <span class=\"source-name\">zhihu</span>\n                            <span class=\"rank-num top\">5</span>\n                            <span class=\"time-info\">06:55~07:17</span>\n                            <span class=\"count-info\">2次</span>\n                        </div>\n                        <div class=\"news-title\">\n                            <a href=\"https://www.zhihu.com/question/1905395386765537540\" target=\"_blank\" class=\"news-link\">目前原神所有自机角色谁最有可能出新形态?</a>\n                        </div>\n                    </div>\n                </div>\n            </div>\n        </div>\n\n        <div class=\"footer\">\n            <div class=\"footer-content\">\n                由 <span class=\"project-name\">TrendRadar</span> 生成 · \n                <a href=\"https://github.com/sansan0/TrendRadar\" target=\"_blank\" class=\"footer-link\">\n                    GitHub 开源项目\n                </a>\n            </div>\n        </div>\n    </div>\n\n    <script>\n        async function saveAsImage() {\n            const button = event.target;\n            const originalText = button.textContent;\n            \n            try {\n                button.textContent = '生成中...';\n                button.disabled = true;\n                window.scrollTo(0, 0);\n                \n                await new Promise(resolve => setTimeout(resolve, 200));\n                \n                const buttons = document.querySelector('.save-buttons');\n                buttons.style.visibility = 'hidden';\n                \n                await new Promise(resolve => setTimeout(resolve, 100));\n                \n                const container = document.querySelector('.container');\n                \n                const canvas = await html2canvas(container, {\n                    backgroundColor: '#ffffff',\n                    scale: 1.5,\n                    useCORS: true,\n                    allowTaint: false,\n                    imageTimeout: 10000,\n                    removeContainer: false,\n                    foreignObjectRendering: false,\n                    logging: false,\n                    width: container.offsetWidth,\n                    height: container.offsetHeight,\n                    x: 0,\n                    y: 0,\n                    scrollX: 0,\n                    scrollY: 0,\n                    windowWidth: window.innerWidth,\n                    windowHeight: window.innerHeight\n                });\n                \n                buttons.style.visibility = 'visible';\n                \n                const link = document.createElement('a');\n                const now = new Date();\n                const filename = `TrendRadar_热点新闻分析_${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}.png`;\n                \n                link.download = filename;\n                link.href = canvas.toDataURL('image/png', 1.0);\n                \n                document.body.appendChild(link);\n                link.click();\n                document.body.removeChild(link);\n                \n                button.textContent = '保存成功!';\n                setTimeout(() => {\n                    button.textContent = originalText;\n                    button.disabled = false;\n                }, 2000);\n                \n            } catch (error) {\n                const buttons = document.querySelector('.save-buttons');\n                buttons.style.visibility = 'visible';\n                button.textContent = '保存失败';\n                setTimeout(() => {\n                    button.textContent = originalText;\n                    button.disabled = false;\n                }, 2000);\n            }\n        }\n        \n        async function saveAsMultipleImages() {\n            const button = event.target;\n            const originalText = button.textContent;\n            const container = document.querySelector('.container');\n            const scale = 1.5;\n            const maxHeight = 5000 / scale;\n            \n            try {\n                button.textContent = '分析中...';\n                button.disabled = true;\n                \n                const wordGroups = Array.from(container.querySelectorAll('.word-group'));\n                const header = container.querySelector('.header');\n                const footer = container.querySelector('.footer');\n                \n                const containerRect = container.getBoundingClientRect();\n                const elements = [];\n                \n                elements.push({\n                    type: 'header',\n                    element: header,\n                    top: 0,\n                    bottom: header.offsetHeight,\n                    height: header.offsetHeight\n                });\n                \n                wordGroups.forEach(group => {\n                    const groupRect = group.getBoundingClientRect();\n                    const wordHeader = group.querySelector('.word-header');\n                    if (wordHeader) {\n                        const headerRect = wordHeader.getBoundingClientRect();\n                        elements.push({\n                            type: 'word-header',\n                            top: groupRect.top - containerRect.top,\n                            bottom: headerRect.bottom - containerRect.top,\n                            height: headerRect.height\n                        });\n                    }\n                    \n                    group.querySelectorAll('.news-item').forEach(item => {\n                        const rect = item.getBoundingClientRect();\n                        elements.push({\n                            type: 'news-item',\n                            top: rect.top - containerRect.top,\n                            bottom: rect.bottom - containerRect.top,\n                            height: rect.height\n                        });\n                    });\n                });\n                \n                const footerRect = footer.getBoundingClientRect();\n                elements.push({\n                    type: 'footer',\n                    top: footerRect.top - containerRect.top,\n                    bottom: footerRect.bottom - containerRect.top,\n                    height: footer.offsetHeight\n                });\n                \n                const segments = [];\n                let currentSegment = { start: 0, end: 0, height: 0 };\n                let headerHeight = header.offsetHeight;\n                currentSegment.height = headerHeight;\n                \n                for (let i = 1; i < elements.length; i++) {\n                    const element = elements[i];\n                    const potentialHeight = element.bottom - currentSegment.start;\n                    \n                    if (potentialHeight > maxHeight && currentSegment.height > headerHeight) {\n                        currentSegment.end = elements[i - 1].bottom;\n                        segments.push(currentSegment);\n                        \n                        currentSegment = {\n                            start: currentSegment.end,\n                            end: 0,\n                            height: element.bottom - currentSegment.end\n                        };\n                    } else {\n                        currentSegment.height = potentialHeight;\n                        currentSegment.end = element.bottom;\n                    }\n                }\n                \n                if (currentSegment.height > 0) {\n                    currentSegment.end = container.offsetHeight;\n                    segments.push(currentSegment);\n                }\n                \n                button.textContent = `生成中 (0/${segments.length})...`;\n                \n                const buttons = document.querySelector('.save-buttons');\n                buttons.style.visibility = 'hidden';\n                \n                const images = [];\n                for (let i = 0; i < segments.length; i++) {\n                    const segment = segments[i];\n                    button.textContent = `生成中 (${i + 1}/${segments.length})...`;\n                    \n                    const tempContainer = document.createElement('div');\n                    tempContainer.style.cssText = `\n                        position: absolute;\n                        left: -9999px;\n                        top: 0;\n                        width: ${container.offsetWidth}px;\n                        background: white;\n                    `;\n                    \n                    const clonedContainer = container.cloneNode(true);\n                    const clonedButtons = clonedContainer.querySelector('.save-buttons');\n                    if (clonedButtons) {\n                        clonedButtons.style.display = 'none';\n                    }\n                    \n                    tempContainer.appendChild(clonedContainer);\n                    document.body.appendChild(tempContainer);\n                    \n                    await new Promise(resolve => setTimeout(resolve, 100));\n                    \n                    const canvas = await html2canvas(clonedContainer, {\n                        backgroundColor: '#ffffff',\n                        scale: scale,\n                        useCORS: true,\n                        allowTaint: false,\n                        imageTimeout: 10000,\n                        logging: false,\n                        width: container.offsetWidth,\n                        height: segment.end - segment.start,\n                        x: 0,\n                        y: segment.start,\n                        windowWidth: window.innerWidth,\n                        windowHeight: window.innerHeight\n                    });\n                    \n                    images.push(canvas.toDataURL('image/png', 1.0));\n                    document.body.removeChild(tempContainer);\n                }\n                \n                buttons.style.visibility = 'visible';\n                \n                const now = new Date();\n                const baseFilename = `TrendRadar_热点新闻分析_${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;\n                \n                for (let i = 0; i < images.length; i++) {\n                    const link = document.createElement('a');\n                    link.download = `${baseFilename}_part${i + 1}.png`;\n                    link.href = images[i];\n                    document.body.appendChild(link);\n                    link.click();\n                    document.body.removeChild(link);\n                    \n                    await new Promise(resolve => setTimeout(resolve, 100));\n                }\n                \n                button.textContent = `已保存 ${segments.length} 张图片!`;\n                setTimeout(() => {\n                    button.textContent = originalText;\n                    button.disabled = false;\n                }, 2000);\n                \n            } catch (error) {\n                console.error('分段保存失败:', error);\n                const buttons = document.querySelector('.save-buttons');\n                buttons.style.visibility = 'visible';\n                button.textContent = '保存失败';\n                setTimeout(() => {\n                    button.textContent = originalText;\n                    button.disabled = false;\n                }, 2000);\n            }\n        }\n        \n        document.addEventListener('DOMContentLoaded', function() {\n            window.scrollTo(0, 0);\n        });\n    </script>\n</body>\n</html>"
  },
  {
    "path": "mcp_server/__init__.py",
    "content": "\"\"\"\nTrendRadar MCP Server\n\n提供基于MCP协议的新闻聚合数据查询和系统管理接口。\n\n\"\"\"\n\n__version__ = \"4.0.0\"\n"
  },
  {
    "path": "mcp_server/server.py",
    "content": "\"\"\"\nTrendRadar MCP Server - FastMCP 2.0 实现\n\n使用 FastMCP 2.0 提供生产级 MCP 工具服务器。\n支持 stdio 和 HTTP 两种传输模式。\n\"\"\"\n\nimport asyncio\nimport json\nfrom typing import List, Optional, Dict, Union\n\nfrom fastmcp import FastMCP\n\nfrom .tools.data_query import DataQueryTools\nfrom .tools.analytics import AnalyticsTools\nfrom .tools.search_tools import SearchTools\nfrom .tools.config_mgmt import ConfigManagementTools\nfrom .tools.system import SystemManagementTools\nfrom .tools.storage_sync import StorageSyncTools\nfrom .tools.article_reader import ArticleReaderTools\nfrom .tools.notification import NotificationTools\nfrom .utils.date_parser import DateParser\nfrom .utils.errors import MCPError\n\n\n# 创建 FastMCP 2.0 应用\nmcp = FastMCP('trendradar-news')\n\n# 全局工具实例（在第一次请求时初始化）\n_tools_instances = {}\n\n\ndef _get_tools(project_root: Optional[str] = None):\n    \"\"\"获取或创建工具实例（单例模式）\"\"\"\n    if not _tools_instances:\n        _tools_instances['data'] = DataQueryTools(project_root)\n        _tools_instances['analytics'] = AnalyticsTools(project_root)\n        _tools_instances['search'] = SearchTools(project_root)\n        _tools_instances['config'] = ConfigManagementTools(project_root)\n        _tools_instances['system'] = SystemManagementTools(project_root)\n        _tools_instances['storage'] = StorageSyncTools(project_root)\n        _tools_instances['article'] = ArticleReaderTools(project_root)\n        _tools_instances['notification'] = NotificationTools(project_root)\n    return _tools_instances\n\n\n# ==================== MCP Resources ====================\n\n@mcp.resource(\"config://platforms\")\nasync def get_platforms_resource() -> str:\n    \"\"\"\n    获取支持的平台列表\n\n    返回 config.yaml 中配置的所有平台信息，包括 ID 和名称。\n    \"\"\"\n    tools = _get_tools()\n    config = await asyncio.to_thread(\n        tools['config'].get_current_config, section=\"crawler\"\n    )\n    return json.dumps({\n        \"platforms\": config.get(\"platforms\", []),\n        \"description\": \"TrendRadar 支持的热榜平台列表\"\n    }, ensure_ascii=False, indent=2)\n\n\n@mcp.resource(\"config://rss-feeds\")\nasync def get_rss_feeds_resource() -> str:\n    \"\"\"\n    获取 RSS 订阅源列表\n\n    返回当前配置的所有 RSS 源信息。\n    \"\"\"\n    tools = _get_tools()\n    status = await asyncio.to_thread(tools['data'].get_rss_feeds_status)\n    return json.dumps({\n        \"feeds\": status.get(\"today_feeds\", {}),\n        \"description\": \"TrendRadar 支持的 RSS 订阅源列表\"\n    }, ensure_ascii=False, indent=2)\n\n\n@mcp.resource(\"data://available-dates\")\nasync def get_available_dates_resource() -> str:\n    \"\"\"\n    获取可用的数据日期范围\n\n    返回本地存储中可查询的日期列表。\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['storage'].list_available_dates, source=\"local\"\n    )\n    return json.dumps({\n        \"dates\": result.get(\"data\", {}).get(\"local\", {}).get(\"dates\", []),\n        \"description\": \"本地存储中可查询的日期列表\"\n    }, ensure_ascii=False, indent=2)\n\n\n@mcp.resource(\"config://keywords\")\nasync def get_keywords_resource() -> str:\n    \"\"\"\n    获取关注词配置\n\n    返回 frequency_words.txt 中配置的关注词分组。\n    \"\"\"\n    tools = _get_tools()\n    config = await asyncio.to_thread(\n        tools['config'].get_current_config, section=\"keywords\"\n    )\n    return json.dumps({\n        \"word_groups\": config.get(\"word_groups\", []),\n        \"total_groups\": config.get(\"total_groups\", 0),\n        \"description\": \"TrendRadar 关注词配置\"\n    }, ensure_ascii=False, indent=2)\n\n\n# ==================== 日期解析工具（优先调用）====================\n\n@mcp.tool\nasync def resolve_date_range(\n    expression: str\n) -> str:\n    \"\"\"\n    【推荐优先调用】将自然语言日期表达式解析为标准日期范围\n\n    **为什么需要这个工具？**\n    用户经常使用\"本周\"、\"最近7天\"等自然语言表达日期，但 AI 模型自己计算日期\n    可能导致不一致的结果。此工具在服务器端使用精确的当前时间计算，确保所有\n    AI 模型获得一致的日期范围。\n\n    **推荐使用流程：**\n    1. 用户说\"分析AI本周的情感倾向\"\n    2. AI 调用 resolve_date_range(\"本周\") → 获取精确日期范围\n    3. AI 调用 analyze_sentiment(topic=\"ai\", date_range=上一步返回的date_range)\n\n    Args:\n        expression: 自然语言日期表达式，支持：\n            - 单日: \"今天\", \"昨天\", \"today\", \"yesterday\"\n            - 周: \"本周\", \"上周\", \"this week\", \"last week\"\n            - 月: \"本月\", \"上月\", \"this month\", \"last month\"\n            - 最近N天: \"最近7天\", \"最近30天\", \"last 7 days\", \"last 30 days\"\n            - 动态: \"最近5天\", \"last 10 days\"（任意天数）\n\n    Returns:\n        JSON格式的日期范围，可直接用于其他工具的 date_range 参数：\n        {\n            \"success\": true,\n            \"expression\": \"本周\",\n            \"date_range\": {\n                \"start\": \"2025-11-18\",\n                \"end\": \"2025-11-26\"\n            },\n            \"current_date\": \"2025-11-26\",\n            \"description\": \"本周（周一到周日，11-18 至 11-26）\"\n        }\n\n    Examples:\n        用户：\"分析AI本周的情感倾向\"\n        AI调用步骤：\n        1. resolve_date_range(\"本周\")\n           → {\"date_range\": {\"start\": \"2025-11-18\", \"end\": \"2025-11-26\"}, ...}\n        2. analyze_sentiment(topic=\"ai\", date_range={\"start\": \"2025-11-18\", \"end\": \"2025-11-26\"})\n\n        用户：\"看看最近7天的特斯拉新闻\"\n        AI调用步骤：\n        1. resolve_date_range(\"最近7天\")\n           → {\"date_range\": {\"start\": \"2025-11-20\", \"end\": \"2025-11-26\"}, ...}\n        2. search_news(query=\"特斯拉\", date_range={\"start\": \"2025-11-20\", \"end\": \"2025-11-26\"})\n    \"\"\"\n    try:\n        result = await asyncio.to_thread(DateParser.resolve_date_range_expression, expression)\n        return json.dumps(result, ensure_ascii=False, indent=2)\n    except MCPError as e:\n        return json.dumps({\n            \"success\": False,\n            \"error\": e.to_dict()\n        }, ensure_ascii=False, indent=2)\n    except Exception as e:\n        return json.dumps({\n            \"success\": False,\n            \"error\": {\n                \"code\": \"INTERNAL_ERROR\",\n                \"message\": str(e)\n            }\n        }, ensure_ascii=False, indent=2)\n\n\n# ==================== 数据查询工具 ====================\n\n@mcp.tool\nasync def get_latest_news(\n    platforms: Optional[List[str]] = None,\n    limit: int = 50,\n    include_url: bool = False\n) -> str:\n    \"\"\"\n    获取最新一批爬取的新闻数据，快速了解当前热点\n\n    Args:\n        platforms: 平台ID列表，如 ['zhihu', 'weibo']，不指定则使用所有平台\n        limit: 返回条数限制，默认50，最大1000\n        include_url: 是否包含URL链接，默认False（节省token）\n\n    Returns:\n        JSON格式的新闻列表\n\n    **数据展示建议**\n    - 默认展示全部返回数据，除非用户明确要求总结\n    - 用户说\"总结\"或\"挑重点\"时才进行筛选\n    - 用户问\"为什么只显示部分\"说明需要完整数据\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['data'].get_latest_news,\n        platforms=platforms, limit=limit, include_url=include_url\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def get_trending_topics(\n    top_n: int = 10,\n    mode: str = 'current',\n    extract_mode: str = 'keywords'\n) -> str:\n    \"\"\"\n    获取热点话题统计\n\n    Args:\n        top_n: 返回TOP N话题，默认10\n        mode: 时间模式\n            - \"daily\": 当日累计数据统计\n            - \"current\": 最新一批数据统计（默认）\n        extract_mode: 提取模式\n            - \"keywords\": 统计预设关注词（基于 config/frequency_words.txt，默认）\n            - \"auto_extract\": 自动从新闻标题提取高频词（无需预设，自动发现热点）\n\n    Returns:\n        JSON格式的话题频率统计列表\n\n    Examples:\n        - 使用预设关注词: get_trending_topics(mode=\"current\")\n        - 自动提取热点: get_trending_topics(extract_mode=\"auto_extract\", top_n=20)\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['data'].get_trending_topics,\n        top_n=top_n, mode=mode, extract_mode=extract_mode\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n# ==================== RSS 数据查询工具 ====================\n\n@mcp.tool\nasync def get_latest_rss(\n    feeds: Optional[List[str]] = None,\n    days: int = 1,\n    limit: int = 50,\n    include_summary: bool = False\n) -> str:\n    \"\"\"\n    获取最新的 RSS 订阅数据（支持多日查询）\n\n    RSS 数据与热榜新闻分开存储，按时间流展示，适合获取特定来源的最新内容。\n\n    Args:\n        feeds: RSS 源 ID 列表，如 ['hacker-news', '36kr']，不指定则返回所有源\n        days: 获取最近 N 天的数据，默认 1（仅今天），最大 30 天\n        limit: 返回条数限制，默认50，最大500\n        include_summary: 是否包含文章摘要，默认False（节省token）\n\n    Returns:\n        JSON格式的 RSS 条目列表\n\n    Examples:\n        - get_latest_rss()\n        - get_latest_rss(days=7, feeds=['hacker-news'])\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['data'].get_latest_rss,\n        feeds=feeds, days=days, limit=limit, include_summary=include_summary\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def search_rss(\n    keyword: str,\n    feeds: Optional[List[str]] = None,\n    days: int = 7,\n    limit: int = 50,\n    include_summary: bool = False\n) -> str:\n    \"\"\"\n    搜索 RSS 数据\n\n    在 RSS 订阅数据中搜索包含指定关键词的文章。\n\n    Args:\n        keyword: 搜索关键词（必需）\n        feeds: RSS 源 ID 列表，如 ['hacker-news', '36kr']\n               - 不指定时：搜索所有 RSS 源\n        days: 搜索最近 N 天的数据，默认 7 天，最大 30 天\n        limit: 返回条数限制，默认50\n        include_summary: 是否包含文章摘要，默认False\n\n    Returns:\n        JSON格式的匹配 RSS 条目列表\n\n    Examples:\n        - search_rss(keyword=\"AI\")\n        - search_rss(keyword=\"machine learning\", feeds=['hacker-news'], days=14)\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['data'].search_rss,\n        keyword=keyword,\n        feeds=feeds,\n        days=days,\n        limit=limit,\n        include_summary=include_summary\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def get_rss_feeds_status() -> str:\n    \"\"\"\n    获取 RSS 源状态信息\n\n    查看当前配置的 RSS 源及其数据统计信息。\n\n    Returns:\n        JSON格式的 RSS 源状态，包含：\n        - available_dates: 有 RSS 数据的日期列表\n        - total_dates: 总日期数\n        - today_feeds: 今日各 RSS 源的数据统计\n            - {feed_id}: { name, item_count }\n        - generated_at: 生成时间\n\n    Examples:\n        - get_rss_feeds_status()  # 查看所有 RSS 源状态\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(tools['data'].get_rss_feeds_status)\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def get_news_by_date(\n    date_range: Optional[Union[Dict[str, str], str]] = None,\n    platforms: Optional[List[str]] = None,\n    limit: int = 50,\n    include_url: bool = False\n) -> str:\n    \"\"\"\n    获取指定日期的新闻数据，用于历史数据分析和对比\n\n    Args:\n        date_range: 日期范围，支持多种格式:\n            - 范围对象: {\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"}\n            - 自然语言: \"今天\", \"昨天\", \"本周\", \"最近7天\"\n            - 单日字符串: \"2025-01-15\"\n            - 默认值: \"今天\"\n        platforms: 平台ID列表，如 ['zhihu', 'weibo']，不指定则使用所有平台\n        limit: 返回条数限制，默认50，最大1000\n        include_url: 是否包含URL链接，默认False（节省token）\n\n    Returns:\n        JSON格式的新闻列表，包含标题、平台、排名等信息\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['data'].get_news_by_date,\n        date_range=date_range,\n        platforms=platforms,\n        limit=limit,\n        include_url=include_url\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n\n# ==================== 高级数据分析工具 ====================\n\n@mcp.tool\nasync def analyze_topic_trend(\n    topic: str,\n    analysis_type: str = \"trend\",\n    date_range: Optional[Union[Dict[str, str], str]] = None,\n    granularity: str = \"day\",\n    spike_threshold: float = 3.0,\n    time_window: int = 24,\n    lookahead_hours: int = 6,\n    confidence_threshold: float = 0.7\n) -> str:\n    \"\"\"\n    统一话题趋势分析工具 - 整合多种趋势分析模式\n\n    建议：使用自然语言日期时，先调用 resolve_date_range 获取精确日期范围。\n\n    Args:\n        topic: 话题关键词（必需）\n        analysis_type: 分析类型\n            - \"trend\": 热度趋势分析（默认）\n            - \"lifecycle\": 生命周期分析\n            - \"viral\": 异常热度检测\n            - \"predict\": 话题预测\n        date_range: 日期范围，格式 {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}，默认最近7天\n        granularity: 时间粒度，默认\"day\"\n        spike_threshold: 热度突增倍数阈值（viral模式），默认3.0\n        time_window: 检测时间窗口小时数（viral模式），默认24\n        lookahead_hours: 预测未来小时数（predict模式），默认6\n        confidence_threshold: 置信度阈值（predict模式），默认0.7\n\n    Returns:\n        JSON格式的趋势分析结果\n\n    Examples:\n        - analyze_topic_trend(topic=\"AI\", date_range={\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"})\n        - analyze_topic_trend(topic=\"特斯拉\", analysis_type=\"lifecycle\")\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['analytics'].analyze_topic_trend_unified,\n        topic=topic,\n        analysis_type=analysis_type,\n        date_range=date_range,\n        granularity=granularity,\n        threshold=spike_threshold,\n        time_window=time_window,\n        lookahead_hours=lookahead_hours,\n        confidence_threshold=confidence_threshold\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def analyze_data_insights(\n    insight_type: str = \"platform_compare\",\n    topic: Optional[str] = None,\n    date_range: Optional[Union[Dict[str, str], str]] = None,\n    min_frequency: int = 3,\n    top_n: int = 20\n) -> str:\n    \"\"\"\n    统一数据洞察分析工具 - 整合多种数据分析模式\n\n    Args:\n        insight_type: 洞察类型，可选值：\n            - \"platform_compare\": 平台对比分析（对比不同平台对话题的关注度）\n            - \"platform_activity\": 平台活跃度统计（统计各平台发布频率和活跃时间）\n            - \"keyword_cooccur\": 关键词共现分析（分析关键词同时出现的模式）\n        topic: 话题关键词（可选，platform_compare模式适用）\n        date_range: **【对象类型】** 日期范围（可选）\n                    - **格式**: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}\n                    - **示例**: {\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"}\n                    - **重要**: 必须是对象格式，不能传递整数\n        min_frequency: 最小共现频次（keyword_cooccur模式），默认3\n        top_n: 返回TOP N结果（keyword_cooccur模式），默认20\n\n    Returns:\n        JSON格式的数据洞察分析结果\n\n    Examples:\n        - analyze_data_insights(insight_type=\"platform_compare\", topic=\"人工智能\")\n        - analyze_data_insights(insight_type=\"platform_activity\", date_range={\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"})\n        - analyze_data_insights(insight_type=\"keyword_cooccur\", min_frequency=5, top_n=15)\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['analytics'].analyze_data_insights_unified,\n        insight_type=insight_type,\n        topic=topic,\n        date_range=date_range,\n        min_frequency=min_frequency,\n        top_n=top_n\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def analyze_sentiment(\n    topic: Optional[str] = None,\n    platforms: Optional[List[str]] = None,\n    date_range: Optional[Union[Dict[str, str], str]] = None,\n    limit: int = 50,\n    sort_by_weight: bool = True,\n    include_url: bool = False\n) -> str:\n    \"\"\"\n    分析新闻的情感倾向和热度趋势\n\n    建议：使用自然语言日期时，先调用 resolve_date_range 获取精确日期范围。\n\n    Args:\n        topic: 话题关键词（可选）\n        platforms: 平台ID列表，如 ['zhihu', 'weibo']，不指定则使用所有平台\n        date_range: 日期范围，格式 {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}，默认今天\n        limit: 返回新闻数量，默认50，最大100（会对标题去重）\n        sort_by_weight: 是否按热度权重排序，默认True\n        include_url: 是否包含URL链接，默认False（节省token）\n\n    Returns:\n        JSON格式的分析结果，包含情感分布、热度趋势和相关新闻\n\n    Examples:\n        - analyze_sentiment(topic=\"AI\", date_range={\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"})\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['analytics'].analyze_sentiment,\n        topic=topic,\n        platforms=platforms,\n        date_range=date_range,\n        limit=limit,\n        sort_by_weight=sort_by_weight,\n        include_url=include_url\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def find_related_news(\n    reference_title: str,\n    date_range: Optional[Union[Dict[str, str], str]] = None,\n    threshold: float = 0.5,\n    limit: int = 50,\n    include_url: bool = False\n) -> str:\n    \"\"\"\n    查找与指定新闻标题相关的其他新闻（支持当天和历史数据）\n\n    Args:\n        reference_title: 参考新闻标题（完整或部分）\n        date_range: 日期范围（可选）\n            - 不指定: 只查询今天的数据\n            - \"today\", \"yesterday\", \"last_week\", \"last_month\": 预设值\n            - {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}: 自定义范围\n        threshold: 相似度阈值，0-1之间，默认0.5（越高匹配越严格）\n        limit: 返回条数限制，默认50\n        include_url: 是否包含URL链接，默认False（节省token）\n\n    Returns:\n        JSON格式的相关新闻列表，按相似度排序\n\n    Examples:\n        - find_related_news(reference_title=\"特斯拉降价\")\n        - find_related_news(reference_title=\"AI突破\", date_range=\"last_week\")\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['search'].find_related_news_unified,\n        reference_title=reference_title,\n        date_range=date_range,\n        threshold=threshold,\n        limit=limit,\n        include_url=include_url\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def generate_summary_report(\n    report_type: str = \"daily\",\n    date_range: Optional[Union[Dict[str, str], str]] = None\n) -> str:\n    \"\"\"\n    每日/每周摘要生成器 - 自动生成热点摘要报告\n\n    Args:\n        report_type: 报告类型（daily/weekly）\n        date_range: **【对象类型】** 自定义日期范围（可选）\n                    - **格式**: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}\n                    - **示例**: {\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"}\n                    - **重要**: 必须是对象格式，不能传递整数\n\n    Returns:\n        JSON格式的摘要报告，包含Markdown格式内容\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['analytics'].generate_summary_report,\n        report_type=report_type,\n        date_range=date_range\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def aggregate_news(\n    date_range: Optional[Union[Dict[str, str], str]] = None,\n    platforms: Optional[List[str]] = None,\n    similarity_threshold: float = 0.7,\n    limit: int = 50,\n    include_url: bool = False\n) -> str:\n    \"\"\"\n    跨平台新闻聚合 - 对相似新闻进行去重合并\n\n    将不同平台报道的同一事件合并为一条聚合新闻，显示跨平台覆盖情况和综合热度。\n\n    Args:\n        date_range: 日期范围，不指定则查询今天\n        platforms: 平台ID列表，如 ['zhihu', 'weibo']，不指定则使用所有平台\n        similarity_threshold: 相似度阈值，0.3-1.0，默认0.7（越高越严格）\n        limit: 返回聚合新闻数量，默认50\n        include_url: 是否包含URL链接，默认False\n\n    Returns:\n        JSON格式的聚合结果，包含去重统计、聚合新闻列表和平台覆盖统计\n\n    Examples:\n        - aggregate_news()\n        - aggregate_news(similarity_threshold=0.8)\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['analytics'].aggregate_news,\n        date_range=date_range,\n        platforms=platforms,\n        similarity_threshold=similarity_threshold,\n        limit=limit,\n        include_url=include_url\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def compare_periods(\n    period1: Union[Dict[str, str], str],\n    period2: Union[Dict[str, str], str],\n    topic: Optional[str] = None,\n    compare_type: str = \"overview\",\n    platforms: Optional[List[str]] = None,\n    top_n: int = 10\n) -> str:\n    \"\"\"\n    时期对比分析 - 比较两个时间段的新闻数据\n\n    对比不同时期的热点话题、平台活跃度、新闻数量等维度。\n\n    **使用场景：**\n    - 对比本周和上周的热点变化\n    - 分析某个话题在两个时期的热度差异\n    - 查看各平台活跃度的周期性变化\n\n    Args:\n        period1: 第一个时间段（基准期）\n            - {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}: 日期范围\n            - \"today\", \"yesterday\", \"this_week\", \"last_week\", \"this_month\", \"last_month\": 预设值\n        period2: 第二个时间段（对比期，格式同 period1）\n        topic: 可选的话题关键词（聚焦特定话题的对比）\n        compare_type: 对比类型\n            - \"overview\": 总体概览（默认）- 新闻数量、关键词变化、TOP新闻\n            - \"topic_shift\": 话题变化分析 - 上升话题、下降话题、新出现话题\n            - \"platform_activity\": 平台活跃度对比 - 各平台新闻数量变化\n        platforms: 平台过滤列表，如 ['zhihu', 'weibo']\n        top_n: 返回 TOP N 结果，默认10\n\n    Returns:\n        JSON格式的对比分析结果，包含：\n        - periods: 两个时期的日期范围\n        - compare_type: 对比类型\n        - overview/topic_shift/platform_comparison: 具体对比结果（根据类型）\n\n    Examples:\n        - compare_periods(period1=\"last_week\", period2=\"this_week\")  # 周环比\n        - compare_periods(period1=\"last_month\", period2=\"this_month\", compare_type=\"topic_shift\")\n        - compare_periods(\n            period1={\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"},\n            period2={\"start\": \"2025-01-08\", \"end\": \"2025-01-14\"},\n            topic=\"人工智能\"\n          )\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['analytics'].compare_periods,\n        period1=period1,\n        period2=period2,\n        topic=topic,\n        compare_type=compare_type,\n        platforms=platforms,\n        top_n=top_n\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n# ==================== 智能检索工具 ====================\n\n@mcp.tool\nasync def search_news(\n    query: str,\n    search_mode: str = \"keyword\",\n    date_range: Optional[Union[Dict[str, str], str]] = None,\n    platforms: Optional[List[str]] = None,\n    limit: int = 50,\n    sort_by: str = \"relevance\",\n    threshold: float = 0.6,\n    include_url: bool = False,\n    include_rss: bool = False,\n    rss_limit: int = 20\n) -> str:\n    \"\"\"\n    统一搜索接口，支持多种搜索模式，可同时搜索热榜和RSS\n\n    建议：使用自然语言日期时，先调用 resolve_date_range 获取精确日期范围。\n\n    Args:\n        query: 搜索关键词或内容片段\n        search_mode: 搜索模式\n            - \"keyword\": 精确关键词匹配（默认）\n            - \"fuzzy\": 模糊内容匹配\n            - \"entity\": 实体名称搜索（人物/地点/机构）\n        date_range: 日期范围，格式 {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}，默认今天\n        platforms: 平台ID列表，如 ['zhihu', 'weibo']，不指定则使用所有平台\n        limit: 热榜返回条数限制，默认50\n        sort_by: 排序方式 - \"relevance\"（相关度）/ \"weight\"（权重）/ \"date\"（日期）\n        threshold: 相似度阈值（仅fuzzy模式），0-1，默认0.6\n        include_url: 是否包含URL链接，默认False\n        include_rss: 是否同时搜索RSS数据，默认False\n        rss_limit: RSS返回条数限制，默认20\n\n    Returns:\n        JSON格式的搜索结果，包含热榜新闻列表和可选的RSS结果\n\n    Examples:\n        - search_news(query=\"AI\")\n        - search_news(query=\"AI\", include_rss=True)\n        - search_news(query=\"特斯拉\", date_range={\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"})\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['search'].search_news_unified,\n        query=query,\n        search_mode=search_mode,\n        date_range=date_range,\n        platforms=platforms,\n        limit=limit,\n        sort_by=sort_by,\n        threshold=threshold,\n        include_url=include_url,\n        include_rss=include_rss,\n        rss_limit=rss_limit\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n# ==================== 配置与系统管理工具 ====================\n\n@mcp.tool\nasync def get_current_config(\n    section: str = \"all\"\n) -> str:\n    \"\"\"\n    获取当前系统配置\n\n    Args:\n        section: 配置节，可选值：\n            - \"all\": 所有配置（默认）\n            - \"crawler\": 爬虫配置\n            - \"push\": 推送配置\n            - \"keywords\": 关键词配置\n            - \"weights\": 权重配置\n\n    Returns:\n        JSON格式的配置信息\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(tools['config'].get_current_config, section=section)\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def get_system_status() -> str:\n    \"\"\"\n    获取系统运行状态和健康检查信息\n\n    返回系统版本、数据统计、缓存状态等信息\n\n    Returns:\n        JSON格式的系统状态信息\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(tools['system'].get_system_status)\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def check_version(\n    proxy_url: Optional[str] = None\n) -> str:\n    \"\"\"\n    检查版本更新（同时检查 TrendRadar 和 MCP Server）\n\n    比较本地版本与 GitHub 远程版本，判断是否需要更新。\n\n    Args:\n        proxy_url: 可选的代理URL，用于访问 GitHub（如 http://127.0.0.1:7890）\n\n    Returns:\n        JSON格式的版本检查结果，包含两个组件的版本对比和是否需要更新\n\n    Examples:\n        - check_version()\n        - check_version(proxy_url=\"http://127.0.0.1:7890\")\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(tools['system'].check_version, proxy_url=proxy_url)\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def trigger_crawl(\n    platforms: Optional[List[str]] = None,\n    save_to_local: bool = False,\n    include_url: bool = False\n) -> str:\n    \"\"\"\n    手动触发一次爬取任务（可选持久化）\n\n    Args:\n        platforms: 平台ID列表，如 ['zhihu', 'weibo']，不指定则使用所有平台\n        save_to_local: 是否保存到本地 output 目录，默认 False\n        include_url: 是否包含URL链接，默认False（节省token）\n\n    Returns:\n        JSON格式的任务状态信息，包含成功/失败平台列表和新闻数据\n\n    Examples:\n        - trigger_crawl(platforms=['zhihu'])\n        - trigger_crawl(save_to_local=True)\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['system'].trigger_crawl,\n        platforms=platforms, save_to_local=save_to_local, include_url=include_url\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n# ==================== 存储同步工具 ====================\n\n@mcp.tool\nasync def sync_from_remote(\n    days: int = 7\n) -> str:\n    \"\"\"\n    从远程存储拉取数据到本地\n\n    用于 MCP Server 等场景：爬虫存到远程云存储（如 Cloudflare R2），\n    MCP Server 拉取到本地进行分析查询。\n\n    Args:\n        days: 拉取最近 N 天的数据，默认 7 天\n              - 0: 不拉取\n              - 7: 拉取最近一周的数据\n              - 30: 拉取最近一个月的数据\n\n    Returns:\n        JSON格式的同步结果，包含：\n        - success: 是否成功\n        - synced_files: 成功同步的文件数量\n        - synced_dates: 成功同步的日期列表\n        - skipped_dates: 跳过的日期（本地已存在）\n        - failed_dates: 失败的日期及错误信息\n        - message: 操作结果描述\n\n    Examples:\n        - sync_from_remote()  # 拉取最近7天\n        - sync_from_remote(days=30)  # 拉取最近30天\n\n    Note:\n        需要在 config/config.yaml 中配置远程存储（storage.remote）或设置环境变量：\n        - S3_ENDPOINT_URL: 服务端点\n        - S3_BUCKET_NAME: 存储桶名称\n        - S3_ACCESS_KEY_ID: 访问密钥 ID\n        - S3_SECRET_ACCESS_KEY: 访问密钥\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(tools['storage'].sync_from_remote, days=days)\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def get_storage_status() -> str:\n    \"\"\"\n    获取存储配置和状态\n\n    查看当前存储后端配置、本地和远程存储的状态信息。\n\n    Returns:\n        JSON格式的存储状态信息，包含本地/远程存储状态和拉取配置\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(tools['storage'].get_storage_status)\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def list_available_dates(\n    source: str = \"both\"\n) -> str:\n    \"\"\"\n    列出本地/远程可用的日期范围\n\n    查看本地和远程存储中有哪些日期的数据可用。\n\n    Args:\n        source: 数据来源\n            - \"local\": 仅本地\n            - \"remote\": 仅远程\n            - \"both\": 同时列出并对比（默认）\n\n    Returns:\n        JSON格式的日期列表，包含各来源的日期信息和对比结果\n\n    Examples:\n        - list_available_dates()\n        - list_available_dates(source=\"local\")\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(tools['storage'].list_available_dates, source=source)\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n# ==================== 文章内容读取工具 ====================\n\n@mcp.tool\nasync def read_article(\n    url: str,\n    timeout: int = 30\n) -> str:\n    \"\"\"\n    读取指定 URL 的文章内容，返回 LLM 友好的 Markdown 格式\n\n    通过 Jina AI Reader 将网页转换为干净的 Markdown，自动去除广告、导航栏等噪音内容。\n    适合用于：阅读新闻正文、获取文章详情、分析文章内容。\n\n    **典型使用流程：**\n    1. 先用 search_news(include_url=True) 搜索新闻获取链接\n    2. 再用 read_article(url=链接) 读取正文内容\n    3. AI 对 Markdown 正文进行分析、摘要、翻译等\n\n    Args:\n        url: 文章链接（必需），以 http:// 或 https:// 开头\n        timeout: 请求超时时间（秒），默认 30，最大 60\n\n    Returns:\n        JSON格式的文章内容，包含完整 Markdown 正文\n\n    Examples:\n        - read_article(url=\"https://example.com/news/123\")\n\n    Note:\n        - 使用 Jina AI Reader 免费服务（100 RPM 限制）\n        - 每次请求间隔 5 秒（内置速率控制）\n        - 部分付费墙/登录墙页面可能无法完整获取\n    \"\"\"\n    tools = _get_tools()\n    timeout = min(max(timeout, 10), 60)\n    result = await asyncio.to_thread(\n        tools['article'].read_article,\n        url=url, timeout=timeout\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def read_articles_batch(\n    urls: List[str],\n    timeout: int = 30\n) -> str:\n    \"\"\"\n    批量读取多篇文章内容（最多 5 篇，间隔 5 秒）\n\n    逐篇请求文章内容，每篇之间自动间隔 5 秒以遵守速率限制。\n\n    **典型使用流程：**\n    1. 先用 search_news(include_url=True) 搜索新闻获取多个链接\n    2. 再用 read_articles_batch(urls=[...]) 批量读取正文\n    3. AI 对多篇文章进行对比分析、综合报告\n\n    Args:\n        urls: 文章链接列表（必需），最多处理 5 篇\n        timeout: 每篇的请求超时时间（秒），默认 30\n\n    Returns:\n        JSON格式的批量读取结果，包含每篇的完整内容和状态\n\n    Examples:\n        - read_articles_batch(urls=[\"https://a.com/1\", \"https://b.com/2\"])\n\n    Note:\n        - 单次最多读取 5 篇，超出部分会被跳过\n        - 5 篇约需 25-30 秒（每篇间隔 5 秒）\n        - 单篇失败不影响其他篇的读取\n    \"\"\"\n    tools = _get_tools()\n    timeout = min(max(timeout, 10), 60)\n    result = await asyncio.to_thread(\n        tools['article'].read_articles_batch,\n        urls=urls, timeout=timeout\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n# ==================== 通知推送工具 ====================\n\n\n@mcp.tool\nasync def get_channel_format_guide(channel: Optional[str] = None) -> str:\n    \"\"\"\n    获取通知渠道的格式化策略指南\n\n    返回各渠道支持的 Markdown 特性、格式限制和最佳格式化提示词。\n    在调用 send_notification 之前使用此工具，可以了解目标渠道的格式要求，\n    从而生成最佳排版效果的消息内容。\n\n    各渠道格式差异概览：\n    - 飞书：支持 **粗体**、<font color>彩色文本、[链接](url)、--- 分割线\n    - 钉钉：支持 ### 标题、**粗体**、> 引用、--- 分割线，不支持颜色\n    - 企业微信：仅支持 **粗体**、[链接](url)、> 引用，不支持标题和分割线\n    - Telegram：自动转为 HTML，支持粗体/斜体/删除线/代码/链接/引用块\n    - ntfy：支持标准 Markdown，不支持颜色\n    - Bark：iOS 推送，仅支持粗体和链接，内容需精简\n    - Slack：自动转为 mrkdwn，*粗体*、~删除线~、<url|链接>\n    - 邮件：自动转为完整 HTML 网页，支持标题/样式/分割线\n    - 通用 Webhook：标准 Markdown 或自定义模板\n\n    Args:\n        channel: 指定渠道 ID（可选），不指定返回所有渠道策略\n                 可选值: feishu, dingtalk, wework, telegram, email, ntfy, bark, slack, generic_webhook\n\n    Returns:\n        JSON格式的渠道格式化策略，包含支持特性、限制和格式化提示词\n\n    Examples:\n        - get_channel_format_guide()  # 获取所有渠道策略\n        - get_channel_format_guide(channel=\"feishu\")  # 获取飞书策略\n        - get_channel_format_guide(channel=\"telegram\")  # 获取 Telegram 策略\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['notification'].get_channel_format_guide,\n        channel=channel\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def get_notification_channels() -> str:\n    \"\"\"\n    获取所有已配置的通知渠道及其状态\n\n    检测 config.yaml 和 .env 环境变量中的通知渠道配置。\n    支持 9 个渠道：飞书、钉钉、企业微信、Telegram、邮件、ntfy、Bark、Slack、通用 Webhook。\n\n    Returns:\n        JSON格式的渠道状态，包含每个渠道是否已配置及配置来源\n\n    Examples:\n        - get_notification_channels()\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(tools['notification'].get_notification_channels)\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n@mcp.tool\nasync def send_notification(\n    message: str,\n    title: str = \"TrendRadar 通知\",\n    channels: Optional[List[str]] = None,\n) -> str:\n    \"\"\"\n    向已配置的通知渠道发送消息\n\n    接受 markdown 格式内容，内部自动适配各渠道的格式要求和限制：\n    - 飞书：Markdown 卡片消息（支持 **粗体**、<font color>彩色文本、[链接](url)、---）\n    - 钉钉：Markdown（自动降级标题为 ###、剥离 <font> 标签和删除线）\n    - 企业微信：Markdown（自动剥离 # 标题、---、<font> 标签、删除线）\n    - Telegram：HTML（自动转换 **→<b>、*→<i>、~~→<s>、>→<blockquote>）\n    - Email：HTML 邮件（完整网页样式，支持 # 标题、---、粗体斜体）\n    - ntfy：Markdown（自动剥离 <font> 标签）\n    - Bark：Markdown（自动简化为粗体+链接，适配 iOS 推送）\n    - Slack：mrkdwn（自动转换 **→*、~~→~、[text](url)→<url|text>）\n    - 通用 Webhook：Markdown（支持自定义模板）\n\n    提示：发送前可调用 get_channel_format_guide 获取目标渠道的详细格式化策略，\n    以生成最佳排版效果的消息内容。\n\n    Args:\n        message: markdown 格式的消息内容（必需）\n        title: 消息标题，默认 \"TrendRadar 通知\"\n        channels: 指定发送的渠道列表，不指定则发送到所有已配置渠道\n                  可选值: feishu, dingtalk, wework, telegram, email, ntfy, bark, slack, generic_webhook\n\n    Returns:\n        JSON格式的发送结果，包含每个渠道的发送状态\n\n    Examples:\n        - send_notification(message=\"**测试消息**\\\\n这是一条测试通知\")\n        - send_notification(message=\"紧急通知\", title=\"系统告警\", channels=[\"feishu\", \"dingtalk\"])\n    \"\"\"\n    tools = _get_tools()\n    result = await asyncio.to_thread(\n        tools['notification'].send_notification,\n        message=message, title=title, channels=channels\n    )\n    return json.dumps(result, ensure_ascii=False, indent=2)\n\n\n# ==================== 启动入口 ====================\n\ndef run_server(\n    project_root: Optional[str] = None,\n    transport: str = 'stdio',\n    host: str = '0.0.0.0',\n    port: int = 3333\n):\n    \"\"\"\n    启动 MCP 服务器\n\n    Args:\n        project_root: 项目根目录路径\n        transport: 传输模式，'stdio' 或 'http'\n        host: HTTP模式的监听地址，默认 0.0.0.0\n        port: HTTP模式的监听端口，默认 3333\n    \"\"\"\n    # 初始化工具实例\n    _get_tools(project_root)\n\n    # 打印启动信息\n    print()\n    print(\"=\" * 60)\n    print(\"  TrendRadar MCP Server - FastMCP 2.0\")\n    print(\"=\" * 60)\n    print(f\"  传输模式: {transport.upper()}\")\n\n    if transport == 'stdio':\n        print(\"  协议: MCP over stdio (标准输入输出)\")\n        print(\"  说明: 通过标准输入输出与 MCP 客户端通信\")\n    elif transport == 'http':\n        print(f\"  协议: MCP over HTTP (生产环境)\")\n        print(f\"  服务器监听: {host}:{port}\")\n\n    if project_root:\n        print(f\"  项目目录: {project_root}\")\n    else:\n        print(\"  项目目录: 当前目录\")\n\n    print()\n    print(\"  已注册的工具:\")\n    print(\"    === 日期解析工具（推荐优先调用）===\")\n    print(\"    0. resolve_date_range       - 解析自然语言日期为标准格式\")\n    print()\n    print(\"    === 基础数据查询（P0核心）===\")\n    print(\"    1. get_latest_news        - 获取最新新闻\")\n    print(\"    2. get_news_by_date       - 按日期查询新闻（支持自然语言）\")\n    print(\"    3. get_trending_topics    - 获取趋势话题（支持自动提取）\")\n    print()\n    print(\"    === RSS 数据查询 ===\")\n    print(\"    4. get_latest_rss         - 获取最新 RSS 订阅数据\")\n    print(\"    5. search_rss             - 搜索 RSS 数据\")\n    print(\"    6. get_rss_feeds_status   - 获取 RSS 源状态\")\n    print()\n    print(\"    === 智能检索工具 ===\")\n    print(\"    7. search_news            - 统一新闻搜索（关键词/模糊/实体）\")\n    print(\"    8. find_related_news      - 相关新闻查找（支持历史数据）\")\n    print()\n    print(\"    === 高级数据分析 ===\")\n    print(\"    9. analyze_topic_trend      - 统一话题趋势分析（热度/生命周期/爆火/预测）\")\n    print(\"    10. analyze_data_insights   - 统一数据洞察分析（平台对比/活跃度/关键词共现）\")\n    print(\"    11. analyze_sentiment       - 情感倾向分析\")\n    print(\"    12. aggregate_news          - 跨平台新闻聚合去重\")\n    print(\"    13. compare_periods         - 时期对比分析（周环比/月环比）\")\n    print(\"    14. generate_summary_report - 每日/每周摘要生成\")\n    print()\n    print(\"    === 配置与系统管理 ===\")\n    print(\"    15. get_current_config      - 获取当前系统配置\")\n    print(\"    16. get_system_status       - 获取系统运行状态\")\n    print(\"    17. check_version           - 检查版本更新（对比本地与远程版本）\")\n    print(\"    18. trigger_crawl           - 手动触发爬取任务\")\n    print()\n    print(\"    === 存储同步工具 ===\")\n    print(\"    19. sync_from_remote        - 从远程存储拉取数据到本地\")\n    print(\"    20. get_storage_status      - 获取存储配置和状态\")\n    print(\"    21. list_available_dates    - 列出本地/远程可用日期\")\n    print()\n    print(\"    === 文章内容读取 ===\")\n    print(\"    22. read_article            - 读取单篇文章内容（Markdown格式）\")\n    print(\"    23. read_articles_batch     - 批量读取多篇文章（自动限速）\")\n    print()\n    print(\"    === 通知推送工具 ===\")\n    print(\"    24. get_channel_format_guide  - 获取渠道格式化策略指南（提示词）\")\n    print(\"    25. get_notification_channels - 获取已配置的通知渠道状态\")\n    print(\"    26. send_notification         - 向通知渠道发送消息（自动适配格式）\")\n    print(\"=\" * 60)\n    print()\n\n    # 根据传输模式运行服务器\n    if transport == 'stdio':\n        mcp.run(transport='stdio')\n    elif transport == 'http':\n        # HTTP 模式（生产推荐）\n        mcp.run(\n            transport='http',\n            host=host,\n            port=port,\n            path='/mcp'  # HTTP 端点路径\n        )\n    else:\n        raise ValueError(f\"不支持的传输模式: {transport}\")\n\n\nif __name__ == '__main__':\n    import argparse\n\n    parser = argparse.ArgumentParser(\n        description='TrendRadar MCP Server - 新闻热点聚合 MCP 工具服务器',\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=\"\"\"\n详细配置教程请查看: README-Cherry-Studio.md\n        \"\"\"\n    )\n    parser.add_argument(\n        '--transport',\n        choices=['stdio', 'http'],\n        default='stdio',\n        help='传输模式：stdio (默认) 或 http (生产环境)'\n    )\n    parser.add_argument(\n        '--host',\n        default='0.0.0.0',\n        help='HTTP模式的监听地址，默认 0.0.0.0'\n    )\n    parser.add_argument(\n        '--port',\n        type=int,\n        default=3333,\n        help='HTTP模式的监听端口，默认 3333'\n    )\n    parser.add_argument(\n        '--project-root',\n        help='项目根目录路径'\n    )\n\n    args = parser.parse_args()\n\n    run_server(\n        project_root=args.project_root,\n        transport=args.transport,\n        host=args.host,\n        port=args.port\n    )\n"
  },
  {
    "path": "mcp_server/services/__init__.py",
    "content": "\"\"\"\n服务层模块\n\n提供数据访问、缓存、解析等核心服务。\n\"\"\"\n"
  },
  {
    "path": "mcp_server/services/cache_service.py",
    "content": "\"\"\"\n缓存服务\n\n实现TTL缓存机制，提升数据访问性能。\n\"\"\"\n\nimport hashlib\nimport json\nimport time\nfrom typing import Any, Optional\nfrom threading import Lock\n\n\ndef make_cache_key(namespace: str, **params) -> str:\n    \"\"\"\n    生成结构化缓存 key\n\n    通过对参数排序和哈希，确保相同参数组合总是生成相同的 key。\n\n    Args:\n        namespace: 缓存命名空间，如 \"latest_news\", \"trending_topics\"\n        **params: 缓存参数\n\n    Returns:\n        格式化的缓存 key，如 \"latest_news:a1b2c3d4\"\n\n    Examples:\n        >>> make_cache_key(\"latest_news\", platforms=[\"zhihu\"], limit=50)\n        'latest_news:8f14e45f'\n        >>> make_cache_key(\"search\", query=\"AI\", mode=\"keyword\")\n        'search:3c6e0b8a'\n    \"\"\"\n    if not params:\n        return namespace\n\n    # 对参数进行规范化处理\n    normalized_params = {}\n    for k, v in params.items():\n        if v is None:\n            continue  # 跳过 None 值\n        elif isinstance(v, (list, tuple)):\n            # 列表排序后转为字符串\n            normalized_params[k] = json.dumps(sorted(v) if all(isinstance(i, str) for i in v) else list(v), ensure_ascii=False)\n        elif isinstance(v, dict):\n            # 字典按键排序后转为字符串\n            normalized_params[k] = json.dumps(v, sort_keys=True, ensure_ascii=False)\n        else:\n            normalized_params[k] = str(v)\n\n    # 排序参数并生成哈希\n    sorted_params = sorted(normalized_params.items())\n    param_str = \"&\".join(f\"{k}={v}\" for k, v in sorted_params)\n\n    # 使用 MD5 生成短哈希（取前8位）\n    hash_value = hashlib.md5(param_str.encode('utf-8')).hexdigest()[:8]\n\n    return f\"{namespace}:{hash_value}\"\n\n\nclass CacheService:\n    \"\"\"缓存服务类\"\"\"\n\n    def __init__(self):\n        \"\"\"初始化缓存服务\"\"\"\n        self._cache = {}\n        self._timestamps = {}\n        self._lock = Lock()\n\n    def get(self, key: str, ttl: int = 900) -> Optional[Any]:\n        \"\"\"\n        获取缓存数据\n\n        Args:\n            key: 缓存键\n            ttl: 存活时间（秒），默认15分钟\n\n        Returns:\n            缓存的值，如果不存在或已过期则返回None\n        \"\"\"\n        with self._lock:\n            if key in self._cache:\n                # 检查是否过期\n                if time.time() - self._timestamps[key] < ttl:\n                    return self._cache[key]\n                else:\n                    # 已过期，删除缓存\n                    del self._cache[key]\n                    del self._timestamps[key]\n        return None\n\n    def set(self, key: str, value: Any) -> None:\n        \"\"\"\n        设置缓存数据\n\n        Args:\n            key: 缓存键\n            value: 缓存值\n        \"\"\"\n        with self._lock:\n            self._cache[key] = value\n            self._timestamps[key] = time.time()\n\n    def delete(self, key: str) -> bool:\n        \"\"\"\n        删除缓存\n\n        Args:\n            key: 缓存键\n\n        Returns:\n            是否成功删除\n        \"\"\"\n        with self._lock:\n            if key in self._cache:\n                del self._cache[key]\n                del self._timestamps[key]\n                return True\n        return False\n\n    def clear(self) -> None:\n        \"\"\"清空所有缓存\"\"\"\n        with self._lock:\n            self._cache.clear()\n            self._timestamps.clear()\n\n    def cleanup_expired(self, ttl: int = 900) -> int:\n        \"\"\"\n        清理过期缓存\n\n        Args:\n            ttl: 存活时间（秒）\n\n        Returns:\n            清理的条目数量\n        \"\"\"\n        with self._lock:\n            current_time = time.time()\n            expired_keys = [\n                key for key, timestamp in self._timestamps.items()\n                if current_time - timestamp >= ttl\n            ]\n\n            for key in expired_keys:\n                del self._cache[key]\n                del self._timestamps[key]\n\n            return len(expired_keys)\n\n    def get_stats(self) -> dict:\n        \"\"\"\n        获取缓存统计信息\n\n        Returns:\n            统计信息字典\n        \"\"\"\n        with self._lock:\n            return {\n                \"total_entries\": len(self._cache),\n                \"oldest_entry_age\": (\n                    time.time() - min(self._timestamps.values())\n                    if self._timestamps else 0\n                ),\n                \"newest_entry_age\": (\n                    time.time() - max(self._timestamps.values())\n                    if self._timestamps else 0\n                )\n            }\n\n\n# 全局缓存实例\n_global_cache = None\n\n\ndef get_cache() -> CacheService:\n    \"\"\"\n    获取全局缓存实例\n\n    Returns:\n        全局缓存服务实例\n    \"\"\"\n    global _global_cache\n    if _global_cache is None:\n        _global_cache = CacheService()\n    return _global_cache\n"
  },
  {
    "path": "mcp_server/services/data_service.py",
    "content": "\"\"\"\n数据访问服务\n\n提供统一的数据查询接口,封装数据访问逻辑。\n\"\"\"\n\nimport re\nfrom collections import Counter\nfrom datetime import datetime, timedelta\nfrom typing import Dict, List, Optional, Tuple\n\nfrom .cache_service import get_cache\nfrom .parser_service import ParserService\nfrom ..utils.errors import DataNotFoundError\n\n\nclass DataService:\n    \"\"\"数据访问服务类\"\"\"\n\n    # 中文停用词列表（用于 auto_extract 模式）\n    STOPWORDS = {\n        '的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都', '一',\n        '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有',\n        '看', '好', '自己', '这', '那', '来', '被', '与', '为', '对', '将', '从',\n        '以', '及', '等', '但', '或', '而', '于', '中', '由', '可', '可以', '已',\n        '已经', '还', '更', '最', '再', '因为', '所以', '如果', '虽然', '然而',\n        '什么', '怎么', '如何', '哪', '哪些', '多少', '几', '这个', '那个',\n        '他', '她', '它', '他们', '她们', '我们', '你们', '大家', '自己',\n        '这样', '那样', '怎样', '这么', '那么', '多么', '非常', '特别',\n        '应该', '可能', '能够', '需要', '必须', '一定', '肯定', '确实',\n        '正在', '已经', '曾经', '将要', '即将', '刚刚', '马上', '立刻',\n        '回应', '发布', '表示', '称', '曝', '官方', '最新', '重磅', '突发',\n        '热搜', '刷屏', '引发', '关注', '网友', '评论', '转发', '点赞'\n    }\n\n    def __init__(self, project_root: str = None):\n        \"\"\"\n        初始化数据服务\n\n        Args:\n            project_root: 项目根目录\n        \"\"\"\n        self.parser = ParserService(project_root)\n        self.cache = get_cache()\n\n    def get_latest_news(\n        self,\n        platforms: Optional[List[str]] = None,\n        limit: int = 50,\n        include_url: bool = False\n    ) -> List[Dict]:\n        \"\"\"\n        获取最新一批爬取的新闻数据\n\n        Args:\n            platforms: 平台ID列表,None表示所有平台\n            limit: 返回条数限制\n            include_url: 是否包含URL链接,默认False(节省token)\n\n        Returns:\n            新闻列表\n\n        Raises:\n            DataNotFoundError: 数据不存在\n        \"\"\"\n        # 尝试从缓存获取\n        cache_key = f\"latest_news:{','.join(platforms or [])}:{limit}:{include_url}\"\n        cached = self.cache.get(cache_key, ttl=900)  # 15分钟缓存\n        if cached:\n            return cached\n\n        # 读取今天的数据\n        all_titles, id_to_name, timestamps = self.parser.read_all_titles_for_date(\n            date=None,\n            platform_ids=platforms\n        )\n\n        # 获取最新的文件时间\n        if timestamps:\n            latest_timestamp = max(timestamps.values())\n            fetch_time = datetime.fromtimestamp(latest_timestamp)\n        else:\n            fetch_time = datetime.now()\n\n        # 转换为新闻列表\n        news_list = []\n        for platform_id, titles in all_titles.items():\n            platform_name = id_to_name.get(platform_id, platform_id)\n\n            for title, info in titles.items():\n                # 取第一个排名\n                rank = info[\"ranks\"][0] if info[\"ranks\"] else 0\n\n                news_item = {\n                    \"title\": title,\n                    \"platform\": platform_id,\n                    \"platform_name\": platform_name,\n                    \"rank\": rank,\n                    \"timestamp\": fetch_time.strftime(\"%Y-%m-%d %H:%M:%S\")\n                }\n\n                # 条件性添加 URL 字段\n                if include_url:\n                    news_item[\"url\"] = info.get(\"url\", \"\")\n                    news_item[\"mobileUrl\"] = info.get(\"mobileUrl\", \"\")\n\n                news_list.append(news_item)\n\n        # 按排名排序\n        news_list.sort(key=lambda x: x[\"rank\"])\n\n        # 限制返回数量\n        result = news_list[:limit]\n\n        # 缓存结果\n        self.cache.set(cache_key, result)\n\n        return result\n\n    def get_news_by_date(\n        self,\n        target_date: datetime,\n        platforms: Optional[List[str]] = None,\n        limit: int = 50,\n        include_url: bool = False\n    ) -> List[Dict]:\n        \"\"\"\n        按指定日期获取新闻\n\n        Args:\n            target_date: 目标日期\n            platforms: 平台ID列表,None表示所有平台\n            limit: 返回条数限制\n            include_url: 是否包含URL链接,默认False(节省token)\n\n        Returns:\n            新闻列表\n\n        Raises:\n            DataNotFoundError: 数据不存在\n\n        Examples:\n            >>> service = DataService()\n            >>> news = service.get_news_by_date(\n            ...     target_date=datetime(2025, 10, 10),\n            ...     platforms=['zhihu'],\n            ...     limit=20\n            ... )\n        \"\"\"\n        # 尝试从缓存获取\n        date_str = target_date.strftime(\"%Y-%m-%d\")\n        cache_key = f\"news_by_date:{date_str}:{','.join(platforms or [])}:{limit}:{include_url}\"\n        cached = self.cache.get(cache_key, ttl=900)  # 15分钟缓存\n        if cached:\n            return cached\n\n        # 读取指定日期的数据\n        all_titles, id_to_name, timestamps = self.parser.read_all_titles_for_date(\n            date=target_date,\n            platform_ids=platforms\n        )\n\n        # 转换为新闻列表\n        news_list = []\n        for platform_id, titles in all_titles.items():\n            platform_name = id_to_name.get(platform_id, platform_id)\n\n            for title, info in titles.items():\n                # 计算平均排名\n                avg_rank = sum(info[\"ranks\"]) / len(info[\"ranks\"]) if info[\"ranks\"] else 0\n\n                news_item = {\n                    \"title\": title,\n                    \"platform\": platform_id,\n                    \"platform_name\": platform_name,\n                    \"rank\": info[\"ranks\"][0] if info[\"ranks\"] else 0,\n                    \"avg_rank\": round(avg_rank, 2),\n                    \"count\": len(info[\"ranks\"]),\n                    \"date\": date_str\n                }\n\n                # 条件性添加 URL 字段\n                if include_url:\n                    news_item[\"url\"] = info.get(\"url\", \"\")\n                    news_item[\"mobileUrl\"] = info.get(\"mobileUrl\", \"\")\n\n                news_list.append(news_item)\n\n        # 按排名排序\n        news_list.sort(key=lambda x: x[\"rank\"])\n\n        # 限制返回数量\n        result = news_list[:limit]\n\n        # 缓存结果(历史数据缓存更久)\n        self.cache.set(cache_key, result)\n\n        return result\n\n    def search_news_by_keyword(\n        self,\n        keyword: str,\n        date_range: Optional[Tuple[datetime, datetime]] = None,\n        platforms: Optional[List[str]] = None,\n        limit: Optional[int] = None\n    ) -> Dict:\n        \"\"\"\n        按关键词搜索新闻\n\n        Args:\n            keyword: 搜索关键词\n            date_range: 日期范围 (start_date, end_date)\n            platforms: 平台过滤列表\n            limit: 返回条数限制(可选)\n\n        Returns:\n            搜索结果字典\n\n        Raises:\n            DataNotFoundError: 数据不存在\n        \"\"\"\n        # 确定搜索日期范围\n        if date_range:\n            start_date, end_date = date_range\n        else:\n            # 默认搜索今天\n            start_date = end_date = datetime.now()\n\n        # 收集所有匹配的新闻\n        results = []\n        platform_distribution = Counter()\n\n        # 遍历日期范围\n        current_date = start_date\n        while current_date <= end_date:\n            try:\n                all_titles, id_to_name, _ = self.parser.read_all_titles_for_date(\n                    date=current_date,\n                    platform_ids=platforms\n                )\n\n                # 搜索包含关键词的标题\n                for platform_id, titles in all_titles.items():\n                    platform_name = id_to_name.get(platform_id, platform_id)\n\n                    for title, info in titles.items():\n                        if keyword.lower() in title.lower():\n                            # 计算平均排名\n                            avg_rank = sum(info[\"ranks\"]) / len(info[\"ranks\"]) if info[\"ranks\"] else 0\n\n                            results.append({\n                                \"title\": title,\n                                \"platform\": platform_id,\n                                \"platform_name\": platform_name,\n                                \"ranks\": info[\"ranks\"],\n                                \"count\": len(info[\"ranks\"]),\n                                \"avg_rank\": round(avg_rank, 2),\n                                \"url\": info.get(\"url\", \"\"),\n                                \"mobileUrl\": info.get(\"mobileUrl\", \"\"),\n                                \"date\": current_date.strftime(\"%Y-%m-%d\")\n                            })\n\n                            platform_distribution[platform_id] += 1\n\n            except DataNotFoundError:\n                # 该日期没有数据,继续下一天\n                pass\n\n            # 下一天\n            current_date += timedelta(days=1)\n\n        if not results:\n            raise DataNotFoundError(\n                f\"未找到包含关键词 '{keyword}' 的新闻\",\n                suggestion=\"请尝试其他关键词或扩大日期范围\"\n            )\n\n        # 计算统计信息\n        total_ranks = []\n        for item in results:\n            total_ranks.extend(item[\"ranks\"])\n\n        avg_rank = sum(total_ranks) / len(total_ranks) if total_ranks else 0\n\n        # 限制返回数量(如果指定)\n        total_found = len(results)\n        if limit is not None and limit > 0:\n            results = results[:limit]\n\n        return {\n            \"results\": results,\n            \"total\": len(results),\n            \"total_found\": total_found,\n            \"statistics\": {\n                \"platform_distribution\": dict(platform_distribution),\n                \"avg_rank\": round(avg_rank, 2),\n                \"keyword\": keyword\n            }\n        }\n\n    def _extract_words_from_title(self, title: str, min_length: int = 2) -> List[str]:\n        \"\"\"\n        从标题中提取有意义的词语（用于 auto_extract 模式）\n\n        Args:\n            title: 新闻标题\n            min_length: 最小词长\n\n        Returns:\n            关键词列表\n        \"\"\"\n        # 移除URL和特殊字符\n        title = re.sub(r'http[s]?://\\S+', '', title)\n        title = re.sub(r'\\[.*?\\]', '', title)  # 移除方括号内容\n        title = re.sub(r'[【】《》「」『』\"\"''・·•]', '', title)  # 移除中文标点\n\n        # 使用正则表达式分词（中文和英文）\n        # 匹配连续的中文字符或英文单词\n        words = re.findall(r'[\\u4e00-\\u9fff]{2,}|[a-zA-Z]{2,}[a-zA-Z0-9]*', title)\n\n        # 过滤停用词和短词\n        keywords = [\n            word for word in words\n            if word and len(word) >= min_length and word.lower() not in self.STOPWORDS\n            and word not in self.STOPWORDS\n        ]\n\n        return keywords\n\n    def get_trending_topics(\n        self,\n        top_n: int = 10,\n        mode: str = \"current\",\n        extract_mode: str = \"keywords\"\n    ) -> Dict:\n        \"\"\"\n        获取热点话题统计\n\n        Args:\n            top_n: 返回TOP N话题\n            mode: 时间模式\n                - \"daily\": 当日累计数据统计\n                - \"current\": 最新一批数据统计（默认）\n            extract_mode: 提取模式\n                - \"keywords\": 统计预设关注词（基于 config/frequency_words.txt）\n                - \"auto_extract\": 自动从新闻标题提取高频词\n\n        Returns:\n            话题频率统计字典\n\n        Raises:\n            DataNotFoundError: 数据不存在\n        \"\"\"\n        # 尝试从缓存获取\n        cache_key = f\"trending_topics:{top_n}:{mode}:{extract_mode}\"\n        cached = self.cache.get(cache_key, ttl=900)  # 15分钟缓存\n        if cached:\n            return cached\n\n        # 读取今天的数据\n        all_titles, id_to_name, timestamps = self.parser.read_all_titles_for_date()\n\n        if not all_titles:\n            raise DataNotFoundError(\n                \"未找到今天的新闻数据\",\n                suggestion=\"请确保爬虫已经运行并生成了数据\"\n            )\n\n        # 根据 mode 选择要处理的标题数据\n        if mode == \"daily\":\n            titles_to_process = all_titles\n        elif mode == \"current\":\n            titles_to_process = all_titles  # 简化实现\n        else:\n            raise ValueError(f\"不支持的模式: {mode}。支持的模式: daily, current\")\n\n        # 统计词频\n        word_frequency = Counter()\n        keyword_to_news = {}\n\n        # 预加载关键词数据（避免在循环内重复调用）\n        if extract_mode == \"keywords\":\n            from trendradar.core.frequency import _word_matches\n            word_groups = self.parser.parse_frequency_words()\n\n        # 遍历要处理的标题\n        for platform_id, titles in titles_to_process.items():\n            for title in titles.keys():\n                if extract_mode == \"keywords\":\n                    # 基于预设关键词统计（支持正则匹配）\n                    title_lower = title.lower()\n\n                    for group in word_groups:\n                        all_words = group.get(\"required\", []) + group.get(\"normal\", [])\n                        # 检查是否匹配词组中的任意一个词\n                        matched = any(_word_matches(word_config, title_lower) for word_config in all_words)\n\n                        if matched:\n                            # 使用组的 display_name（组别名或行别名拼接）\n                            display_key = group.get(\"display_name\") or group.get(\"group_key\", \"\")\n\n                            word_frequency[display_key] += 1\n                            if display_key not in keyword_to_news:\n                                keyword_to_news[display_key] = []\n                            keyword_to_news[display_key].append(title)\n                            break  # 每个标题只计入第一个匹配的词组\n\n                elif extract_mode == \"auto_extract\":\n                    # 自动提取关键词\n                    extracted_words = self._extract_words_from_title(title)\n                    for word in extracted_words:\n                        word_frequency[word] += 1\n                        if word not in keyword_to_news:\n                            keyword_to_news[word] = []\n                        keyword_to_news[word].append(title)\n\n        # 获取TOP N关键词\n        top_keywords = word_frequency.most_common(top_n)\n\n        # 构建话题列表\n        topics = []\n        for keyword, frequency in top_keywords:\n            matched_news = keyword_to_news.get(keyword, [])\n\n            topics.append({\n                \"keyword\": keyword,\n                \"frequency\": frequency,\n                \"matched_news\": len(set(matched_news)),  # 去重后的新闻数量\n                \"trend\": \"stable\",\n                \"weight_score\": 0.0\n            })\n\n        # 构建结果\n        result = {\n            \"topics\": topics,\n            \"generated_at\": datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\"),\n            \"mode\": mode,\n            \"extract_mode\": extract_mode,\n            \"total_keywords\": len(word_frequency),\n            \"description\": self._get_mode_description(mode, extract_mode)\n        }\n\n        # 缓存结果\n        self.cache.set(cache_key, result)\n\n        return result\n\n    def _get_mode_description(self, mode: str, extract_mode: str = \"keywords\") -> str:\n        \"\"\"获取模式描述\"\"\"\n        mode_desc = {\n            \"daily\": \"当日累计统计\",\n            \"current\": \"最新一批统计\"\n        }.get(mode, \"未知时间模式\")\n\n        extract_desc = {\n            \"keywords\": \"基于预设关注词\",\n            \"auto_extract\": \"自动提取高频词\"\n        }.get(extract_mode, \"未知提取模式\")\n\n        return f\"{mode_desc} - {extract_desc}\"\n\n    def get_current_config(self, section: str = \"all\") -> Dict:\n        \"\"\"\n        获取当前系统配置\n\n        Args:\n            section: 配置节 - all/crawler/push/keywords/weights\n\n        Returns:\n            配置字典\n\n        Raises:\n            FileParseError: 配置文件解析错误\n        \"\"\"\n        # 解析配置文件\n        config_data = self.parser.parse_yaml_config()\n        word_groups = self.parser.parse_frequency_words()\n\n        # 根据section返回对应配置\n        advanced = config_data.get(\"advanced\", {})\n        advanced_crawler = advanced.get(\"crawler\", {})\n        platforms_config = config_data.get(\"platforms\", {})\n\n        if section == \"all\" or section == \"crawler\":\n            crawler_config = {\n                \"enable_crawler\": platforms_config.get(\"enabled\", True),\n                \"use_proxy\": advanced_crawler.get(\"use_proxy\", False),\n                \"request_interval\": advanced_crawler.get(\"request_interval\", 1),\n                \"retry_times\": 3,\n                \"platforms\": [p[\"id\"] for p in platforms_config.get(\"sources\", [])]\n            }\n\n        if section == \"all\" or section == \"push\":\n            notification = config_data.get(\"notification\", {})\n            batch_size = advanced.get(\"batch_size\", {})\n            push_config = {\n                \"enable_notification\": notification.get(\"enabled\", True),\n                \"enabled_channels\": [],\n                \"message_batch_size\": batch_size.get(\"default\", 4000),\n                \"push_window\": {}  # 已迁移至调度系统（schedule + timeline.yaml）\n            }\n\n            # 检测已配置的通知渠道（合并 config.yaml + .env）\n            from trendradar.core.loader import _load_webhook_config\n\n            webhook_config = _load_webhook_config(config_data)\n\n            channel_checks = {\n                \"feishu\": [webhook_config.get(\"FEISHU_WEBHOOK_URL\")],\n                \"dingtalk\": [webhook_config.get(\"DINGTALK_WEBHOOK_URL\")],\n                \"wework\": [webhook_config.get(\"WEWORK_WEBHOOK_URL\")],\n                \"telegram\": [webhook_config.get(\"TELEGRAM_BOT_TOKEN\"), webhook_config.get(\"TELEGRAM_CHAT_ID\")],\n                \"email\": [webhook_config.get(\"EMAIL_FROM\"), webhook_config.get(\"EMAIL_PASSWORD\"), webhook_config.get(\"EMAIL_TO\")],\n                \"ntfy\": [webhook_config.get(\"NTFY_SERVER_URL\"), webhook_config.get(\"NTFY_TOPIC\")],\n                \"bark\": [webhook_config.get(\"BARK_URL\")],\n                \"slack\": [webhook_config.get(\"SLACK_WEBHOOK_URL\")],\n                \"generic_webhook\": [webhook_config.get(\"GENERIC_WEBHOOK_URL\")],\n            }\n            for ch_id, required_values in channel_checks.items():\n                if all(required_values):\n                    push_config[\"enabled_channels\"].append(ch_id)\n\n        if section == \"all\" or section == \"keywords\":\n            keywords_config = {\n                \"word_groups\": word_groups,\n                \"total_groups\": len(word_groups)\n            }\n\n        if section == \"all\" or section == \"weights\":\n            weight = advanced.get(\"weight\", {})\n            weights_config = {\n                \"rank_weight\": weight.get(\"rank\", 0.6),\n                \"frequency_weight\": weight.get(\"frequency\", 0.3),\n                \"hotness_weight\": weight.get(\"hotness\", 0.1)\n            }\n\n        # 组装结果\n        if section == \"all\":\n            result = {\n                \"crawler\": crawler_config,\n                \"push\": push_config,\n                \"keywords\": keywords_config,\n                \"weights\": weights_config\n            }\n        elif section == \"crawler\":\n            result = crawler_config\n        elif section == \"push\":\n            result = push_config\n        elif section == \"keywords\":\n            result = keywords_config\n        elif section == \"weights\":\n            result = weights_config\n        else:\n            result = {}\n\n        return result\n\n    def get_available_date_range(self, db_type: str = \"news\") -> Tuple[Optional[datetime], Optional[datetime]]:\n        \"\"\"\n        扫描 output 目录，返回实际可用的日期范围\n\n        Args:\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            (最早日期, 最新日期) 元组，如果没有数据则返回 (None, None)\n\n        Examples:\n            >>> service = DataService()\n            >>> earliest, latest = service.get_available_date_range()\n            >>> print(f\"可用日期范围：{earliest} 至 {latest}\")\n        \"\"\"\n        return self.parser.get_available_date_range(db_type)\n\n    def get_system_status(self) -> Dict:\n        \"\"\"\n        获取系统运行状态\n\n        Returns:\n            系统状态字典\n        \"\"\"\n        # 获取数据统计\n        output_dir = self.parser.project_root / \"output\"\n\n        total_storage = 0\n\n        # 使用 parser 的方法获取日期范围\n        oldest_record, latest_record = self.get_available_date_range(db_type=\"news\")\n\n        # 计算 output 目录总存储大小\n        if output_dir.exists():\n            for item in output_dir.rglob(\"*\"):\n                if item.is_file():\n                    total_storage += item.stat().st_size\n\n        # 读取版本信息\n        version_file = self.parser.project_root / \"version\"\n        version = \"unknown\"\n        if version_file.exists():\n            try:\n                with open(version_file, \"r\") as f:\n                    version = f.read().strip()\n            except:\n                pass\n\n        return {\n            \"system\": {\n                \"version\": version,\n                \"project_root\": str(self.parser.project_root)\n            },\n            \"data\": {\n                \"total_storage\": f\"{total_storage / 1024 / 1024:.2f} MB\",\n                \"oldest_record\": oldest_record.strftime(\"%Y-%m-%d\") if oldest_record else None,\n                \"latest_record\": latest_record.strftime(\"%Y-%m-%d\") if latest_record else None,\n            },\n            \"cache\": self.cache.get_stats(),\n            \"health\": \"healthy\"\n        }\n\n    # ========================================\n    # RSS 数据查询方法\n    # ========================================\n\n    def get_latest_rss(\n        self,\n        feeds: Optional[List[str]] = None,\n        days: int = 1,\n        limit: int = 50,\n        include_summary: bool = False\n    ) -> List[Dict]:\n        \"\"\"\n        获取最新的 RSS 数据（支持多日查询）\n\n        Args:\n            feeds: RSS 源 ID 列表，None 表示所有源\n            days: 获取最近 N 天的数据，默认 1（仅今天），最大 30 天\n            limit: 返回条数限制\n            include_summary: 是否包含摘要，默认 False（节省 token）\n\n        Returns:\n            RSS 条目列表（按 URL 去重）\n\n        Raises:\n            DataNotFoundError: 数据不存在\n        \"\"\"\n        days = min(max(days, 1), 30)  # 限制 1-30 天\n        cache_key = f\"latest_rss:{','.join(feeds or [])}:{days}:{limit}:{include_summary}\"\n        cached = self.cache.get(cache_key, ttl=900)\n        if cached:\n            return cached\n\n        rss_list = []\n        seen_urls = set()  # 跨日期 URL 去重\n        today = datetime.now()\n\n        for i in range(days):\n            target_date = today - timedelta(days=i)\n\n            try:\n                all_items, id_to_name, timestamps = self.parser.read_all_titles_for_date(\n                    date=target_date,\n                    platform_ids=feeds,\n                    db_type=\"rss\"\n                )\n\n                # 获取抓取时间\n                if timestamps:\n                    latest_timestamp = max(timestamps.values())\n                    fetch_time = datetime.fromtimestamp(latest_timestamp)\n                else:\n                    fetch_time = target_date\n\n                # 转换为列表\n                for feed_id, items in all_items.items():\n                    feed_name = id_to_name.get(feed_id, feed_id)\n\n                    for title, info in items.items():\n                        # 跨日期 URL 去重\n                        url = info.get(\"url\", \"\")\n                        if url and url in seen_urls:\n                            continue\n                        if url:\n                            seen_urls.add(url)\n\n                        rss_item = {\n                            \"title\": title,\n                            \"feed_id\": feed_id,\n                            \"feed_name\": feed_name,\n                            \"url\": url,\n                            \"published_at\": info.get(\"published_at\", \"\"),\n                            \"author\": info.get(\"author\", \"\"),\n                            \"date\": target_date.strftime(\"%Y-%m-%d\"),\n                            \"fetch_time\": fetch_time.strftime(\"%Y-%m-%d %H:%M:%S\") if isinstance(fetch_time, datetime) else target_date.strftime(\"%Y-%m-%d\")\n                        }\n\n                        if include_summary:\n                            rss_item[\"summary\"] = info.get(\"summary\", \"\")\n\n                        rss_list.append(rss_item)\n\n            except DataNotFoundError:\n                continue\n\n        # 按发布时间排序（最新的在前）\n        rss_list.sort(key=lambda x: x.get(\"published_at\", \"\"), reverse=True)\n\n        # 限制返回数量\n        result = rss_list[:limit]\n\n        # 缓存结果\n        self.cache.set(cache_key, result)\n\n        return result\n\n    def search_rss(\n        self,\n        keyword: str,\n        feeds: Optional[List[str]] = None,\n        days: int = 7,\n        limit: int = 50,\n        include_summary: bool = False\n    ) -> List[Dict]:\n        \"\"\"\n        搜索 RSS 数据（跨日期自动去重）\n\n        Args:\n            keyword: 搜索关键词\n            feeds: RSS 源 ID 列表，None 表示所有源\n            days: 搜索最近 N 天的数据\n            limit: 返回条数限制\n            include_summary: 是否包含摘要\n\n        Returns:\n            匹配的 RSS 条目列表（按 URL 去重）\n        \"\"\"\n        cache_key = f\"search_rss:{keyword}:{','.join(feeds or [])}:{days}:{limit}:{include_summary}\"\n        cached = self.cache.get(cache_key, ttl=900)\n        if cached:\n            return cached\n\n        results = []\n        seen_urls = set()  # 用于 URL 去重\n        today = datetime.now()\n\n        for i in range(days):\n            target_date = today - timedelta(days=i)\n\n            try:\n                all_items, id_to_name, _ = self.parser.read_all_titles_for_date(\n                    date=target_date,\n                    platform_ids=feeds,\n                    db_type=\"rss\"\n                )\n\n                for feed_id, items in all_items.items():\n                    feed_name = id_to_name.get(feed_id, feed_id)\n\n                    for title, info in items.items():\n                        # 跨日期去重：如果 URL 已出现过则跳过\n                        url = info.get(\"url\", \"\")\n                        if url and url in seen_urls:\n                            continue\n                        if url:\n                            seen_urls.add(url)\n\n                        # 关键词匹配（标题或摘要）\n                        summary = info.get(\"summary\", \"\")\n                        if keyword.lower() in title.lower() or keyword.lower() in summary.lower():\n                            rss_item = {\n                                \"title\": title,\n                                \"feed_id\": feed_id,\n                                \"feed_name\": feed_name,\n                                \"url\": url,\n                                \"published_at\": info.get(\"published_at\", \"\"),\n                                \"author\": info.get(\"author\", \"\"),\n                                \"date\": target_date.strftime(\"%Y-%m-%d\")\n                            }\n\n                            if include_summary:\n                                rss_item[\"summary\"] = summary\n\n                            results.append(rss_item)\n\n            except DataNotFoundError:\n                continue\n\n        # 按发布时间排序\n        results.sort(key=lambda x: x.get(\"published_at\", \"\"), reverse=True)\n\n        # 限制返回数量\n        result = results[:limit]\n\n        # 缓存结果\n        self.cache.set(cache_key, result)\n\n        return result\n\n    def get_rss_feeds_status(self) -> Dict:\n        \"\"\"\n        获取 RSS 源状态\n\n        Returns:\n            RSS 源状态信息\n        \"\"\"\n        cache_key = \"rss_feeds_status\"\n        cached = self.cache.get(cache_key, ttl=900)\n        if cached:\n            return cached\n\n        # 获取可用的 RSS 日期\n        available_dates = self.parser.get_available_dates(db_type=\"rss\")\n\n        # 获取今天的 RSS 数据统计\n        today_stats = {}\n        try:\n            all_items, id_to_name, _ = self.parser.read_all_titles_for_date(\n                date=None,\n                platform_ids=None,\n                db_type=\"rss\"\n            )\n\n            for feed_id, items in all_items.items():\n                today_stats[feed_id] = {\n                    \"name\": id_to_name.get(feed_id, feed_id),\n                    \"item_count\": len(items)\n                }\n\n        except DataNotFoundError:\n            pass\n\n        result = {\n            \"available_dates\": available_dates[:10],  # 最近 10 天\n            \"total_dates\": len(available_dates),\n            \"today_feeds\": today_stats,\n            \"generated_at\": datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n        }\n\n        self.cache.set(cache_key, result)\n\n        return result\n"
  },
  {
    "path": "mcp_server/services/parser_service.py",
    "content": "\"\"\"\n数据解析服务\n\nv2.0.0: 仅支持 SQLite 数据库，移除 TXT 文件支持\n新存储结构：output/{type}/{date}.db\n\"\"\"\n\nimport re\nimport sqlite3\nfrom pathlib import Path\nfrom typing import Dict, List, Tuple, Optional\nfrom datetime import datetime\n\nimport yaml\n\nfrom ..utils.errors import FileParseError, DataNotFoundError\nfrom .cache_service import get_cache\n\n\nclass ParserService:\n    \"\"\"数据解析服务类\"\"\"\n\n    def __init__(self, project_root: str = None):\n        \"\"\"\n        初始化解析服务\n\n        Args:\n            project_root: 项目根目录，默认为当前目录的父目录\n        \"\"\"\n        if project_root is None:\n            current_file = Path(__file__)\n            self.project_root = current_file.parent.parent.parent\n        else:\n            self.project_root = Path(project_root)\n\n        self.cache = get_cache()\n\n        # frequency_words.txt mtime 缓存\n        self._freq_words_cache: Optional[List[Dict]] = None\n        self._freq_words_mtime: float = 0.0\n\n    @staticmethod\n    def clean_title(title: str) -> str:\n        \"\"\"清理标题文本\"\"\"\n        title = re.sub(r'\\s+', ' ', title)\n        title = title.strip()\n        return title\n\n    def get_date_folder_name(self, date: datetime = None) -> str:\n        \"\"\"\n        获取日期字符串（ISO 格式）\n\n        Args:\n            date: 日期对象，默认为今天\n\n        Returns:\n            日期字符串（YYYY-MM-DD）\n        \"\"\"\n        if date is None:\n            date = datetime.now()\n        return date.strftime(\"%Y-%m-%d\")\n\n    def _get_db_path(self, date: datetime = None, db_type: str = \"news\") -> Optional[Path]:\n        \"\"\"\n        获取数据库文件路径\n\n        新结构：output/{type}/{date}.db\n\n        Args:\n            date: 日期对象，默认为今天\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            数据库文件路径，如果不存在则返回 None\n        \"\"\"\n        date_str = self.get_date_folder_name(date)\n        db_path = self.project_root / \"output\" / db_type / f\"{date_str}.db\"\n        if db_path.exists():\n            return db_path\n        return None\n\n    def _read_from_sqlite(\n        self,\n        date: datetime = None,\n        platform_ids: Optional[List[str]] = None,\n        db_type: str = \"news\"\n    ) -> Optional[Tuple[Dict, Dict, Dict]]:\n        \"\"\"\n        从 SQLite 数据库读取数据\n\n        Args:\n            date: 日期对象，默认为今天\n            platform_ids: 平台ID列表，None表示所有平台\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            (all_titles, id_to_name, all_timestamps) 元组，如果数据库不存在返回 None\n        \"\"\"\n        db_path = self._get_db_path(date, db_type)\n        if db_path is None:\n            return None\n\n        all_titles = {}\n        id_to_name = {}\n        all_timestamps = {}\n\n        try:\n            conn = sqlite3.connect(str(db_path))\n            conn.row_factory = sqlite3.Row\n            cursor = conn.cursor()\n\n            if db_type == \"news\":\n                return self._read_news_from_sqlite(cursor, platform_ids, all_titles, id_to_name, all_timestamps)\n            elif db_type == \"rss\":\n                return self._read_rss_from_sqlite(cursor, platform_ids, all_titles, id_to_name, all_timestamps)\n\n        except Exception as e:\n            print(f\"Warning: 从 SQLite 读取数据失败: {e}\")\n            return None\n        finally:\n            if 'conn' in locals():\n                conn.close()\n\n    def _read_news_from_sqlite(\n        self,\n        cursor,\n        platform_ids: Optional[List[str]],\n        all_titles: Dict,\n        id_to_name: Dict,\n        all_timestamps: Dict\n    ) -> Optional[Tuple[Dict, Dict, Dict]]:\n        \"\"\"从热榜数据库读取数据\"\"\"\n        # 检查表是否存在\n        cursor.execute(\"\"\"\n            SELECT name FROM sqlite_master\n            WHERE type='table' AND name='news_items'\n        \"\"\")\n        if not cursor.fetchone():\n            return None\n\n        # 构建查询\n        if platform_ids:\n            placeholders = ','.join(['?' for _ in platform_ids])\n            query = f\"\"\"\n                SELECT n.id, n.platform_id, p.name as platform_name, n.title,\n                       n.rank, n.url, n.mobile_url,\n                       n.first_crawl_time, n.last_crawl_time, n.crawl_count\n                FROM news_items n\n                LEFT JOIN platforms p ON n.platform_id = p.id\n                WHERE n.platform_id IN ({placeholders})\n            \"\"\"\n            cursor.execute(query, platform_ids)\n        else:\n            cursor.execute(\"\"\"\n                SELECT n.id, n.platform_id, p.name as platform_name, n.title,\n                       n.rank, n.url, n.mobile_url,\n                       n.first_crawl_time, n.last_crawl_time, n.crawl_count\n                FROM news_items n\n                LEFT JOIN platforms p ON n.platform_id = p.id\n            \"\"\")\n\n        rows = cursor.fetchall()\n\n        # 收集所有 news_item_id 用于查询历史排名\n        news_ids = [row['id'] for row in rows]\n        rank_history_map = {}\n\n        if news_ids:\n            placeholders = \",\".join(\"?\" * len(news_ids))\n            cursor.execute(f\"\"\"\n                SELECT news_item_id, rank FROM rank_history\n                WHERE news_item_id IN ({placeholders})\n                ORDER BY news_item_id, crawl_time\n            \"\"\", news_ids)\n\n            for rh_row in cursor.fetchall():\n                news_id = rh_row['news_item_id']\n                rank = rh_row['rank']\n                if news_id not in rank_history_map:\n                    rank_history_map[news_id] = []\n                rank_history_map[news_id].append(rank)\n\n        for row in rows:\n            news_id = row['id']\n            platform_id = row['platform_id']\n            platform_name = row['platform_name'] or platform_id\n            title = row['title']\n\n            if platform_id not in id_to_name:\n                id_to_name[platform_id] = platform_name\n\n            if platform_id not in all_titles:\n                all_titles[platform_id] = {}\n\n            ranks = rank_history_map.get(news_id, [row['rank']])\n\n            all_titles[platform_id][title] = {\n                \"ranks\": ranks,\n                \"url\": row['url'] or \"\",\n                \"mobileUrl\": row['mobile_url'] or \"\",\n                \"first_time\": row['first_crawl_time'] or \"\",\n                \"last_time\": row['last_crawl_time'] or \"\",\n                \"count\": row['crawl_count'] or 1,\n            }\n\n        # 获取抓取时间作为 timestamps\n        cursor.execute(\"\"\"\n            SELECT crawl_time, created_at FROM crawl_records\n            ORDER BY crawl_time\n        \"\"\")\n        for row in cursor.fetchall():\n            crawl_time = row['crawl_time']\n            created_at = row['created_at']\n            try:\n                ts = datetime.strptime(created_at, \"%Y-%m-%d %H:%M:%S\").timestamp()\n            except (ValueError, TypeError):\n                ts = datetime.now().timestamp()\n            all_timestamps[f\"{crawl_time}.db\"] = ts\n\n        if not all_titles:\n            return None\n\n        return (all_titles, id_to_name, all_timestamps)\n\n    def _read_rss_from_sqlite(\n        self,\n        cursor,\n        feed_ids: Optional[List[str]],\n        all_items: Dict,\n        id_to_name: Dict,\n        all_timestamps: Dict\n    ) -> Optional[Tuple[Dict, Dict, Dict]]:\n        \"\"\"从 RSS 数据库读取数据\"\"\"\n        # 检查表是否存在\n        cursor.execute(\"\"\"\n            SELECT name FROM sqlite_master\n            WHERE type='table' AND name='rss_items'\n        \"\"\")\n        if not cursor.fetchone():\n            return None\n\n        # 构建查询\n        if feed_ids:\n            placeholders = ','.join(['?' for _ in feed_ids])\n            query = f\"\"\"\n                SELECT i.id, i.feed_id, f.name as feed_name, i.title,\n                       i.url, i.published_at, i.summary, i.author,\n                       i.first_crawl_time, i.last_crawl_time, i.crawl_count\n                FROM rss_items i\n                LEFT JOIN rss_feeds f ON i.feed_id = f.id\n                WHERE i.feed_id IN ({placeholders})\n                ORDER BY i.published_at DESC\n            \"\"\"\n            cursor.execute(query, feed_ids)\n        else:\n            cursor.execute(\"\"\"\n                SELECT i.id, i.feed_id, f.name as feed_name, i.title,\n                       i.url, i.published_at, i.summary, i.author,\n                       i.first_crawl_time, i.last_crawl_time, i.crawl_count\n                FROM rss_items i\n                LEFT JOIN rss_feeds f ON i.feed_id = f.id\n                ORDER BY i.published_at DESC\n            \"\"\")\n\n        rows = cursor.fetchall()\n\n        for row in rows:\n            feed_id = row['feed_id']\n            feed_name = row['feed_name'] or feed_id\n            title = row['title']\n\n            if feed_id not in id_to_name:\n                id_to_name[feed_id] = feed_name\n\n            if feed_id not in all_items:\n                all_items[feed_id] = {}\n\n            all_items[feed_id][title] = {\n                \"url\": row['url'] or \"\",\n                \"published_at\": row['published_at'] or \"\",\n                \"summary\": row['summary'] or \"\",\n                \"author\": row['author'] or \"\",\n                \"first_time\": row['first_crawl_time'] or \"\",\n                \"last_time\": row['last_crawl_time'] or \"\",\n                \"count\": row['crawl_count'] or 1,\n            }\n\n        # 获取抓取时间\n        cursor.execute(\"\"\"\n            SELECT crawl_time, created_at FROM rss_crawl_records\n            ORDER BY crawl_time\n        \"\"\")\n        for row in cursor.fetchall():\n            crawl_time = row['crawl_time']\n            created_at = row['created_at']\n            try:\n                ts = datetime.strptime(created_at, \"%Y-%m-%d %H:%M:%S\").timestamp()\n            except (ValueError, TypeError):\n                ts = datetime.now().timestamp()\n            all_timestamps[f\"{crawl_time}.db\"] = ts\n\n        if not all_items:\n            return None\n\n        return (all_items, id_to_name, all_timestamps)\n\n    def read_all_titles_for_date(\n        self,\n        date: datetime = None,\n        platform_ids: Optional[List[str]] = None,\n        db_type: str = \"news\"\n    ) -> Tuple[Dict, Dict, Dict]:\n        \"\"\"\n        读取指定日期的所有数据（带缓存）\n\n        Args:\n            date: 日期对象，默认为今天\n            platform_ids: 平台/Feed ID列表，None表示所有\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            (all_titles, id_to_name, all_timestamps) 元组\n\n        Raises:\n            DataNotFoundError: 数据不存在\n        \"\"\"\n        date_str = self.get_date_folder_name(date)\n        platform_key = ','.join(sorted(platform_ids)) if platform_ids else 'all'\n        cache_key = f\"read_all:{db_type}:{date_str}:{platform_key}\"\n\n        is_today = (date is None) or (date.date() == datetime.now().date())\n        ttl = 900 if is_today else 900\n\n        cached = self.cache.get(cache_key, ttl=ttl)\n        if cached:\n            return cached\n\n        result = self._read_from_sqlite(date, platform_ids, db_type)\n        if result:\n            self.cache.set(cache_key, result)\n            return result\n\n        raise DataNotFoundError(\n            f\"未找到 {date_str} 的 {db_type} 数据\",\n            suggestion=\"请先运行爬虫或检查日期是否正确\"\n        )\n\n    def parse_yaml_config(self, config_path: str = None) -> dict:\n        \"\"\"\n        解析YAML配置文件\n\n        Args:\n            config_path: 配置文件路径，默认为 config/config.yaml\n\n        Returns:\n            配置字典\n\n        Raises:\n            FileParseError: 配置文件解析错误\n        \"\"\"\n        if config_path is None:\n            config_path = self.project_root / \"config\" / \"config.yaml\"\n        else:\n            config_path = Path(config_path)\n\n        if not config_path.exists():\n            raise FileParseError(str(config_path), \"配置文件不存在\")\n\n        try:\n            with open(config_path, \"r\", encoding=\"utf-8\") as f:\n                config_data = yaml.safe_load(f)\n            return config_data\n        except Exception as e:\n            raise FileParseError(str(config_path), str(e))\n\n    def parse_frequency_words(self, words_file: str = None) -> List[Dict]:\n        \"\"\"\n        解析关键词配置文件（带 mtime 缓存）\n\n        仅当 frequency_words.txt 被修改时才重新解析，避免循环内重复 IO。\n\n        复用 trendradar.core.frequency 的解析逻辑，支持：\n        - # 开头的注释行\n        - 空行分隔词组\n        - [组别名] 作为词组第一行，给整组指定别名\n        - +前缀必须词、!前缀过滤词、@数量限制\n        - /pattern/ 正则表达式语法\n        - => 别名 显示名称语法\n        - [GLOBAL_FILTER] 全局过滤区域\n\n        显示名称优先级：组别名 > 行别名拼接 > 关键词拼接\n\n        Args:\n            words_file: 关键词文件路径，默认为 config/frequency_words.txt\n\n        Returns:\n            词组列表\n\n        Raises:\n            FileParseError: 文件解析错误\n        \"\"\"\n        import os\n        from trendradar.core.frequency import load_frequency_words\n\n        if words_file is None:\n            words_file = str(self.project_root / \"config\" / \"frequency_words.txt\")\n        else:\n            words_file = str(words_file)\n\n        try:\n            current_mtime = os.path.getmtime(words_file)\n\n            if self._freq_words_cache is not None and current_mtime == self._freq_words_mtime:\n                return self._freq_words_cache\n\n            word_groups, filter_words, global_filters = load_frequency_words(words_file)\n            self._freq_words_cache = word_groups\n            self._freq_words_mtime = current_mtime\n            return word_groups\n        except FileNotFoundError:\n            return []\n        except Exception as e:\n            raise FileParseError(words_file, str(e))\n\n    def get_available_dates(self, db_type: str = \"news\") -> List[str]:\n        \"\"\"\n        获取可用的日期列表\n\n        Args:\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            日期字符串列表（YYYY-MM-DD 格式，降序排列）\n        \"\"\"\n        db_dir = self.project_root / \"output\" / db_type\n        if not db_dir.exists():\n            return []\n\n        dates = []\n        for db_file in db_dir.glob(\"*.db\"):\n            date_match = re.match(r'(\\d{4}-\\d{2}-\\d{2})\\.db$', db_file.name)\n            if date_match:\n                dates.append(date_match.group(1))\n\n        return sorted(dates, reverse=True)\n\n    def get_available_date_range(self, db_type: str = \"news\") -> Tuple[Optional[datetime], Optional[datetime]]:\n        \"\"\"\n        获取可用的日期范围\n\n        Args:\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            (最早日期, 最新日期) 元组，如果没有数据则返回 (None, None)\n        \"\"\"\n        dates = self.get_available_dates(db_type)\n        if not dates:\n            return (None, None)\n\n        earliest = datetime.strptime(dates[-1], \"%Y-%m-%d\")\n        latest = datetime.strptime(dates[0], \"%Y-%m-%d\")\n        return (earliest, latest)\n"
  },
  {
    "path": "mcp_server/tools/__init__.py",
    "content": "\"\"\"\nMCP 工具模块\n\n包含所有MCP工具的实现。\n\"\"\"\n"
  },
  {
    "path": "mcp_server/tools/analytics.py",
    "content": "\"\"\"\n高级数据分析工具\n\n提供热度趋势分析、平台对比、关键词共现、情感分析等高级分析功能。\n\"\"\"\n\nimport os\nimport re\nfrom collections import Counter, defaultdict\nfrom datetime import datetime, timedelta\nfrom typing import Dict, List, Optional, Union\nfrom difflib import SequenceMatcher\n\nimport yaml\n\nfrom trendradar.core.analyzer import calculate_news_weight as _calculate_news_weight\n\nfrom ..services.data_service import DataService\nfrom ..utils.validators import (\n    validate_platforms,\n    validate_limit,\n    validate_keyword,\n    validate_top_n,\n    validate_date_range,\n    validate_threshold\n)\nfrom ..utils.errors import MCPError, InvalidParameterError, DataNotFoundError\n\n\n# 权重配置 mtime 缓存（避免重复读取同一配置文件）\n_weight_config_cache: Optional[Dict] = None\n_weight_config_mtime: float = 0.0\n_weight_config_path: Optional[str] = None\n\n_WEIGHT_DEFAULT_CONFIG = {\n    \"RANK_WEIGHT\": 0.6,\n    \"FREQUENCY_WEIGHT\": 0.3,\n    \"HOTNESS_WEIGHT\": 0.1,\n}\n\n\ndef _get_weight_config() -> Dict:\n    \"\"\"\n    从 config.yaml 读取权重配置（带 mtime 缓存）\n\n    仅当配置文件被修改时才重新读取，避免循环内重复 IO。\n\n    Returns:\n        权重配置字典，包含 RANK_WEIGHT, FREQUENCY_WEIGHT, HOTNESS_WEIGHT\n    \"\"\"\n    global _weight_config_cache, _weight_config_mtime, _weight_config_path\n\n    try:\n        # 首次调用时计算路径（之后复用）\n        if _weight_config_path is None:\n            current_dir = os.path.dirname(os.path.abspath(__file__))\n            _weight_config_path = os.path.normpath(\n                os.path.join(current_dir, \"..\", \"..\", \"config\", \"config.yaml\")\n            )\n\n        current_mtime = os.path.getmtime(_weight_config_path)\n\n        # 文件未修改且缓存有效，直接返回\n        if _weight_config_cache is not None and current_mtime == _weight_config_mtime:\n            return _weight_config_cache\n\n        # 文件已修改或首次读取，重新解析\n        with open(_weight_config_path, 'r', encoding='utf-8') as f:\n            config = yaml.safe_load(f)\n            weight = config.get('advanced', {}).get('weight', {})\n            _weight_config_cache = {\n                \"RANK_WEIGHT\": weight.get('rank', 0.6),\n                \"FREQUENCY_WEIGHT\": weight.get('frequency', 0.3),\n                \"HOTNESS_WEIGHT\": weight.get('hotness', 0.1),\n            }\n            _weight_config_mtime = current_mtime\n            return _weight_config_cache\n    except Exception:\n        return _WEIGHT_DEFAULT_CONFIG\n\n\ndef calculate_news_weight(news_data: Dict, rank_threshold: int = 5) -> float:\n    \"\"\"\n    计算新闻权重（用于排序）\n\n    复用 trendradar.core.analyzer.calculate_news_weight 实现，\n    权重配置从 config.yaml 的 advanced.weight 读取。\n\n    Args:\n        news_data: 新闻数据字典，包含 ranks 和 count 字段\n        rank_threshold: 高排名阈值，默认5\n\n    Returns:\n        权重分数（0-100之间的浮点数）\n    \"\"\"\n    return _calculate_news_weight(news_data, rank_threshold, _get_weight_config())\n\n\nclass AnalyticsTools:\n    \"\"\"高级数据分析工具类\"\"\"\n\n    def __init__(self, project_root: str = None):\n        \"\"\"\n        初始化分析工具\n\n        Args:\n            project_root: 项目根目录\n        \"\"\"\n        self.data_service = DataService(project_root)\n\n    def analyze_data_insights_unified(\n        self,\n        insight_type: str = \"platform_compare\",\n        topic: Optional[str] = None,\n        date_range: Optional[Union[Dict[str, str], str]] = None,\n        min_frequency: int = 3,\n        top_n: int = 20\n    ) -> Dict:\n        \"\"\"\n        统一数据洞察分析工具 - 整合多种数据分析模式\n\n        Args:\n            insight_type: 洞察类型，可选值：\n                - \"platform_compare\": 平台对比分析（对比不同平台对话题的关注度）\n                - \"platform_activity\": 平台活跃度统计（统计各平台发布频率和活跃时间）\n                - \"keyword_cooccur\": 关键词共现分析（分析关键词同时出现的模式）\n            topic: 话题关键词（可选，platform_compare模式适用）\n            date_range: 日期范围，格式: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}\n            min_frequency: 最小共现频次（keyword_cooccur模式），默认3\n            top_n: 返回TOP N结果（keyword_cooccur模式），默认20\n\n        Returns:\n            数据洞察分析结果字典\n\n        Examples:\n            - analyze_data_insights_unified(insight_type=\"platform_compare\", topic=\"人工智能\")\n            - analyze_data_insights_unified(insight_type=\"platform_activity\", date_range={...})\n            - analyze_data_insights_unified(insight_type=\"keyword_cooccur\", min_frequency=5)\n        \"\"\"\n        try:\n            # 参数验证\n            if insight_type not in [\"platform_compare\", \"platform_activity\", \"keyword_cooccur\"]:\n                raise InvalidParameterError(\n                    f\"无效的洞察类型: {insight_type}\",\n                    suggestion=\"支持的类型: platform_compare, platform_activity, keyword_cooccur\"\n                )\n\n            # 根据洞察类型调用相应方法\n            if insight_type == \"platform_compare\":\n                return self.compare_platforms(\n                    topic=topic,\n                    date_range=date_range\n                )\n            elif insight_type == \"platform_activity\":\n                return self.get_platform_activity_stats(\n                    date_range=date_range\n                )\n            else:  # keyword_cooccur\n                return self.analyze_keyword_cooccurrence(\n                    min_frequency=min_frequency,\n                    top_n=top_n\n                )\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def analyze_topic_trend_unified(\n        self,\n        topic: str,\n        analysis_type: str = \"trend\",\n        date_range: Optional[Union[Dict[str, str], str]] = None,\n        granularity: str = \"day\",\n        threshold: float = 3.0,\n        time_window: int = 24,\n        lookahead_hours: int = 6,\n        confidence_threshold: float = 0.7\n    ) -> Dict:\n        \"\"\"\n        统一话题趋势分析工具 - 整合多种趋势分析模式\n\n        Args:\n            topic: 话题关键词（必需）\n            analysis_type: 分析类型，可选值：\n                - \"trend\": 热度趋势分析（追踪话题的热度变化）\n                - \"lifecycle\": 生命周期分析（从出现到消失的完整周期）\n                - \"viral\": 异常热度检测（识别突然爆火的话题）\n                - \"predict\": 话题预测（预测未来可能的热点）\n            date_range: 日期范围（trend和lifecycle模式），可选\n                       - **格式**: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}\n                       - **默认**: 不指定时默认分析最近7天\n            granularity: 时间粒度（trend模式），默认\"day\"（hour/day）\n            threshold: 热度突增倍数阈值（viral模式），默认3.0\n            time_window: 检测时间窗口小时数（viral模式），默认24\n            lookahead_hours: 预测未来小时数（predict模式），默认6\n            confidence_threshold: 置信度阈值（predict模式），默认0.7\n\n        Returns:\n            趋势分析结果字典\n\n        Examples (假设今天是 2025-11-17):\n            - 用户：\"分析AI最近7天的趋势\" → analyze_topic_trend_unified(topic=\"人工智能\", analysis_type=\"trend\", date_range={\"start\": \"2025-11-11\", \"end\": \"2025-11-17\"})\n            - 用户：\"看看特斯拉本月的热度\" → analyze_topic_trend_unified(topic=\"特斯拉\", analysis_type=\"lifecycle\", date_range={\"start\": \"2025-11-01\", \"end\": \"2025-11-17\"})\n            - analyze_topic_trend_unified(topic=\"比特币\", analysis_type=\"viral\", threshold=3.0)\n            - analyze_topic_trend_unified(topic=\"ChatGPT\", analysis_type=\"predict\", lookahead_hours=6)\n        \"\"\"\n        try:\n            # 参数验证\n            topic = validate_keyword(topic)\n\n            if analysis_type not in [\"trend\", \"lifecycle\", \"viral\", \"predict\"]:\n                raise InvalidParameterError(\n                    f\"无效的分析类型: {analysis_type}\",\n                    suggestion=\"支持的类型: trend, lifecycle, viral, predict\"\n                )\n\n            # 根据分析类型调用相应方法\n            if analysis_type == \"trend\":\n                return self.get_topic_trend_analysis(\n                    topic=topic,\n                    date_range=date_range,\n                    granularity=granularity\n                )\n            elif analysis_type == \"lifecycle\":\n                return self.analyze_topic_lifecycle(\n                    topic=topic,\n                    date_range=date_range\n                )\n            elif analysis_type == \"viral\":\n                # viral模式不需要topic参数，使用通用检测\n                return self.detect_viral_topics(\n                    threshold=threshold,\n                    time_window=time_window\n                )\n            else:  # predict\n                # predict模式不需要topic参数，使用通用预测\n                return self.predict_trending_topics(\n                    lookahead_hours=lookahead_hours,\n                    confidence_threshold=confidence_threshold\n                )\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def get_topic_trend_analysis(\n        self,\n        topic: str,\n        date_range: Optional[Union[Dict[str, str], str]] = None,\n        granularity: str = \"day\"\n    ) -> Dict:\n        \"\"\"\n        热度趋势分析 - 追踪特定话题的热度变化趋势\n\n        Args:\n            topic: 话题关键词\n            date_range: 日期范围（可选）\n                       - **格式**: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}\n                       - **默认**: 不指定时默认分析最近7天\n            granularity: 时间粒度，仅支持 day（天）\n\n        Returns:\n            趋势分析结果字典\n\n        Examples:\n            用户询问示例：\n            - \"帮我分析一下'人工智能'这个话题最近一周的热度趋势\"\n            - \"查看'比特币'过去一周的热度变化\"\n            - \"看看'iPhone'最近7天的趋势如何\"\n            - \"分析'特斯拉'最近一个月的热度趋势\"\n            - \"查看'ChatGPT'2024年12月的趋势变化\"\n\n            代码调用示例：\n            >>> tools = AnalyticsTools()\n            >>> # 分析7天趋势（假设今天是 2025-11-17）\n            >>> result = tools.get_topic_trend_analysis(\n            ...     topic=\"人工智能\",\n            ...     date_range={\"start\": \"2025-11-11\", \"end\": \"2025-11-17\"},\n            ...     granularity=\"day\"\n            ... )\n            >>> # 分析历史月份趋势\n            >>> result = tools.get_topic_trend_analysis(\n            ...     topic=\"特斯拉\",\n            ...     date_range={\"start\": \"2024-12-01\", \"end\": \"2024-12-31\"},\n            ...     granularity=\"day\"\n            ... )\n            >>> print(result['trend_data'])\n        \"\"\"\n        try:\n            # 验证参数\n            topic = validate_keyword(topic)\n\n            # 验证粒度参数（只支持day）\n            if granularity != \"day\":\n                from ..utils.errors import InvalidParameterError\n                raise InvalidParameterError(\n                    f\"不支持的粒度参数: {granularity}\",\n                    suggestion=\"当前仅支持 'day' 粒度，因为底层数据按天聚合\"\n                )\n\n            # 处理日期范围（不指定时默认最近7天）\n            if date_range:\n                from ..utils.validators import validate_date_range\n                date_range_tuple = validate_date_range(date_range)\n                start_date, end_date = date_range_tuple\n            else:\n                # 默认最近7天\n                end_date = datetime.now()\n                start_date = end_date - timedelta(days=6)\n\n            # 收集趋势数据\n            trend_data = []\n            current_date = start_date\n\n            while current_date <= end_date:\n                try:\n                    all_titles, _, _ = self.data_service.parser.read_all_titles_for_date(\n                        date=current_date\n                    )\n\n                    # 统计该时间点的话题出现次数\n                    count = 0\n                    matched_titles = []\n\n                    for _, titles in all_titles.items():\n                        for title in titles.keys():\n                            if topic.lower() in title.lower():\n                                count += 1\n                                matched_titles.append(title)\n\n                    trend_data.append({\n                        \"date\": current_date.strftime(\"%Y-%m-%d\"),\n                        \"count\": count,\n                        \"sample_titles\": matched_titles[:3]  # 只保留前3个样本\n                    })\n\n                except DataNotFoundError:\n                    trend_data.append({\n                        \"date\": current_date.strftime(\"%Y-%m-%d\"),\n                        \"count\": 0,\n                        \"sample_titles\": []\n                    })\n\n                # 按天增加时间\n                current_date += timedelta(days=1)\n\n            # 计算趋势指标\n            counts = [item[\"count\"] for item in trend_data]\n            total_days = (end_date - start_date).days + 1\n\n            if len(counts) >= 2:\n                # 计算涨跌幅度\n                first_non_zero = next((c for c in counts if c > 0), 0)\n                last_count = counts[-1]\n\n                if first_non_zero > 0:\n                    change_rate = ((last_count - first_non_zero) / first_non_zero) * 100\n                else:\n                    change_rate = 0\n\n                # 找到峰值时间\n                max_count = max(counts)\n                peak_index = counts.index(max_count)\n                peak_time = trend_data[peak_index][\"date\"]\n            else:\n                change_rate = 0\n                peak_time = None\n                max_count = 0\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": f\"话题「{topic}」的热度趋势分析\",\n                    \"topic\": topic,\n                    \"date_range\": {\n                        \"start\": start_date.strftime(\"%Y-%m-%d\"),\n                        \"end\": end_date.strftime(\"%Y-%m-%d\"),\n                        \"total_days\": total_days\n                    },\n                    \"granularity\": granularity,\n                    \"total_mentions\": sum(counts),\n                    \"average_mentions\": round(sum(counts) / len(counts), 2) if counts else 0,\n                    \"peak_count\": max_count,\n                    \"peak_time\": peak_time,\n                    \"change_rate\": round(change_rate, 2),\n                    \"trend_direction\": \"上升\" if change_rate > 10 else \"下降\" if change_rate < -10 else \"稳定\"\n                },\n                \"data\": trend_data\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def compare_platforms(\n        self,\n        topic: Optional[str] = None,\n        date_range: Optional[Union[Dict[str, str], str]] = None\n    ) -> Dict:\n        \"\"\"\n        平台对比分析 - 对比不同平台对同一话题的关注度\n\n        Args:\n            topic: 话题关键词（可选，不指定则对比整体活跃度）\n            date_range: 日期范围，格式: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}\n\n        Returns:\n            平台对比分析结果\n\n        Examples:\n            用户询问示例：\n            - \"对比一下各个平台对'人工智能'话题的关注度\"\n            - \"看看知乎和微博哪个平台更关注科技新闻\"\n            - \"分析各平台今天的热点分布\"\n\n            代码调用示例：\n            >>> # 对比各平台（假设今天是 2025-11-17）\n            >>> result = tools.compare_platforms(\n            ...     topic=\"人工智能\",\n            ...     date_range={\"start\": \"2025-11-08\", \"end\": \"2025-11-17\"}\n            ... )\n            >>> print(result['platform_stats'])\n        \"\"\"\n        try:\n            # 参数验证\n            if topic:\n                topic = validate_keyword(topic)\n            date_range_tuple = validate_date_range(date_range)\n\n            # 确定日期范围\n            if date_range_tuple:\n                start_date, end_date = date_range_tuple\n            else:\n                start_date = end_date = datetime.now()\n\n            # 收集各平台数据\n            platform_stats = defaultdict(lambda: {\n                \"total_news\": 0,\n                \"topic_mentions\": 0,\n                \"unique_titles\": set(),\n                \"top_keywords\": Counter()\n            })\n\n            # 遍历日期范围\n            current_date = start_date\n            while current_date <= end_date:\n                try:\n                    all_titles, id_to_name, _ = self.data_service.parser.read_all_titles_for_date(\n                        date=current_date\n                    )\n\n                    for platform_id, titles in all_titles.items():\n                        platform_name = id_to_name.get(platform_id, platform_id)\n\n                        for title in titles.keys():\n                            platform_stats[platform_name][\"total_news\"] += 1\n                            platform_stats[platform_name][\"unique_titles\"].add(title)\n\n                            # 如果指定了话题，统计包含话题的新闻\n                            if topic and topic.lower() in title.lower():\n                                platform_stats[platform_name][\"topic_mentions\"] += 1\n\n                            # 提取关键词（简单分词）\n                            keywords = self._extract_keywords(title)\n                            platform_stats[platform_name][\"top_keywords\"].update(keywords)\n\n                except DataNotFoundError:\n                    pass\n\n                current_date += timedelta(days=1)\n\n            # 转换为可序列化的格式\n            result_stats = {}\n            for platform, stats in platform_stats.items():\n                coverage_rate = 0\n                if stats[\"total_news\"] > 0:\n                    coverage_rate = (stats[\"topic_mentions\"] / stats[\"total_news\"]) * 100\n\n                result_stats[platform] = {\n                    \"total_news\": stats[\"total_news\"],\n                    \"topic_mentions\": stats[\"topic_mentions\"],\n                    \"unique_titles\": len(stats[\"unique_titles\"]),\n                    \"coverage_rate\": round(coverage_rate, 2),\n                    \"top_keywords\": [\n                        {\"keyword\": k, \"count\": v}\n                        for k, v in stats[\"top_keywords\"].most_common(5)\n                    ]\n                }\n\n            # 找出各平台独有的热点\n            unique_topics = self._find_unique_topics(platform_stats)\n\n            return {\n                \"success\": True,\n                \"topic\": topic,\n                \"date_range\": {\n                    \"start\": start_date.strftime(\"%Y-%m-%d\"),\n                    \"end\": end_date.strftime(\"%Y-%m-%d\")\n                },\n                \"platform_stats\": result_stats,\n                \"unique_topics\": unique_topics,\n                \"total_platforms\": len(result_stats)\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def analyze_keyword_cooccurrence(\n        self,\n        min_frequency: int = 3,\n        top_n: int = 20\n    ) -> Dict:\n        \"\"\"\n        关键词共现分析 - 分析哪些关键词经常同时出现\n\n        Args:\n            min_frequency: 最小共现频次\n            top_n: 返回TOP N关键词对\n\n        Returns:\n            关键词共现分析结果\n\n        Examples:\n            用户询问示例：\n            - \"分析一下哪些关键词经常一起出现\"\n            - \"看看'人工智能'经常和哪些词一起出现\"\n            - \"找出今天新闻中的关键词关联\"\n\n            代码调用示例：\n            >>> tools = AnalyticsTools()\n            >>> result = tools.analyze_keyword_cooccurrence(\n            ...     min_frequency=5,\n            ...     top_n=15\n            ... )\n            >>> print(result['cooccurrence_pairs'])\n        \"\"\"\n        try:\n            # 参数验证\n            min_frequency = validate_limit(min_frequency, default=3, max_limit=100)\n            top_n = validate_top_n(top_n, default=20)\n\n            # 读取今天的数据\n            all_titles, _, _ = self.data_service.parser.read_all_titles_for_date()\n\n            # 关键词共现统计\n            cooccurrence = Counter()\n            keyword_titles = defaultdict(list)\n\n            for platform_id, titles in all_titles.items():\n                for title in titles.keys():\n                    # 提取关键词\n                    keywords = self._extract_keywords(title)\n\n                    # 记录每个关键词出现的标题\n                    for kw in keywords:\n                        keyword_titles[kw].append(title)\n\n                    # 计算两两共现\n                    if len(keywords) >= 2:\n                        for i, kw1 in enumerate(keywords):\n                            for kw2 in keywords[i+1:]:\n                                # 统一排序，避免重复\n                                pair = tuple(sorted([kw1, kw2]))\n                                cooccurrence[pair] += 1\n\n            # 过滤低频共现\n            filtered_pairs = [\n                (pair, count) for pair, count in cooccurrence.items()\n                if count >= min_frequency\n            ]\n\n            # 排序并取TOP N\n            top_pairs = sorted(filtered_pairs, key=lambda x: x[1], reverse=True)[:top_n]\n\n            # 构建结果\n            result_pairs = []\n            for (kw1, kw2), count in top_pairs:\n                # 找出同时包含两个关键词的标题样本\n                titles_with_both = [\n                    title for title in keyword_titles[kw1]\n                    if kw2 in self._extract_keywords(title)\n                ]\n\n                result_pairs.append({\n                    \"keyword1\": kw1,\n                    \"keyword2\": kw2,\n                    \"cooccurrence_count\": count,\n                    \"sample_titles\": titles_with_both[:3]\n                })\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"关键词共现分析结果\",\n                    \"total\": len(result_pairs),\n                    \"min_frequency\": min_frequency,\n                    \"generated_at\": datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n                },\n                \"data\": result_pairs\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def analyze_sentiment(\n        self,\n        topic: Optional[str] = None,\n        platforms: Optional[List[str]] = None,\n        date_range: Optional[Union[Dict[str, str], str]] = None,\n        limit: int = 50,\n        sort_by_weight: bool = True,\n        include_url: bool = False\n    ) -> Dict:\n        \"\"\"\n        情感倾向分析 - 生成用于 AI 情感分析的结构化提示词\n\n        本工具收集新闻数据并生成优化的 AI 提示词，你可以将其发送给 AI 进行深度情感分析。\n\n        Args:\n            topic: 话题关键词（可选），只分析包含该关键词的新闻\n            platforms: 平台过滤列表（可选），如 ['zhihu', 'weibo']\n            date_range: 日期范围（可选），格式: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}\n                       不指定则默认查询今天的数据\n            limit: 返回新闻数量限制，默认50，最大100\n            sort_by_weight: 是否按权重排序，默认True（推荐）\n            include_url: 是否包含URL链接，默认False（节省token）\n\n        Returns:\n            包含 AI 提示词和新闻数据的结构化结果\n\n        Examples:\n            用户询问示例：\n            - \"分析一下今天新闻的情感倾向\"\n            - \"看看'特斯拉'相关新闻是正面还是负面的\"\n            - \"分析各平台对'人工智能'的情感态度\"\n            - \"看看'特斯拉'相关新闻是正面还是负面的，请选择一周内的前10条新闻来分析\"\n\n            代码调用示例：\n            >>> tools = AnalyticsTools()\n            >>> # 分析今天的特斯拉新闻，返回前10条\n            >>> result = tools.analyze_sentiment(\n            ...     topic=\"特斯拉\",\n            ...     limit=10\n            ... )\n            >>> # 分析一周内的特斯拉新闻（假设今天是 2025-11-17）\n            >>> result = tools.analyze_sentiment(\n            ...     topic=\"特斯拉\",\n            ...     date_range={\"start\": \"2025-11-11\", \"end\": \"2025-11-17\"},\n            ...     limit=10\n            ... )\n            >>> print(result['ai_prompt'])  # 获取生成的提示词\n        \"\"\"\n        try:\n            # 参数验证\n            if topic:\n                topic = validate_keyword(topic)\n            platforms = validate_platforms(platforms)\n            limit = validate_limit(limit, default=50)\n\n            # 处理日期范围\n            if date_range:\n                date_range_tuple = validate_date_range(date_range)\n                start_date, end_date = date_range_tuple\n            else:\n                # 默认今天\n                start_date = end_date = datetime.now()\n\n            # 收集新闻数据（支持多天）\n            all_news_items = []\n            current_date = start_date\n\n            while current_date <= end_date:\n                try:\n                    all_titles, id_to_name, _ = self.data_service.parser.read_all_titles_for_date(\n                        date=current_date,\n                        platform_ids=platforms\n                    )\n\n                    # 收集该日期的新闻\n                    for platform_id, titles in all_titles.items():\n                        platform_name = id_to_name.get(platform_id, platform_id)\n                        for title, info in titles.items():\n                            # 如果指定了话题，只收集包含话题的标题\n                            if topic and topic.lower() not in title.lower():\n                                continue\n\n                            news_item = {\n                                \"platform\": platform_name,\n                                \"title\": title,\n                                \"ranks\": info.get(\"ranks\", []),\n                                \"count\": len(info.get(\"ranks\", [])),\n                                \"date\": current_date.strftime(\"%Y-%m-%d\")\n                            }\n\n                            # 条件性添加 URL 字段\n                            if include_url:\n                                news_item[\"url\"] = info.get(\"url\", \"\")\n                                news_item[\"mobileUrl\"] = info.get(\"mobileUrl\", \"\")\n\n                            all_news_items.append(news_item)\n\n                except DataNotFoundError:\n                    # 该日期没有数据，继续下一天\n                    pass\n\n                # 下一天\n                current_date += timedelta(days=1)\n\n            if not all_news_items:\n                time_desc = \"今天\" if start_date == end_date else f\"{start_date.strftime('%Y-%m-%d')} 至 {end_date.strftime('%Y-%m-%d')}\"\n                raise DataNotFoundError(\n                    f\"未找到相关新闻（{time_desc}）\",\n                    suggestion=\"请尝试其他话题、日期范围或平台\"\n                )\n\n            # 去重（同一标题只保留一次）\n            unique_news = {}\n            for item in all_news_items:\n                key = f\"{item['platform']}::{item['title']}\"\n                if key not in unique_news:\n                    unique_news[key] = item\n                else:\n                    # 合并 ranks（如果同一新闻在多天出现）\n                    existing = unique_news[key]\n                    existing[\"ranks\"].extend(item[\"ranks\"])\n                    existing[\"count\"] = len(existing[\"ranks\"])\n\n            deduplicated_news = list(unique_news.values())\n\n            # 按权重排序（如果启用）\n            if sort_by_weight:\n                deduplicated_news.sort(\n                    key=lambda x: calculate_news_weight(x),\n                    reverse=True\n                )\n\n            # 限制返回数量\n            selected_news = deduplicated_news[:limit]\n\n            # 生成 AI 提示词\n            ai_prompt = self._create_sentiment_analysis_prompt(\n                news_data=selected_news,\n                topic=topic\n            )\n\n            # 构建时间范围描述\n            if start_date == end_date:\n                time_range_desc = start_date.strftime(\"%Y-%m-%d\")\n            else:\n                time_range_desc = f\"{start_date.strftime('%Y-%m-%d')} 至 {end_date.strftime('%Y-%m-%d')}\"\n\n            result = {\n                \"success\": True,\n                \"method\": \"ai_prompt_generation\",\n                \"summary\": {\n                    \"description\": \"情感分析数据和AI提示词\",\n                    \"total_found\": len(deduplicated_news),\n                    \"returned\": len(selected_news),\n                    \"requested_limit\": limit,\n                    \"duplicates_removed\": len(all_news_items) - len(deduplicated_news),\n                    \"topic\": topic,\n                    \"time_range\": time_range_desc,\n                    \"platforms\": list(set(item[\"platform\"] for item in selected_news)),\n                    \"sorted_by_weight\": sort_by_weight\n                },\n                \"ai_prompt\": ai_prompt,\n                \"data\": selected_news,\n                \"usage_note\": \"请将 ai_prompt 字段的内容发送给 AI 进行情感分析\"\n            }\n\n            # 如果返回数量少于请求数量，增加提示\n            if len(selected_news) < limit and len(deduplicated_news) >= limit:\n                result[\"note\"] = \"返回数量少于请求数量是因为去重逻辑（同一标题在不同平台只保留一次）\"\n            elif len(deduplicated_news) < limit:\n                result[\"note\"] = f\"在指定时间范围内仅找到 {len(deduplicated_news)} 条匹配的新闻\"\n\n            return result\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def _create_sentiment_analysis_prompt(\n        self,\n        news_data: List[Dict],\n        topic: Optional[str]\n    ) -> str:\n        \"\"\"\n        创建情感分析的 AI 提示词\n\n        Args:\n            news_data: 新闻数据列表（已排序和限制数量）\n            topic: 话题关键词\n\n        Returns:\n            格式化的 AI 提示词\n        \"\"\"\n        # 按平台分组\n        platform_news = defaultdict(list)\n        for item in news_data:\n            platform_news[item[\"platform\"]].append({\n                \"title\": item[\"title\"],\n                \"date\": item.get(\"date\", \"\")\n            })\n\n        # 构建提示词\n        prompt_parts = []\n\n        # 1. 任务说明\n        if topic:\n            prompt_parts.append(f\"请分析以下关于「{topic}」的新闻标题的情感倾向。\")\n        else:\n            prompt_parts.append(\"请分析以下新闻标题的情感倾向。\")\n\n        prompt_parts.append(\"\")\n        prompt_parts.append(\"分析要求：\")\n        prompt_parts.append(\"1. 识别每条新闻的情感倾向（正面/负面/中性）\")\n        prompt_parts.append(\"2. 统计各情感类别的数量和百分比\")\n        prompt_parts.append(\"3. 分析不同平台的情感差异\")\n        prompt_parts.append(\"4. 总结整体情感趋势\")\n        prompt_parts.append(\"5. 列举典型的正面和负面新闻样本\")\n        prompt_parts.append(\"\")\n\n        # 2. 数据概览\n        prompt_parts.append(f\"数据概览：\")\n        prompt_parts.append(f\"- 总新闻数：{len(news_data)}\")\n        prompt_parts.append(f\"- 覆盖平台：{len(platform_news)}\")\n\n        # 时间范围\n        dates = set(item.get(\"date\", \"\") for item in news_data if item.get(\"date\"))\n        if dates:\n            date_list = sorted(dates)\n            if len(date_list) == 1:\n                prompt_parts.append(f\"- 时间范围：{date_list[0]}\")\n            else:\n                prompt_parts.append(f\"- 时间范围：{date_list[0]} 至 {date_list[-1]}\")\n\n        prompt_parts.append(\"\")\n\n        # 3. 按平台展示新闻\n        prompt_parts.append(\"新闻列表（按平台分类，已按重要性排序）：\")\n        prompt_parts.append(\"\")\n\n        for platform, items in sorted(platform_news.items()):\n            prompt_parts.append(f\"【{platform}】({len(items)} 条)\")\n            for i, item in enumerate(items, 1):\n                title = item[\"title\"]\n                date_str = f\" [{item['date']}]\" if item.get(\"date\") else \"\"\n                prompt_parts.append(f\"{i}. {title}{date_str}\")\n            prompt_parts.append(\"\")\n\n        # 4. 输出格式说明\n        prompt_parts.append(\"请按以下格式输出分析结果：\")\n        prompt_parts.append(\"\")\n        prompt_parts.append(\"## 情感分布统计\")\n        prompt_parts.append(\"- 正面：XX条 (XX%)\")\n        prompt_parts.append(\"- 负面：XX条 (XX%)\")\n        prompt_parts.append(\"- 中性：XX条 (XX%)\")\n        prompt_parts.append(\"\")\n        prompt_parts.append(\"## 平台情感对比\")\n        prompt_parts.append(\"[各平台的情感倾向差异]\")\n        prompt_parts.append(\"\")\n        prompt_parts.append(\"## 整体情感趋势\")\n        prompt_parts.append(\"[总体分析和关键发现]\")\n        prompt_parts.append(\"\")\n        prompt_parts.append(\"## 典型样本\")\n        prompt_parts.append(\"正面新闻样本：\")\n        prompt_parts.append(\"[列举3-5条]\")\n        prompt_parts.append(\"\")\n        prompt_parts.append(\"负面新闻样本：\")\n        prompt_parts.append(\"[列举3-5条]\")\n\n        return \"\\n\".join(prompt_parts)\n\n    def find_similar_news(\n        self,\n        reference_title: str,\n        threshold: float = 0.6,\n        limit: int = 50,\n        include_url: bool = False\n    ) -> Dict:\n        \"\"\"\n        相似新闻查找 - 基于标题相似度查找相关新闻\n\n        Args:\n            reference_title: 参考标题\n            threshold: 相似度阈值（0-1之间）\n            limit: 返回条数限制，默认50\n            include_url: 是否包含URL链接，默认False（节省token）\n\n        Returns:\n            相似新闻列表\n\n        Examples:\n            用户询问示例：\n            - \"找出和'特斯拉降价'相似的新闻\"\n            - \"查找关于iPhone发布的类似报道\"\n            - \"看看有没有和这条新闻相似的报道\"\n\n            代码调用示例：\n            >>> tools = AnalyticsTools()\n            >>> result = tools.find_similar_news(\n            ...     reference_title=\"特斯拉宣布降价\",\n            ...     threshold=0.6,\n            ...     limit=10\n            ... )\n            >>> print(result['similar_news'])\n        \"\"\"\n        try:\n            # 参数验证\n            reference_title = validate_keyword(reference_title)\n            threshold = validate_threshold(threshold, default=0.6, min_value=0.0, max_value=1.0)\n            limit = validate_limit(limit, default=50)\n\n            # 读取数据\n            all_titles, id_to_name, _ = self.data_service.parser.read_all_titles_for_date()\n\n            # 计算相似度\n            similar_items = []\n\n            for platform_id, titles in all_titles.items():\n                platform_name = id_to_name.get(platform_id, platform_id)\n\n                for title, info in titles.items():\n                    if title == reference_title:\n                        continue\n\n                    # 计算相似度\n                    similarity = self._calculate_similarity(reference_title, title)\n\n                    if similarity >= threshold:\n                        news_item = {\n                            \"title\": title,\n                            \"platform\": platform_id,\n                            \"platform_name\": platform_name,\n                            \"similarity\": round(similarity, 3),\n                            \"rank\": info[\"ranks\"][0] if info[\"ranks\"] else 0\n                        }\n\n                        # 条件性添加 URL 字段\n                        if include_url:\n                            news_item[\"url\"] = info.get(\"url\", \"\")\n\n                        similar_items.append(news_item)\n\n            # 按相似度排序\n            similar_items.sort(key=lambda x: x[\"similarity\"], reverse=True)\n\n            # 限制数量\n            result_items = similar_items[:limit]\n\n            if not result_items:\n                raise DataNotFoundError(\n                    f\"未找到相似度超过 {threshold} 的新闻\",\n                    suggestion=\"请降低相似度阈值或尝试其他标题\"\n                )\n\n            result = {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"相似新闻搜索结果\",\n                    \"total_found\": len(similar_items),\n                    \"returned\": len(result_items),\n                    \"requested_limit\": limit,\n                    \"threshold\": threshold,\n                    \"reference_title\": reference_title\n                },\n                \"data\": result_items\n            }\n\n            if len(similar_items) < limit:\n                result[\"note\"] = f\"相似度阈值 {threshold} 下仅找到 {len(similar_items)} 条相似新闻\"\n\n            return result\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def search_by_entity(\n        self,\n        entity: str,\n        entity_type: Optional[str] = None,\n        limit: int = 50,\n        sort_by_weight: bool = True\n    ) -> Dict:\n        \"\"\"\n        实体识别搜索 - 搜索包含特定人物/地点/机构的新闻\n\n        Args:\n            entity: 实体名称\n            entity_type: 实体类型（person/location/organization），可选\n            limit: 返回条数限制，默认50，最大200\n            sort_by_weight: 是否按权重排序，默认True\n\n        Returns:\n            实体相关新闻列表\n\n        Examples:\n            用户询问示例：\n            - \"搜索马斯克相关的新闻\"\n            - \"查找关于特斯拉公司的报道，返回前20条\"\n            - \"看看北京有什么新闻\"\n\n            代码调用示例：\n            >>> tools = AnalyticsTools()\n            >>> result = tools.search_by_entity(\n            ...     entity=\"马斯克\",\n            ...     entity_type=\"person\",\n            ...     limit=20\n            ... )\n            >>> print(result['related_news'])\n        \"\"\"\n        try:\n            # 参数验证\n            entity = validate_keyword(entity)\n            limit = validate_limit(limit, default=50)\n\n            if entity_type and entity_type not in [\"person\", \"location\", \"organization\"]:\n                raise InvalidParameterError(\n                    f\"无效的实体类型: {entity_type}\",\n                    suggestion=\"支持的类型: person, location, organization\"\n                )\n\n            # 读取数据\n            all_titles, id_to_name, _ = self.data_service.parser.read_all_titles_for_date()\n\n            # 搜索包含实体的新闻\n            related_news = []\n            entity_context = Counter()  # 统计实体周边的词\n\n            for platform_id, titles in all_titles.items():\n                platform_name = id_to_name.get(platform_id, platform_id)\n\n                for title, info in titles.items():\n                    if entity in title:\n                        url = info.get(\"url\", \"\")\n                        mobile_url = info.get(\"mobileUrl\", \"\")\n                        ranks = info.get(\"ranks\", [])\n                        count = len(ranks)\n\n                        related_news.append({\n                            \"title\": title,\n                            \"platform\": platform_id,\n                            \"platform_name\": platform_name,\n                            \"url\": url,\n                            \"mobileUrl\": mobile_url,\n                            \"ranks\": ranks,\n                            \"count\": count,\n                            \"rank\": ranks[0] if ranks else 999\n                        })\n\n                        # 提取实体周边的关键词\n                        keywords = self._extract_keywords(title)\n                        entity_context.update(keywords)\n\n            if not related_news:\n                raise DataNotFoundError(\n                    f\"未找到包含实体 '{entity}' 的新闻\",\n                    suggestion=\"请尝试其他实体名称\"\n                )\n\n            # 移除实体本身\n            if entity in entity_context:\n                del entity_context[entity]\n\n            # 按权重排序（如果启用）\n            if sort_by_weight:\n                related_news.sort(\n                    key=lambda x: calculate_news_weight(x),\n                    reverse=True\n                )\n            else:\n                # 按排名排序\n                related_news.sort(key=lambda x: x[\"rank\"])\n\n            # 限制返回数量\n            result_news = related_news[:limit]\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": f\"实体「{entity}」相关新闻\",\n                    \"entity\": entity,\n                    \"entity_type\": entity_type or \"auto\",\n                    \"total_found\": len(related_news),\n                    \"returned\": len(result_news),\n                    \"sorted_by_weight\": sort_by_weight\n                },\n                \"data\": result_news,\n                \"related_keywords\": [\n                    {\"keyword\": k, \"count\": v}\n                    for k, v in entity_context.most_common(10)\n                ]\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def generate_summary_report(\n        self,\n        report_type: str = \"daily\",\n        date_range: Optional[Union[Dict[str, str], str]] = None\n    ) -> Dict:\n        \"\"\"\n        每日/每周摘要生成器 - 自动生成热点摘要报告\n\n        Args:\n            report_type: 报告类型（daily/weekly）\n            date_range: 自定义日期范围（可选）\n\n        Returns:\n            Markdown格式的摘要报告\n\n        Examples:\n            用户询问示例：\n            - \"生成今天的新闻摘要报告\"\n            - \"给我一份本周的热点总结\"\n            - \"生成过去7天的新闻分析报告\"\n\n            代码调用示例：\n            >>> tools = AnalyticsTools()\n            >>> result = tools.generate_summary_report(\n            ...     report_type=\"daily\"\n            ... )\n            >>> print(result['markdown_report'])\n        \"\"\"\n        try:\n            # 参数验证\n            if report_type not in [\"daily\", \"weekly\"]:\n                raise InvalidParameterError(\n                    f\"无效的报告类型: {report_type}\",\n                    suggestion=\"支持的类型: daily, weekly\"\n                )\n\n            # 确定日期范围\n            if date_range:\n                date_range_tuple = validate_date_range(date_range)\n                start_date, end_date = date_range_tuple\n            else:\n                if report_type == \"daily\":\n                    start_date = end_date = datetime.now()\n                else:  # weekly\n                    end_date = datetime.now()\n                    start_date = end_date - timedelta(days=6)\n\n            # 收集数据\n            all_keywords = Counter()\n            all_platforms_news = defaultdict(int)\n            all_titles_list = []\n\n            current_date = start_date\n            while current_date <= end_date:\n                try:\n                    all_titles, id_to_name, _ = self.data_service.parser.read_all_titles_for_date(\n                        date=current_date\n                    )\n\n                    for platform_id, titles in all_titles.items():\n                        platform_name = id_to_name.get(platform_id, platform_id)\n                        all_platforms_news[platform_name] += len(titles)\n\n                        for title in titles.keys():\n                            all_titles_list.append({\n                                \"title\": title,\n                                \"platform\": platform_name,\n                                \"date\": current_date.strftime(\"%Y-%m-%d\")\n                            })\n\n                            # 提取关键词\n                            keywords = self._extract_keywords(title)\n                            all_keywords.update(keywords)\n\n                except DataNotFoundError:\n                    pass\n\n                current_date += timedelta(days=1)\n\n            # 生成报告\n            report_title = f\"{'每日' if report_type == 'daily' else '每周'}新闻热点摘要\"\n            date_str = f\"{start_date.strftime('%Y-%m-%d')}\" if report_type == \"daily\" else f\"{start_date.strftime('%Y-%m-%d')} 至 {end_date.strftime('%Y-%m-%d')}\"\n\n            # 构建Markdown报告\n            markdown = f\"\"\"# {report_title}\n\n**报告日期**: {date_str}\n**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n---\n\n## 📊 数据概览\n\n- **总新闻数**: {len(all_titles_list)}\n- **覆盖平台**: {len(all_platforms_news)}\n- **热门关键词数**: {len(all_keywords)}\n\n## 🔥 TOP 10 热门话题\n\n\"\"\"\n\n            # 添加TOP 10关键词\n            for i, (keyword, count) in enumerate(all_keywords.most_common(10), 1):\n                markdown += f\"{i}. **{keyword}** - 出现 {count} 次\\n\"\n\n            # 平台分析\n            markdown += \"\\n## 📱 平台活跃度\\n\\n\"\n            sorted_platforms = sorted(all_platforms_news.items(), key=lambda x: x[1], reverse=True)\n\n            for platform, count in sorted_platforms:\n                markdown += f\"- **{platform}**: {count} 条新闻\\n\"\n\n            # 趋势变化（如果是周报）\n            if report_type == \"weekly\":\n                markdown += \"\\n## 📈 趋势分析\\n\\n\"\n                markdown += \"本周热度持续的话题（样本数据）：\\n\\n\"\n\n                # 简单的趋势分析\n                top_keywords = [kw for kw, _ in all_keywords.most_common(5)]\n                for keyword in top_keywords:\n                    markdown += f\"- **{keyword}**: 持续热门\\n\"\n\n            # 添加样本新闻（按权重选择，确保确定性）\n            markdown += \"\\n## 📰 精选新闻样本\\n\\n\"\n\n            # 确定性选取：按标题的权重排序，取前5条\n            # 这样相同输入总是返回相同结果\n            if all_titles_list:\n                # 计算每条新闻的权重分数（基于关键词出现次数）\n                news_with_scores = []\n                for news in all_titles_list:\n                    # 简单权重：统计包含TOP关键词的次数\n                    score = 0\n                    title_lower = news['title'].lower()\n                    for keyword, count in all_keywords.most_common(10):\n                        if keyword.lower() in title_lower:\n                            score += count\n                    news_with_scores.append((news, score))\n\n                # 按权重降序排序，权重相同则按标题字母顺序（确保确定性）\n                news_with_scores.sort(key=lambda x: (-x[1], x[0]['title']))\n\n                # 取前5条\n                sample_news = [item[0] for item in news_with_scores[:5]]\n\n                for news in sample_news:\n                    markdown += f\"- [{news['platform']}] {news['title']}\\n\"\n\n            markdown += \"\\n---\\n\\n*本报告由 TrendRadar MCP 自动生成*\\n\"\n\n            return {\n                \"success\": True,\n                \"report_type\": report_type,\n                \"date_range\": {\n                    \"start\": start_date.strftime(\"%Y-%m-%d\"),\n                    \"end\": end_date.strftime(\"%Y-%m-%d\")\n                },\n                \"markdown_report\": markdown,\n                \"statistics\": {\n                    \"total_news\": len(all_titles_list),\n                    \"platforms_count\": len(all_platforms_news),\n                    \"keywords_count\": len(all_keywords),\n                    \"top_keyword\": all_keywords.most_common(1)[0] if all_keywords else None\n                }\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def get_platform_activity_stats(\n        self,\n        date_range: Optional[Union[Dict[str, str], str]] = None\n    ) -> Dict:\n        \"\"\"\n        平台活跃度统计 - 统计各平台的发布频率和活跃时间段\n\n        Args:\n            date_range: 日期范围（可选）\n\n        Returns:\n            平台活跃度统计结果\n\n        Examples:\n            用户询问示例：\n            - \"统计各平台今天的活跃度\"\n            - \"看看哪个平台更新最频繁\"\n            - \"分析各平台的发布时间规律\"\n\n            代码调用示例：\n            >>> # 查看各平台活跃度（假设今天是 2025-11-17）\n            >>> result = tools.get_platform_activity_stats(\n            ...     date_range={\"start\": \"2025-11-08\", \"end\": \"2025-11-17\"}\n            ... )\n            >>> print(result['platform_activity'])\n        \"\"\"\n        try:\n            # 参数验证\n            date_range_tuple = validate_date_range(date_range)\n\n            # 确定日期范围\n            if date_range_tuple:\n                start_date, end_date = date_range_tuple\n            else:\n                start_date = end_date = datetime.now()\n\n            # 统计各平台活跃度\n            platform_activity = defaultdict(lambda: {\n                \"total_updates\": 0,\n                \"days_active\": set(),\n                \"news_count\": 0,\n                \"hourly_distribution\": Counter()\n            })\n\n            # 遍历日期范围\n            current_date = start_date\n            while current_date <= end_date:\n                try:\n                    all_titles, id_to_name, timestamps = self.data_service.parser.read_all_titles_for_date(\n                        date=current_date\n                    )\n\n                    for platform_id, titles in all_titles.items():\n                        platform_name = id_to_name.get(platform_id, platform_id)\n\n                        platform_activity[platform_name][\"news_count\"] += len(titles)\n                        platform_activity[platform_name][\"days_active\"].add(current_date.strftime(\"%Y-%m-%d\"))\n\n                        # 统计更新次数（基于文件数量）\n                        platform_activity[platform_name][\"total_updates\"] += len(timestamps)\n\n                        # 统计时间分布（基于文件名中的时间）\n                        for filename in timestamps.keys():\n                            # 解析文件名中的小时（格式：HHMM.txt）\n                            match = re.match(r'(\\d{2})(\\d{2})\\.txt', filename)\n                            if match:\n                                hour = int(match.group(1))\n                                platform_activity[platform_name][\"hourly_distribution\"][hour] += 1\n\n                except DataNotFoundError:\n                    pass\n\n                current_date += timedelta(days=1)\n\n            # 转换为可序列化的格式\n            result_activity = {}\n            for platform, stats in platform_activity.items():\n                days_count = len(stats[\"days_active\"])\n                avg_news_per_day = stats[\"news_count\"] / days_count if days_count > 0 else 0\n\n                # 找出最活跃的时间段\n                most_active_hours = stats[\"hourly_distribution\"].most_common(3)\n\n                result_activity[platform] = {\n                    \"total_updates\": stats[\"total_updates\"],\n                    \"news_count\": stats[\"news_count\"],\n                    \"days_active\": days_count,\n                    \"avg_news_per_day\": round(avg_news_per_day, 2),\n                    \"most_active_hours\": [\n                        {\"hour\": f\"{hour:02d}:00\", \"count\": count}\n                        for hour, count in most_active_hours\n                    ],\n                    \"activity_score\": round(stats[\"news_count\"] / max(days_count, 1), 2)\n                }\n\n            # 按活跃度排序\n            sorted_platforms = sorted(\n                result_activity.items(),\n                key=lambda x: x[1][\"activity_score\"],\n                reverse=True\n            )\n\n            return {\n                \"success\": True,\n                \"date_range\": {\n                    \"start\": start_date.strftime(\"%Y-%m-%d\"),\n                    \"end\": end_date.strftime(\"%Y-%m-%d\")\n                },\n                \"platform_activity\": dict(sorted_platforms),\n                \"most_active_platform\": sorted_platforms[0][0] if sorted_platforms else None,\n                \"total_platforms\": len(result_activity)\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def analyze_topic_lifecycle(\n        self,\n        topic: str,\n        date_range: Optional[Union[Dict[str, str], str]] = None\n    ) -> Dict:\n        \"\"\"\n        话题生命周期分析 - 追踪话题从出现到消失的完整周期\n\n        Args:\n            topic: 话题关键词\n            date_range: 日期范围（可选）\n                       - **格式**: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}\n                       - **默认**: 不指定时默认分析最近7天\n\n        Returns:\n            话题生命周期分析结果\n\n        Examples:\n            用户询问示例：\n            - \"分析'人工智能'这个话题的生命周期\"\n            - \"看看'iPhone'话题是昙花一现还是持续热点\"\n            - \"追踪'比特币'话题的热度变化\"\n\n            代码调用示例：\n            >>> # 分析话题生命周期（假设今天是 2025-11-17）\n            >>> result = tools.analyze_topic_lifecycle(\n            ...     topic=\"人工智能\",\n            ...     date_range={\"start\": \"2025-10-19\", \"end\": \"2025-11-17\"}\n            ... )\n            >>> print(result['lifecycle_stage'])\n        \"\"\"\n        try:\n            # 参数验证\n            topic = validate_keyword(topic)\n\n            # 处理日期范围（不指定时默认最近7天）\n            if date_range:\n                from ..utils.validators import validate_date_range\n                date_range_tuple = validate_date_range(date_range)\n                start_date, end_date = date_range_tuple\n            else:\n                # 默认最近7天\n                end_date = datetime.now()\n                start_date = end_date - timedelta(days=6)\n\n            # 收集话题历史数据\n            lifecycle_data = []\n            current_date = start_date\n            while current_date <= end_date:\n                try:\n                    all_titles, _, _ = self.data_service.parser.read_all_titles_for_date(\n                        date=current_date\n                    )\n\n                    # 统计该日的话题出现次数\n                    count = 0\n                    for _, titles in all_titles.items():\n                        for title in titles.keys():\n                            if topic.lower() in title.lower():\n                                count += 1\n\n                    lifecycle_data.append({\n                        \"date\": current_date.strftime(\"%Y-%m-%d\"),\n                        \"count\": count\n                    })\n\n                except DataNotFoundError:\n                    lifecycle_data.append({\n                        \"date\": current_date.strftime(\"%Y-%m-%d\"),\n                        \"count\": 0\n                    })\n\n                current_date += timedelta(days=1)\n\n            # 计算分析天数\n            total_days = (end_date - start_date).days + 1\n\n            # 分析生命周期阶段\n            counts = [item[\"count\"] for item in lifecycle_data]\n\n            if not any(counts):\n                time_desc = f\"{start_date.strftime('%Y-%m-%d')} 至 {end_date.strftime('%Y-%m-%d')}\"\n                raise DataNotFoundError(\n                    f\"在 {time_desc} 内未找到话题 '{topic}'\",\n                    suggestion=\"请尝试其他话题或扩大时间范围\"\n                )\n\n            # 找到首次出现和最后出现\n            first_appearance = next((item[\"date\"] for item in lifecycle_data if item[\"count\"] > 0), None)\n            last_appearance = next((item[\"date\"] for item in reversed(lifecycle_data) if item[\"count\"] > 0), None)\n\n            # 计算峰值\n            max_count = max(counts)\n            peak_index = counts.index(max_count)\n            peak_date = lifecycle_data[peak_index][\"date\"]\n\n            # 计算平均值和标准差（简单实现）\n            non_zero_counts = [c for c in counts if c > 0]\n            avg_count = sum(non_zero_counts) / len(non_zero_counts) if non_zero_counts else 0\n\n            # 判断生命周期阶段\n            recent_counts = counts[-3:]  # 最近3天\n            early_counts = counts[:3]    # 前3天\n\n            if sum(recent_counts) > sum(early_counts):\n                lifecycle_stage = \"上升期\"\n            elif sum(recent_counts) < sum(early_counts) * 0.5:\n                lifecycle_stage = \"衰退期\"\n            elif max_count in recent_counts:\n                lifecycle_stage = \"爆发期\"\n            else:\n                lifecycle_stage = \"稳定期\"\n\n            # 分类：昙花一现 vs 持续热点\n            active_days = sum(1 for c in counts if c > 0)\n\n            if active_days <= 2 and max_count > avg_count * 2:\n                topic_type = \"昙花一现\"\n            elif active_days >= total_days * 0.6:\n                topic_type = \"持续热点\"\n            else:\n                topic_type = \"周期性热点\"\n\n            return {\n                \"success\": True,\n                \"topic\": topic,\n                \"date_range\": {\n                    \"start\": start_date.strftime(\"%Y-%m-%d\"),\n                    \"end\": end_date.strftime(\"%Y-%m-%d\"),\n                    \"total_days\": total_days\n                },\n                \"lifecycle_data\": lifecycle_data,\n                \"analysis\": {\n                    \"first_appearance\": first_appearance,\n                    \"last_appearance\": last_appearance,\n                    \"peak_date\": peak_date,\n                    \"peak_count\": max_count,\n                    \"active_days\": active_days,\n                    \"avg_daily_mentions\": round(avg_count, 2),\n                    \"lifecycle_stage\": lifecycle_stage,\n                    \"topic_type\": topic_type\n                }\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def detect_viral_topics(\n        self,\n        threshold: float = 3.0,\n        time_window: int = 24\n    ) -> Dict:\n        \"\"\"\n        异常热度检测 - 自动识别突然爆火的话题\n\n        Args:\n            threshold: 热度突增倍数阈值\n            time_window: 检测时间窗口（小时）\n\n        Returns:\n            爆火话题列表\n\n        Examples:\n            用户询问示例：\n            - \"检测今天有哪些突然爆火的话题\"\n            - \"看看有没有热度异常的新闻\"\n            - \"预警可能的重大事件\"\n\n            代码调用示例：\n            >>> tools = AnalyticsTools()\n            >>> result = tools.detect_viral_topics(\n            ...     threshold=3.0,\n            ...     time_window=24\n            ... )\n            >>> print(result['viral_topics'])\n        \"\"\"\n        try:\n            # 参数验证\n            threshold = validate_threshold(threshold, default=3.0, min_value=1.0, max_value=100.0)\n            time_window = validate_limit(time_window, default=24, max_limit=72)\n\n            # 读取当前和之前的数据\n            current_all_titles, _, _ = self.data_service.parser.read_all_titles_for_date()\n\n            # 读取昨天的数据作为基准\n            yesterday = datetime.now() - timedelta(days=1)\n            try:\n                previous_all_titles, _, _ = self.data_service.parser.read_all_titles_for_date(\n                    date=yesterday\n                )\n            except DataNotFoundError:\n                previous_all_titles = {}\n\n            # 统计当前的关键词频率\n            current_keywords = Counter()\n            current_keyword_titles = defaultdict(list)\n\n            for _, titles in current_all_titles.items():\n                for title in titles.keys():\n                    keywords = self._extract_keywords(title)\n                    current_keywords.update(keywords)\n\n                    for kw in keywords:\n                        current_keyword_titles[kw].append(title)\n\n            # 统计之前的关键词频率\n            previous_keywords = Counter()\n\n            for _, titles in previous_all_titles.items():\n                for title in titles.keys():\n                    keywords = self._extract_keywords(title)\n                    previous_keywords.update(keywords)\n\n            # 检测异常热度\n            viral_topics = []\n\n            for keyword, current_count in current_keywords.items():\n                previous_count = previous_keywords.get(keyword, 0)\n\n                # 计算增长倍数\n                if previous_count == 0:\n                    # 新出现的话题\n                    if current_count >= 5:  # 至少出现5次才认为是爆火\n                        growth_rate = float('inf')\n                        is_viral = True\n                    else:\n                        continue\n                else:\n                    growth_rate = current_count / previous_count\n                    is_viral = growth_rate >= threshold\n\n                if is_viral:\n                    viral_topics.append({\n                        \"keyword\": keyword,\n                        \"current_count\": current_count,\n                        \"previous_count\": previous_count,\n                        \"growth_rate\": round(growth_rate, 2) if growth_rate != float('inf') else \"新话题\",\n                        \"sample_titles\": current_keyword_titles[keyword][:3],\n                        \"alert_level\": \"高\" if growth_rate > threshold * 2 else \"中\"\n                    })\n\n            # 按增长率排序\n            viral_topics.sort(\n                key=lambda x: x[\"current_count\"] if x[\"growth_rate\"] == \"新话题\" else x[\"growth_rate\"],\n                reverse=True\n            )\n\n            if not viral_topics:\n                return {\n                    \"success\": True,\n                    \"summary\": {\n                        \"description\": \"异常热度检测结果\",\n                        \"total\": 0,\n                        \"threshold\": threshold,\n                        \"time_window\": time_window\n                    },\n                    \"data\": [],\n                    \"message\": f\"未检测到热度增长超过 {threshold} 倍的话题\"\n                }\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"异常热度检测结果\",\n                    \"total\": len(viral_topics),\n                    \"threshold\": threshold,\n                    \"time_window\": time_window,\n                    \"detection_time\": datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n                },\n                \"data\": viral_topics\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def predict_trending_topics(\n        self,\n        lookahead_hours: int = 6,\n        confidence_threshold: float = 0.7\n    ) -> Dict:\n        \"\"\"\n        话题预测 - 基于历史数据预测未来可能的热点\n\n        Args:\n            lookahead_hours: 预测未来多少小时\n            confidence_threshold: 置信度阈值\n\n        Returns:\n            预测的潜力话题列表\n\n        Examples:\n            用户询问示例：\n            - \"预测接下来6小时可能的热点话题\"\n            - \"有哪些话题可能会火起来\"\n            - \"早期发现潜力话题\"\n\n            代码调用示例：\n            >>> tools = AnalyticsTools()\n            >>> result = tools.predict_trending_topics(\n            ...     lookahead_hours=6,\n            ...     confidence_threshold=0.7\n            ... )\n            >>> print(result['predicted_topics'])\n        \"\"\"\n        try:\n            # 参数验证\n            lookahead_hours = validate_limit(lookahead_hours, default=6, max_limit=48)\n            confidence_threshold = validate_threshold(\n                confidence_threshold,\n                default=0.7,\n                min_value=0.0,\n                max_value=1.0,\n                param_name=\"confidence_threshold\"\n            )\n\n            # 收集最近3天的数据用于预测\n            keyword_trends = defaultdict(list)\n\n            for days_ago in range(3, 0, -1):\n                date = datetime.now() - timedelta(days=days_ago)\n\n                try:\n                    all_titles, _, _ = self.data_service.parser.read_all_titles_for_date(\n                        date=date\n                    )\n\n                    # 统计关键词\n                    keywords_count = Counter()\n                    for _, titles in all_titles.items():\n                        for title in titles.keys():\n                            keywords = self._extract_keywords(title)\n                            keywords_count.update(keywords)\n\n                    # 记录每个关键词的历史数据\n                    for keyword, count in keywords_count.items():\n                        keyword_trends[keyword].append(count)\n\n                except DataNotFoundError:\n                    pass\n\n            # 添加今天的数据\n            try:\n                all_titles, _, _ = self.data_service.parser.read_all_titles_for_date()\n\n                keywords_count = Counter()\n                keyword_titles = defaultdict(list)\n\n                for _, titles in all_titles.items():\n                    for title in titles.keys():\n                        keywords = self._extract_keywords(title)\n                        keywords_count.update(keywords)\n\n                        for kw in keywords:\n                            keyword_titles[kw].append(title)\n\n                for keyword, count in keywords_count.items():\n                    keyword_trends[keyword].append(count)\n\n            except DataNotFoundError:\n                raise DataNotFoundError(\n                    \"未找到今天的数据\",\n                    suggestion=\"请等待爬虫任务完成\"\n                )\n\n            # 预测潜力话题\n            predicted_topics = []\n\n            for keyword, trend_data in keyword_trends.items():\n                if len(trend_data) < 2:\n                    continue\n\n                # 简单的线性趋势预测\n                # 计算增长率\n                recent_value = trend_data[-1]\n                previous_value = trend_data[-2] if len(trend_data) >= 2 else 0\n\n                if previous_value == 0:\n                    if recent_value >= 3:\n                        growth_rate = 1.0\n                    else:\n                        continue\n                else:\n                    growth_rate = (recent_value - previous_value) / previous_value\n\n                # 判断是否是上升趋势\n                if growth_rate > 0.3:  # 增长超过30%\n                    # 计算置信度（基于趋势的稳定性）\n                    if len(trend_data) >= 3:\n                        # 检查是否连续增长\n                        is_consistent = all(\n                            trend_data[i] <= trend_data[i+1]\n                            for i in range(len(trend_data)-1)\n                        )\n                        confidence = 0.9 if is_consistent else 0.7\n                    else:\n                        confidence = 0.6\n\n                    if confidence >= confidence_threshold:\n                        predicted_topics.append({\n                            \"keyword\": keyword,\n                            \"current_count\": recent_value,\n                            \"growth_rate\": round(growth_rate * 100, 2),\n                            \"confidence\": round(confidence, 2),\n                            \"trend_data\": trend_data,\n                            \"prediction\": \"上升趋势，可能成为热点\",\n                            \"sample_titles\": keyword_titles.get(keyword, [])[:3]\n                        })\n\n            # 按置信度和增长率排序\n            predicted_topics.sort(\n                key=lambda x: (x[\"confidence\"], x[\"growth_rate\"]),\n                reverse=True\n            )\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"热点话题预测结果\",\n                    \"total\": len(predicted_topics),\n                    \"returned\": min(20, len(predicted_topics)),\n                    \"lookahead_hours\": lookahead_hours,\n                    \"confidence_threshold\": confidence_threshold,\n                    \"prediction_time\": datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n                },\n                \"data\": predicted_topics[:20],  # 返回TOP 20\n                \"note\": \"预测基于历史趋势，实际结果可能有偏差\"\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    # ==================== 辅助方法 ====================\n\n    def _extract_keywords(self, title: str, min_length: int = 2) -> List[str]:\n        \"\"\"\n        从标题中提取关键词（简单实现）\n\n        Args:\n            title: 标题文本\n            min_length: 最小关键词长度\n\n        Returns:\n            关键词列表\n        \"\"\"\n        # 移除URL和特殊字符\n        title = re.sub(r'http[s]?://\\S+', '', title)\n        title = re.sub(r'[^\\w\\s]', ' ', title)\n\n        # 简单分词（按空格和常见分隔符）\n        words = re.split(r'[\\s，。！？、]+', title)\n\n        # 过滤停用词和短词\n        stopwords = {'的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都', '一', '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', '看', '好', '自己', '这'}\n\n        keywords = [\n            word.strip() for word in words\n            if word.strip() and len(word.strip()) >= min_length and word.strip() not in stopwords\n        ]\n\n        return keywords\n\n    def _calculate_similarity(self, text1: str, text2: str) -> float:\n        \"\"\"\n        计算两个文本的相似度\n\n        Args:\n            text1: 文本1\n            text2: 文本2\n\n        Returns:\n            相似度分数（0-1之间）\n        \"\"\"\n        # 使用 SequenceMatcher 计算相似度\n        return SequenceMatcher(None, text1, text2).ratio()\n\n    def _find_unique_topics(self, platform_stats: Dict) -> Dict[str, List[str]]:\n        \"\"\"\n        找出各平台独有的热点话题\n\n        Args:\n            platform_stats: 平台统计数据\n\n        Returns:\n            各平台独有话题字典\n        \"\"\"\n        unique_topics = {}\n\n        # 获取每个平台的TOP关键词\n        platform_keywords = {}\n        for platform, stats in platform_stats.items():\n            top_keywords = set([kw for kw, _ in stats[\"top_keywords\"].most_common(10)])\n            platform_keywords[platform] = top_keywords\n\n        # 找出独有关键词\n        for platform, keywords in platform_keywords.items():\n            # 找出其他平台的所有关键词\n            other_keywords = set()\n            for other_platform, other_kws in platform_keywords.items():\n                if other_platform != platform:\n                    other_keywords.update(other_kws)\n\n            # 找出独有的\n            unique = keywords - other_keywords\n            if unique:\n                unique_topics[platform] = list(unique)[:5]  # 最多5个\n\n        return unique_topics\n\n    # ==================== 跨平台聚合工具 ====================\n\n    def aggregate_news(\n        self,\n        date_range: Optional[Union[Dict[str, str], str]] = None,\n        platforms: Optional[List[str]] = None,\n        similarity_threshold: float = 0.7,\n        limit: int = 50,\n        include_url: bool = False\n    ) -> Dict:\n        \"\"\"\n        跨平台新闻聚合 - 对相似新闻进行去重合并\n\n        将不同平台报道的同一事件合并为一条聚合新闻，\n        显示该新闻在各平台的覆盖情况和综合热度。\n\n        Args:\n            date_range: 日期范围（可选）\n                - 不指定: 查询今天\n                - {\\\"start\\\": \\\"YYYY-MM-DD\\\", \\\"end\\\": \\\"YYYY-MM-DD\\\"}: 日期范围\n            platforms: 平台过滤列表，如 ['zhihu', 'weibo']\n            similarity_threshold: 相似度阈值，0-1之间，默认0.7\n            limit: 返回聚合新闻数量，默认50\n            include_url: 是否包含URL链接，默认False\n\n        Returns:\n            聚合结果字典，包含：\n            - aggregated_news: 聚合后的新闻列表\n            - statistics: 聚合统计信息\n        \"\"\"\n        try:\n            # 参数验证\n            platforms = validate_platforms(platforms)\n            similarity_threshold = validate_threshold(\n                similarity_threshold, default=0.7, min_value=0.3, max_value=1.0\n            )\n            limit = validate_limit(limit, default=50)\n\n            # 处理日期范围\n            if date_range:\n                date_range_tuple = validate_date_range(date_range)\n                start_date, end_date = date_range_tuple\n            else:\n                start_date = end_date = datetime.now()\n\n            # 收集所有新闻\n            all_news = []\n            current_date = start_date\n\n            while current_date <= end_date:\n                try:\n                    all_titles, id_to_name, _ = self.data_service.parser.read_all_titles_for_date(\n                        date=current_date,\n                        platform_ids=platforms\n                    )\n\n                    for platform_id, titles in all_titles.items():\n                        platform_name = id_to_name.get(platform_id, platform_id)\n\n                        for title, info in titles.items():\n                            news_item = {\n                                \"title\": title,\n                                \"platform\": platform_id,\n                                \"platform_name\": platform_name,\n                                \"date\": current_date.strftime(\"%Y-%m-%d\"),\n                                \"ranks\": info.get(\"ranks\", []),\n                                \"count\": len(info.get(\"ranks\", [])),\n                                \"rank\": info[\"ranks\"][0] if info[\"ranks\"] else 999\n                            }\n\n                            if include_url:\n                                news_item[\"url\"] = info.get(\"url\", \"\")\n                                news_item[\"mobileUrl\"] = info.get(\"mobileUrl\", \"\")\n\n                            # 计算权重\n                            news_item[\"weight\"] = calculate_news_weight(news_item)\n                            all_news.append(news_item)\n\n                except DataNotFoundError:\n                    pass\n\n                current_date += timedelta(days=1)\n\n            if not all_news:\n                return {\n                    \"success\": True,\n                    \"summary\": {\n                        \"description\": \"跨平台新闻聚合结果\",\n                        \"total\": 0,\n                        \"returned\": 0\n                    },\n                    \"data\": [],\n                    \"message\": \"未找到新闻数据\"\n                }\n\n            # 执行聚合\n            aggregated = self._aggregate_similar_news(\n                all_news, similarity_threshold, include_url\n            )\n\n            # 按综合权重排序\n            aggregated.sort(key=lambda x: x[\"aggregate_weight\"], reverse=True)\n\n            # 限制返回数量\n            results = aggregated[:limit]\n\n            # 统计信息\n            total_original = len(all_news)\n            total_aggregated = len(aggregated)\n            dedup_rate = 1 - (total_aggregated / total_original) if total_original > 0 else 0\n\n            platform_coverage = Counter()\n            for item in aggregated:\n                for p in item[\"platforms\"]:\n                    platform_coverage[p] += 1\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"跨平台新闻聚合结果\",\n                    \"original_count\": total_original,\n                    \"aggregated_count\": total_aggregated,\n                    \"returned\": len(results),\n                    \"deduplication_rate\": f\"{dedup_rate * 100:.1f}%\",\n                    \"similarity_threshold\": similarity_threshold,\n                    \"date_range\": {\n                        \"start\": start_date.strftime(\"%Y-%m-%d\"),\n                        \"end\": end_date.strftime(\"%Y-%m-%d\")\n                    }\n                },\n                \"data\": results,\n                \"statistics\": {\n                    \"platform_coverage\": dict(platform_coverage),\n                    \"multi_platform_news\": len([a for a in aggregated if len(a[\"platforms\"]) > 1]),\n                    \"single_platform_news\": len([a for a in aggregated if len(a[\"platforms\"]) == 1])\n                }\n            }\n\n        except MCPError as e:\n            return {\"success\": False, \"error\": e.to_dict()}\n        except Exception as e:\n            return {\"success\": False, \"error\": {\"code\": \"INTERNAL_ERROR\", \"message\": str(e)}}\n\n    def _aggregate_similar_news(\n        self,\n        news_list: List[Dict],\n        threshold: float,\n        include_url: bool\n    ) -> List[Dict]:\n        \"\"\"\n        对新闻列表进行相似度聚合\n\n        使用双层过滤策略：先用 Jaccard 快速粗筛，再用 SequenceMatcher 精确计算\n\n        Args:\n            news_list: 新闻列表\n            threshold: 相似度阈值\n            include_url: 是否包含URL\n\n        Returns:\n            聚合后的新闻列表\n        \"\"\"\n        if not news_list:\n            return []\n\n        # 预计算字符集合用于快速过滤\n        prepared_news = []\n        for news in news_list:\n            char_set = set(news[\"title\"])\n            prepared_news.append({\n                \"data\": news,\n                \"char_set\": char_set,\n                \"set_len\": len(char_set)\n            })\n\n        # 按权重排序\n        sorted_items = sorted(prepared_news, key=lambda x: x[\"data\"].get(\"weight\", 0), reverse=True)\n\n        aggregated = []\n        used_indices = set()\n        PRE_FILTER_RATIO = 0.5  # 粗筛阈值系数\n\n        for i, item in enumerate(sorted_items):\n            if i in used_indices:\n                continue\n\n            news = item[\"data\"]\n            base_set = item[\"char_set\"]\n            base_len = item[\"set_len\"]\n\n            group = {\n                \"representative_title\": news[\"title\"],\n                \"platforms\": [news[\"platform_name\"]],\n                \"platform_ids\": [news[\"platform\"]],\n                \"dates\": [news[\"date\"]],\n                \"best_rank\": news[\"rank\"],\n                \"total_count\": news[\"count\"],\n                \"aggregate_weight\": news.get(\"weight\", 0),\n                \"sources\": [{\n                    \"platform\": news[\"platform_name\"],\n                    \"rank\": news[\"rank\"],\n                    \"date\": news[\"date\"]\n                }]\n            }\n\n            if include_url and news.get(\"url\"):\n                group[\"urls\"] = [{\n                    \"platform\": news[\"platform_name\"],\n                    \"url\": news.get(\"url\", \"\"),\n                    \"mobileUrl\": news.get(\"mobileUrl\", \"\")\n                }]\n\n            used_indices.add(i)\n\n            # 查找相似新闻\n            for j in range(i + 1, len(sorted_items)):\n                if j in used_indices:\n                    continue\n\n                compare_item = sorted_items[j]\n                compare_set = compare_item[\"char_set\"]\n                compare_len = compare_item[\"set_len\"]\n\n                # 快速粗筛：长度检查\n                if base_len == 0 or compare_len == 0:\n                    continue\n\n                # 快速粗筛：长度比例检查\n                if min(base_len, compare_len) / max(base_len, compare_len) < (threshold * PRE_FILTER_RATIO):\n                    continue\n\n                # 快速粗筛：Jaccard 相似度\n                intersection = len(base_set & compare_set)\n                union = len(base_set | compare_set)\n                jaccard_sim = intersection / union if union > 0 else 0\n\n                if jaccard_sim < (threshold * PRE_FILTER_RATIO):\n                    continue\n\n                # 精确计算：SequenceMatcher\n                other_news = compare_item[\"data\"]\n                real_similarity = self._calculate_similarity(news[\"title\"], other_news[\"title\"])\n\n                if real_similarity >= threshold:\n                    # 合并到当前组\n                    if other_news[\"platform_name\"] not in group[\"platforms\"]:\n                        group[\"platforms\"].append(other_news[\"platform_name\"])\n                        group[\"platform_ids\"].append(other_news[\"platform\"])\n\n                    if other_news[\"date\"] not in group[\"dates\"]:\n                        group[\"dates\"].append(other_news[\"date\"])\n\n                    group[\"best_rank\"] = min(group[\"best_rank\"], other_news[\"rank\"])\n                    group[\"total_count\"] += other_news[\"count\"]\n                    group[\"aggregate_weight\"] += other_news.get(\"weight\", 0) * 0.5  # 额外权重\n\n                    group[\"sources\"].append({\n                        \"platform\": other_news[\"platform_name\"],\n                        \"rank\": other_news[\"rank\"],\n                        \"date\": other_news[\"date\"]\n                    })\n\n                    if include_url and other_news.get(\"url\"):\n                        if \"urls\" not in group:\n                            group[\"urls\"] = []\n                        group[\"urls\"].append({\n                            \"platform\": other_news[\"platform_name\"],\n                            \"url\": other_news.get(\"url\", \"\"),\n                            \"mobileUrl\": other_news.get(\"mobileUrl\", \"\")\n                        })\n\n                    used_indices.add(j)\n\n            # 添加聚合信息\n            group[\"platform_count\"] = len(group[\"platforms\"])\n            group[\"is_cross_platform\"] = len(group[\"platforms\"]) > 1\n\n            aggregated.append(group)\n\n        return aggregated\n\n    # ==================== 时期对比分析工具 ====================\n\n    def compare_periods(\n        self,\n        period1: Union[Dict[str, str], str],\n        period2: Union[Dict[str, str], str],\n        topic: Optional[str] = None,\n        compare_type: str = \"overview\",\n        platforms: Optional[List[str]] = None,\n        top_n: int = 10\n    ) -> Dict:\n        \"\"\"\n        时期对比分析 - 比较两个时间段的新闻数据\n\n        支持多种对比维度：热度对比、话题变化、平台活跃度等。\n\n        Args:\n            period1: 第一个时间段\n                - {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}: 日期范围\n                - \"today\", \"yesterday\", \"last_week\", \"last_month\": 预设值\n            period2: 第二个时间段（格式同 period1）\n            topic: 可选的话题关键词（聚焦特定话题的对比）\n            compare_type: 对比类型\n                - \"overview\": 总体概览（默认）\n                - \"topic_shift\": 话题变化分析\n                - \"platform_activity\": 平台活跃度对比\n            platforms: 平台过滤列表\n            top_n: 返回 TOP N 结果，默认10\n\n        Returns:\n            对比分析结果字典\n        \"\"\"\n        try:\n            # 参数验证\n            platforms = validate_platforms(platforms)\n            top_n = validate_top_n(top_n, default=10)\n\n            if compare_type not in [\"overview\", \"topic_shift\", \"platform_activity\"]:\n                raise InvalidParameterError(\n                    f\"不支持的对比类型: {compare_type}\",\n                    suggestion=\"支持的类型: overview, topic_shift, platform_activity\"\n                )\n\n            # 解析时间段\n            date_range1 = self._parse_period(period1)\n            date_range2 = self._parse_period(period2)\n\n            if not date_range1 or not date_range2:\n                raise InvalidParameterError(\n                    \"无效的时间段格式\",\n                    suggestion=\"使用 {'start': 'YYYY-MM-DD', 'end': 'YYYY-MM-DD'} 或预设值如 'last_week'\"\n                )\n\n            # 收集两个时期的数据\n            data1 = self._collect_period_data(date_range1, platforms, topic)\n            data2 = self._collect_period_data(date_range2, platforms, topic)\n\n            # 根据对比类型执行不同的分析\n            if compare_type == \"overview\":\n                analysis_result = self._compare_overview(data1, data2, date_range1, date_range2, top_n)\n            elif compare_type == \"topic_shift\":\n                analysis_result = self._compare_topic_shift(data1, data2, date_range1, date_range2, top_n)\n            else:  # platform_activity\n                analysis_result = self._compare_platform_activity(data1, data2, date_range1, date_range2)\n\n            result = {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": f\"时期对比分析（{compare_type}）\",\n                    \"compare_type\": compare_type,\n                    \"periods\": {\n                        \"period1\": {\n                            \"start\": date_range1[0].strftime(\"%Y-%m-%d\"),\n                            \"end\": date_range1[1].strftime(\"%Y-%m-%d\")\n                        },\n                        \"period2\": {\n                            \"start\": date_range2[0].strftime(\"%Y-%m-%d\"),\n                            \"end\": date_range2[1].strftime(\"%Y-%m-%d\")\n                        }\n                    }\n                },\n                \"data\": analysis_result\n            }\n\n            if topic:\n                result[\"summary\"][\"topic_filter\"] = topic\n\n            return result\n\n        except MCPError as e:\n            return {\"success\": False, \"error\": e.to_dict()}\n        except Exception as e:\n            return {\"success\": False, \"error\": {\"code\": \"INTERNAL_ERROR\", \"message\": str(e)}}\n\n    def _parse_period(self, period: Union[Dict[str, str], str]) -> Optional[tuple]:\n        \"\"\"解析时间段为日期范围元组\"\"\"\n        today = datetime.now()\n\n        if isinstance(period, str):\n            if period == \"today\":\n                return (today, today)\n            elif period == \"yesterday\":\n                yesterday = today - timedelta(days=1)\n                return (yesterday, yesterday)\n            elif period == \"last_week\":\n                return (today - timedelta(days=7), today - timedelta(days=1))\n            elif period == \"this_week\":\n                # 本周一到今天\n                days_since_monday = today.weekday()\n                monday = today - timedelta(days=days_since_monday)\n                return (monday, today)\n            elif period == \"last_month\":\n                return (today - timedelta(days=30), today - timedelta(days=1))\n            elif period == \"this_month\":\n                first_of_month = today.replace(day=1)\n                return (first_of_month, today)\n            else:\n                return None\n        elif isinstance(period, dict):\n            try:\n                start = datetime.strptime(period[\"start\"], \"%Y-%m-%d\")\n                end = datetime.strptime(period[\"end\"], \"%Y-%m-%d\")\n                return (start, end)\n            except (KeyError, ValueError):\n                return None\n        return None\n\n    def _collect_period_data(\n        self,\n        date_range: tuple,\n        platforms: Optional[List[str]],\n        topic: Optional[str]\n    ) -> Dict:\n        \"\"\"收集指定时期的新闻数据\"\"\"\n        start_date, end_date = date_range\n        all_news = []\n        all_keywords = Counter()\n        platform_stats = Counter()\n\n        current_date = start_date\n        while current_date <= end_date:\n            try:\n                all_titles, id_to_name, _ = self.data_service.parser.read_all_titles_for_date(\n                    date=current_date,\n                    platform_ids=platforms\n                )\n\n                for platform_id, titles in all_titles.items():\n                    platform_name = id_to_name.get(platform_id, platform_id)\n\n                    for title, info in titles.items():\n                        # 如果指定了话题，过滤不相关的新闻\n                        if topic and topic.lower() not in title.lower():\n                            continue\n\n                        news_item = {\n                            \"title\": title,\n                            \"platform\": platform_id,\n                            \"platform_name\": platform_name,\n                            \"date\": current_date.strftime(\"%Y-%m-%d\"),\n                            \"ranks\": info.get(\"ranks\", []),\n                            \"rank\": info[\"ranks\"][0] if info[\"ranks\"] else 999\n                        }\n                        news_item[\"weight\"] = calculate_news_weight(news_item)\n                        all_news.append(news_item)\n\n                        # 统计平台\n                        platform_stats[platform_name] += 1\n\n                        # 提取关键词\n                        keywords = self._extract_keywords(title)\n                        all_keywords.update(keywords)\n\n            except DataNotFoundError:\n                pass\n\n            current_date += timedelta(days=1)\n\n        return {\n            \"news\": all_news,\n            \"news_count\": len(all_news),\n            \"keywords\": all_keywords,\n            \"platform_stats\": platform_stats,\n            \"date_range\": date_range\n        }\n\n    def _compare_overview(\n        self,\n        data1: Dict,\n        data2: Dict,\n        range1: tuple,\n        range2: tuple,\n        top_n: int\n    ) -> Dict:\n        \"\"\"总体概览对比\"\"\"\n        # 计算变化\n        count_change = data2[\"news_count\"] - data1[\"news_count\"]\n        count_change_pct = (count_change / data1[\"news_count\"] * 100) if data1[\"news_count\"] > 0 else 0\n\n        # TOP 关键词对比\n        top_kw1 = [kw for kw, _ in data1[\"keywords\"].most_common(top_n)]\n        top_kw2 = [kw for kw, _ in data2[\"keywords\"].most_common(top_n)]\n\n        new_keywords = [kw for kw in top_kw2 if kw not in top_kw1]\n        disappeared_keywords = [kw for kw in top_kw1 if kw not in top_kw2]\n        persistent_keywords = [kw for kw in top_kw1 if kw in top_kw2]\n\n        # TOP 新闻对比\n        top_news1 = sorted(data1[\"news\"], key=lambda x: x.get(\"weight\", 0), reverse=True)[:top_n]\n        top_news2 = sorted(data2[\"news\"], key=lambda x: x.get(\"weight\", 0), reverse=True)[:top_n]\n\n        return {\n            \"overview\": {\n                \"period1_count\": data1[\"news_count\"],\n                \"period2_count\": data2[\"news_count\"],\n                \"count_change\": count_change,\n                \"count_change_percent\": f\"{count_change_pct:+.1f}%\"\n            },\n            \"keyword_analysis\": {\n                \"new_keywords\": new_keywords[:5],\n                \"disappeared_keywords\": disappeared_keywords[:5],\n                \"persistent_keywords\": persistent_keywords[:5]\n            },\n            \"top_news\": {\n                \"period1\": [{\"title\": n[\"title\"], \"platform\": n[\"platform_name\"]} for n in top_news1],\n                \"period2\": [{\"title\": n[\"title\"], \"platform\": n[\"platform_name\"]} for n in top_news2]\n            }\n        }\n\n    def _compare_topic_shift(\n        self,\n        data1: Dict,\n        data2: Dict,\n        range1: tuple,\n        range2: tuple,\n        top_n: int\n    ) -> Dict:\n        \"\"\"话题变化分析\"\"\"\n        kw1 = data1[\"keywords\"]\n        kw2 = data2[\"keywords\"]\n\n        # 计算热度变化\n        all_keywords = set(kw1.keys()) | set(kw2.keys())\n        keyword_changes = []\n\n        for kw in all_keywords:\n            count1 = kw1.get(kw, 0)\n            count2 = kw2.get(kw, 0)\n            change = count2 - count1\n\n            if count1 > 0:\n                change_pct = (change / count1) * 100\n            elif count2 > 0:\n                change_pct = 100  # 新出现\n            else:\n                change_pct = 0\n\n            keyword_changes.append({\n                \"keyword\": kw,\n                \"period1_count\": count1,\n                \"period2_count\": count2,\n                \"change\": change,\n                \"change_percent\": round(change_pct, 1)\n            })\n\n        # 按变化幅度排序\n        rising = sorted([k for k in keyword_changes if k[\"change\"] > 0],\n                       key=lambda x: x[\"change\"], reverse=True)[:top_n]\n        falling = sorted([k for k in keyword_changes if k[\"change\"] < 0],\n                        key=lambda x: x[\"change\"])[:top_n]\n        new_topics = [k for k in keyword_changes if k[\"period1_count\"] == 0 and k[\"period2_count\"] > 0][:top_n]\n\n        return {\n            \"rising_topics\": rising,\n            \"falling_topics\": falling,\n            \"new_topics\": new_topics,\n            \"total_keywords\": {\n                \"period1\": len(kw1),\n                \"period2\": len(kw2)\n            }\n        }\n\n    def _compare_platform_activity(\n        self,\n        data1: Dict,\n        data2: Dict,\n        range1: tuple,\n        range2: tuple\n    ) -> Dict:\n        \"\"\"平台活跃度对比\"\"\"\n        ps1 = data1[\"platform_stats\"]\n        ps2 = data2[\"platform_stats\"]\n\n        all_platforms = set(ps1.keys()) | set(ps2.keys())\n        platform_changes = []\n\n        for platform in all_platforms:\n            count1 = ps1.get(platform, 0)\n            count2 = ps2.get(platform, 0)\n            change = count2 - count1\n\n            if count1 > 0:\n                change_pct = (change / count1) * 100\n            elif count2 > 0:\n                change_pct = 100\n            else:\n                change_pct = 0\n\n            platform_changes.append({\n                \"platform\": platform,\n                \"period1_count\": count1,\n                \"period2_count\": count2,\n                \"change\": change,\n                \"change_percent\": round(change_pct, 1)\n            })\n\n        # 按变化排序\n        platform_changes.sort(key=lambda x: x[\"change\"], reverse=True)\n\n        return {\n            \"platform_comparison\": platform_changes,\n            \"most_active_growth\": platform_changes[0] if platform_changes else None,\n            \"least_active_growth\": platform_changes[-1] if platform_changes else None,\n            \"total_activity\": {\n                \"period1\": sum(ps1.values()),\n                \"period2\": sum(ps2.values())\n            }\n        }\n"
  },
  {
    "path": "mcp_server/tools/article_reader.py",
    "content": "\"\"\"\n文章内容读取工具\n\n通过 Jina AI Reader API 将 URL 转换为 LLM 友好的 Markdown 格式。\n支持单篇和批量读取，内置速率限制和并发控制。\n\n\"\"\"\n\nimport time\nfrom typing import Dict, List\n\nimport requests\n\nfrom ..utils.errors import MCPError, InvalidParameterError\n\n\n# Jina Reader 配置\nJINA_READER_BASE = \"https://r.jina.ai\"\nDEFAULT_TIMEOUT = 30  # 秒\nMAX_BATCH_SIZE = 5  # 单次批量最大篇数\nBATCH_INTERVAL = 5.0  # 批量请求间隔（秒）\n\n\nclass ArticleReaderTools:\n    \"\"\"文章内容读取工具类\"\"\"\n\n    def __init__(self, project_root: str = None, jina_api_key: str = None):\n        \"\"\"\n        初始化文章读取工具\n\n        Args:\n            project_root: 项目根目录\n            jina_api_key: Jina API Key（可选，有 Key 可提升速率限制）\n        \"\"\"\n        self.project_root = project_root\n        self.jina_api_key = jina_api_key\n        self._last_request_time = 0.0\n\n    def _build_headers(self) -> Dict[str, str]:\n        \"\"\"构建请求头\"\"\"\n        headers = {\n            \"Accept\": \"text/markdown\",\n            \"X-Return-Format\": \"markdown\",\n            \"X-No-Cache\": \"true\",\n        }\n        if self.jina_api_key:\n            headers[\"Authorization\"] = f\"Bearer {self.jina_api_key}\"\n        return headers\n\n    def _throttle(self):\n        \"\"\"速率控制：确保请求间隔 5 秒\"\"\"\n        now = time.time()\n        elapsed = now - self._last_request_time\n        if elapsed < BATCH_INTERVAL:\n            time.sleep(BATCH_INTERVAL - elapsed)\n        self._last_request_time = time.time()\n\n    def read_article(\n        self,\n        url: str,\n        timeout: int = DEFAULT_TIMEOUT\n    ) -> Dict:\n        \"\"\"\n        读取单篇文章内容（Markdown 格式）\n\n        Args:\n            url: 文章链接\n            timeout: 请求超时时间（秒），默认 30\n\n        Returns:\n            文章内容字典\n        \"\"\"\n        try:\n            if not url or not url.startswith((\"http://\", \"https://\")):\n                raise InvalidParameterError(\n                    f\"无效的 URL: {url}\",\n                    suggestion=\"URL 必须以 http:// 或 https:// 开头\"\n                )\n\n            self._throttle()\n\n            response = requests.get(\n                f\"{JINA_READER_BASE}/{url}\",\n                headers=self._build_headers(),\n                timeout=timeout\n            )\n\n            if response.status_code == 200:\n                return {\n                    \"success\": True,\n                    \"data\": {\n                        \"url\": url,\n                        \"content\": response.text,\n                        \"format\": \"markdown\",\n                        \"content_length\": len(response.text)\n                    }\n                }\n            elif response.status_code == 429:\n                return {\n                    \"success\": False,\n                    \"error\": {\n                        \"code\": \"RATE_LIMITED\",\n                        \"message\": \"Jina Reader 速率限制，请稍后重试\",\n                        \"suggestion\": \"免费限制: 100 RPM / 2 并发，可配置 API Key 提升限额\"\n                    }\n                }\n            else:\n                return {\n                    \"success\": False,\n                    \"error\": {\n                        \"code\": \"FETCH_FAILED\",\n                        \"message\": f\"HTTP {response.status_code}: {response.reason}\",\n                        \"url\": url\n                    }\n                }\n\n        except requests.Timeout:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"TIMEOUT\",\n                    \"message\": f\"请求超时（{timeout}秒）\",\n                    \"url\": url,\n                    \"suggestion\": \"可尝试增加 timeout 参数\"\n                }\n            }\n        except MCPError as e:\n            return {\"success\": False, \"error\": e.to_dict()}\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"REQUEST_ERROR\",\n                    \"message\": str(e),\n                    \"url\": url\n                }\n            }\n\n    def read_articles_batch(\n        self,\n        urls: List[str],\n        timeout: int = DEFAULT_TIMEOUT\n    ) -> Dict:\n        \"\"\"\n        批量读取多篇文章内容（最多 5 篇，间隔 5 秒）\n\n        Args:\n            urls: 文章链接列表\n            timeout: 每篇的请求超时时间（秒）\n\n        Returns:\n            批量读取结果\n        \"\"\"\n        try:\n            if not urls:\n                raise InvalidParameterError(\n                    \"URL 列表不能为空\",\n                    suggestion=\"请提供至少一个 URL\"\n                )\n\n            # 限制最多 5 篇\n            actual_urls = urls[:MAX_BATCH_SIZE]\n            skipped = len(urls) - len(actual_urls)\n\n            results = []\n            succeeded = 0\n            failed = 0\n\n            for i, url in enumerate(actual_urls):\n                result = self.read_article(url=url, timeout=timeout)\n\n                results.append({\n                    \"index\": i + 1,\n                    \"url\": url,\n                    \"success\": result[\"success\"],\n                    \"data\": result.get(\"data\"),\n                    \"error\": result.get(\"error\")\n                })\n\n                if result[\"success\"]:\n                    succeeded += 1\n                else:\n                    failed += 1\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"批量文章读取结果\",\n                    \"requested\": len(urls),\n                    \"processed\": len(actual_urls),\n                    \"succeeded\": succeeded,\n                    \"failed\": failed,\n                    \"skipped\": skipped,\n                    \"interval_seconds\": BATCH_INTERVAL,\n                },\n                \"articles\": results,\n                \"note\": f\"已跳过 {skipped} 篇（单次上限 {MAX_BATCH_SIZE} 篇）\" if skipped > 0 else None\n            }\n\n        except MCPError as e:\n            return {\"success\": False, \"error\": e.to_dict()}\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"BATCH_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n"
  },
  {
    "path": "mcp_server/tools/config_mgmt.py",
    "content": "\"\"\"\n配置管理工具\n\n实现配置查询和管理功能。\n\"\"\"\n\nfrom typing import Dict, Optional, Any, TypedDict\n\nfrom ..services.data_service import DataService\nfrom ..utils.validators import validate_config_section\nfrom ..utils.errors import MCPError\n\n\nclass ErrorInfo(TypedDict, total=False):\n    \"\"\"错误信息结构\"\"\"\n    code: str\n    message: str\n    suggestion: str\n\n\nclass ConfigResult(TypedDict):\n    \"\"\"配置查询结果 - success 字段必需，其他字段可选\"\"\"\n    success: bool\n    config: Optional[Dict[str, Any]]\n    section: Optional[str]\n    error: Optional[ErrorInfo]\n\n\nclass ConfigManagementTools:\n    \"\"\"配置管理工具类\"\"\"\n\n    def __init__(self, project_root: str = None):\n        \"\"\"\n        初始化配置管理工具\n\n        Args:\n            project_root: 项目根目录\n        \"\"\"\n        self.data_service = DataService(project_root)\n\n    def get_current_config(self, section: Optional[str] = None) -> ConfigResult:\n        \"\"\"\n        获取当前系统配置\n\n        Args:\n            section: 配置节 - all/crawler/push/keywords/weights，默认all\n\n        Returns:\n            配置字典\n\n        Example:\n            >>> tools = ConfigManagementTools()\n            >>> result = tools.get_current_config(section=\"crawler\")\n            >>> print(result['crawler']['platforms'])\n        \"\"\"\n        try:\n            # 参数验证\n            section = validate_config_section(section)\n\n            # 获取配置\n            config = self.data_service.get_current_config(section=section)\n\n            return ConfigResult(\n                success=True,\n                config=config,\n                section=section,\n                error=None\n            )\n\n        except MCPError as e:\n            return ConfigResult(\n                success=False,\n                config=None,\n                section=None,\n                error=e.to_dict()\n            )\n        except Exception as e:\n            return ConfigResult(\n                success=False,\n                config=None,\n                section=None,\n                error={\"code\": \"INTERNAL_ERROR\", \"message\": str(e), \"suggestion\": \"请查看服务日志获取详细信息\"}\n            )\n"
  },
  {
    "path": "mcp_server/tools/data_query.py",
    "content": "\"\"\"\n数据查询工具\n\n实现P0核心的数据查询工具。\n\"\"\"\n\nfrom typing import Dict, List, Optional, Union\n\nfrom ..services.data_service import DataService\nfrom ..utils.validators import (\n    validate_platforms,\n    validate_limit,\n    validate_keyword,\n    validate_date_range,\n    validate_top_n,\n    validate_mode,\n    validate_date_query,\n    normalize_date_range\n)\nfrom ..utils.errors import MCPError\n\n\nclass DataQueryTools:\n    \"\"\"数据查询工具类\"\"\"\n\n    def __init__(self, project_root: str = None):\n        \"\"\"\n        初始化数据查询工具\n\n        Args:\n            project_root: 项目根目录\n        \"\"\"\n        self.data_service = DataService(project_root)\n\n    def get_latest_news(\n        self,\n        platforms: Optional[List[str]] = None,\n        limit: Optional[int] = None,\n        include_url: bool = False\n    ) -> Dict:\n        \"\"\"\n        获取最新一批爬取的新闻数据\n\n        Args:\n            platforms: 平台ID列表，如 ['zhihu', 'weibo']\n            limit: 返回条数限制，默认20\n            include_url: 是否包含URL链接，默认False（节省token）\n\n        Returns:\n            新闻列表字典\n\n        Example:\n            >>> tools = DataQueryTools()\n            >>> result = tools.get_latest_news(platforms=['zhihu'], limit=10)\n            >>> print(result['total'])\n            10\n        \"\"\"\n        try:\n            # 参数验证\n            platforms = validate_platforms(platforms)\n            limit = validate_limit(limit, default=50)\n\n            # 获取数据\n            news_list = self.data_service.get_latest_news(\n                platforms=platforms,\n                limit=limit,\n                include_url=include_url\n            )\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"最新一批爬取的新闻数据\",\n                    \"total\": len(news_list),\n                    \"returned\": len(news_list),\n                    \"platforms\": platforms or \"全部平台\"\n                },\n                \"data\": news_list\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def search_news_by_keyword(\n        self,\n        keyword: str,\n        date_range: Optional[Union[Dict, str]] = None,\n        platforms: Optional[List[str]] = None,\n        limit: Optional[int] = None\n    ) -> Dict:\n        \"\"\"\n        按关键词搜索历史新闻\n\n        Args:\n            keyword: 搜索关键词（必需）\n            date_range: 日期范围，格式: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}\n            platforms: 平台过滤列表\n            limit: 返回条数限制（可选，默认返回所有）\n\n        Returns:\n            搜索结果字典\n\n        Example (假设今天是 2025-11-17):\n            >>> tools = DataQueryTools()\n            >>> result = tools.search_news_by_keyword(\n            ...     keyword=\"人工智能\",\n            ...     date_range={\"start\": \"2025-11-08\", \"end\": \"2025-11-17\"},\n            ...     limit=50\n            ... )\n            >>> print(result['total'])\n        \"\"\"\n        try:\n            # 参数验证\n            keyword = validate_keyword(keyword)\n            date_range_tuple = validate_date_range(date_range)\n            platforms = validate_platforms(platforms)\n\n            if limit is not None:\n                limit = validate_limit(limit, default=100)\n\n            # 搜索数据\n            search_result = self.data_service.search_news_by_keyword(\n                keyword=keyword,\n                date_range=date_range_tuple,\n                platforms=platforms,\n                limit=limit\n            )\n\n            return {\n                **search_result,\n                \"success\": True\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def get_trending_topics(\n        self,\n        top_n: Optional[int] = None,\n        mode: Optional[str] = None,\n        extract_mode: Optional[str] = None\n    ) -> Dict:\n        \"\"\"\n        获取热点话题统计\n\n        Args:\n            top_n: 返回TOP N话题，默认10\n            mode: 时间模式\n                - \"daily\": 当日累计数据统计\n                - \"current\": 最新一批数据统计（默认）\n            extract_mode: 提取模式\n                - \"keywords\": 统计预设关注词（基于 config/frequency_words.txt，默认）\n                - \"auto_extract\": 自动从新闻标题提取高频词\n\n        Returns:\n            话题频率统计字典\n\n        Example:\n            >>> tools = DataQueryTools()\n            >>> # 使用预设关注词\n            >>> result = tools.get_trending_topics(top_n=5, mode=\"current\")\n            >>> # 自动提取高频词\n            >>> result = tools.get_trending_topics(top_n=10, extract_mode=\"auto_extract\")\n        \"\"\"\n        try:\n            # 参数验证\n            top_n = validate_top_n(top_n, default=10)\n            valid_modes = [\"daily\", \"current\"]\n            mode = validate_mode(mode, valid_modes, default=\"current\")\n\n            # 验证 extract_mode\n            if extract_mode is None:\n                extract_mode = \"keywords\"\n            elif extract_mode not in [\"keywords\", \"auto_extract\"]:\n                return {\n                    \"success\": False,\n                    \"error\": {\n                        \"code\": \"INVALID_PARAMETER\",\n                        \"message\": f\"不支持的提取模式: {extract_mode}\",\n                        \"suggestion\": \"支持的模式: keywords, auto_extract\"\n                    }\n                }\n\n            # 获取趋势话题\n            trending_result = self.data_service.get_trending_topics(\n                top_n=top_n,\n                mode=mode,\n                extract_mode=extract_mode\n            )\n\n            return {\n                **trending_result,\n                \"success\": True\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def get_news_by_date(\n        self,\n        date_range: Optional[Union[Dict[str, str], str]] = None,\n        platforms: Optional[List[str]] = None,\n        limit: Optional[int] = None,\n        include_url: bool = False\n    ) -> Dict:\n        \"\"\"\n        按日期查询新闻，支持自然语言日期\n\n        Args:\n            date_range: 日期范围（可选，默认\"今天\"），支持：\n                - 范围对象：{\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"}\n                - 相对日期：今天、昨天、前天、3天前\n                - 单日字符串：2025-10-10\n            platforms: 平台ID列表，如 ['zhihu', 'weibo']\n            limit: 返回条数限制，默认50\n            include_url: 是否包含URL链接，默认False（节省token）\n\n        Returns:\n            新闻列表字典\n\n        Example:\n            >>> tools = DataQueryTools()\n            >>> # 不指定日期，默认查询今天\n            >>> result = tools.get_news_by_date(platforms=['zhihu'], limit=20)\n            >>> # 指定日期\n            >>> result = tools.get_news_by_date(\n            ...     date_range=\"昨天\",\n            ...     platforms=['zhihu'],\n            ...     limit=20\n            ... )\n            >>> print(result['total'])\n            20\n        \"\"\"\n        try:\n            # 参数验证 - 默认今天\n            if date_range is None:\n                date_range = \"今天\"\n\n            # 规范化 date_range（处理 JSON 字符串序列化问题）\n            date_range = normalize_date_range(date_range)\n\n            # 处理 date_range：支持字符串或对象\n            if isinstance(date_range, dict):\n                # 范围对象，取 start 日期\n                date_str = date_range.get('start', '今天')\n            else:\n                date_str = date_range\n            target_date = validate_date_query(date_str)\n            platforms = validate_platforms(platforms)\n            limit = validate_limit(limit, default=50)\n\n            # 获取数据\n            news_list = self.data_service.get_news_by_date(\n                target_date=target_date,\n                platforms=platforms,\n                limit=limit,\n                include_url=include_url\n            )\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": f\"按日期查询的新闻（{target_date.strftime('%Y-%m-%d')}）\",\n                    \"total\": len(news_list),\n                    \"returned\": len(news_list),\n                    \"date\": target_date.strftime(\"%Y-%m-%d\"),\n                    \"date_range\": date_range,\n                    \"platforms\": platforms or \"全部平台\"\n                },\n                \"data\": news_list\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    # ========================================\n    # RSS 数据查询方法\n    # ========================================\n\n    def get_latest_rss(\n        self,\n        feeds: Optional[List[str]] = None,\n        days: int = 1,\n        limit: Optional[int] = None,\n        include_summary: bool = False\n    ) -> Dict:\n        \"\"\"\n        获取最新的 RSS 数据（支持多日查询）\n\n        Args:\n            feeds: RSS 源 ID 列表，如 ['hacker-news', '36kr']\n            days: 获取最近 N 天的数据，默认 1（仅今天），最大 30 天\n            limit: 返回条数限制，默认50\n            include_summary: 是否包含摘要，默认False（节省token）\n\n        Returns:\n            RSS 条目列表字典\n        \"\"\"\n        try:\n            limit = validate_limit(limit, default=50)\n\n            rss_list = self.data_service.get_latest_rss(\n                feeds=feeds,\n                days=days,\n                limit=limit,\n                include_summary=include_summary\n            )\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": f\"最近 {days} 天的 RSS 订阅数据\" if days > 1 else \"最新的 RSS 订阅数据\",\n                    \"total\": len(rss_list),\n                    \"returned\": len(rss_list),\n                    \"days\": days,\n                    \"feeds\": feeds or \"全部订阅源\"\n                },\n                \"data\": rss_list\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def search_rss(\n        self,\n        keyword: str,\n        feeds: Optional[List[str]] = None,\n        days: int = 7,\n        limit: Optional[int] = None,\n        include_summary: bool = False\n    ) -> Dict:\n        \"\"\"\n        搜索 RSS 数据\n\n        Args:\n            keyword: 搜索关键词\n            feeds: RSS 源 ID 列表\n            days: 搜索最近 N 天的数据，默认 7 天\n            limit: 返回条数限制，默认50\n            include_summary: 是否包含摘要\n\n        Returns:\n            匹配的 RSS 条目列表\n        \"\"\"\n        try:\n            keyword = validate_keyword(keyword)\n            limit = validate_limit(limit, default=50)\n\n            if days < 1 or days > 30:\n                days = 7\n\n            rss_list = self.data_service.search_rss(\n                keyword=keyword,\n                feeds=feeds,\n                days=days,\n                limit=limit,\n                include_summary=include_summary\n            )\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": f\"RSS 搜索结果（关键词: {keyword}）\",\n                    \"total\": len(rss_list),\n                    \"returned\": len(rss_list),\n                    \"keyword\": keyword,\n                    \"feeds\": feeds or \"全部订阅源\",\n                    \"days\": days\n                },\n                \"data\": rss_list\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def get_rss_feeds_status(self) -> Dict:\n        \"\"\"\n        获取 RSS 源状态\n\n        Returns:\n            RSS 源状态信息\n        \"\"\"\n        try:\n            status = self.data_service.get_rss_feeds_status()\n\n            return {\n                **status,\n                \"success\": True\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n"
  },
  {
    "path": "mcp_server/tools/notification.py",
    "content": "# coding=utf-8\n\"\"\"\n通知推送工具\n\n支持向已配置的通知渠道发送消息，自动检测 config.yaml 和 .env 中的渠道配置。\n接受 markdown 格式内容，内部按各渠道要求自动转换格式后发送。\n\"\"\"\n\nimport json\nimport os\nimport re\nimport smtplib\nimport time\nfrom datetime import datetime\nfrom email.header import Header\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.text import MIMEText\nfrom email.utils import formataddr, formatdate, make_msgid\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional\nfrom urllib.parse import urlparse\n\nimport requests\nimport yaml\n\nfrom trendradar.core.loader import _load_webhook_config, _load_notification_config\nfrom trendradar.notification.batch import (\n    truncate_to_bytes,\n    get_batch_header,\n    get_max_batch_header_size,\n    add_batch_headers,\n)\nfrom trendradar.notification.formatters import strip_markdown\nfrom trendradar.notification.senders import SMTP_CONFIGS\n\nfrom ..utils.errors import MCPError, InvalidParameterError\n\n\n# ==================== 渠道启用判断规则 ====================\n\n# 每个渠道需要哪些配置项都非空才算\"已配置\"\n# 注意：NTFY_SERVER_URL 在 loader 中有默认值 \"https://ntfy.sh\"，不作为判断依据\n_CHANNEL_REQUIREMENTS = {\n    \"feishu\": [\"FEISHU_WEBHOOK_URL\"],\n    \"dingtalk\": [\"DINGTALK_WEBHOOK_URL\"],\n    \"wework\": [\"WEWORK_WEBHOOK_URL\"],\n    \"telegram\": [\"TELEGRAM_BOT_TOKEN\", \"TELEGRAM_CHAT_ID\"],\n    \"email\": [\"EMAIL_FROM\", \"EMAIL_PASSWORD\", \"EMAIL_TO\"],\n    \"ntfy\": [\"NTFY_TOPIC\"],\n    \"bark\": [\"BARK_URL\"],\n    \"slack\": [\"SLACK_WEBHOOK_URL\"],\n    \"generic_webhook\": [\"GENERIC_WEBHOOK_URL\"],\n}\n\n# 渠道显示名称\n_CHANNEL_NAMES = {\n    \"feishu\": \"飞书\",\n    \"dingtalk\": \"钉钉\",\n    \"wework\": \"企业微信\",\n    \"telegram\": \"Telegram\",\n    \"email\": \"邮件\",\n    \"ntfy\": \"ntfy\",\n    \"bark\": \"Bark\",\n    \"slack\": \"Slack\",\n    \"generic_webhook\": \"通用 Webhook\",\n}\n\n\n# ==================== 批次处理配置 ====================\n\n# 各渠道最大批次字节数的默认值\n# 运行时从 config.yaml → advanced.batch_size 读取覆盖\n_CHANNEL_BATCH_SIZES_DEFAULT = {\n    \"feishu\": 30000,    # config.yaml: advanced.batch_size.feishu\n    \"dingtalk\": 20000,  # config.yaml: advanced.batch_size.dingtalk\n    \"wework\": 4000,     # config.yaml: advanced.batch_size.default\n    \"telegram\": 4000,   # config.yaml: advanced.batch_size.default\n    \"email\": 0,         # 邮件无字节限制，不分批\n    \"ntfy\": 3800,       # 严格 4KB 限制（ntfy 代码默认值）\n    \"bark\": 4000,       # config.yaml: advanced.batch_size.bark\n    \"slack\": 4000,      # config.yaml: advanced.batch_size.slack\n    \"generic_webhook\": 4000,\n}\n\n# 显示最新消息在前的渠道，批次需反序发送\n_REVERSE_BATCH_CHANNELS = {\"ntfy\", \"bark\"}\n\n# 批次发送间隔默认值（秒），运行时从 config.yaml → advanced.batch_send_interval 读取\n_BATCH_INTERVAL_DEFAULT = 3.0\n\n\n# ==================== 批次处理 ====================\n# truncate_to_bytes, get_batch_header, get_max_batch_header_size,\n# add_batch_headers 复用自 trendradar.notification.batch\n\n\ndef _split_text_into_batches(text: str, max_bytes: int) -> List[str]:\n    \"\"\"将文本按字节限制分批，优先在段落边界（双换行）切割\n\n    分割策略（参考 trendradar splitter.py 的原子性保证）：\n    1. 优先按段落（双换行 \\\\n\\\\n）拆分\n    2. 段落仍超限时，按单行（\\\\n）拆分\n    3. 单行仍超限时，用 _truncate_to_bytes 安全截断\n\n    Args:\n        text: 已转换为目标渠道格式的文本\n        max_bytes: 单批最大字节数（已扣除批次头部预留）\n\n    Returns:\n        分批后的文本列表\n    \"\"\"\n    if max_bytes <= 0 or len(text.encode(\"utf-8\")) <= max_bytes:\n        return [text]\n\n    # 按段落分割\n    paragraphs = text.split(\"\\n\\n\")\n    batches = []\n    current = \"\"\n\n    for para in paragraphs:\n        candidate = f\"{current}\\n\\n{para}\" if current else para\n        if len(candidate.encode(\"utf-8\")) <= max_bytes:\n            current = candidate\n        else:\n            # 当前段落放不下，先保存已有内容\n            if current:\n                batches.append(current)\n                current = \"\"\n\n            # 检查单个段落是否超限\n            if len(para.encode(\"utf-8\")) <= max_bytes:\n                current = para\n            else:\n                # 段落本身超限，按行拆分\n                lines = para.split(\"\\n\")\n                for line in lines:\n                    candidate = f\"{current}\\n{line}\" if current else line\n                    if len(candidate.encode(\"utf-8\")) <= max_bytes:\n                        current = candidate\n                    else:\n                        if current:\n                            batches.append(current)\n                            current = \"\"\n                        # 单行超限，循环截断直到处理完\n                        if len(line.encode(\"utf-8\")) > max_bytes:\n                            remaining = line\n                            while remaining:\n                                chunk = truncate_to_bytes(remaining, max_bytes)\n                                if not chunk:\n                                    break\n                                batches.append(chunk)\n                                # 移除已截断的部分\n                                remaining = remaining[len(chunk):]\n                        else:\n                            current = line\n\n    if current:\n        batches.append(current)\n\n    return batches if batches else [text]\n\n\ndef _format_for_channel(message: str, channel_id: str) -> str:\n    \"\"\"将通用 Markdown 适配并转换为目标渠道格式\n\n    统一入口：先适配（剥离不支持的语法），再转换（Markdown→HTML/mrkdwn 等）。\n    返回的文本可以直接用于字节分割和发送。\n\n    Args:\n        message: 原始 Markdown 格式文本\n        channel_id: 目标渠道 ID\n\n    Returns:\n        目标渠道格式的文本\n    \"\"\"\n    if channel_id == \"feishu\":\n        return _adapt_markdown_for_feishu(message)\n    elif channel_id == \"dingtalk\":\n        return _adapt_markdown_for_dingtalk(message)\n    elif channel_id == \"wework\":\n        return _adapt_markdown_for_wework(message)\n    elif channel_id == \"telegram\":\n        return _markdown_to_telegram_html(message)\n    elif channel_id == \"ntfy\":\n        return _adapt_markdown_for_ntfy(message)\n    elif channel_id == \"bark\":\n        return _adapt_markdown_for_bark(message)\n    elif channel_id == \"slack\":\n        return _convert_markdown_to_slack(message)\n    else:\n        # email, generic_webhook: 保持原始 Markdown\n        return message\n\n\ndef _prepare_batches(message: str, channel_id: str, batch_sizes: Dict = None) -> List[str]:\n    \"\"\"完整的分批管线：格式适配 → 字节分割 → 添加批次头部\n\n    Args:\n        message: 原始 Markdown 格式文本\n        channel_id: 目标渠道 ID\n        batch_sizes: 各渠道批次大小字典（来自 config.yaml），None 使用默认值\n\n    Returns:\n        准备好的批次列表（已添加头部，已处理反序）\n    \"\"\"\n    sizes = batch_sizes or _CHANNEL_BATCH_SIZES_DEFAULT\n    max_bytes = sizes.get(channel_id, sizes.get(\"default\", 4000))\n    if max_bytes <= 0:\n        # 无字节限制（如 email），返回原始文本\n        return [message]\n\n    formatted = _format_for_channel(message, channel_id)\n\n    # 预留批次头部空间后分割\n    header_reserve = get_max_batch_header_size(channel_id)\n    batches = _split_text_into_batches(formatted, max_bytes - header_reserve)\n\n    # 添加批次头部（单批时不添加）\n    batches = add_batch_headers(batches, channel_id, max_bytes)\n\n    # ntfy/Bark 反序发送（客户端显示最新在前）\n    if channel_id in _REVERSE_BATCH_CHANNELS and len(batches) > 1:\n        batches = list(reversed(batches))\n\n    return batches\n\nCHANNEL_FORMAT_GUIDES = {\n    \"feishu\": {\n        \"name\": \"飞书\",\n        \"format\": \"Markdown（卡片消息）\",\n        \"max_length\": \"约 29000 字节\",\n        \"supported\": [\n            \"**粗体**\",\n            \"[链接文本](URL)\",\n            \"<font color='red/green/grey/orange/blue'>彩色文本</font>\",\n            \"---（分割线）\",\n            \"换行分隔段落\",\n        ],\n        \"unsupported\": [\n            \"# 标题语法（不渲染为标题样式）\",\n            \"> 引用块\",\n            \"表格 / 图片嵌入\",\n        ],\n        \"prompt\": (\n            \"飞书卡片 Markdown 格式化策略：\\n\"\n            \"1. 用 **粗体** 作小标题和重点词\\n\"\n            \"2. 用 <font color='red'>红色</font> 标记紧急/重要内容\\n\"\n            \"3. 用 <font color='grey'>灰色</font> 标记辅助信息（时间、来源）\\n\"\n            \"4. 用 <font color='orange'>橙色</font> 标记警告\\n\"\n            \"5. 用 <font color='green'>绿色</font> 标记正面/成功信息\\n\"\n            \"6. 用 [文本](URL) 添加可点击链接\\n\"\n            \"7. 用 --- 分割不同主题区域\\n\"\n            \"8. 不要用 # 标题语法（卡片内不渲染）\\n\"\n            \"9. 不要用 > 引用语法\\n\"\n            \"10. 用换行 + 粗体模拟层级结构\"\n        ),\n    },\n    \"dingtalk\": {\n        \"name\": \"钉钉\",\n        \"format\": \"Markdown\",\n        \"max_length\": \"约 20000 字节\",\n        \"supported\": [\n            \"### 三级标题 / #### 四级标题\",\n            \"**粗体**\",\n            \"[链接文本](URL)\",\n            \"> 引用块\",\n            \"---（分割线）\",\n            \"- 无序列表 / 1. 有序列表\",\n        ],\n        \"unsupported\": [\n            \"# 一级标题 / ## 二级标题（可能不渲染）\",\n            \"<font> 彩色文本\",\n            \"~~删除线~~\",\n            \"表格 / 图片嵌入\",\n        ],\n        \"prompt\": (\n            \"钉钉 Markdown 格式化策略：\\n\"\n            \"1. 用 ### 或 #### 作章节标题（不用 # 和 ##）\\n\"\n            \"2. 用 **粗体** 突出关键词和数据\\n\"\n            \"3. 用 > 引用块展示备注或补充说明\\n\"\n            \"4. 用 --- 分割不同主题区域\\n\"\n            \"5. 用 [文本](URL) 添加可点击链接\\n\"\n            \"6. 用有序列表（1. 2. 3.）组织要点\\n\"\n            \"7. 不要用 <font> 颜色标签（钉钉不支持）\\n\"\n            \"8. 不要用删除线语法\\n\"\n            \"9. 标题和正文之间加空行提升可读性\"\n        ),\n    },\n    \"wework\": {\n        \"name\": \"企业微信\",\n        \"format\": \"Markdown（群机器人）/ 纯文本（个人微信）\",\n        \"max_length\": \"约 4000 字节\",\n        \"supported\": [\n            \"**粗体**\",\n            \"[链接文本](URL)\",\n            \"> 引用块（仅首行生效）\",\n        ],\n        \"unsupported\": [\n            \"# 标题语法\",\n            \"---（水平分割线）\",\n            \"<font> 彩色文本\",\n            \"~~删除线~~\",\n            \"表格 / 图片嵌入 / 有序列表\",\n        ],\n        \"prompt\": (\n            \"企业微信 Markdown 格式化策略：\\n\"\n            \"1. 用 **粗体** 作小标题和重点词\\n\"\n            \"2. 用 [文本](URL) 添加可点击链接\\n\"\n            \"3. 用 > 引用块展示备注（仅首行生效）\\n\"\n            \"4. 内容要简洁，受 4KB 限制\\n\"\n            \"5. 不要用 # 标题语法（不渲染）\\n\"\n            \"6. 不要用 ---（不渲染），用多个换行分隔区域\\n\"\n            \"7. 不要用 <font> 颜色标签\\n\"\n            \"8. 不要用删除线和有序列表\\n\"\n            \"9. 用换行 + 粗体模拟层级结构\\n\"\n            \"10. 个人微信模式下所有格式被剥离为纯文本\"\n        ),\n    },\n    \"telegram\": {\n        \"name\": \"Telegram\",\n        \"format\": \"HTML（自动从 Markdown 转换）\",\n        \"max_length\": \"约 4096 字符\",\n        \"supported\": [\n            \"<b>粗体</b>（从 **粗体** 转换）\",\n            \"<i>斜体</i>（从 *斜体* 转换）\",\n            \"<s>删除线</s>（从 ~~删除线~~ 转换）\",\n            \"<code>行内代码</code>（从 `代码` 转换）\",\n            \"<a href='URL'>链接</a>（从 [文本](URL) 转换）\",\n            \"<blockquote>引用块</blockquote>（从 > 引用 转换）\",\n        ],\n        \"unsupported\": [\n            \"# 标题语法（自动剥离 # 前缀）\",\n            \"---（分割线，自动剥离）\",\n            \"<font> 彩色文本（自动剥离）\",\n            \"表格 / 图片嵌入\",\n        ],\n        \"prompt\": (\n            \"Telegram HTML 格式化策略（输入仍为 Markdown，自动转换为 HTML）：\\n\"\n            \"1. 用 **粗体** 突出关键词（转为 <b>）\\n\"\n            \"2. 用 *斜体* 标记辅助信息（转为 <i>）\\n\"\n            \"3. 用 `代码` 标记数据值/时间（转为 <code>）\\n\"\n            \"4. 用 [文本](URL) 添加链接（转为 <a>）\\n\"\n            \"5. 用 > 开头的行作引用块（转为 <blockquote>）\\n\"\n            \"6. 不要用 # 标题（Telegram 无标题样式，仅剥离 #）\\n\"\n            \"7. 不要用 --- 分割线（被剥离），用空行分隔\\n\"\n            \"8. 不要用 <font> 颜色标签（被剥离）\\n\"\n            \"9. 内容受 4096 字符限制，保持简洁\\n\"\n            \"10. 链接默认禁用预览，适合信息密集型消息\"\n        ),\n    },\n    \"email\": {\n        \"name\": \"邮件\",\n        \"format\": \"HTML（完整网页，从 Markdown 转换）\",\n        \"max_length\": \"无硬限制\",\n        \"supported\": [\n            \"# / ## / ### 标题（转为 <h1>/<h2>/<h3>）\",\n            \"**粗体** / *斜体* / ~~删除线~~\",\n            \"[链接文本](URL)\",\n            \"`行内代码`\",\n            \"---（水平分割线）\",\n        ],\n        \"unsupported\": [\n            \"<font> 彩色文本（转义显示）\",\n            \"复杂表格\",\n        ],\n        \"prompt\": (\n            \"邮件 HTML 格式化策略（输入为 Markdown，自动转换为带样式 HTML）：\\n\"\n            \"1. 用 # / ## / ### 创建清晰的标题层级\\n\"\n            \"2. 用 **粗体** 和 *斜体* 增强可读性\\n\"\n            \"3. 用 [文本](URL) 添加链接（蓝色可点击）\\n\"\n            \"4. 用 --- 分割不同章节\\n\"\n            \"5. 用 `代码` 标记技术术语或数据\\n\"\n            \"6. 可以写较长内容，邮件无严格长度限制\\n\"\n            \"7. 邮件主题自动追加日期时间\\n\"\n            \"8. 自动附带纯文本备用版本\"\n        ),\n    },\n    \"ntfy\": {\n        \"name\": \"ntfy\",\n        \"format\": \"Markdown（原生支持）\",\n        \"max_length\": \"约 3800 字节（单条 4KB 限制）\",\n        \"supported\": [\n            \"**粗体** / *斜体*\",\n            \"[链接文本](URL)\",\n            \"> 引用块\",\n            \"`行内代码`\",\n            \"- 列表\",\n        ],\n        \"unsupported\": [\n            \"# 标题语法（渲染取决于客户端）\",\n            \"<font> 彩色文本\",\n            \"---（渲染取决于客户端）\",\n            \"表格\",\n        ],\n        \"prompt\": (\n            \"ntfy Markdown 格式化策略：\\n\"\n            \"1. 用 **粗体** 突出关键词\\n\"\n            \"2. 用 [文本](URL) 添加可点击链接\\n\"\n            \"3. 用 > 引用块展示备注\\n\"\n            \"4. 用 `代码` 标记数据值\\n\"\n            \"5. 内容要精炼，受 4KB 限制\\n\"\n            \"6. 不要用 <font> 颜色标签（无效）\\n\"\n            \"7. 不要依赖 # 标题和 --- 分割线\\n\"\n            \"8. 用空行和粗体组织信息层级\"\n        ),\n    },\n    \"bark\": {\n        \"name\": \"Bark\",\n        \"format\": \"Markdown（iOS 推送）\",\n        \"max_length\": \"约 3600 字节（APNs 4KB 限制）\",\n        \"supported\": [\n            \"**粗体**\",\n            \"[链接文本](URL)\",\n            \"基础文本格式\",\n        ],\n        \"unsupported\": [\n            \"# 标题语法\",\n            \"<font> 彩色文本\",\n            \"---（分割线）\",\n            \"> 引用块\",\n            \"复杂嵌套格式\",\n        ],\n        \"prompt\": (\n            \"Bark 格式化策略（iOS 推送通知）：\\n\"\n            \"1. 内容要极度精简，移动端阅读场景\\n\"\n            \"2. 用 **粗体** 标记核心信息\\n\"\n            \"3. 用 [文本](URL) 添加链接\\n\"\n            \"4. 不要用标题/颜色/引用等复杂格式\\n\"\n            \"5. 受 APNs 4KB 限制，控制内容长度\\n\"\n            \"6. 层级结构靠缩进和换行实现\\n\"\n            \"7. 适合简短通知和摘要，不适合长文\"\n        ),\n    },\n    \"slack\": {\n        \"name\": \"Slack\",\n        \"format\": \"mrkdwn（Slack 专有格式，自动从 Markdown 转换）\",\n        \"max_length\": \"约 4000 字节\",\n        \"supported\": [\n            \"*粗体*（从 **粗体** 转换）\",\n            \"_斜体_\",\n            \"~删除线~（从 ~~删除线~~ 转换）\",\n            \"<URL|链接文本>（从 [文本](URL) 转换）\",\n            \"`行内代码`\",\n            \"```代码块```\",\n            \"> 引用块\",\n        ],\n        \"unsupported\": [\n            \"# 标题语法（剥离为粗体）\",\n            \"<font> 彩色文本\",\n            \"--- 分割线（渲染不稳定）\",\n            \"表格\",\n        ],\n        \"prompt\": (\n            \"Slack mrkdwn 格式化策略（输入为 Markdown，自动转换为 mrkdwn）：\\n\"\n            \"1. 用 **粗体** 突出关键词（转为 *粗体*）\\n\"\n            \"2. 用 ~~删除线~~ 标记过时信息（转为 ~删除线~）\\n\"\n            \"3. 用 [文本](URL) 添加链接（转为 <URL|文本>）\\n\"\n            \"4. 用 > 引用块展示备注\\n\"\n            \"5. 用 `代码` 标记数据值\\n\"\n            \"6. 不要用 # 标题（Slack 无标题样式）\\n\"\n            \"7. 不要用 <font> 颜色标签\\n\"\n            \"8. 用空行和粗体组织信息层级\"\n        ),\n    },\n    \"generic_webhook\": {\n        \"name\": \"通用 Webhook\",\n        \"format\": \"Markdown（或自定义模板）\",\n        \"max_length\": \"约 4000 字节\",\n        \"supported\": [\"标准 Markdown 语法\"],\n        \"unsupported\": [\"取决于接收端\"],\n        \"prompt\": (\n            \"通用 Webhook 格式化策略：\\n\"\n            \"1. 使用标准 Markdown 格式\\n\"\n            \"2. 避免使用特殊平台专有语法\\n\"\n            \"3. 如配置了自定义模板，内容会填充到 {content} 占位符\"\n        ),\n    },\n}\n\n\n# ==================== 渠道 Markdown 适配 ====================\n\ndef _adapt_markdown_for_feishu(text: str) -> str:\n    \"\"\"将通用 Markdown 适配为飞书卡片 Markdown 格式\n\n    飞书卡片支持：**粗体**, [链接](url), <font color='...'>, ---\n    不支持：# 标题, > 引用块\n    \"\"\"\n    # 将 # 标题转换为粗体（飞书卡片不渲染标题语法）\n    text = re.sub(r'^#{1,6}\\s+(.+)$', r'**\\1**', text, flags=re.MULTILINE)\n    # 去除引用语法前缀（飞书不支持）\n    text = re.sub(r'^>\\s*', '', text, flags=re.MULTILINE)\n    # 清理多余空行\n    text = re.sub(r'\\n{3,}', '\\n\\n', text)\n    return text.strip()\n\n\ndef _adapt_markdown_for_dingtalk(text: str) -> str:\n    \"\"\"将通用 Markdown 适配为钉钉 Markdown 格式\n\n    钉钉支持：### #### 标题, **粗体**, [链接](url), > 引用, ---\n    不支持：# ## 标题, <font> 彩色文本, ~~删除线~~\n    \"\"\"\n    # 去除 <font> 标签（钉钉不支持，保留内容）\n    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\\1', text)\n    # 将 # 和 ## 标题降级为 ### （钉钉仅支持 ### 和 ####）\n    text = re.sub(r'^##\\s+(.+)$', r'### \\1', text, flags=re.MULTILINE)\n    text = re.sub(r'^#\\s+(.+)$', r'### \\1', text, flags=re.MULTILINE)\n    # 去除删除线语法（钉钉不支持）\n    text = re.sub(r'~~(.+?)~~', r'\\1', text)\n    # 清理多余空行\n    text = re.sub(r'\\n{3,}', '\\n\\n', text)\n    return text.strip()\n\n\ndef _adapt_markdown_for_wework(text: str) -> str:\n    \"\"\"将通用 Markdown 适配为企业微信 Markdown 格式\n\n    企业微信支持：**粗体**, [链接](url), > 引用（有限）\n    不支持：# 标题, ---, <font>, ~~删除线~~, 有序列表\n    \"\"\"\n    # 去除 <font> 标签（保留内容）\n    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\\1', text)\n    # 将 # 标题转换为粗体（企业微信不渲染标题语法）\n    text = re.sub(r'^#{1,6}\\s+(.+)$', r'**\\1**', text, flags=re.MULTILINE)\n    # 将 --- 分割线替换为多个换行（企业微信不渲染水平线）\n    text = re.sub(r'^[\\-\\*]{3,}\\s*$', '\\n\\n', text, flags=re.MULTILINE)\n    # 去除删除线语法（企业微信不支持）\n    text = re.sub(r'~~(.+?)~~', r'\\1', text)\n    # 清理多余空行（保留最多两个）\n    text = re.sub(r'\\n{4,}', '\\n\\n\\n', text)\n    return text.strip()\n\n\ndef _adapt_markdown_for_ntfy(text: str) -> str:\n    \"\"\"将通用 Markdown 适配为 ntfy 格式\n\n    ntfy 支持：**粗体**, *斜体*, [链接](url), > 引用, `代码`\n    不可靠：# 标题, ---, <font>\n    \"\"\"\n    # 去除 <font> 标签（ntfy 不支持）\n    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\\1', text)\n    # 清理多余空行\n    text = re.sub(r'\\n{3,}', '\\n\\n', text)\n    return text.strip()\n\n\ndef _adapt_markdown_for_bark(text: str) -> str:\n    \"\"\"将通用 Markdown 适配为 Bark 格式（iOS 推送）\n\n    Bark 支持：**粗体**, [链接](url), 基础文本\n    不支持：# 标题, <font>, ---, > 引用, 复杂嵌套\n    \"\"\"\n    # 去除 <font> 标签（保留内容）\n    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\\1', text)\n    # 将 # 标题转换为粗体\n    text = re.sub(r'^#{1,6}\\s+(.+)$', r'**\\1**', text, flags=re.MULTILINE)\n    # 将 --- 替换为换行\n    text = re.sub(r'^[\\-\\*]{3,}\\s*$', '\\n', text, flags=re.MULTILINE)\n    # 去除引用语法\n    text = re.sub(r'^>\\s*', '', text, flags=re.MULTILINE)\n    # 去除删除线语法\n    text = re.sub(r'~~(.+?)~~', r'\\1', text)\n    # 清理多余空行\n    text = re.sub(r'\\n{3,}', '\\n\\n', text)\n    return text.strip()\n\n\n# ==================== 格式转换 ====================\n\ndef _markdown_to_telegram_html(text: str) -> str:\n    \"\"\"\n    将 markdown 转换为 Telegram 支持的 HTML 格式\n\n    Telegram 支持的标签：<b>, <i>, <s>, <code>, <a href=\"url\">text</a>, <blockquote>\n    \"\"\"\n    # 预处理：去除 <font> 标签（Telegram 不支持，保留内容）\n    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\\1', text)\n\n    lines = text.split('\\n')\n    result_lines = []\n    in_blockquote = False\n\n    for line in lines:\n        # 将标题符号 # ## ### 转换为粗体\n        header_match = re.match(r'^(#{1,6})\\s+(.+)$', line)\n        if header_match:\n            line = f'**{header_match.group(2)}**'\n\n        # 去除水平分割线\n        if re.match(r'^[\\-\\*]{3,}\\s*$', line):\n            if in_blockquote:\n                result_lines.append('</blockquote>')\n                in_blockquote = False\n            line = ''\n\n        # 处理引用块 > text → <blockquote>text</blockquote>\n        quote_match = re.match(r'^>\\s*(.*)$', line)\n        if quote_match:\n            if not in_blockquote:\n                result_lines.append('<blockquote>')\n                in_blockquote = True\n            result_lines.append(quote_match.group(1))\n            continue\n        elif in_blockquote:\n            result_lines.append('</blockquote>')\n            in_blockquote = False\n\n        result_lines.append(line)\n\n    if in_blockquote:\n        result_lines.append('</blockquote>')\n\n    text = '\\n'.join(result_lines)\n\n    # 转义 HTML 实体（在标记替换之前，但在 blockquote 标签之后）\n    # 分段处理：保留已生成的 HTML 标签\n    parts = re.split(r'(</?blockquote>)', text)\n    escaped_parts = []\n    for part in parts:\n        if part in ('<blockquote>', '</blockquote>'):\n            escaped_parts.append(part)\n        else:\n            part = part.replace('&', '&amp;')\n            part = part.replace('<', '&lt;')\n            part = part.replace('>', '&gt;')\n            escaped_parts.append(part)\n    text = ''.join(escaped_parts)\n\n    # 转换链接 [text](url) → <a href=\"url\">text</a>\n    text = re.sub(r'\\[([^\\]]+)\\]\\(([^)]+)\\)', r'<a href=\"\\2\">\\1</a>', text)\n\n    # 转换粗体 **text** → <b>text</b>\n    text = re.sub(r'\\*\\*(.+?)\\*\\*', r'<b>\\1</b>', text)\n\n    # 转换斜体 *text* → <i>text</i>\n    text = re.sub(r'\\*(.+?)\\*', r'<i>\\1</i>', text)\n\n    # 转换删除线 ~~text~~ → <s>text</s>\n    text = re.sub(r'~~(.+?)~~', r'<s>\\1</s>', text)\n\n    # 转换行内代码 `code` → <code>code</code>\n    text = re.sub(r'`(.+?)`', r'<code>\\1</code>', text)\n\n    # 清理多余空行\n    text = re.sub(r'\\n{3,}', '\\n\\n', text)\n\n    return text.strip()\n\n\ndef _convert_markdown_to_slack(text: str) -> str:\n    \"\"\"将 Markdown 转换为 Slack mrkdwn 格式（增强版）\n\n    Slack mrkdwn 与标准 Markdown 差异：\n    - 粗体: *text* (非 **text**)\n    - 删除线: ~text~ (非 ~~text~~)\n    - 链接: <url|text> (非 [text](url))\n    - 不支持标题语法\n    \"\"\"\n    # 去除 <font> 标签（保留内容）\n    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\\1', text)\n    # 将 # 标题转换为粗体（Slack 无标题样式）\n    text = re.sub(r'^#{1,6}\\s+(.+)$', r'**\\1**', text, flags=re.MULTILINE)\n    # 去除 --- 分割线（Slack 渲染不稳定）\n    text = re.sub(r'^[\\-\\*]{3,}\\s*$', '', text, flags=re.MULTILINE)\n    # 转换链接格式: [文本](url) → <url|文本>\n    text = re.sub(r'\\[([^\\]]+)\\]\\(([^)]+)\\)', r'<\\2|\\1>', text)\n    # 转换删除线: ~~文本~~ → ~文本~\n    text = re.sub(r'~~(.+?)~~', r'~\\1~', text)\n    # 转换粗体: **文本** → *文本*（必须在删除线之后）\n    text = re.sub(r'\\*\\*([^*]+)\\*\\*', r'*\\1*', text)\n    # 清理多余空行\n    text = re.sub(r'\\n{3,}', '\\n\\n', text)\n    return text.strip()\n\n\ndef _markdown_to_simple_html(text: str) -> str:\n    \"\"\"\n    将 markdown 转换为简单 HTML（用于 Email）\n    \"\"\"\n    html = text\n\n    # 转义\n    html = html.replace('&', '&amp;')\n    html = html.replace('<', '&lt;')\n    html = html.replace('>', '&gt;')\n\n    # 链接\n    html = re.sub(r'\\[([^\\]]+)\\]\\(([^)]+)\\)', r'<a href=\"\\2\">\\1</a>', html)\n\n    # 标题 ### → <h3>\n    html = re.sub(r'^### (.+)$', r'<h3>\\1</h3>', html, flags=re.MULTILINE)\n    html = re.sub(r'^## (.+)$', r'<h2>\\1</h2>', html, flags=re.MULTILINE)\n    html = re.sub(r'^# (.+)$', r'<h1>\\1</h1>', html, flags=re.MULTILINE)\n\n    # 粗体\n    html = re.sub(r'\\*\\*(.+?)\\*\\*', r'<strong>\\1</strong>', html)\n\n    # 斜体\n    html = re.sub(r'\\*(.+?)\\*', r'<em>\\1</em>', html)\n\n    # 删除线\n    html = re.sub(r'~~(.+?)~~', r'<del>\\1</del>', html)\n\n    # 行内代码\n    html = re.sub(r'`(.+?)`', r'<code>\\1</code>', html)\n\n    # 分割线\n    html = re.sub(r'^[\\-\\*]{3,}\\s*$', '<hr>', html, flags=re.MULTILINE)\n\n    # 换行\n    html = html.replace('\\n', '<br>\\n')\n\n    return f\"\"\"<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\"><title>TrendRadar 通知</title>\n<style>body{{font-family:sans-serif;padding:20px;max-width:800px;margin:0 auto}}\na{{color:#1a73e8}}h1,h2,h3{{color:#333}}hr{{border:none;border-top:1px solid #ddd;margin:16px 0}}\ncode{{background:#f5f5f5;padding:2px 6px;border-radius:3px}}</style>\n</head><body>{html}</body></html>\"\"\"\n\n\n# ==================== 各渠道发送器 ====================\n\ndef _send_feishu(webhook_url: str, content: str, title: str) -> Dict:\n    \"\"\"飞书发送（纯文本消息，与 trendradar send_to_feishu 一致）\n\n    飞书 webhook 使用 msg_type: \"text\"，所有信息整合到 content.text 中。\n    \"\"\"\n    payload = {\n        \"msg_type\": \"text\",\n        \"content\": {\n            \"text\": content,\n        },\n    }\n    try:\n        resp = requests.post(webhook_url, json=payload, timeout=30)\n        data = resp.json()\n        ok = resp.status_code == 200 and (data.get(\"code\") == 0 or data.get(\"StatusCode\") == 0)\n        detail = \"\"\n        if not ok:\n            detail = data.get(\"msg\") or data.get(\"StatusMessage\", \"\")\n        return {\"success\": ok, \"detail\": detail}\n    except Exception as e:\n        return {\"success\": False, \"detail\": str(e)}\n\n\ndef _send_dingtalk(webhook_url: str, content: str, title: str) -> Dict:\n    \"\"\"钉钉发送（接收已适配的 Markdown）\"\"\"\n    payload = {\n        \"msgtype\": \"markdown\",\n        \"markdown\": {\"title\": title, \"text\": content}\n    }\n    try:\n        resp = requests.post(webhook_url, json=payload, timeout=30)\n        data = resp.json()\n        ok = resp.status_code == 200 and data.get(\"errcode\") == 0\n        return {\"success\": ok, \"detail\": data.get(\"errmsg\", \"\") if not ok else \"\"}\n    except Exception as e:\n        return {\"success\": False, \"detail\": str(e)}\n\n\ndef _send_wework(webhook_url: str, content: str, title: str, msg_type: str = \"markdown\") -> Dict:\n    \"\"\"企业微信发送（接收已适配的 Markdown，text 模式自动剥离格式）\"\"\"\n    if msg_type == \"text\":\n        payload = {\"msgtype\": \"text\", \"text\": {\"content\": strip_markdown(content)}}\n    else:\n        payload = {\"msgtype\": \"markdown\", \"markdown\": {\"content\": content}}\n\n    try:\n        resp = requests.post(webhook_url, json=payload, timeout=30)\n        data = resp.json()\n        ok = resp.status_code == 200 and data.get(\"errcode\") == 0\n        return {\"success\": ok, \"detail\": data.get(\"errmsg\", \"\") if not ok else \"\"}\n    except Exception as e:\n        return {\"success\": False, \"detail\": str(e)}\n\n\ndef _send_telegram(bot_token: str, chat_id: str, content: str, title: str) -> Dict:\n    \"\"\"Telegram 发送（接收已转换的 HTML）\"\"\"\n    url = f\"https://api.telegram.org/bot{bot_token}/sendMessage\"\n    payload = {\n        \"chat_id\": chat_id,\n        \"text\": content,\n        \"parse_mode\": \"HTML\",\n        \"disable_web_page_preview\": True,\n    }\n    try:\n        resp = requests.post(url, json=payload, timeout=30)\n        data = resp.json()\n        ok = resp.status_code == 200 and data.get(\"ok\")\n        return {\"success\": ok, \"detail\": data.get(\"description\", \"\") if not ok else \"\"}\n    except Exception as e:\n        return {\"success\": False, \"detail\": str(e)}\n\n\ndef _send_email(\n    from_email: str, password: str, to_email: str,\n    message: str, title: str,\n    smtp_server: str = \"\", smtp_port: str = \"\"\n) -> Dict:\n    \"\"\"邮件发送（HTML 格式）\"\"\"\n    try:\n        domain = from_email.split(\"@\")[-1].lower()\n        html_content = _markdown_to_simple_html(message)\n\n        # SMTP 配置\n        if smtp_server and smtp_port:\n            server_host = smtp_server\n            port = int(smtp_port)\n            use_tls = port != 465\n        elif domain in SMTP_CONFIGS:\n            cfg = SMTP_CONFIGS[domain]\n            server_host = cfg[\"server\"]\n            port = cfg[\"port\"]\n            use_tls = cfg[\"encryption\"] == \"TLS\"\n        else:\n            server_host = f\"smtp.{domain}\"\n            port = 587\n            use_tls = True\n\n        msg = MIMEMultipart(\"alternative\")\n        msg[\"From\"] = formataddr((\"TrendRadar\", from_email))\n\n        recipients = [addr.strip() for addr in to_email.split(\",\")]\n        msg[\"To\"] = \", \".join(recipients)\n\n        now = datetime.now()\n        msg[\"Subject\"] = Header(f\"{title} - {now.strftime('%m月%d日 %H:%M')}\", \"utf-8\")\n        msg[\"MIME-Version\"] = \"1.0\"\n        msg[\"Date\"] = formatdate(localtime=True)\n        msg[\"Message-ID\"] = make_msgid()\n\n        # 纯文本备选\n        msg.attach(MIMEText(strip_markdown(message), \"plain\", \"utf-8\"))\n        # HTML 主体\n        msg.attach(MIMEText(html_content, \"html\", \"utf-8\"))\n\n        if use_tls:\n            server = smtplib.SMTP(server_host, port, timeout=30)\n            server.ehlo()\n            server.starttls()\n            server.ehlo()\n        else:\n            server = smtplib.SMTP_SSL(server_host, port, timeout=30)\n            server.ehlo()\n\n        server.login(from_email, password)\n        server.send_message(msg)\n        server.quit()\n\n        return {\"success\": True, \"detail\": \"\"}\n    except Exception as e:\n        return {\"success\": False, \"detail\": str(e)}\n\n\ndef _send_ntfy(server_url: str, topic: str, content: str, title: str, token: str = \"\") -> Dict:\n    \"\"\"ntfy 发送（接收已适配的 Markdown，与 trendradar send_to_ntfy 一致）\n\n    注意：Title 使用 ASCII 字符避免 HTTP header 编码问题。\n    支持 429 速率限制重试。\n    \"\"\"\n    base_url = server_url.rstrip(\"/\")\n    if not base_url.startswith((\"http://\", \"https://\")):\n        base_url = f\"https://{base_url}\"\n    url = f\"{base_url}/{topic}\"\n\n    headers = {\n        \"Content-Type\": \"text/plain; charset=utf-8\",\n        \"Markdown\": \"yes\",\n        \"Title\": \"TrendRadar Notification\",  # ASCII，避免 HTTP header 编码问题\n        \"Priority\": \"default\",\n        \"Tags\": \"news\",\n    }\n    if token:\n        headers[\"Authorization\"] = f\"Bearer {token}\"\n\n    try:\n        resp = requests.post(url, data=content.encode(\"utf-8\"), headers=headers, timeout=30)\n        if resp.status_code == 200:\n            return {\"success\": True, \"detail\": \"\"}\n        elif resp.status_code == 429:\n            # 速率限制，等待后重试一次（与 trendradar 一致）\n            time.sleep(10)\n            retry_resp = requests.post(url, data=content.encode(\"utf-8\"), headers=headers, timeout=30)\n            ok = retry_resp.status_code == 200\n            return {\"success\": ok, \"detail\": \"\" if ok else f\"retry status={retry_resp.status_code}\"}\n        elif resp.status_code == 413:\n            return {\"success\": False, \"detail\": f\"消息过大被拒绝 ({len(content.encode('utf-8'))} bytes)\"}\n        else:\n            return {\"success\": False, \"detail\": f\"status={resp.status_code}\"}\n    except Exception as e:\n        return {\"success\": False, \"detail\": str(e)}\n\n\ndef _send_bark(bark_url: str, content: str, title: str) -> Dict:\n    \"\"\"Bark 发送（接收已适配的 Markdown，iOS 推送）\"\"\"\n    parsed = urlparse(bark_url)\n    device_key = parsed.path.strip('/').split('/')[0] if parsed.path else None\n    if not device_key:\n        return {\"success\": False, \"detail\": f\"无法从 URL 提取 device_key: {bark_url}\"}\n\n    api_endpoint = f\"{parsed.scheme}://{parsed.netloc}/push\"\n    payload = {\n        \"title\": title,\n        \"markdown\": content,\n        \"device_key\": device_key,\n        \"sound\": \"default\",\n        \"group\": \"TrendRadar\",\n        \"action\": \"none\",\n    }\n\n    try:\n        resp = requests.post(api_endpoint, json=payload, timeout=30)\n        data = resp.json()\n        ok = resp.status_code == 200 and data.get(\"code\") == 200\n        return {\"success\": ok, \"detail\": data.get(\"message\", \"\") if not ok else \"\"}\n    except Exception as e:\n        return {\"success\": False, \"detail\": str(e)}\n\n\ndef _send_slack(webhook_url: str, content: str, title: str) -> Dict:\n    \"\"\"Slack 发送（接收已转换的 mrkdwn）\"\"\"\n    payload = {\"text\": content}\n\n    try:\n        resp = requests.post(webhook_url, json=payload, timeout=30)\n        ok = resp.status_code == 200 and resp.text == \"ok\"\n        return {\"success\": ok, \"detail\": \"\" if ok else resp.text}\n    except Exception as e:\n        return {\"success\": False, \"detail\": str(e)}\n\n\ndef _send_generic_webhook(\n    webhook_url: str, message: str, title: str, payload_template: str = \"\"\n) -> Dict:\n    \"\"\"通用 Webhook 发送（Markdown 格式，支持自定义模板）\"\"\"\n    try:\n        if payload_template:\n            json_content = json.dumps(message)[1:-1]\n            json_title = json.dumps(title)[1:-1]\n            payload_str = payload_template.replace(\"{content}\", json_content).replace(\"{title}\", json_title)\n            try:\n                payload = json.loads(payload_str)\n            except json.JSONDecodeError:\n                payload = {\"title\": title, \"content\": message}\n        else:\n            payload = {\"title\": title, \"content\": message}\n\n        resp = requests.post(\n            webhook_url,\n            headers={\"Content-Type\": \"application/json\"},\n            json=payload,\n            timeout=30,\n        )\n        ok = 200 <= resp.status_code < 300\n        return {\"success\": ok, \"detail\": \"\" if ok else f\"status={resp.status_code}\"}\n    except Exception as e:\n        return {\"success\": False, \"detail\": str(e)}\n\n\n# ==================== 工具类 ====================\n\nclass NotificationTools:\n    \"\"\"通知推送工具类\"\"\"\n\n    def __init__(self, project_root: str = None):\n        if project_root:\n            self.project_root = Path(project_root)\n        else:\n            current_file = Path(__file__)\n            self.project_root = current_file.parent.parent.parent\n\n    def _load_merged_config(self) -> Dict[str, Any]:\n        \"\"\"\n        加载合并后的通知配置（config.yaml + .env）\n\n        Returns:\n            包含 webhook 配置和通知参数的合并字典\n        \"\"\"\n        config_path = self.project_root / \"config\" / \"config.yaml\"\n        if config_path.exists():\n            with open(config_path, \"r\", encoding=\"utf-8\") as f:\n                config_data = yaml.safe_load(f)\n        else:\n            config_data = {}\n\n        webhook_config = _load_webhook_config(config_data)\n        notification_config = _load_notification_config(config_data)\n        return {**webhook_config, **notification_config}\n\n    def _detect_config_source(self, env_key: str, yaml_value: str) -> str:\n        \"\"\"检测配置项来源：env / yaml / 未配置\"\"\"\n        env_val = os.environ.get(env_key, \"\").strip()\n        if env_val:\n            return \"env\"\n        elif yaml_value:\n            return \"yaml\"\n        return \"\"\n\n    def get_channel_format_guide(self, channel: Optional[str] = None) -> Dict:\n        \"\"\"\n        获取渠道格式化策略指南\n\n        返回各渠道支持的 Markdown 特性、限制和最佳格式化提示词，\n        供 LLM 在生成推送内容时参考，确保内容样式贴合目标渠道。\n\n        Args:\n            channel: 指定渠道 ID，None 返回所有渠道的策略\n\n        Returns:\n            格式化策略字典\n        \"\"\"\n        if channel:\n            if channel not in CHANNEL_FORMAT_GUIDES:\n                valid = list(CHANNEL_FORMAT_GUIDES.keys())\n                return {\n                    \"success\": False,\n                    \"error\": {\n                        \"code\": \"INVALID_CHANNEL\",\n                        \"message\": f\"无效的渠道: {channel}\",\n                        \"suggestion\": f\"支持的渠道: {valid}\",\n                    },\n                }\n            guide = CHANNEL_FORMAT_GUIDES[channel]\n            return {\n                \"success\": True,\n                \"channel\": channel,\n                \"guide\": guide,\n            }\n        else:\n            return {\n                \"success\": True,\n                \"summary\": f\"共 {len(CHANNEL_FORMAT_GUIDES)} 个渠道的格式化策略\",\n                \"guides\": CHANNEL_FORMAT_GUIDES,\n            }\n\n    def get_notification_channels(self) -> Dict:\n        \"\"\"\n        获取所有通知渠道的配置状态\n\n        检测 config.yaml 和 .env 环境变量，返回每个渠道是否已配置。\n\n        Returns:\n            渠道状态字典\n        \"\"\"\n        try:\n            config = self._load_merged_config()\n            enabled = config.get(\"ENABLE_NOTIFICATION\", True)\n\n            # 从 yaml 直接读取（用于判断来源）\n            config_path = self.project_root / \"config\" / \"config.yaml\"\n            yaml_channels = {}\n            if config_path.exists():\n                with open(config_path, \"r\", encoding=\"utf-8\") as f:\n                    raw = yaml.safe_load(f) or {}\n                    yaml_channels = raw.get(\"notification\", {}).get(\"channels\", {})\n\n            channels = []\n            env_key_map = {\n                \"FEISHU_WEBHOOK_URL\": (\"feishu\", \"webhook_url\"),\n                \"DINGTALK_WEBHOOK_URL\": (\"dingtalk\", \"webhook_url\"),\n                \"WEWORK_WEBHOOK_URL\": (\"wework\", \"webhook_url\"),\n                \"TELEGRAM_BOT_TOKEN\": (\"telegram\", \"bot_token\"),\n                \"TELEGRAM_CHAT_ID\": (\"telegram\", \"chat_id\"),\n                \"EMAIL_FROM\": (\"email\", \"from\"),\n                \"EMAIL_PASSWORD\": (\"email\", \"password\"),\n                \"EMAIL_TO\": (\"email\", \"to\"),\n                \"NTFY_SERVER_URL\": (\"ntfy\", \"server_url\"),\n                \"NTFY_TOPIC\": (\"ntfy\", \"topic\"),\n                \"BARK_URL\": (\"bark\", \"url\"),\n                \"SLACK_WEBHOOK_URL\": (\"slack\", \"webhook_url\"),\n                \"GENERIC_WEBHOOK_URL\": (\"generic_webhook\", \"webhook_url\"),\n            }\n\n            for channel_id, required_keys in _CHANNEL_REQUIREMENTS.items():\n                is_configured = all(config.get(k) for k in required_keys)\n\n                # 判断来源\n                sources = set()\n                for key in required_keys:\n                    ch_name, field = env_key_map.get(key, (\"\", \"\"))\n                    yaml_val = yaml_channels.get(ch_name, {}).get(field, \"\")\n                    src = self._detect_config_source(key, yaml_val)\n                    if src:\n                        sources.add(src)\n\n                channels.append({\n                    \"id\": channel_id,\n                    \"name\": _CHANNEL_NAMES.get(channel_id, channel_id),\n                    \"configured\": is_configured,\n                    \"source\": list(sources) if sources else [],\n                })\n\n            configured_count = sum(1 for ch in channels if ch[\"configured\"])\n\n            return {\n                \"success\": True,\n                \"notification_enabled\": enabled,\n                \"summary\": f\"{configured_count}/{len(channels)} 个渠道已配置\",\n                \"channels\": channels,\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\"code\": \"INTERNAL_ERROR\", \"message\": str(e)},\n            }\n\n    def send_notification(\n        self,\n        message: str,\n        title: str = \"TrendRadar 通知\",\n        channels: Optional[List[str]] = None,\n    ) -> Dict:\n        \"\"\"\n        向已配置的通知渠道发送消息\n\n        接受 markdown 格式内容，内部自动转换为各渠道要求的格式。\n\n        Args:\n            message: markdown 格式的消息内容\n            title: 消息标题\n            channels: 指定发送的渠道列表，None 表示发送到所有已配置渠道\n                      可选值: feishu, dingtalk, wework, telegram, email, ntfy, bark, slack, generic_webhook\n\n        Returns:\n            发送结果字典\n        \"\"\"\n        if not message or not message.strip():\n            return {\n                \"success\": False,\n                \"error\": {\"code\": \"EMPTY_MESSAGE\", \"message\": \"消息内容不能为空\"},\n            }\n\n        try:\n            config = self._load_merged_config()\n\n            if not config.get(\"ENABLE_NOTIFICATION\", True):\n                return {\n                    \"success\": False,\n                    \"error\": {\"code\": \"NOTIFICATION_DISABLED\", \"message\": \"通知功能已禁用（notification.enabled = false）\"},\n                }\n\n            # 确定目标渠道\n            all_channel_ids = list(_CHANNEL_REQUIREMENTS.keys())\n            if channels:\n                # 验证渠道名称\n                invalid = [ch for ch in channels if ch not in all_channel_ids]\n                if invalid:\n                    raise InvalidParameterError(\n                        f\"无效的渠道: {invalid}\",\n                        suggestion=f\"支持的渠道: {all_channel_ids}\"\n                    )\n                target_channels = channels\n            else:\n                # 发送到所有已配置渠道\n                target_channels = [\n                    ch_id for ch_id, keys in _CHANNEL_REQUIREMENTS.items()\n                    if all(config.get(k) for k in keys)\n                ]\n\n            if not target_channels:\n                return {\n                    \"success\": False,\n                    \"error\": {\n                        \"code\": \"NO_CHANNELS\",\n                        \"message\": \"没有已配置的目标渠道\",\n                        \"suggestion\": \"请在 config.yaml 或 .env 中配置至少一个通知渠道\",\n                    },\n                }\n\n            # 逐渠道发送\n            results = {}\n            for ch_id in target_channels:\n                required_keys = _CHANNEL_REQUIREMENTS[ch_id]\n                if not all(config.get(k) for k in required_keys):\n                    results[ch_id] = {\"success\": False, \"detail\": \"渠道未配置\"}\n                    continue\n\n                result = self._dispatch_to_channel(ch_id, config, message, title)\n                results[ch_id] = result\n\n            success_count = sum(1 for r in results.values() if r[\"success\"])\n            total = len(results)\n\n            return {\n                \"success\": success_count > 0,\n                \"summary\": f\"{success_count}/{total} 个渠道发送成功\",\n                \"results\": {\n                    ch_id: {\n                        \"name\": _CHANNEL_NAMES.get(ch_id, ch_id),\n                        **r,\n                    }\n                    for ch_id, r in results.items()\n                },\n            }\n\n        except MCPError as e:\n            return {\"success\": False, \"error\": e.to_dict()}\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\"code\": \"INTERNAL_ERROR\", \"message\": str(e)},\n            }\n\n    def _dispatch_to_channel(\n        self, channel_id: str, config: Dict, message: str, title: str\n    ) -> Dict:\n        \"\"\"分发消息到指定渠道（格式适配 → 字节分批 → 多账号 × 逐批发送）\n\n        从 config.yaml → advanced.batch_size / batch_send_interval 读取配置。\n        \"\"\"\n        # 从 config 读取批次配置（与 trendradar 一致）\n        batch_sizes = self._get_batch_sizes()\n        batch_interval = self._get_batch_interval()\n\n        # Email 无字节限制，不走分批管线\n        if channel_id == \"email\":\n            return _send_email(\n                config[\"EMAIL_FROM\"],\n                config[\"EMAIL_PASSWORD\"],\n                config[\"EMAIL_TO\"],\n                message, title,\n                config.get(\"EMAIL_SMTP_SERVER\", \"\"),\n                config.get(\"EMAIL_SMTP_PORT\", \"\"),\n            )\n\n        # 统一分批管线：格式适配 → 字节分割 → 添加批次头部 → (可选)反序\n        batches = _prepare_batches(message, channel_id, batch_sizes)\n\n        # 按渠道路由发送\n        if channel_id == \"feishu\":\n            return self._send_batched_multi_account(\n                config[\"FEISHU_WEBHOOK_URL\"], batches, channel_id,\n                lambda url, content: _send_feishu(url, content, title),\n                batch_interval,\n            )\n        elif channel_id == \"dingtalk\":\n            return self._send_batched_multi_account(\n                config[\"DINGTALK_WEBHOOK_URL\"], batches, channel_id,\n                lambda url, content: _send_dingtalk(url, content, title),\n                batch_interval,\n            )\n        elif channel_id == \"wework\":\n            msg_type = config.get(\"WEWORK_MSG_TYPE\", \"markdown\")\n            return self._send_batched_multi_account(\n                config[\"WEWORK_WEBHOOK_URL\"], batches, channel_id,\n                lambda url, content: _send_wework(url, content, title, msg_type),\n                batch_interval,\n            )\n        elif channel_id == \"telegram\":\n            return self._send_batched_telegram(\n                config, batches, title, batch_interval,\n            )\n        elif channel_id == \"ntfy\":\n            return self._send_batched_ntfy(\n                config, batches, title, batch_interval,\n            )\n        elif channel_id == \"bark\":\n            return self._send_batched_multi_account(\n                config[\"BARK_URL\"], batches, channel_id,\n                lambda url, content: _send_bark(url, content, title),\n                batch_interval,\n            )\n        elif channel_id == \"slack\":\n            return self._send_batched_multi_account(\n                config[\"SLACK_WEBHOOK_URL\"], batches, channel_id,\n                lambda url, content: _send_slack(url, content, title),\n                batch_interval,\n            )\n        elif channel_id == \"generic_webhook\":\n            template = config.get(\"GENERIC_WEBHOOK_TEMPLATE\", \"\")\n            return self._send_batched_multi_account(\n                config[\"GENERIC_WEBHOOK_URL\"], batches, channel_id,\n                lambda url, content: _send_generic_webhook(url, content, title, template),\n                batch_interval,\n            )\n        else:\n            return {\"success\": False, \"detail\": f\"未知渠道: {channel_id}\"}\n\n    def _get_batch_sizes(self) -> Dict:\n        \"\"\"从 config.yaml 读取 advanced.batch_size，合并到默认值\"\"\"\n        try:\n            config_path = self.project_root / \"config\" / \"config.yaml\"\n            if config_path.exists():\n                with open(config_path, \"r\", encoding=\"utf-8\") as f:\n                    raw = yaml.safe_load(f) or {}\n                advanced = raw.get(\"advanced\", {})\n                cfg_sizes = advanced.get(\"batch_size\", {})\n                # 从 config 构建渠道映射\n                sizes = dict(_CHANNEL_BATCH_SIZES_DEFAULT)\n                default_size = cfg_sizes.get(\"default\", 4000)\n                for ch_id in sizes:\n                    if ch_id in cfg_sizes:\n                        sizes[ch_id] = cfg_sizes[ch_id]\n                    elif ch_id not in (\"email\", \"ntfy\") and sizes[ch_id] == 4000:\n                        # 使用 config 中的 default\n                        sizes[ch_id] = default_size\n                return sizes\n        except Exception:\n            pass\n        return dict(_CHANNEL_BATCH_SIZES_DEFAULT)\n\n    def _get_batch_interval(self) -> float:\n        \"\"\"从 config.yaml 读取 advanced.batch_send_interval\"\"\"\n        try:\n            config_path = self.project_root / \"config\" / \"config.yaml\"\n            if config_path.exists():\n                with open(config_path, \"r\", encoding=\"utf-8\") as f:\n                    raw = yaml.safe_load(f) or {}\n                return float(raw.get(\"advanced\", {}).get(\"batch_send_interval\", _BATCH_INTERVAL_DEFAULT))\n        except Exception:\n            pass\n        return _BATCH_INTERVAL_DEFAULT\n\n    def _send_batched_multi_account(\n        self, urls_str: str, batches: List[str], channel_id: str, send_func,\n        batch_interval: float = _BATCH_INTERVAL_DEFAULT,\n    ) -> Dict:\n        \"\"\"多账号 × 逐批发送（; 分隔的 URL）\"\"\"\n        urls = [u.strip() for u in urls_str.split(\";\") if u.strip()]\n        if not urls:\n            return {\"success\": False, \"detail\": \"URL 为空\"}\n\n        any_ok = False\n        details = []\n        for url in urls:\n            for i, batch in enumerate(batches):\n                r = send_func(url, batch)\n                if r[\"success\"]:\n                    any_ok = True\n                elif r[\"detail\"]:\n                    details.append(r[\"detail\"])\n                # 批次间间隔\n                if i < len(batches) - 1:\n                    time.sleep(batch_interval)\n\n        return {\n            \"success\": any_ok,\n            \"detail\": \"; \".join(details) if details else \"\",\n            \"batches\": len(batches),\n        }\n\n    def _send_batched_telegram(\n        self, config: Dict, batches: List[str], title: str,\n        batch_interval: float = _BATCH_INTERVAL_DEFAULT,\n    ) -> Dict:\n        \"\"\"Telegram 多账号 × 逐批发送（token/chat_id 配对）\"\"\"\n        tokens = config[\"TELEGRAM_BOT_TOKEN\"].split(\";\")\n        chat_ids = config[\"TELEGRAM_CHAT_ID\"].split(\";\")\n        if len(tokens) != len(chat_ids):\n            return {\"success\": False, \"detail\": \"bot_token 和 chat_id 数量不一致\"}\n\n        any_ok = False\n        details = []\n        for token, cid in zip(tokens, chat_ids):\n            token, cid = token.strip(), cid.strip()\n            if not (token and cid):\n                continue\n            for i, batch in enumerate(batches):\n                r = _send_telegram(token, cid, batch, title)\n                if r[\"success\"]:\n                    any_ok = True\n                elif r[\"detail\"]:\n                    details.append(r[\"detail\"])\n                if i < len(batches) - 1:\n                    time.sleep(batch_interval)\n\n        return {\n            \"success\": any_ok,\n            \"detail\": \"; \".join(details) if details else \"\",\n            \"batches\": len(batches),\n        }\n\n    def _send_batched_ntfy(\n        self, config: Dict, batches: List[str], title: str,\n        batch_interval: float = _BATCH_INTERVAL_DEFAULT,\n    ) -> Dict:\n        \"\"\"ntfy 多账号 × 逐批发送（server/topic/token 配对，含速率限制处理）\"\"\"\n        servers = config[\"NTFY_SERVER_URL\"].split(\";\")\n        topics = config[\"NTFY_TOPIC\"].split(\";\")\n        tokens_str = config.get(\"NTFY_TOKEN\", \"\")\n        tokens = tokens_str.split(\";\") if tokens_str else [\"\"]\n        if len(servers) != len(topics):\n            return {\"success\": False, \"detail\": \"server_url 和 topic 数量不一致\"}\n\n        any_ok = False\n        details = []\n        for i, (srv, topic) in enumerate(zip(servers, topics)):\n            srv, topic = srv.strip(), topic.strip()\n            tk = tokens[i].strip() if i < len(tokens) else \"\"\n            if not (srv and topic):\n                continue\n            # ntfy.sh 公共服务器用 2s 间隔（与 trendradar 一致）\n            interval = 2.0 if \"ntfy.sh\" in srv else batch_interval\n            for j, batch in enumerate(batches):\n                r = _send_ntfy(srv, topic, batch, title, tk)\n                if r[\"success\"]:\n                    any_ok = True\n                elif r[\"detail\"]:\n                    details.append(r[\"detail\"])\n                if j < len(batches) - 1:\n                    time.sleep(interval)\n\n        return {\n            \"success\": any_ok,\n            \"detail\": \"; \".join(details) if details else \"\",\n            \"batches\": len(batches),\n        }\n"
  },
  {
    "path": "mcp_server/tools/search_tools.py",
    "content": "\"\"\"\n智能新闻检索工具\n\n提供模糊搜索、链接查询、历史相关新闻检索等高级搜索功能。\n\"\"\"\n\nimport re\nfrom collections import Counter\nfrom datetime import datetime, timedelta\nfrom difflib import SequenceMatcher\nfrom typing import Dict, List, Optional, Tuple, Union\n\nfrom ..services.data_service import DataService\nfrom ..utils.validators import validate_keyword, validate_limit, validate_threshold, normalize_date_range\nfrom ..utils.errors import MCPError, InvalidParameterError, DataNotFoundError\n\n\nclass SearchTools:\n    \"\"\"智能新闻检索工具类\"\"\"\n\n    def __init__(self, project_root: str = None):\n        \"\"\"\n        初始化智能检索工具\n\n        Args:\n            project_root: 项目根目录\n        \"\"\"\n        self.data_service = DataService(project_root)\n\n    def search_news_unified(\n        self,\n        query: str,\n        search_mode: str = \"keyword\",\n        date_range: Optional[Union[Dict[str, str], str]] = None,\n        platforms: Optional[List[str]] = None,\n        limit: int = 50,\n        sort_by: str = \"relevance\",\n        threshold: float = 0.6,\n        include_url: bool = False,\n        include_rss: bool = False,\n        rss_limit: int = 20\n    ) -> Dict:\n        \"\"\"\n        统一新闻搜索工具 - 整合多种搜索模式，支持同时搜索热榜和RSS\n\n        Args:\n            query: 查询内容（必需）- 关键词、内容片段或实体名称\n            search_mode: 搜索模式，可选值：\n                - \"keyword\": 精确关键词匹配（默认）\n                - \"fuzzy\": 模糊内容匹配（使用相似度算法）\n                - \"entity\": 实体名称搜索（自动按权重排序）\n            date_range: 日期范围（可选）\n                       - **格式**: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}\n                       - **示例**: {\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"}\n                       - **默认**: 不指定时默认查询今天\n                       - **注意**: start和end可以相同（表示单日查询）\n            platforms: 平台过滤列表，如 ['zhihu', 'weibo']\n            limit: 热榜返回条数限制，默认50\n            sort_by: 排序方式，可选值：\n                - \"relevance\": 按相关度排序（默认）\n                - \"weight\": 按新闻权重排序\n                - \"date\": 按日期排序\n            threshold: 相似度阈值（仅fuzzy模式有效），0-1之间，默认0.6\n            include_url: 是否包含URL链接，默认False（节省token）\n            include_rss: 是否同时搜索RSS数据，默认False\n            rss_limit: RSS返回条数限制，默认20\n\n        Returns:\n            搜索结果字典，包含匹配的新闻列表（热榜和RSS分开展示）\n\n        Examples:\n            - search_news_unified(query=\"人工智能\", search_mode=\"keyword\")\n            - search_news_unified(query=\"特斯拉降价\", search_mode=\"fuzzy\", threshold=0.4)\n            - search_news_unified(query=\"马斯克\", search_mode=\"entity\", limit=20)\n            - search_news_unified(query=\"AI\", include_rss=True)  # 同时搜索热榜和RSS\n            - search_news_unified(query=\"iPhone 16\", date_range={\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"})\n        \"\"\"\n        try:\n            # 参数验证\n            query = validate_keyword(query)\n\n            if search_mode not in [\"keyword\", \"fuzzy\", \"entity\"]:\n                raise InvalidParameterError(\n                    f\"无效的搜索模式: {search_mode}\",\n                    suggestion=\"支持的模式: keyword, fuzzy, entity\"\n                )\n\n            if sort_by not in [\"relevance\", \"weight\", \"date\"]:\n                raise InvalidParameterError(\n                    f\"无效的排序方式: {sort_by}\",\n                    suggestion=\"支持的排序: relevance, weight, date\"\n                )\n\n            limit = validate_limit(limit, default=50)\n            threshold = validate_threshold(threshold, default=0.6, min_value=0.0, max_value=1.0)\n\n            # 处理日期范围\n            if date_range:\n                from ..utils.validators import validate_date_range\n                date_range_tuple = validate_date_range(date_range)\n                start_date, end_date = date_range_tuple\n            else:\n                # 不指定日期时，使用最新可用数据日期（而非 datetime.now()）\n                earliest, latest = self.data_service.get_available_date_range()\n\n                if latest is None:\n                    # 没有任何可用数据\n                    return {\n                        \"success\": False,\n                        \"error\": {\n                            \"code\": \"NO_DATA_AVAILABLE\",\n                            \"message\": \"output 目录下没有可用的新闻数据\",\n                            \"suggestion\": \"请先运行爬虫生成数据，或检查 output 目录\"\n                        }\n                    }\n\n                # 使用最新可用日期\n                start_date = end_date = latest\n\n            # 收集所有匹配的新闻\n            all_matches = []\n            current_date = start_date\n\n            while current_date <= end_date:\n                try:\n                    all_titles, id_to_name, timestamps = self.data_service.parser.read_all_titles_for_date(\n                        date=current_date,\n                        platform_ids=platforms\n                    )\n\n                    # 根据搜索模式执行不同的搜索逻辑\n                    if search_mode == \"keyword\":\n                        matches = self._search_by_keyword_mode(\n                            query, all_titles, id_to_name, current_date, include_url\n                        )\n                    elif search_mode == \"fuzzy\":\n                        matches = self._search_by_fuzzy_mode(\n                            query, all_titles, id_to_name, current_date, threshold, include_url\n                        )\n                    else:  # entity\n                        matches = self._search_by_entity_mode(\n                            query, all_titles, id_to_name, current_date, include_url\n                        )\n\n                    all_matches.extend(matches)\n\n                except DataNotFoundError:\n                    # 该日期没有数据，继续下一天\n                    pass\n\n                current_date += timedelta(days=1)\n\n            if not all_matches:\n                # 获取可用日期范围用于错误提示\n                earliest, latest = self.data_service.get_available_date_range()\n\n                # 判断时间范围描述\n                if start_date.date() == datetime.now().date() and start_date == end_date:\n                    time_desc = \"今天\"\n                elif start_date == end_date:\n                    time_desc = start_date.strftime(\"%Y-%m-%d\")\n                else:\n                    time_desc = f\"{start_date.strftime('%Y-%m-%d')} 至 {end_date.strftime('%Y-%m-%d')}\"\n\n                # 构建错误消息\n                if earliest and latest:\n                    available_desc = f\"{earliest.strftime('%Y-%m-%d')} 至 {latest.strftime('%Y-%m-%d')}\"\n                    message = f\"未找到匹配的新闻（查询范围: {time_desc}，可用数据: {available_desc}）\"\n                else:\n                    message = f\"未找到匹配的新闻（{time_desc}）\"\n\n                result = {\n                    \"success\": True,\n                    \"results\": [],\n                    \"total\": 0,\n                    \"query\": query,\n                    \"search_mode\": search_mode,\n                    \"time_range\": time_desc,\n                    \"message\": message\n                }\n                return result\n\n            # 统一排序逻辑\n            if sort_by == \"relevance\":\n                all_matches.sort(key=lambda x: x.get(\"similarity_score\", 1.0), reverse=True)\n            elif sort_by == \"weight\":\n                from .analytics import calculate_news_weight\n                all_matches.sort(key=lambda x: calculate_news_weight(x), reverse=True)\n            elif sort_by == \"date\":\n                all_matches.sort(key=lambda x: x.get(\"date\", \"\"), reverse=True)\n\n            # 限制返回数量\n            results = all_matches[:limit]\n\n            # 构建时间范围描述（正确判断是否为今天）\n            if start_date.date() == datetime.now().date() and start_date == end_date:\n                time_range_desc = \"今天\"\n            elif start_date == end_date:\n                time_range_desc = start_date.strftime(\"%Y-%m-%d\")\n            else:\n                time_range_desc = f\"{start_date.strftime('%Y-%m-%d')} 至 {end_date.strftime('%Y-%m-%d')}\"\n\n            result = {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": f\"新闻搜索结果（{search_mode}模式）\",\n                    \"total_found\": len(all_matches),\n                    \"returned\": len(results),\n                    \"requested_limit\": limit,\n                    \"search_mode\": search_mode,\n                    \"query\": query,\n                    \"platforms\": platforms or \"所有平台\",\n                    \"time_range\": time_range_desc,\n                    \"sort_by\": sort_by\n                },\n                \"data\": results\n            }\n\n            if search_mode == \"fuzzy\":\n                result[\"summary\"][\"threshold\"] = threshold\n                if len(all_matches) < limit:\n                    result[\"note\"] = f\"模糊搜索模式下，相似度阈值 {threshold} 仅匹配到 {len(all_matches)} 条结果\"\n\n            # 如果启用 RSS 搜索，同时搜索 RSS 数据\n            if include_rss:\n                rss_results = self._search_rss_by_keyword(\n                    query=query,\n                    start_date=start_date,\n                    end_date=end_date,\n                    limit=rss_limit,\n                    include_url=include_url\n                )\n                result[\"rss\"] = rss_results[\"items\"]\n                result[\"rss_total\"] = rss_results[\"total\"]\n                result[\"summary\"][\"include_rss\"] = True\n                result[\"summary\"][\"rss_found\"] = rss_results[\"total\"]\n                result[\"summary\"][\"rss_returned\"] = len(rss_results[\"items\"])\n\n            return result\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def _search_by_keyword_mode(\n        self,\n        query: str,\n        all_titles: Dict,\n        id_to_name: Dict,\n        current_date: datetime,\n        include_url: bool\n    ) -> List[Dict]:\n        \"\"\"\n        关键词搜索模式（精确匹配）\n\n        Args:\n            query: 搜索关键词\n            all_titles: 所有标题字典\n            id_to_name: 平台ID到名称映射\n            current_date: 当前日期\n\n        Returns:\n            匹配的新闻列表\n        \"\"\"\n        matches = []\n        query_lower = query.lower()\n\n        for platform_id, titles in all_titles.items():\n            platform_name = id_to_name.get(platform_id, platform_id)\n\n            for title, info in titles.items():\n                # 精确包含判断\n                if query_lower in title.lower():\n                    news_item = {\n                        \"title\": title,\n                        \"platform\": platform_id,\n                        \"platform_name\": platform_name,\n                        \"date\": current_date.strftime(\"%Y-%m-%d\"),\n                        \"similarity_score\": 1.0,  # 精确匹配，相似度为1\n                        \"ranks\": info.get(\"ranks\", []),\n                        \"count\": len(info.get(\"ranks\", [])),\n                        \"rank\": info[\"ranks\"][0] if info[\"ranks\"] else 999\n                    }\n\n                    # 条件性添加 URL 字段\n                    if include_url:\n                        news_item[\"url\"] = info.get(\"url\", \"\")\n                        news_item[\"mobileUrl\"] = info.get(\"mobileUrl\", \"\")\n\n                    matches.append(news_item)\n\n        return matches\n\n    def _search_by_fuzzy_mode(\n        self,\n        query: str,\n        all_titles: Dict,\n        id_to_name: Dict,\n        current_date: datetime,\n        threshold: float,\n        include_url: bool\n    ) -> List[Dict]:\n        \"\"\"\n        模糊搜索模式（使用相似度算法）\n\n        Args:\n            query: 搜索内容\n            all_titles: 所有标题字典\n            id_to_name: 平台ID到名称映射\n            current_date: 当前日期\n            threshold: 相似度阈值\n\n        Returns:\n            匹配的新闻列表\n        \"\"\"\n        matches = []\n\n        for platform_id, titles in all_titles.items():\n            platform_name = id_to_name.get(platform_id, platform_id)\n\n            for title, info in titles.items():\n                # 模糊匹配\n                is_match, similarity = self._fuzzy_match(query, title, threshold)\n\n                if is_match:\n                    news_item = {\n                        \"title\": title,\n                        \"platform\": platform_id,\n                        \"platform_name\": platform_name,\n                        \"date\": current_date.strftime(\"%Y-%m-%d\"),\n                        \"similarity_score\": round(similarity, 4),\n                        \"ranks\": info.get(\"ranks\", []),\n                        \"count\": len(info.get(\"ranks\", [])),\n                        \"rank\": info[\"ranks\"][0] if info[\"ranks\"] else 999\n                    }\n\n                    # 条件性添加 URL 字段\n                    if include_url:\n                        news_item[\"url\"] = info.get(\"url\", \"\")\n                        news_item[\"mobileUrl\"] = info.get(\"mobileUrl\", \"\")\n\n                    matches.append(news_item)\n\n        return matches\n\n    def _search_by_entity_mode(\n        self,\n        query: str,\n        all_titles: Dict,\n        id_to_name: Dict,\n        current_date: datetime,\n        include_url: bool\n    ) -> List[Dict]:\n        \"\"\"\n        实体搜索模式（自动按权重排序）\n\n        Args:\n            query: 实体名称\n            all_titles: 所有标题字典\n            id_to_name: 平台ID到名称映射\n            current_date: 当前日期\n\n        Returns:\n            匹配的新闻列表\n        \"\"\"\n        matches = []\n\n        for platform_id, titles in all_titles.items():\n            platform_name = id_to_name.get(platform_id, platform_id)\n\n            for title, info in titles.items():\n                # 实体搜索：精确包含实体名称\n                if query in title:\n                    news_item = {\n                        \"title\": title,\n                        \"platform\": platform_id,\n                        \"platform_name\": platform_name,\n                        \"date\": current_date.strftime(\"%Y-%m-%d\"),\n                        \"similarity_score\": 1.0,\n                        \"ranks\": info.get(\"ranks\", []),\n                        \"count\": len(info.get(\"ranks\", [])),\n                        \"rank\": info[\"ranks\"][0] if info[\"ranks\"] else 999\n                    }\n\n                    # 条件性添加 URL 字段\n                    if include_url:\n                        news_item[\"url\"] = info.get(\"url\", \"\")\n                        news_item[\"mobileUrl\"] = info.get(\"mobileUrl\", \"\")\n\n                    matches.append(news_item)\n\n        return matches\n\n    def _calculate_similarity(self, text1: str, text2: str) -> float:\n        \"\"\"\n        计算两个文本的相似度\n\n        Args:\n            text1: 文本1\n            text2: 文本2\n\n        Returns:\n            相似度分数 (0-1之间)\n        \"\"\"\n        # 使用 difflib.SequenceMatcher 计算序列相似度\n        return SequenceMatcher(None, text1.lower(), text2.lower()).ratio()\n\n    def _fuzzy_match(self, query: str, text: str, threshold: float = 0.3) -> Tuple[bool, float]:\n        \"\"\"\n        模糊匹配函数\n\n        Args:\n            query: 查询文本\n            text: 待匹配文本\n            threshold: 匹配阈值\n\n        Returns:\n            (是否匹配, 相似度分数)\n        \"\"\"\n        # 直接包含判断\n        if query.lower() in text.lower():\n            return True, 1.0\n\n        # 计算整体相似度\n        similarity = self._calculate_similarity(query, text)\n        if similarity >= threshold:\n            return True, similarity\n\n        # 分词后的部分匹配\n        query_words = set(self._extract_keywords(query))\n        text_words = set(self._extract_keywords(text))\n\n        if not query_words or not text_words:\n            return False, 0.0\n\n        # 计算关键词重合度\n        common_words = query_words & text_words\n        keyword_overlap = len(common_words) / len(query_words)\n\n        if keyword_overlap >= 0.5:  # 50%的关键词重合\n            return True, keyword_overlap\n\n        return False, similarity\n\n    def _extract_keywords(self, text: str, min_length: int = 2) -> List[str]:\n        \"\"\"\n        从文本中提取关键词\n\n        Args:\n            text: 输入文本\n            min_length: 最小词长\n\n        Returns:\n            关键词列表\n        \"\"\"\n        # 移除URL和特殊字符\n        text = re.sub(r'http[s]?://\\S+', '', text)\n        text = re.sub(r'\\[.*?\\]', '', text)  # 移除方括号内容\n\n        # 使用正则表达式分词（中文和英文）\n        words = re.findall(r'[\\w]+', text)\n\n        # 过滤短词\n        keywords = [word for word in words if word and len(word) >= min_length]\n\n        return keywords\n\n    def _calculate_keyword_overlap(self, keywords1: List[str], keywords2: List[str]) -> float:\n        \"\"\"\n        计算两个关键词列表的重合度\n\n        Args:\n            keywords1: 关键词列表1\n            keywords2: 关键词列表2\n\n        Returns:\n            重合度分数 (0-1之间)\n        \"\"\"\n        if not keywords1 or not keywords2:\n            return 0.0\n\n        set1 = set(keywords1)\n        set2 = set(keywords2)\n\n        # Jaccard 相似度\n        intersection = len(set1 & set2)\n        union = len(set1 | set2)\n\n        if union == 0:\n            return 0.0\n\n        return intersection / union\n\n    def _jaccard_similarity(self, list1: List[str], list2: List[str]) -> float:\n        \"\"\"\n        计算两个列表的 Jaccard 相似度\n\n        Args:\n            list1: 列表1\n            list2: 列表2\n\n        Returns:\n            Jaccard 相似度 (0-1之间)\n        \"\"\"\n        if not list1 or not list2:\n            return 0.0\n\n        set1 = set(list1)\n        set2 = set(list2)\n\n        intersection = len(set1 & set2)\n        union = len(set1 | set2)\n\n        if union == 0:\n            return 0.0\n\n        return intersection / union\n\n    def search_related_news_history(\n        self,\n        reference_title: str,\n        time_preset: str = \"yesterday\",\n        start_date: Optional[datetime] = None,\n        end_date: Optional[datetime] = None,\n        threshold: float = 0.4,\n        limit: int = 50,\n        include_url: bool = False\n    ) -> Dict:\n        \"\"\"\n        在历史数据中搜索与给定新闻相关的新闻\n\n        Args:\n            reference_title: 参考新闻标题或内容\n            time_preset: 时间范围预设值，可选：\n                - \"yesterday\": 昨天\n                - \"last_week\": 上周 (7天)\n                - \"last_month\": 上个月 (30天)\n                - \"custom\": 自定义日期范围（需要提供 start_date 和 end_date）\n            start_date: 自定义开始日期（仅当 time_preset=\"custom\" 时有效）\n            end_date: 自定义结束日期（仅当 time_preset=\"custom\" 时有效）\n            threshold: 相似度阈值 (0-1之间)，默认0.4\n            limit: 返回条数限制，默认50\n            include_url: 是否包含URL链接，默认False（节省token）\n\n        Returns:\n            搜索结果字典，包含相关新闻列表\n\n        Example:\n            >>> tools = SearchTools()\n            >>> result = tools.search_related_news_history(\n            ...     reference_title=\"人工智能技术突破\",\n            ...     time_preset=\"last_week\",\n            ...     threshold=0.4,\n            ...     limit=50\n            ... )\n            >>> for news in result['results']:\n            ...     print(f\"{news['date']}: {news['title']} (相似度: {news['similarity_score']})\")\n        \"\"\"\n        try:\n            # 参数验证\n            reference_title = validate_keyword(reference_title)\n            threshold = validate_threshold(threshold, default=0.4, min_value=0.0, max_value=1.0)\n            limit = validate_limit(limit, default=50)\n\n            # 确定查询日期范围\n            today = datetime.now()\n\n            if time_preset == \"yesterday\":\n                search_start = today - timedelta(days=1)\n                search_end = today - timedelta(days=1)\n            elif time_preset == \"last_week\":\n                search_start = today - timedelta(days=7)\n                search_end = today - timedelta(days=1)\n            elif time_preset == \"last_month\":\n                search_start = today - timedelta(days=30)\n                search_end = today - timedelta(days=1)\n            elif time_preset == \"custom\":\n                if not start_date or not end_date:\n                    raise InvalidParameterError(\n                        \"自定义时间范围需要提供 start_date 和 end_date\",\n                        suggestion=\"请提供 start_date 和 end_date 参数\"\n                    )\n                search_start = start_date\n                search_end = end_date\n            else:\n                raise InvalidParameterError(\n                    f\"不支持的时间范围: {time_preset}\",\n                    suggestion=\"请使用 'yesterday', 'last_week', 'last_month' 或 'custom'\"\n                )\n\n            # 提取参考文本的关键词\n            reference_keywords = self._extract_keywords(reference_title)\n\n            if not reference_keywords:\n                raise InvalidParameterError(\n                    \"无法从参考文本中提取关键词\",\n                    suggestion=\"请提供更详细的文本内容\"\n                )\n\n            # 收集所有相关新闻\n            all_related_news = []\n            current_date = search_start\n\n            while current_date <= search_end:\n                try:\n                    # 读取该日期的数据\n                    all_titles, id_to_name, _ = self.data_service.parser.read_all_titles_for_date(current_date)\n\n                    # 搜索相关新闻\n                    for platform_id, titles in all_titles.items():\n                        platform_name = id_to_name.get(platform_id, platform_id)\n\n                        for title, info in titles.items():\n                            # 计算标题相似度\n                            title_similarity = self._calculate_similarity(reference_title, title)\n\n                            # 提取标题关键词\n                            title_keywords = self._extract_keywords(title)\n\n                            # 计算关键词重合度\n                            keyword_overlap = self._calculate_keyword_overlap(\n                                reference_keywords,\n                                title_keywords\n                            )\n\n                            # 综合相似度 (70% 关键词重合 + 30% 文本相似度)\n                            combined_score = keyword_overlap * 0.7 + title_similarity * 0.3\n\n                            if combined_score >= threshold:\n                                news_item = {\n                                    \"title\": title,\n                                    \"platform\": platform_id,\n                                    \"platform_name\": platform_name,\n                                    \"date\": current_date.strftime(\"%Y-%m-%d\"),\n                                    \"similarity_score\": round(combined_score, 4),\n                                    \"keyword_overlap\": round(keyword_overlap, 4),\n                                    \"text_similarity\": round(title_similarity, 4),\n                                    \"common_keywords\": list(set(reference_keywords) & set(title_keywords)),\n                                    \"rank\": info[\"ranks\"][0] if info[\"ranks\"] else 0\n                                }\n\n                                # 条件性添加 URL 字段\n                                if include_url:\n                                    news_item[\"url\"] = info.get(\"url\", \"\")\n                                    news_item[\"mobileUrl\"] = info.get(\"mobileUrl\", \"\")\n\n                                all_related_news.append(news_item)\n\n                except DataNotFoundError:\n                    # 该日期没有数据，继续下一天\n                    pass\n                except Exception as e:\n                    # 记录错误但继续处理其他日期\n                    print(f\"Warning: 处理日期 {current_date.strftime('%Y-%m-%d')} 时出错: {e}\")\n\n                # 移动到下一天\n                current_date += timedelta(days=1)\n\n            if not all_related_news:\n                return {\n                    \"success\": True,\n                    \"results\": [],\n                    \"total\": 0,\n                    \"query\": reference_title,\n                    \"time_preset\": time_preset,\n                    \"date_range\": {\n                        \"start\": search_start.strftime(\"%Y-%m-%d\"),\n                        \"end\": search_end.strftime(\"%Y-%m-%d\")\n                    },\n                    \"message\": \"未找到相关新闻\"\n                }\n\n            # 按相似度排序\n            all_related_news.sort(key=lambda x: x[\"similarity_score\"], reverse=True)\n\n            # 限制返回数量\n            results = all_related_news[:limit]\n\n            # 统计信息\n            platform_distribution = Counter([news[\"platform\"] for news in all_related_news])\n            date_distribution = Counter([news[\"date\"] for news in all_related_news])\n\n            result = {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"历史相关新闻搜索结果\",\n                    \"total_found\": len(all_related_news),\n                    \"returned\": len(results),\n                    \"requested_limit\": limit,\n                    \"threshold\": threshold,\n                    \"reference_title\": reference_title,\n                    \"reference_keywords\": reference_keywords,\n                    \"time_preset\": time_preset,\n                    \"date_range\": {\n                        \"start\": search_start.strftime(\"%Y-%m-%d\"),\n                        \"end\": search_end.strftime(\"%Y-%m-%d\")\n                    }\n                },\n                \"data\": results,\n                \"statistics\": {\n                    \"platform_distribution\": dict(platform_distribution),\n                    \"date_distribution\": dict(date_distribution),\n                    \"avg_similarity\": round(\n                        sum([news[\"similarity_score\"] for news in all_related_news]) / len(all_related_news),\n                        4\n                    ) if all_related_news else 0.0\n                }\n            }\n\n            if len(all_related_news) < limit:\n                result[\"note\"] = f\"相关性阈值 {threshold} 下仅找到 {len(all_related_news)} 条相关新闻\"\n\n            return result\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def find_related_news_unified(\n        self,\n        reference_title: str,\n        date_range: Optional[Union[Dict[str, str], str]] = None,\n        threshold: float = 0.5,\n        limit: int = 50,\n        include_url: bool = False\n    ) -> Dict:\n        \"\"\"\n        统一的相关新闻查找工具 - 整合相似新闻和历史相关搜索\n\n        Args:\n            reference_title: 参考新闻标题\n            date_range: 日期范围（可选）\n                - 不指定: 只查询今天的数据\n                - {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}: 查询指定日期范围\n                - \"today\": 今天\n                - \"yesterday\": 昨天\n                - \"last_week\": 最近7天\n                - \"last_month\": 最近30天\n            threshold: 相似度阈值，0-1之间，默认0.5\n            limit: 返回条数限制，默认50\n            include_url: 是否包含URL链接，默认False\n\n        Returns:\n            相关新闻列表，按相似度排序\n        \"\"\"\n        try:\n            # 参数验证\n            reference_title = validate_keyword(reference_title)\n            threshold = validate_threshold(threshold, default=0.5, min_value=0.0, max_value=1.0)\n            limit = validate_limit(limit, default=50)\n\n            # 确定日期范围\n            today = datetime.now()\n\n            # 规范化 date_range（处理 JSON 字符串序列化问题）\n            date_range = normalize_date_range(date_range)\n\n            if date_range is None or date_range == \"today\":\n                # 只查询今天\n                search_dates = [today]\n            elif isinstance(date_range, str):\n                # 预设时间范围\n                if date_range == \"yesterday\":\n                    search_dates = [today - timedelta(days=1)]\n                elif date_range == \"last_week\":\n                    search_dates = [today - timedelta(days=i) for i in range(7)]\n                elif date_range == \"last_month\":\n                    search_dates = [today - timedelta(days=i) for i in range(30)]\n                else:\n                    # 单日字符串格式\n                    try:\n                        single_date = datetime.strptime(date_range, \"%Y-%m-%d\")\n                        search_dates = [single_date]\n                    except ValueError:\n                        search_dates = [today]\n            elif isinstance(date_range, dict):\n                # 日期范围对象\n                start_str = date_range.get(\"start\")\n                end_str = date_range.get(\"end\")\n                if start_str and end_str:\n                    start_date = datetime.strptime(start_str, \"%Y-%m-%d\")\n                    end_date = datetime.strptime(end_str, \"%Y-%m-%d\")\n                    search_dates = []\n                    current = start_date\n                    while current <= end_date:\n                        search_dates.append(current)\n                        current += timedelta(days=1)\n                else:\n                    search_dates = [today]\n            else:\n                search_dates = [today]\n\n            # 提取参考标题的关键词\n            reference_keywords = self._extract_keywords(reference_title)\n\n            # 收集所有相关新闻\n            all_related_news = []\n            \n            for search_date in search_dates:\n                try:\n                    all_titles, id_to_name, _ = self.data_service.parser.read_all_titles_for_date(search_date)\n                    \n                    for platform_id, titles in all_titles.items():\n                        platform_name = id_to_name.get(platform_id, platform_id)\n                        \n                        for title, info in titles.items():\n                            if title == reference_title:\n                                continue\n                            \n                            # 计算相似度（使用混合算法）\n                            text_similarity = self._calculate_similarity(reference_title, title)\n                            \n                            # 如果有关键词，也计算关键词重合度\n                            if reference_keywords:\n                                title_keywords = self._extract_keywords(title)\n                                keyword_similarity = self._jaccard_similarity(reference_keywords, title_keywords)\n                                # 混合相似度：70% 文本 + 30% 关键词\n                                similarity = 0.7 * text_similarity + 0.3 * keyword_similarity\n                            else:\n                                similarity = text_similarity\n                            \n                            if similarity >= threshold:\n                                news_item = {\n                                    \"title\": title,\n                                    \"platform\": platform_id,\n                                    \"platform_name\": platform_name,\n                                    \"date\": search_date.strftime(\"%Y-%m-%d\"),\n                                    \"similarity\": round(similarity, 3),\n                                    \"rank\": info[\"ranks\"][0] if info[\"ranks\"] else 0\n                                }\n                                \n                                if include_url:\n                                    news_item[\"url\"] = info.get(\"url\", \"\")\n                                \n                                all_related_news.append(news_item)\n                                \n                except Exception:\n                    # 某天数据读取失败，跳过\n                    continue\n\n            # 按相似度排序\n            all_related_news.sort(key=lambda x: x[\"similarity\"], reverse=True)\n            \n            # 限制数量\n            results = all_related_news[:limit]\n\n            # 统计信息\n            from collections import Counter\n            platform_dist = Counter([n[\"platform_name\"] for n in all_related_news])\n            date_dist = Counter([n[\"date\"] for n in all_related_news])\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"相关新闻搜索结果\",\n                    \"total_found\": len(all_related_news),\n                    \"returned\": len(results),\n                    \"reference_title\": reference_title,\n                    \"threshold\": threshold,\n                    \"date_range\": {\n                        \"start\": min(search_dates).strftime(\"%Y-%m-%d\"),\n                        \"end\": max(search_dates).strftime(\"%Y-%m-%d\")\n                    } if search_dates else None\n                },\n                \"data\": results,\n                \"statistics\": {\n                    \"platform_distribution\": dict(platform_dist),\n                    \"date_distribution\": dict(date_dist)\n                }\n            }\n\n        except MCPError as e:\n            return {\"success\": False, \"error\": e.to_dict()}\n        except Exception as e:\n            return {\"success\": False, \"error\": {\"code\": \"INTERNAL_ERROR\", \"message\": str(e)}}\n\n    def _search_rss_by_keyword(\n        self,\n        query: str,\n        start_date: datetime,\n        end_date: datetime,\n        limit: int = 20,\n        include_url: bool = False\n    ) -> Dict:\n        \"\"\"\n        在 RSS 数据中搜索关键词\n\n        Args:\n            query: 搜索关键词\n            start_date: 开始日期\n            end_date: 结束日期\n            limit: 返回条数限制\n            include_url: 是否包含 URL\n\n        Returns:\n            RSS 搜索结果字典\n        \"\"\"\n        all_rss_matches = []\n        query_lower = query.lower()\n        current_date = start_date\n\n        while current_date <= end_date:\n            try:\n                # 读取该日期的 RSS 数据\n                all_titles, id_to_name, _ = self.data_service.parser.read_all_titles_for_date(\n                    date=current_date,\n                    platform_ids=None,\n                    db_type=\"rss\"\n                )\n\n                for feed_id, items in all_titles.items():\n                    feed_name = id_to_name.get(feed_id, feed_id)\n\n                    for title, info in items.items():\n                        # 关键词匹配（标题或摘要）\n                        title_match = query_lower in title.lower()\n                        summary = info.get(\"summary\", \"\")\n                        summary_match = query_lower in summary.lower() if summary else False\n\n                        if title_match or summary_match:\n                            rss_item = {\n                                \"title\": title,\n                                \"feed_id\": feed_id,\n                                \"feed_name\": feed_name,\n                                \"date\": current_date.strftime(\"%Y-%m-%d\"),\n                                \"published_at\": info.get(\"published_at\", \"\"),\n                                \"author\": info.get(\"author\", \"\"),\n                                \"match_in\": \"title\" if title_match else \"summary\"\n                            }\n\n                            if include_url:\n                                rss_item[\"url\"] = info.get(\"url\", \"\")\n\n                            all_rss_matches.append(rss_item)\n\n            except DataNotFoundError:\n                # 该日期没有 RSS 数据，继续下一天\n                pass\n            except Exception:\n                # 其他错误，跳过\n                pass\n\n            current_date += timedelta(days=1)\n\n        # 按发布时间排序（最新的在前）\n        all_rss_matches.sort(key=lambda x: x.get(\"published_at\", \"\"), reverse=True)\n\n        return {\n            \"items\": all_rss_matches[:limit],\n            \"total\": len(all_rss_matches)\n        }\n"
  },
  {
    "path": "mcp_server/tools/storage_sync.py",
    "content": "# coding=utf-8\n\"\"\"\n存储同步工具\n\n实现从远程存储拉取数据到本地、获取存储状态、列出可用日期等功能。\n\"\"\"\n\nimport os\nimport re\nfrom pathlib import Path\nfrom datetime import datetime, timedelta\nfrom typing import Dict, List, Optional\n\nimport yaml\n\nfrom ..utils.errors import MCPError\n\n\nclass StorageSyncTools:\n    \"\"\"存储同步工具类\"\"\"\n\n    def __init__(self, project_root: str = None):\n        \"\"\"\n        初始化存储同步工具\n\n        Args:\n            project_root: 项目根目录\n        \"\"\"\n        if project_root:\n            self.project_root = Path(project_root)\n        else:\n            current_file = Path(__file__)\n            self.project_root = current_file.parent.parent.parent\n\n        self._config = None\n        self._remote_backend = None\n\n    def _load_config(self) -> dict:\n        \"\"\"加载配置文件\"\"\"\n        if self._config is None:\n            config_path = self.project_root / \"config\" / \"config.yaml\"\n            if config_path.exists():\n                with open(config_path, \"r\", encoding=\"utf-8\") as f:\n                    self._config = yaml.safe_load(f)\n            else:\n                self._config = {}\n        return self._config\n\n    def _get_storage_config(self) -> dict:\n        \"\"\"获取存储配置\"\"\"\n        config = self._load_config()\n        return config.get(\"storage\", {})\n\n    def _get_remote_config(self) -> dict:\n        \"\"\"\n        获取远程存储配置（合并配置文件和环境变量）\n        \"\"\"\n        storage_config = self._get_storage_config()\n        remote_config = storage_config.get(\"remote\", {})\n\n        return {\n            \"endpoint_url\": remote_config.get(\"endpoint_url\") or os.environ.get(\"S3_ENDPOINT_URL\", \"\"),\n            \"bucket_name\": remote_config.get(\"bucket_name\") or os.environ.get(\"S3_BUCKET_NAME\", \"\"),\n            \"access_key_id\": remote_config.get(\"access_key_id\") or os.environ.get(\"S3_ACCESS_KEY_ID\", \"\"),\n            \"secret_access_key\": remote_config.get(\"secret_access_key\") or os.environ.get(\"S3_SECRET_ACCESS_KEY\", \"\"),\n            \"region\": remote_config.get(\"region\") or os.environ.get(\"S3_REGION\", \"\"),\n        }\n\n    def _has_remote_config(self) -> bool:\n        \"\"\"检查是否有有效的远程存储配置\"\"\"\n        config = self._get_remote_config()\n        return bool(\n            config.get(\"bucket_name\") and\n            config.get(\"access_key_id\") and\n            config.get(\"secret_access_key\") and\n            config.get(\"endpoint_url\")\n        )\n\n    def _get_remote_backend(self):\n        \"\"\"获取远程存储后端实例\"\"\"\n        if self._remote_backend is not None:\n            return self._remote_backend\n\n        if not self._has_remote_config():\n            return None\n\n        try:\n            from trendradar.storage.remote import RemoteStorageBackend\n\n            remote_config = self._get_remote_config()\n            config = self._load_config()\n            timezone = config.get(\"app\", {}).get(\"timezone\", \"Asia/Shanghai\")\n\n            self._remote_backend = RemoteStorageBackend(\n                bucket_name=remote_config[\"bucket_name\"],\n                access_key_id=remote_config[\"access_key_id\"],\n                secret_access_key=remote_config[\"secret_access_key\"],\n                endpoint_url=remote_config[\"endpoint_url\"],\n                region=remote_config.get(\"region\", \"\"),\n                timezone=timezone,\n            )\n            return self._remote_backend\n        except ImportError:\n            print(\"[存储同步] 远程存储后端需要安装 boto3: pip install boto3\")\n            return None\n        except Exception as e:\n            print(f\"[存储同步] 创建远程后端失败: {e}\")\n            return None\n\n    def _get_local_data_dir(self) -> Path:\n        \"\"\"获取本地数据目录\"\"\"\n        storage_config = self._get_storage_config()\n        local_config = storage_config.get(\"local\", {})\n        data_dir = local_config.get(\"data_dir\", \"output\")\n        return self.project_root / data_dir\n\n    def _parse_date_folder_name(self, folder_name: str) -> Optional[datetime]:\n        \"\"\"\n        解析日期文件夹名称（兼容中文和 ISO 格式）\n\n        支持两种格式：\n        - 中文格式：YYYY年MM月DD日\n        - ISO 格式：YYYY-MM-DD\n        \"\"\"\n        # 尝试 ISO 格式\n        iso_match = re.match(r'(\\d{4})-(\\d{2})-(\\d{2})', folder_name)\n        if iso_match:\n            try:\n                return datetime(\n                    int(iso_match.group(1)),\n                    int(iso_match.group(2)),\n                    int(iso_match.group(3))\n                )\n            except ValueError:\n                pass\n\n        # 尝试中文格式\n        chinese_match = re.match(r'(\\d{4})年(\\d{2})月(\\d{2})日', folder_name)\n        if chinese_match:\n            try:\n                return datetime(\n                    int(chinese_match.group(1)),\n                    int(chinese_match.group(2)),\n                    int(chinese_match.group(3))\n                )\n            except ValueError:\n                pass\n\n        return None\n\n    def _get_local_dates(self, db_type: str = \"news\") -> List[str]:\n        \"\"\"\n        获取本地可用的日期列表\n\n        存储结构: output/{db_type}/{date}.db\n        例如: output/news/2025-12-30.db, output/rss/2025-12-30.db\n\n        Args:\n            db_type: 数据库类型 (\"news\" 或 \"rss\")，默认 \"news\"\n\n        Returns:\n            日期列表（按时间倒序）\n        \"\"\"\n        local_dir = self._get_local_data_dir()\n        dates = set()\n\n        if not local_dir.exists():\n            return []\n\n        # 扫描 output/{db_type}/{date}.db 文件\n        type_dir = local_dir / db_type\n        if type_dir.exists():\n            for item in type_dir.iterdir():\n                if item.is_file() and item.suffix == \".db\":\n                    # 从文件名解析日期 (2025-12-30.db -> 2025-12-30)\n                    date_str = item.stem  # 去除 .db 后缀\n                    folder_date = self._parse_date_folder_name(date_str)\n                    if folder_date:\n                        dates.add(folder_date.strftime(\"%Y-%m-%d\"))\n\n        return sorted(list(dates), reverse=True)\n\n    def _get_all_local_dates(self) -> Dict[str, List[str]]:\n        \"\"\"\n        获取所有本地可用的日期列表（包括 news 和 rss）\n\n        Returns:\n            {\n                \"news\": [\"2025-12-30\", ...],\n                \"rss\": [\"2025-12-30\", ...],\n                \"all\": [\"2025-12-30\", ...]  # 合并去重\n            }\n        \"\"\"\n        news_dates = set(self._get_local_dates(\"news\"))\n        rss_dates = set(self._get_local_dates(\"rss\"))\n        all_dates = news_dates | rss_dates\n\n        return {\n            \"news\": sorted(list(news_dates), reverse=True),\n            \"rss\": sorted(list(rss_dates), reverse=True),\n            \"all\": sorted(list(all_dates), reverse=True)\n        }\n\n    def _calculate_dir_size(self, path: Path) -> int:\n        \"\"\"计算目录大小（字节）\"\"\"\n        total_size = 0\n        if path.exists():\n            for item in path.rglob(\"*\"):\n                if item.is_file():\n                    total_size += item.stat().st_size\n        return total_size\n\n    def sync_from_remote(self, days: int = 7) -> Dict:\n        \"\"\"\n        从远程存储拉取数据到本地\n\n        Args:\n            days: 拉取最近 N 天的数据，默认 7 天\n\n        Returns:\n            同步结果字典\n        \"\"\"\n        try:\n            # 检查远程配置\n            if not self._has_remote_config():\n                return {\n                    \"success\": False,\n                    \"error\": {\n                        \"code\": \"REMOTE_NOT_CONFIGURED\",\n                        \"message\": \"未配置远程存储\",\n                        \"suggestion\": \"请在 config/config.yaml 中配置 storage.remote 或设置环境变量\"\n                    }\n                }\n\n            # 获取远程后端\n            remote_backend = self._get_remote_backend()\n            if remote_backend is None:\n                return {\n                    \"success\": False,\n                    \"error\": {\n                        \"code\": \"REMOTE_BACKEND_FAILED\",\n                        \"message\": \"无法创建远程存储后端\",\n                        \"suggestion\": \"请检查远程存储配置和 boto3 是否已安装\"\n                    }\n                }\n\n            # 获取本地数据目录\n            local_dir = self._get_local_data_dir()\n            local_dir.mkdir(parents=True, exist_ok=True)\n\n            # 获取远程可用日期\n            remote_dates = remote_backend.list_remote_dates()\n\n            # 获取本地已有日期\n            local_dates = set(self._get_local_dates())\n\n            # 计算需要拉取的日期（最近 N 天）\n            from trendradar.utils.time import get_configured_time\n            config = self._load_config()\n            timezone = config.get(\"app\", {}).get(\"timezone\", \"Asia/Shanghai\")\n            now = get_configured_time(timezone)\n\n            target_dates = []\n            for i in range(days):\n                date = now - timedelta(days=i)\n                date_str = date.strftime(\"%Y-%m-%d\")\n                if date_str in remote_dates:\n                    target_dates.append(date_str)\n\n            # 执行拉取\n            synced_dates = []\n            skipped_dates = []\n            failed_dates = []\n\n            for date_str in target_dates:\n                # 检查本地是否已存在\n                if date_str in local_dates:\n                    skipped_dates.append(date_str)\n                    continue\n\n                # 拉取单个日期\n                try:\n                    local_date_dir = local_dir / date_str\n                    local_db_path = local_date_dir / \"news.db\"\n                    remote_key = f\"news/{date_str}.db\"\n\n                    local_date_dir.mkdir(parents=True, exist_ok=True)\n                    remote_backend.s3_client.download_file(\n                        remote_backend.bucket_name,\n                        remote_key,\n                        str(local_db_path)\n                    )\n                    synced_dates.append(date_str)\n                    print(f\"[存储同步] 已拉取: {date_str}\")\n                except Exception as e:\n                    failed_dates.append({\"date\": date_str, \"error\": str(e)})\n                    print(f\"[存储同步] 拉取失败 ({date_str}): {e}\")\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"远程存储同步结果\",\n                    \"synced_files\": len(synced_dates),\n                    \"skipped_count\": len(skipped_dates),\n                    \"failed_count\": len(failed_dates)\n                },\n                \"data\": {\n                    \"synced_dates\": synced_dates,\n                    \"skipped_dates\": skipped_dates,\n                    \"failed_dates\": failed_dates\n                },\n                \"message\": f\"成功同步 {len(synced_dates)} 天数据\" + (\n                    f\"，跳过 {len(skipped_dates)} 天（本地已存在）\" if skipped_dates else \"\"\n                ) + (\n                    f\"，失败 {len(failed_dates)} 天\" if failed_dates else \"\"\n                )\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def get_storage_status(self) -> Dict:\n        \"\"\"\n        获取存储配置和状态\n\n        Returns:\n            存储状态字典\n        \"\"\"\n        try:\n            storage_config = self._get_storage_config()\n            config = self._load_config()\n\n            # 本地存储状态\n            local_config = storage_config.get(\"local\", {})\n            local_dir = self._get_local_data_dir()\n            local_size = self._calculate_dir_size(local_dir)\n\n            # 获取分类的日期列表\n            all_dates = self._get_all_local_dates()\n            news_dates = all_dates[\"news\"]\n            rss_dates = all_dates[\"rss\"]\n            combined_dates = all_dates[\"all\"]\n\n            local_status = {\n                \"data_dir\": local_config.get(\"data_dir\", \"output\"),\n                \"retention_days\": local_config.get(\"retention_days\", 0),\n                \"total_size\": f\"{local_size / 1024 / 1024:.2f} MB\",\n                \"total_size_bytes\": local_size,\n                \"date_count\": len(combined_dates),\n                \"earliest_date\": combined_dates[-1] if combined_dates else None,\n                \"latest_date\": combined_dates[0] if combined_dates else None,\n                \"news\": {\n                    \"date_count\": len(news_dates),\n                    \"dates\": news_dates[:10],  # 最近 10 天\n                },\n                \"rss\": {\n                    \"date_count\": len(rss_dates),\n                    \"dates\": rss_dates[:10],  # 最近 10 天\n                },\n            }\n\n            # 远程存储状态\n            remote_config = storage_config.get(\"remote\", {})\n            has_remote = self._has_remote_config()\n\n            remote_status = {\n                \"configured\": has_remote,\n                \"retention_days\": remote_config.get(\"retention_days\", 0),\n            }\n\n            if has_remote:\n                merged_config = self._get_remote_config()\n                # 脱敏显示\n                endpoint = merged_config.get(\"endpoint_url\", \"\")\n                bucket = merged_config.get(\"bucket_name\", \"\")\n                remote_status[\"endpoint_url\"] = endpoint\n                remote_status[\"bucket_name\"] = bucket\n\n                # 尝试获取远程日期列表\n                remote_backend = self._get_remote_backend()\n                if remote_backend:\n                    try:\n                        remote_dates = remote_backend.list_remote_dates()\n                        remote_status[\"date_count\"] = len(remote_dates)\n                        remote_status[\"earliest_date\"] = remote_dates[-1] if remote_dates else None\n                        remote_status[\"latest_date\"] = remote_dates[0] if remote_dates else None\n                    except Exception as e:\n                        remote_status[\"error\"] = str(e)\n\n            # 拉取配置状态\n            pull_config = storage_config.get(\"pull\", {})\n            pull_status = {\n                \"enabled\": pull_config.get(\"enabled\", False),\n                \"days\": pull_config.get(\"days\", 7),\n            }\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"存储配置和状态信息\",\n                    \"backend\": storage_config.get(\"backend\", \"auto\")\n                },\n                \"data\": {\n                    \"local\": local_status,\n                    \"remote\": remote_status,\n                    \"pull\": pull_status\n                }\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def list_available_dates(self, source: str = \"both\") -> Dict:\n        \"\"\"\n        列出可用的日期范围\n\n        Args:\n            source: 数据来源\n                - \"local\": 仅本地\n                - \"remote\": 仅远程\n                - \"both\": 两者都列出（默认）\n\n        Returns:\n            日期列表字典\n        \"\"\"\n        try:\n            data_result = {}\n            summary_info = {\n                \"description\": \"可用日期列表\",\n                \"source\": source\n            }\n\n            # 本地日期\n            if source in (\"local\", \"both\"):\n                all_dates = self._get_all_local_dates()\n                news_dates = all_dates[\"news\"]\n                rss_dates = all_dates[\"rss\"]\n                combined_dates = all_dates[\"all\"]\n\n                data_result[\"local\"] = {\n                    \"dates\": combined_dates,\n                    \"count\": len(combined_dates),\n                    \"earliest\": combined_dates[-1] if combined_dates else None,\n                    \"latest\": combined_dates[0] if combined_dates else None,\n                    \"news\": {\n                        \"dates\": news_dates,\n                        \"count\": len(news_dates),\n                    },\n                    \"rss\": {\n                        \"dates\": rss_dates,\n                        \"count\": len(rss_dates),\n                    },\n                }\n\n            # 远程日期\n            if source in (\"remote\", \"both\"):\n                if not self._has_remote_config():\n                    data_result[\"remote\"] = {\n                        \"configured\": False,\n                        \"dates\": [],\n                        \"count\": 0,\n                        \"earliest\": None,\n                        \"latest\": None,\n                        \"error\": \"未配置远程存储\"\n                    }\n                else:\n                    remote_backend = self._get_remote_backend()\n                    if remote_backend:\n                        try:\n                            remote_dates = remote_backend.list_remote_dates()\n                            data_result[\"remote\"] = {\n                                \"configured\": True,\n                                \"dates\": remote_dates,\n                                \"count\": len(remote_dates),\n                                \"earliest\": remote_dates[-1] if remote_dates else None,\n                                \"latest\": remote_dates[0] if remote_dates else None,\n                            }\n                        except Exception as e:\n                            data_result[\"remote\"] = {\n                                \"configured\": True,\n                                \"dates\": [],\n                                \"count\": 0,\n                                \"earliest\": None,\n                                \"latest\": None,\n                                \"error\": str(e)\n                            }\n                    else:\n                        data_result[\"remote\"] = {\n                            \"configured\": True,\n                            \"dates\": [],\n                            \"count\": 0,\n                            \"earliest\": None,\n                            \"latest\": None,\n                            \"error\": \"无法创建远程存储后端\"\n                        }\n\n            # 如果同时查询两者，计算差异\n            if source == \"both\" and \"local\" in data_result and \"remote\" in data_result:\n                local_set = set(data_result[\"local\"][\"dates\"])\n                remote_set = set(data_result[\"remote\"].get(\"dates\", []))\n\n                data_result[\"comparison\"] = {\n                    \"only_local\": sorted(list(local_set - remote_set), reverse=True),\n                    \"only_remote\": sorted(list(remote_set - local_set), reverse=True),\n                    \"both\": sorted(list(local_set & remote_set), reverse=True),\n                }\n\n            return {\n                \"success\": True,\n                \"summary\": summary_info,\n                \"data\": data_result\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n"
  },
  {
    "path": "mcp_server/tools/system.py",
    "content": "\"\"\"\n系统管理工具\n\n实现系统状态查询和爬虫触发功能。\n\"\"\"\n\nfrom pathlib import Path\nfrom typing import Dict, List, Optional\n\nfrom ..services.data_service import DataService\nfrom ..utils.validators import validate_platforms\nfrom ..utils.errors import MCPError, CrawlTaskError\n\n\nclass SystemManagementTools:\n    \"\"\"系统管理工具类\"\"\"\n\n    def __init__(self, project_root: str = None):\n        \"\"\"\n        初始化系统管理工具\n\n        Args:\n            project_root: 项目根目录\n        \"\"\"\n        self.data_service = DataService(project_root)\n        if project_root:\n            self.project_root = Path(project_root)\n        else:\n            # 获取项目根目录\n            current_file = Path(__file__)\n            self.project_root = current_file.parent.parent.parent\n\n    def get_system_status(self) -> Dict:\n        \"\"\"\n        获取系统运行状态和健康检查信息\n\n        Returns:\n            系统状态字典\n\n        Example:\n            >>> tools = SystemManagementTools()\n            >>> result = tools.get_system_status()\n            >>> print(result['system']['version'])\n        \"\"\"\n        try:\n            # 获取系统状态\n            status = self.data_service.get_system_status()\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"系统运行状态和健康检查信息\"\n                },\n                \"data\": status\n            }\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n\n    def trigger_crawl(self, platforms: Optional[List[str]] = None, save_to_local: bool = False, include_url: bool = False) -> Dict:\n        \"\"\"\n        手动触发一次临时爬取任务（可选持久化）\n\n        Args:\n            platforms: 指定平台列表，为空则爬取所有平台\n            save_to_local: 是否保存到本地 output 目录，默认 False\n            include_url: 是否包含URL链接，默认False（节省token）\n\n        Returns:\n            爬取结果字典，包含新闻数据和保存路径（如果保存）\n\n        Example:\n            >>> tools = SystemManagementTools()\n            >>> # 临时爬取，不保存\n            >>> result = tools.trigger_crawl(platforms=['zhihu', 'weibo'])\n            >>> print(result['data'])\n            >>> # 爬取并保存到本地\n            >>> result = tools.trigger_crawl(platforms=['zhihu'], save_to_local=True)\n            >>> print(result['saved_files'])\n        \"\"\"\n        try:\n            import time\n            import yaml\n            from trendradar.crawler.fetcher import DataFetcher\n            from trendradar.storage.local import LocalStorageBackend\n            from trendradar.storage.base import convert_crawl_results_to_news_data\n            from trendradar.utils.time import get_configured_time, format_date_folder, format_time_filename\n            from ..services.cache_service import get_cache\n\n            # 参数验证\n            platforms = validate_platforms(platforms)\n\n            # 加载配置文件\n            config_path = self.project_root / \"config\" / \"config.yaml\"\n            if not config_path.exists():\n                raise CrawlTaskError(\n                    \"配置文件不存在\",\n                    suggestion=f\"请确保配置文件存在: {config_path}\"\n                )\n\n            # 读取配置\n            with open(config_path, \"r\", encoding=\"utf-8\") as f:\n                config_data = yaml.safe_load(f)\n\n            # 获取平台配置（嵌套结构：{enabled: bool, sources: [...]})\n            platforms_config = config_data.get(\"platforms\", {})\n            if not platforms_config.get(\"enabled\", True):\n                raise CrawlTaskError(\n                    \"热榜平台已禁用\",\n                    suggestion=\"请检查 config/config.yaml 中的 platforms.enabled 配置\"\n                )\n            all_platforms = platforms_config.get(\"sources\", [])\n            if not all_platforms:\n                raise CrawlTaskError(\n                    \"配置文件中没有平台配置\",\n                    suggestion=\"请检查 config/config.yaml 中的 platforms.sources 配置\"\n                )\n\n            # 过滤平台\n            if platforms:\n                target_platforms = [p for p in all_platforms if p[\"id\"] in platforms]\n                if not target_platforms:\n                    raise CrawlTaskError(\n                        f\"指定的平台不存在: {platforms}\",\n                        suggestion=f\"可用平台: {[p['id'] for p in all_platforms]}\"\n                    )\n            else:\n                target_platforms = all_platforms\n\n            # 构建平台ID列表\n            ids = []\n            for platform in target_platforms:\n                if \"name\" in platform:\n                    ids.append((platform[\"id\"], platform[\"name\"]))\n                else:\n                    ids.append(platform[\"id\"])\n\n            print(f\"开始临时爬取，平台: {[p.get('name', p['id']) for p in target_platforms]}\")\n\n            # 初始化数据获取器\n            advanced = config_data.get(\"advanced\", {})\n            crawler_config = advanced.get(\"crawler\", {})\n            proxy_url = None\n            if crawler_config.get(\"use_proxy\"):\n                proxy_url = crawler_config.get(\"default_proxy\")\n            \n            fetcher = DataFetcher(proxy_url=proxy_url)\n            request_interval = crawler_config.get(\"request_interval\", 100)\n\n            # 执行爬取\n            results, id_to_name, failed_ids = fetcher.crawl_websites(\n                ids_list=ids,\n                request_interval=request_interval\n            )\n\n            # 获取当前时间（统一使用 trendradar 的时间工具）\n            # 从配置中读取时区，默认为 Asia/Shanghai\n            timezone = config_data.get(\"app\", {}).get(\"timezone\", \"Asia/Shanghai\")\n            current_time = get_configured_time(timezone)\n            crawl_date = format_date_folder(None, timezone)\n            crawl_time_str = format_time_filename(timezone)\n\n            # 转换为标准数据模型\n            news_data = convert_crawl_results_to_news_data(\n                results=results,\n                id_to_name=id_to_name,\n                failed_ids=failed_ids,\n                crawl_time=crawl_time_str,\n                crawl_date=crawl_date\n            )\n\n            # 初始化存储后端\n            storage = LocalStorageBackend(\n                data_dir=str(self.project_root / \"output\"),\n                enable_txt=True,\n                enable_html=True,\n                timezone=timezone\n            )\n\n            # 尝试持久化数据\n            save_success = False\n            save_error_msg = \"\"\n            saved_files = {}\n\n            try:\n                # 1. 保存到 SQLite (核心持久化)\n                if storage.save_news_data(news_data):\n                    save_success = True\n                \n                # 2. 如果请求保存到本地，生成 TXT/HTML 快照\n                if save_to_local:\n                    # 保存 TXT\n                    txt_path = storage.save_txt_snapshot(news_data)\n                    if txt_path:\n                        saved_files[\"txt\"] = txt_path\n\n                    # 保存 HTML (使用简化版生成器)\n                    html_content = self._generate_simple_html(results, id_to_name, failed_ids, current_time)\n                    html_filename = f\"{crawl_time_str}.html\"\n                    html_path = storage.save_html_report(html_content, html_filename)\n                    if html_path:\n                        saved_files[\"html\"] = html_path\n\n            except Exception as e:\n                # 捕获所有保存错误（特别是 Docker 只读卷导致的 PermissionError）\n                print(f\"[System] 数据保存失败: {e}\")\n                save_success = False\n                save_error_msg = str(e)\n\n            # 3. 清除缓存，确保下次查询获取最新数据\n            # 即使保存失败，内存中的数据可能已经通过其他方式更新，或者是临时的\n            get_cache().clear()\n            print(\"[System] 缓存已清除\")\n\n            # 构建返回结果\n            news_response_data = []\n            for platform_id, titles_data in results.items():\n                platform_name = id_to_name.get(platform_id, platform_id)\n                for title, info in titles_data.items():\n                    news_item = {\n                        \"platform_id\": platform_id,\n                        \"platform_name\": platform_name,\n                        \"title\": title,\n                        \"ranks\": info.get(\"ranks\", [])\n                    }\n                    if include_url:\n                        news_item[\"url\"] = info.get(\"url\", \"\")\n                        news_item[\"mobile_url\"] = info.get(\"mobileUrl\", \"\")\n                    news_response_data.append(news_item)\n\n            result = {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"爬取任务执行结果\",\n                    \"task_id\": f\"crawl_{int(time.time())}\",\n                    \"status\": \"completed\",\n                    \"crawl_time\": current_time.strftime(\"%Y-%m-%d %H:%M:%S\"),\n                    \"total_news\": len(news_response_data),\n                    \"platforms\": list(results.keys()),\n                    \"failed_platforms\": failed_ids,\n                    \"saved_to_local\": save_success and save_to_local\n                },\n                \"data\": news_response_data\n            }\n\n            if save_success:\n                if save_to_local:\n                    result[\"saved_files\"] = saved_files\n                    result[\"note\"] = \"数据已保存到 SQLite 数据库及 output 文件夹\"\n                else:\n                    result[\"note\"] = \"数据已保存到 SQLite 数据库 (仅内存中返回结果，未生成TXT快照)\"\n            else:\n                # 明确告知用户保存失败\n                result[\"saved_to_local\"] = False\n                result[\"save_error\"] = save_error_msg\n                if \"Read-only file system\" in save_error_msg or \"Permission denied\" in save_error_msg:\n                    result[\"note\"] = \"爬取成功，但无法写入数据库（Docker只读模式）。数据仅在本次返回中有效。\"\n                else:\n                    result[\"note\"] = f\"爬取成功但保存失败: {save_error_msg}\"\n\n            # 清理资源\n            storage.cleanup()\n\n            return result\n\n        except MCPError as e:\n            return {\n                \"success\": False,\n                \"error\": e.to_dict()\n            }\n        except Exception as e:\n            import traceback\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e),\n                    \"traceback\": traceback.format_exc()\n                }\n            }\n\n    def _generate_simple_html(self, results: Dict, id_to_name: Dict, failed_ids: List, now) -> str:\n        \"\"\"生成简化的 HTML 报告\"\"\"\n        html = \"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>MCP 爬取结果</title>\n    <style>\n        body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }\n        .container { max-width: 900px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; }\n        h1 { color: #333; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; }\n        .platform { margin-bottom: 30px; }\n        .platform-name { background: #4CAF50; color: white; padding: 10px; border-radius: 5px; margin-bottom: 10px; }\n        .news-item { padding: 8px; border-bottom: 1px solid #eee; }\n        .rank { color: #666; font-weight: bold; margin-right: 10px; }\n        .title { color: #333; }\n        .link { color: #1976D2; text-decoration: none; margin-left: 10px; font-size: 0.9em; }\n        .link:hover { text-decoration: underline; }\n        .failed { background: #ffebee; padding: 10px; border-radius: 5px; margin-top: 20px; }\n        .failed h3 { color: #c62828; margin-top: 0; }\n        .timestamp { color: #666; font-size: 0.9em; text-align: right; margin-top: 20px; }\n    </style>\n</head>\n<body>\n    <div class=\"container\">\n        <h1>MCP 爬取结果</h1>\n\"\"\"\n\n        # 添加时间戳\n        html += f'        <p class=\"timestamp\">爬取时间: {now.strftime(\"%Y-%m-%d %H:%M:%S\")}</p>\\n\\n'\n\n        # 遍历每个平台\n        for platform_id, titles_data in results.items():\n            platform_name = id_to_name.get(platform_id, platform_id)\n            html += f'        <div class=\"platform\">\\n'\n            html += f'            <div class=\"platform-name\">{platform_name}</div>\\n'\n\n            # 排序标题\n            sorted_items = []\n            for title, info in titles_data.items():\n                ranks = info.get(\"ranks\", [])\n                url = info.get(\"url\", \"\")\n                mobile_url = info.get(\"mobileUrl\", \"\")\n                rank = ranks[0] if ranks else 999\n                sorted_items.append((rank, title, url, mobile_url))\n\n            sorted_items.sort(key=lambda x: x[0])\n\n            # 显示新闻\n            for rank, title, url, mobile_url in sorted_items:\n                html += f'            <div class=\"news-item\">\\n'\n                html += f'                <span class=\"rank\">{rank}.</span>\\n'\n                html += f'                <span class=\"title\">{self._html_escape(title)}</span>\\n'\n                if url:\n                    html += f'                <a class=\"link\" href=\"{self._html_escape(url)}\" target=\"_blank\">链接</a>\\n'\n                if mobile_url and mobile_url != url:\n                    html += f'                <a class=\"link\" href=\"{self._html_escape(mobile_url)}\" target=\"_blank\">移动版</a>\\n'\n                html += '            </div>\\n'\n\n            html += '        </div>\\n\\n'\n\n        # 失败的平台\n        if failed_ids:\n            html += '        <div class=\"failed\">\\n'\n            html += '            <h3>请求失败的平台</h3>\\n'\n            html += '            <ul>\\n'\n            for platform_id in failed_ids:\n                html += f'                <li>{self._html_escape(platform_id)}</li>\\n'\n            html += '            </ul>\\n'\n            html += '        </div>\\n'\n\n        html += \"\"\"    </div>\n</body>\n</html>\"\"\"\n\n        return html\n\n    def _html_escape(self, text: str) -> str:\n        \"\"\"HTML 转义\"\"\"\n        if not isinstance(text, str):\n            text = str(text)\n        return (\n            text.replace(\"&\", \"&amp;\")\n            .replace(\"<\", \"&lt;\")\n            .replace(\">\", \"&gt;\")\n            .replace('\"', \"&quot;\")\n            .replace(\"'\", \"&#x27;\")\n        )\n\n    def check_version(self, proxy_url: Optional[str] = None) -> Dict:\n        \"\"\"\n        检查版本更新\n\n        同时检查 TrendRadar 和 MCP Server 两个组件的版本更新。\n        远程版本 URL 从 config.yaml 获取：\n        - version_check_url: TrendRadar 版本\n        - mcp_version_check_url: MCP Server 版本\n\n        Args:\n            proxy_url: 可选的代理URL，用于访问远程版本\n\n        Returns:\n            版本检查结果字典，包含：\n            - success: 是否成功\n            - trendradar: TrendRadar 版本检查结果\n            - mcp: MCP Server 版本检查结果\n            - any_update: 是否有任何组件需要更新\n\n        Example:\n            >>> tools = SystemManagementTools()\n            >>> result = tools.check_version()\n            >>> print(result['data']['any_update'])\n        \"\"\"\n        import yaml\n        import requests\n\n        def parse_version(version_str: str):\n            \"\"\"将版本号字符串解析为元组\"\"\"\n            try:\n                parts = version_str.strip().split(\".\")\n                if len(parts) != 3:\n                    raise ValueError(\"版本号格式不正确\")\n                return int(parts[0]), int(parts[1]), int(parts[2])\n            except:\n                return 0, 0, 0\n\n        def check_single_version(\n            name: str,\n            local_version: str,\n            remote_url: str,\n            proxies: Optional[Dict],\n            headers: Dict\n        ) -> Dict:\n            \"\"\"检查单个组件的版本\"\"\"\n            try:\n                response = requests.get(\n                    remote_url, proxies=proxies, headers=headers, timeout=10\n                )\n                response.raise_for_status()\n                remote_version = response.text.strip()\n\n                local_tuple = parse_version(local_version)\n                remote_tuple = parse_version(remote_version)\n                need_update = local_tuple < remote_tuple\n\n                if need_update:\n                    message = f\"发现新版本 {remote_version}，当前版本 {local_version}，建议更新\"\n                elif local_tuple > remote_tuple:\n                    message = f\"当前版本 {local_version} 高于远程版本 {remote_version}（可能是开发版本）\"\n                else:\n                    message = f\"当前版本 {local_version} 已是最新版本\"\n\n                return {\n                    \"success\": True,\n                    \"name\": name,\n                    \"current_version\": local_version,\n                    \"remote_version\": remote_version,\n                    \"need_update\": need_update,\n                    \"current_parsed\": list(local_tuple),\n                    \"remote_parsed\": list(remote_tuple),\n                    \"message\": message\n                }\n            except requests.exceptions.Timeout:\n                return {\n                    \"success\": False,\n                    \"name\": name,\n                    \"current_version\": local_version,\n                    \"error\": \"获取远程版本超时\"\n                }\n            except requests.exceptions.RequestException as e:\n                return {\n                    \"success\": False,\n                    \"name\": name,\n                    \"current_version\": local_version,\n                    \"error\": f\"网络请求失败: {str(e)}\"\n                }\n            except Exception as e:\n                return {\n                    \"success\": False,\n                    \"name\": name,\n                    \"current_version\": local_version,\n                    \"error\": str(e)\n                }\n\n        try:\n            # 导入本地版本\n            from trendradar import __version__ as trendradar_version\n            from mcp_server import __version__ as mcp_version\n\n            # 从配置文件获取远程版本 URL\n            config_path = self.project_root / \"config\" / \"config.yaml\"\n            if not config_path.exists():\n                return {\n                    \"success\": False,\n                    \"error\": {\n                        \"code\": \"CONFIG_NOT_FOUND\",\n                        \"message\": f\"配置文件不存在: {config_path}\"\n                    }\n                }\n\n            with open(config_path, \"r\", encoding=\"utf-8\") as f:\n                config_data = yaml.safe_load(f)\n\n            advanced_config = config_data.get(\"advanced\", {})\n            trendradar_url = advanced_config.get(\n                \"version_check_url\",\n                \"https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/version\"\n            )\n            mcp_url = advanced_config.get(\n                \"mcp_version_check_url\",\n                \"https://raw.githubusercontent.com/sansan0/TrendRadar/refs/heads/master/version_mcp\"\n            )\n\n            # 配置代理\n            proxies = None\n            if proxy_url:\n                proxies = {\"http\": proxy_url, \"https\": proxy_url}\n\n            # 请求头\n            headers = {\n                \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\",\n                \"Accept\": \"text/plain, */*\",\n                \"Cache-Control\": \"no-cache\",\n            }\n\n            # 检查两个版本\n            trendradar_result = check_single_version(\n                \"TrendRadar\", trendradar_version, trendradar_url, proxies, headers\n            )\n            mcp_result = check_single_version(\n                \"MCP Server\", mcp_version, mcp_url, proxies, headers\n            )\n\n            # 判断是否有任何更新\n            any_update = (\n                (trendradar_result.get(\"success\") and trendradar_result.get(\"need_update\", False)) or\n                (mcp_result.get(\"success\") and mcp_result.get(\"need_update\", False))\n            )\n\n            return {\n                \"success\": True,\n                \"summary\": {\n                    \"description\": \"版本检查结果（TrendRadar + MCP Server）\",\n                    \"any_update\": any_update\n                },\n                \"data\": {\n                    \"trendradar\": trendradar_result,\n                    \"mcp\": mcp_result,\n                    \"any_update\": any_update\n                }\n            }\n\n        except ImportError as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"IMPORT_ERROR\",\n                    \"message\": f\"无法导入版本信息: {str(e)}\"\n                }\n            }\n        except Exception as e:\n            return {\n                \"success\": False,\n                \"error\": {\n                    \"code\": \"INTERNAL_ERROR\",\n                    \"message\": str(e)\n                }\n            }\n"
  },
  {
    "path": "mcp_server/utils/__init__.py",
    "content": "\"\"\"\n工具类模块\n\n提供参数验证、错误处理等辅助功能。\n\"\"\"\n"
  },
  {
    "path": "mcp_server/utils/date_parser.py",
    "content": "\"\"\"\n日期解析工具\n\n支持多种自然语言日期格式解析，包括相对日期和绝对日期。\n\"\"\"\n\nimport re\nfrom datetime import datetime, timedelta\nfrom typing import Tuple, Dict, Optional\n\nfrom .errors import InvalidParameterError\n\n\nclass DateParser:\n    \"\"\"日期解析器类\"\"\"\n\n    # 中文日期映射\n    CN_DATE_MAPPING = {\n        \"今天\": 0,\n        \"昨天\": 1,\n        \"前天\": 2,\n        \"大前天\": 3,\n    }\n\n    # 英文日期映射\n    EN_DATE_MAPPING = {\n        \"today\": 0,\n        \"yesterday\": 1,\n    }\n\n    # 日期范围表达式（用于 resolve_date_range_expression）\n    RANGE_EXPRESSIONS = {\n        # 中文表达式\n        \"今天\": \"today\",\n        \"昨天\": \"yesterday\",\n        \"本周\": \"this_week\",\n        \"这周\": \"this_week\",\n        \"当前周\": \"this_week\",\n        \"上周\": \"last_week\",\n        \"本月\": \"this_month\",\n        \"这个月\": \"this_month\",\n        \"当前月\": \"this_month\",\n        \"上月\": \"last_month\",\n        \"上个月\": \"last_month\",\n        \"最近3天\": \"last_3_days\",\n        \"近3天\": \"last_3_days\",\n        \"最近7天\": \"last_7_days\",\n        \"近7天\": \"last_7_days\",\n        \"最近一周\": \"last_7_days\",\n        \"过去一周\": \"last_7_days\",\n        \"最近14天\": \"last_14_days\",\n        \"近14天\": \"last_14_days\",\n        \"最近两周\": \"last_14_days\",\n        \"过去两周\": \"last_14_days\",\n        \"最近30天\": \"last_30_days\",\n        \"近30天\": \"last_30_days\",\n        \"最近一个月\": \"last_30_days\",\n        \"过去一个月\": \"last_30_days\",\n        # 英文表达式\n        \"today\": \"today\",\n        \"yesterday\": \"yesterday\",\n        \"this week\": \"this_week\",\n        \"current week\": \"this_week\",\n        \"last week\": \"last_week\",\n        \"this month\": \"this_month\",\n        \"current month\": \"this_month\",\n        \"last month\": \"last_month\",\n        \"last 3 days\": \"last_3_days\",\n        \"past 3 days\": \"last_3_days\",\n        \"last 7 days\": \"last_7_days\",\n        \"past 7 days\": \"last_7_days\",\n        \"past week\": \"last_7_days\",\n        \"last 14 days\": \"last_14_days\",\n        \"past 14 days\": \"last_14_days\",\n        \"last 30 days\": \"last_30_days\",\n        \"past 30 days\": \"last_30_days\",\n        \"past month\": \"last_30_days\",\n    }\n\n    # 星期映射\n    WEEKDAY_CN = {\n        \"一\": 0, \"二\": 1, \"三\": 2, \"四\": 3,\n        \"五\": 4, \"六\": 5, \"日\": 6, \"天\": 6\n    }\n\n    WEEKDAY_EN = {\n        \"monday\": 0, \"tuesday\": 1, \"wednesday\": 2, \"thursday\": 3,\n        \"friday\": 4, \"saturday\": 5, \"sunday\": 6\n    }\n\n    @staticmethod\n    def parse_date_query(date_query: str) -> datetime:\n        \"\"\"\n        解析日期查询字符串\n\n        支持的格式：\n        - 相对日期（中文）：今天、昨天、前天、大前天、N天前\n        - 相对日期（英文）：today、yesterday、N days ago\n        - 星期（中文）：上周一、上周二、本周三\n        - 星期（英文）：last monday、this friday\n        - 绝对日期：2025-10-10、10月10日、2025年10月10日\n\n        Args:\n            date_query: 日期查询字符串\n\n        Returns:\n            datetime对象\n\n        Raises:\n            InvalidParameterError: 日期格式无法识别\n\n        Examples:\n            >>> DateParser.parse_date_query(\"今天\")\n            datetime(2025, 10, 11)\n            >>> DateParser.parse_date_query(\"昨天\")\n            datetime(2025, 10, 10)\n            >>> DateParser.parse_date_query(\"3天前\")\n            datetime(2025, 10, 8)\n            >>> DateParser.parse_date_query(\"2025-10-10\")\n            datetime(2025, 10, 10)\n        \"\"\"\n        if not date_query or not isinstance(date_query, str):\n            raise InvalidParameterError(\n                \"日期查询字符串不能为空\",\n                suggestion=\"请提供有效的日期查询，如：今天、昨天、2025-10-10\"\n            )\n\n        date_query = date_query.strip().lower()\n\n        # 1. 尝试解析中文常用相对日期\n        if date_query in DateParser.CN_DATE_MAPPING:\n            days_ago = DateParser.CN_DATE_MAPPING[date_query]\n            return datetime.now() - timedelta(days=days_ago)\n\n        # 2. 尝试解析英文常用相对日期\n        if date_query in DateParser.EN_DATE_MAPPING:\n            days_ago = DateParser.EN_DATE_MAPPING[date_query]\n            return datetime.now() - timedelta(days=days_ago)\n\n        # 3. 尝试解析 \"N天前\" 或 \"N days ago\"\n        cn_days_ago_match = re.match(r'(\\d+)\\s*天前', date_query)\n        if cn_days_ago_match:\n            days = int(cn_days_ago_match.group(1))\n            if days > 365:\n                raise InvalidParameterError(\n                    f\"天数过大: {days}天\",\n                    suggestion=\"请使用小于365天的相对日期或使用绝对日期\"\n                )\n            return datetime.now() - timedelta(days=days)\n\n        en_days_ago_match = re.match(r'(\\d+)\\s*days?\\s+ago', date_query)\n        if en_days_ago_match:\n            days = int(en_days_ago_match.group(1))\n            if days > 365:\n                raise InvalidParameterError(\n                    f\"天数过大: {days}天\",\n                    suggestion=\"请使用小于365天的相对日期或使用绝对日期\"\n                )\n            return datetime.now() - timedelta(days=days)\n\n        # 4. 尝试解析星期（中文）：上周一、本周三\n        cn_weekday_match = re.match(r'(上|本)周([一二三四五六日天])', date_query)\n        if cn_weekday_match:\n            week_type = cn_weekday_match.group(1)  # 上 或 本\n            weekday_str = cn_weekday_match.group(2)\n            target_weekday = DateParser.WEEKDAY_CN[weekday_str]\n            return DateParser._get_date_by_weekday(target_weekday, week_type == \"上\")\n\n        # 5. 尝试解析星期（英文）：last monday、this friday\n        en_weekday_match = re.match(r'(last|this)\\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)', date_query)\n        if en_weekday_match:\n            week_type = en_weekday_match.group(1)  # last 或 this\n            weekday_str = en_weekday_match.group(2)\n            target_weekday = DateParser.WEEKDAY_EN[weekday_str]\n            return DateParser._get_date_by_weekday(target_weekday, week_type == \"last\")\n\n        # 6. 尝试解析绝对日期：YYYY-MM-DD\n        iso_date_match = re.match(r'(\\d{4})-(\\d{1,2})-(\\d{1,2})', date_query)\n        if iso_date_match:\n            year = int(iso_date_match.group(1))\n            month = int(iso_date_match.group(2))\n            day = int(iso_date_match.group(3))\n            try:\n                return datetime(year, month, day)\n            except ValueError as e:\n                raise InvalidParameterError(\n                    f\"无效的日期: {date_query}\",\n                    suggestion=f\"日期值错误: {str(e)}\"\n                )\n\n        # 7. 尝试解析中文日期：MM月DD日 或 YYYY年MM月DD日\n        cn_date_match = re.match(r'(?:(\\d{4})年)?(\\d{1,2})月(\\d{1,2})日', date_query)\n        if cn_date_match:\n            year_str = cn_date_match.group(1)\n            month = int(cn_date_match.group(2))\n            day = int(cn_date_match.group(3))\n\n            # 如果没有年份，使用当前年份\n            if year_str:\n                year = int(year_str)\n            else:\n                year = datetime.now().year\n                # 如果月份大于当前月份，说明是去年\n                current_month = datetime.now().month\n                if month > current_month:\n                    year -= 1\n\n            try:\n                return datetime(year, month, day)\n            except ValueError as e:\n                raise InvalidParameterError(\n                    f\"无效的日期: {date_query}\",\n                    suggestion=f\"日期值错误: {str(e)}\"\n                )\n\n        # 8. 尝试解析斜杠格式：YYYY/MM/DD 或 MM/DD\n        slash_date_match = re.match(r'(?:(\\d{4})/)?(\\d{1,2})/(\\d{1,2})', date_query)\n        if slash_date_match:\n            year_str = slash_date_match.group(1)\n            month = int(slash_date_match.group(2))\n            day = int(slash_date_match.group(3))\n\n            if year_str:\n                year = int(year_str)\n            else:\n                year = datetime.now().year\n                current_month = datetime.now().month\n                if month > current_month:\n                    year -= 1\n\n            try:\n                return datetime(year, month, day)\n            except ValueError as e:\n                raise InvalidParameterError(\n                    f\"无效的日期: {date_query}\",\n                    suggestion=f\"日期值错误: {str(e)}\"\n                )\n\n        # 如果所有格式都不匹配\n        raise InvalidParameterError(\n            f\"无法识别的日期格式: {date_query}\",\n            suggestion=(\n                \"支持的格式:\\n\"\n                \"- 相对日期: 今天、昨天、前天、3天前、today、yesterday、3 days ago\\n\"\n                \"- 星期: 上周一、本周三、last monday、this friday\\n\"\n                \"- 绝对日期: 2025-10-10、10月10日、2025年10月10日\"\n            )\n        )\n\n    @staticmethod\n    def _get_date_by_weekday(target_weekday: int, is_last_week: bool) -> datetime:\n        \"\"\"\n        根据星期几获取日期\n\n        Args:\n            target_weekday: 目标星期 (0=周一, 6=周日)\n            is_last_week: 是否是上周\n\n        Returns:\n            datetime对象\n        \"\"\"\n        today = datetime.now()\n        current_weekday = today.weekday()\n\n        # 计算天数差\n        if is_last_week:\n            # 上周的某一天\n            days_diff = current_weekday - target_weekday + 7\n        else:\n            # 本周的某一天\n            days_diff = current_weekday - target_weekday\n            if days_diff < 0:\n                days_diff += 7\n\n        return today - timedelta(days=days_diff)\n\n    @staticmethod\n    def format_date_folder(date: datetime) -> str:\n        \"\"\"\n        将日期格式化为文件夹名称\n\n        Args:\n            date: datetime对象\n\n        Returns:\n            文件夹名称，格式: YYYY-MM-DD\n\n        Examples:\n            >>> DateParser.format_date_folder(datetime(2025, 10, 11))\n            '2025-10-11'\n        \"\"\"\n        return date.strftime(\"%Y-%m-%d\")\n\n    @staticmethod\n    def validate_date_not_future(date: datetime) -> None:\n        \"\"\"\n        验证日期不在未来\n\n        Args:\n            date: 待验证的日期\n\n        Raises:\n            InvalidParameterError: 日期在未来\n        \"\"\"\n        if date.date() > datetime.now().date():\n            raise InvalidParameterError(\n                f\"不能查询未来的日期: {date.strftime('%Y-%m-%d')}\",\n                suggestion=\"请使用今天或过去的日期\"\n            )\n\n    @staticmethod\n    def validate_date_not_too_old(date: datetime, max_days: int = 365) -> None:\n        \"\"\"\n        验证日期不太久远\n\n        Args:\n            date: 待验证的日期\n            max_days: 最大天数\n\n        Raises:\n            InvalidParameterError: 日期太久远\n        \"\"\"\n        days_ago = (datetime.now().date() - date.date()).days\n        if days_ago > max_days:\n            raise InvalidParameterError(\n                f\"日期太久远: {date.strftime('%Y-%m-%d')} ({days_ago}天前)\",\n                suggestion=f\"请查询{max_days}天内的数据\"\n            )\n\n    @staticmethod\n    def resolve_date_range_expression(expression: str) -> Dict:\n        \"\"\"\n        将自然语言日期表达式解析为标准日期范围\n\n        这是专门为 MCP 工具设计的方法，用于在服务器端解析日期表达式，\n        避免 AI 模型自己计算日期导致的不一致问题。\n\n        Args:\n            expression: 自然语言日期表达式，支持：\n                - 单日: \"今天\", \"昨天\", \"today\", \"yesterday\"\n                - 本周/上周: \"本周\", \"上周\", \"this week\", \"last week\"\n                - 本月/上月: \"本月\", \"上月\", \"this month\", \"last month\"\n                - 最近N天: \"最近7天\", \"最近30天\", \"last 7 days\", \"last 30 days\"\n                - 动态N天: \"最近5天\", \"last 10 days\"\n\n        Returns:\n            解析结果字典：\n            {\n                \"success\": True,\n                \"expression\": \"本周\",\n                \"normalized\": \"this_week\",\n                \"date_range\": {\n                    \"start\": \"2025-11-18\",\n                    \"end\": \"2025-11-24\"\n                },\n                \"current_date\": \"2025-11-26\",\n                \"description\": \"本周（周一到周日）\"\n            }\n\n        Raises:\n            InvalidParameterError: 无法识别的日期表达式\n\n        Examples:\n            >>> DateParser.resolve_date_range_expression(\"本周\")\n            {\"success\": True, \"date_range\": {\"start\": \"2025-11-18\", \"end\": \"2025-11-24\"}, ...}\n\n            >>> DateParser.resolve_date_range_expression(\"最近7天\")\n            {\"success\": True, \"date_range\": {\"start\": \"2025-11-20\", \"end\": \"2025-11-26\"}, ...}\n        \"\"\"\n        if not expression or not isinstance(expression, str):\n            raise InvalidParameterError(\n                \"日期表达式不能为空\",\n                suggestion=\"请提供有效的日期表达式，如：本周、最近7天、last week\"\n            )\n\n        expression_lower = expression.strip().lower()\n        today = datetime.now()\n        today_str = today.strftime(\"%Y-%m-%d\")\n\n        # 1. 尝试匹配预定义表达式\n        normalized = DateParser.RANGE_EXPRESSIONS.get(expression_lower)\n\n        # 2. 尝试匹配动态 \"最近N天\" / \"last N days\" 模式\n        if not normalized:\n            # 中文: 最近N天\n            cn_match = re.match(r'最近(\\d+)天', expression_lower)\n            if cn_match:\n                days = int(cn_match.group(1))\n                normalized = f\"last_{days}_days\"\n\n            # 英文: last N days\n            en_match = re.match(r'(?:last|past)\\s+(\\d+)\\s+days?', expression_lower)\n            if en_match:\n                days = int(en_match.group(1))\n                normalized = f\"last_{days}_days\"\n\n        if not normalized:\n            # 提供支持的表达式列表\n            supported_cn = [\"今天\", \"昨天\", \"本周\", \"上周\", \"本月\", \"上月\",\n                           \"最近7天\", \"最近30天\", \"最近N天\"]\n            supported_en = [\"today\", \"yesterday\", \"this week\", \"last week\",\n                           \"this month\", \"last month\", \"last 7 days\", \"last N days\"]\n            raise InvalidParameterError(\n                f\"无法识别的日期表达式: {expression}\",\n                suggestion=f\"支持的表达式:\\n中文: {', '.join(supported_cn)}\\n英文: {', '.join(supported_en)}\"\n            )\n\n        # 3. 根据 normalized 类型计算日期范围\n        start_date, end_date, description = DateParser._calculate_date_range(\n            normalized, today\n        )\n\n        return {\n            \"success\": True,\n            \"expression\": expression,\n            \"normalized\": normalized,\n            \"date_range\": {\n                \"start\": start_date.strftime(\"%Y-%m-%d\"),\n                \"end\": end_date.strftime(\"%Y-%m-%d\")\n            },\n            \"current_date\": today_str,\n            \"description\": description\n        }\n\n    @staticmethod\n    def _calculate_date_range(\n        normalized: str,\n        today: datetime\n    ) -> Tuple[datetime, datetime, str]:\n        \"\"\"\n        根据标准化的日期类型计算实际日期范围\n\n        Args:\n            normalized: 标准化的日期类型\n            today: 当前日期\n\n        Returns:\n            (start_date, end_date, description) 元组\n        \"\"\"\n        # 单日类型\n        if normalized == \"today\":\n            return today, today, \"今天\"\n\n        if normalized == \"yesterday\":\n            yesterday = today - timedelta(days=1)\n            return yesterday, yesterday, \"昨天\"\n\n        # 本周（周一到周日）\n        if normalized == \"this_week\":\n            # 计算本周一\n            weekday = today.weekday()  # 0=周一, 6=周日\n            start = today - timedelta(days=weekday)\n            end = start + timedelta(days=6)\n            # 如果本周还没结束，end 不能超过今天\n            if end > today:\n                end = today\n            return start, end, f\"本周（周一到周日，{start.strftime('%m-%d')} 至 {end.strftime('%m-%d')}）\"\n\n        # 上周（上周一到上周日）\n        if normalized == \"last_week\":\n            weekday = today.weekday()\n            # 本周一\n            this_monday = today - timedelta(days=weekday)\n            # 上周一\n            start = this_monday - timedelta(days=7)\n            end = start + timedelta(days=6)\n            return start, end, f\"上周（{start.strftime('%m-%d')} 至 {end.strftime('%m-%d')}）\"\n\n        # 本月（本月1日到今天）\n        if normalized == \"this_month\":\n            start = today.replace(day=1)\n            return start, today, f\"本月（{start.strftime('%m-%d')} 至 {today.strftime('%m-%d')}）\"\n\n        # 上月（上月1日到上月最后一天）\n        if normalized == \"last_month\":\n            # 上月最后一天 = 本月1日 - 1天\n            first_of_this_month = today.replace(day=1)\n            end = first_of_this_month - timedelta(days=1)\n            start = end.replace(day=1)\n            return start, end, f\"上月（{start.strftime('%Y-%m-%d')} 至 {end.strftime('%Y-%m-%d')}）\"\n\n        # 最近N天 (last_N_days 格式)\n        match = re.match(r'last_(\\d+)_days', normalized)\n        if match:\n            days = int(match.group(1))\n            start = today - timedelta(days=days - 1)  # 包含今天，所以是 days-1\n            return start, today, f\"最近{days}天（{start.strftime('%m-%d')} 至 {today.strftime('%m-%d')}）\"\n\n        # 兜底：返回今天\n        return today, today, \"今天（默认）\"\n\n    @staticmethod\n    def get_supported_expressions() -> Dict[str, list]:\n        \"\"\"\n        获取支持的日期表达式列表\n\n        Returns:\n            分类的表达式列表\n        \"\"\"\n        return {\n            \"单日\": [\"今天\", \"昨天\", \"today\", \"yesterday\"],\n            \"周\": [\"本周\", \"上周\", \"this week\", \"last week\"],\n            \"月\": [\"本月\", \"上月\", \"this month\", \"last month\"],\n            \"最近N天\": [\"最近3天\", \"最近7天\", \"最近14天\", \"最近30天\",\n                      \"last 3 days\", \"last 7 days\", \"last 14 days\", \"last 30 days\"],\n            \"动态天数\": [\"最近N天\", \"last N days\"]\n        }\n"
  },
  {
    "path": "mcp_server/utils/errors.py",
    "content": "\"\"\"\n自定义错误类\n\n定义MCP Server使用的所有自定义异常类型。\n\"\"\"\n\nfrom typing import Optional, List, Callable\n\n\n# ==================== 延迟加载支持的平台列表 ====================\n\n_get_supported_platforms: Optional[Callable[[], List[str]]] = None\n\n\ndef _load_supported_platforms() -> List[str]:\n    \"\"\"延迟加载支持的平台列表\"\"\"\n    global _get_supported_platforms\n    if _get_supported_platforms is None:\n        try:\n            from .validators import get_supported_platforms\n            _get_supported_platforms = get_supported_platforms\n        except ImportError:\n            # 降级：返回空列表\n            return []\n    return _get_supported_platforms()\n\n\nclass MCPError(Exception):\n    \"\"\"MCP工具错误基类\"\"\"\n\n    def __init__(self, message: str, code: str = \"MCP_ERROR\", suggestion: Optional[str] = None):\n        super().__init__(message)\n        self.code = code\n        self.message = message\n        self.suggestion = suggestion\n\n    def to_dict(self) -> dict:\n        \"\"\"转换为字典格式\"\"\"\n        error_dict = {\n            \"code\": self.code,\n            \"message\": self.message\n        }\n        if self.suggestion:\n            error_dict[\"suggestion\"] = self.suggestion\n        return error_dict\n\n\nclass DataNotFoundError(MCPError):\n    \"\"\"数据不存在错误\"\"\"\n\n    def __init__(self, message: str, suggestion: Optional[str] = None):\n        super().__init__(\n            message=message,\n            code=\"DATA_NOT_FOUND\",\n            suggestion=suggestion or \"请检查日期范围或等待爬取任务完成\"\n        )\n\n\nclass InvalidParameterError(MCPError):\n    \"\"\"参数无效错误\"\"\"\n\n    def __init__(self, message: str, suggestion: Optional[str] = None):\n        super().__init__(\n            message=message,\n            code=\"INVALID_PARAMETER\",\n            suggestion=suggestion or \"请检查参数格式是否正确\"\n        )\n\n\nclass ConfigurationError(MCPError):\n    \"\"\"配置错误\"\"\"\n\n    def __init__(self, message: str, suggestion: Optional[str] = None):\n        super().__init__(\n            message=message,\n            code=\"CONFIGURATION_ERROR\",\n            suggestion=suggestion or \"请检查配置文件是否正确\"\n        )\n\n\nclass PlatformNotSupportedError(MCPError):\n    \"\"\"平台不支持错误\"\"\"\n\n    def __init__(self, platform: str):\n        supported = _load_supported_platforms()\n        suggestion = f\"支持的平台: {', '.join(supported)}\" if supported else \"请检查 config/config.yaml 中的平台配置\"\n        super().__init__(\n            message=f\"平台 '{platform}' 不受支持\",\n            code=\"PLATFORM_NOT_SUPPORTED\",\n            suggestion=suggestion\n        )\n\n\nclass CrawlTaskError(MCPError):\n    \"\"\"爬取任务错误\"\"\"\n\n    def __init__(self, message: str, suggestion: Optional[str] = None):\n        super().__init__(\n            message=message,\n            code=\"CRAWL_TASK_ERROR\",\n            suggestion=suggestion or \"请稍后重试或查看日志\"\n        )\n\n\nclass FileParseError(MCPError):\n    \"\"\"文件解析错误\"\"\"\n\n    def __init__(self, file_path: str, reason: str):\n        super().__init__(\n            message=f\"解析文件 {file_path} 失败: {reason}\",\n            code=\"FILE_PARSE_ERROR\",\n            suggestion=\"请检查文件格式是否正确\"\n        )\n"
  },
  {
    "path": "mcp_server/utils/validators.py",
    "content": "\"\"\"\n参数验证工具\n\n提供统一的参数验证功能。\n支持 MCP 客户端将参数序列化为字符串的情况。\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import List, Optional, Union\nimport os\nimport json\nimport yaml\nimport ast\n\nfrom .errors import InvalidParameterError\nfrom .date_parser import DateParser\n\n\n# ==================== 辅助函数：处理字符串序列化 ====================\n\ndef _parse_string_to_list(value: str) -> List[str]:\n    \"\"\"\n    将字符串解析为列表\n\n    支持格式：\n    - JSON 数组: '[\"zhihu\", \"weibo\"]'\n    - Python 列表字符串: \"['zhihu', 'weibo']\"\n    - 逗号分隔: \"zhihu, weibo\" 或 \"zhihu,weibo\"\n\n    Args:\n        value: 字符串值\n\n    Returns:\n        解析后的列表\n\n    Raises:\n        InvalidParameterError: 解析失败\n    \"\"\"\n    value = value.strip()\n\n    if not value:\n        return []\n\n    # 尝试 JSON 解析: '[\"zhihu\", \"weibo\"]'\n    try:\n        parsed = json.loads(value)\n        if isinstance(parsed, list):\n            return [str(item) for item in parsed]\n        # 如果解析结果不是列表，继续尝试其他方式\n    except json.JSONDecodeError:\n        pass\n\n    # 尝试 Python 字面量解析: \"['zhihu', 'weibo']\"\n    try:\n        parsed = ast.literal_eval(value)\n        if isinstance(parsed, list):\n            return [str(item) for item in parsed]\n        if isinstance(parsed, str):\n            # 单个字符串，包装成列表\n            return [parsed]\n    except (ValueError, SyntaxError):\n        pass\n\n    # 尝试逗号分隔: \"zhihu, weibo\" 或 \"zhihu,weibo\"\n    if ',' in value:\n        items = [item.strip() for item in value.split(',')]\n        return [item for item in items if item]\n\n    # 单个值\n    return [value]\n\n\ndef _parse_string_to_int(value: str, param_name: str = \"参数\") -> int:\n    \"\"\"\n    将字符串解析为整数\n\n    Args:\n        value: 字符串值\n        param_name: 参数名（用于错误消息）\n\n    Returns:\n        解析后的整数\n\n    Raises:\n        InvalidParameterError: 解析失败\n    \"\"\"\n    value = value.strip()\n\n    try:\n        # 尝试直接转换\n        return int(value)\n    except ValueError:\n        pass\n\n    # 尝试解析浮点数后取整\n    try:\n        return int(float(value))\n    except ValueError:\n        raise InvalidParameterError(\n            f\"{param_name} 必须是整数，无法解析: {value}\",\n            suggestion=f\"请提供有效的整数值，如: 10, 50, 100\"\n        )\n\n\ndef _parse_string_to_float(value: str, param_name: str = \"参数\") -> float:\n    \"\"\"\n    将字符串解析为浮点数\n\n    Args:\n        value: 字符串值\n        param_name: 参数名（用于错误消息）\n\n    Returns:\n        解析后的浮点数\n\n    Raises:\n        InvalidParameterError: 解析失败\n    \"\"\"\n    value = value.strip()\n\n    try:\n        return float(value)\n    except ValueError:\n        raise InvalidParameterError(\n            f\"{param_name} 必须是数字，无法解析: {value}\",\n            suggestion=f\"请提供有效的数字值，如: 0.6, 3.0\"\n        )\n\n\ndef _parse_string_to_bool(value: str) -> bool:\n    \"\"\"\n    将字符串解析为布尔值\n\n    Args:\n        value: 字符串值\n\n    Returns:\n        解析后的布尔值\n    \"\"\"\n    value = value.strip().lower()\n\n    if value in ('true', '1', 'yes', 'on'):\n        return True\n    elif value in ('false', '0', 'no', 'off', ''):\n        return False\n    else:\n        # 默认非空字符串为 True\n        return bool(value)\n\n\n# 平台列表 mtime 缓存（避免每次 MCP 调用都重新读取 config.yaml）\n_platforms_cache: Optional[List[str]] = None\n_platforms_config_mtime: float = 0.0\n_platforms_config_path: Optional[str] = None\n\n\ndef get_supported_platforms() -> List[str]:\n    \"\"\"\n    从 config.yaml 动态获取支持的平台列表（带 mtime 缓存）\n\n    仅当 config.yaml 被修改时才重新读取，避免每次 MCP 调用的重复 IO。\n\n    Returns:\n        平台ID列表\n\n    Note:\n        - 读取失败时返回空列表，允许所有平台通过（降级策略）\n        - 平台列表来自 config/config.yaml 中的 platforms 配置\n    \"\"\"\n    global _platforms_cache, _platforms_config_mtime, _platforms_config_path\n\n    try:\n        if _platforms_config_path is None:\n            current_dir = os.path.dirname(os.path.abspath(__file__))\n            _platforms_config_path = os.path.normpath(\n                os.path.join(current_dir, \"..\", \"..\", \"config\", \"config.yaml\")\n            )\n\n        current_mtime = os.path.getmtime(_platforms_config_path)\n\n        if _platforms_cache is not None and current_mtime == _platforms_config_mtime:\n            return _platforms_cache\n\n        with open(_platforms_config_path, 'r', encoding='utf-8') as f:\n            config = yaml.safe_load(f)\n            platforms_config = config.get('platforms', {})\n            sources = platforms_config.get('sources', [])\n            _platforms_cache = [p['id'] for p in sources if 'id' in p]\n            _platforms_config_mtime = current_mtime\n            return _platforms_cache\n    except Exception as e:\n        print(f\"警告：无法加载平台配置: {e}\")\n        return []\n\n\ndef validate_platforms(platforms: Optional[Union[List[str], str]]) -> List[str]:\n    \"\"\"\n    验证平台列表\n\n    Args:\n        platforms: 平台ID列表或字符串，None表示使用 config.yaml 中配置的所有平台\n                   支持多种格式：\n                   - None: 使用默认平台\n                   - [\"zhihu\", \"weibo\"]: JSON 数组\n                   - '[\"zhihu\", \"weibo\"]': JSON 数组字符串\n                   - \"['zhihu', 'weibo']\": Python 列表字符串\n                   - \"zhihu, weibo\": 逗号分隔字符串\n                   - \"zhihu\": 单个平台字符串\n\n    Returns:\n        验证后的平台列表\n\n    Raises:\n        InvalidParameterError: 平台不支持\n\n    Note:\n        - platforms=None 时，返回 config.yaml 中配置的平台列表\n        - 会验证平台ID是否在 config.yaml 的 platforms 配置中\n        - 配置加载失败时，允许所有平台通过（降级策略）\n    \"\"\"\n    supported_platforms = get_supported_platforms()\n\n    if platforms is None:\n        # 返回配置文件中的平台列表（用户的默认配置）\n        return supported_platforms if supported_platforms else []\n\n    # 支持字符串形式的列表输入（某些 MCP 客户端会将 JSON 数组序列化为字符串）\n    if isinstance(platforms, str):\n        platforms = _parse_string_to_list(platforms)\n        if not platforms:\n            # 空字符串或解析后为空，使用默认平台\n            return supported_platforms if supported_platforms else []\n\n    if not isinstance(platforms, list):\n        raise InvalidParameterError(\"platforms 参数必须是列表类型\")\n\n    if not platforms:\n        # 空列表时，返回配置文件中的平台列表\n        return supported_platforms if supported_platforms else []\n\n    # 如果配置加载失败（supported_platforms为空），允许所有平台通过\n    if not supported_platforms:\n        print(\"警告：平台配置未加载，跳过平台验证\")\n        return platforms\n\n    # 验证每个平台是否在配置中\n    invalid_platforms = [p for p in platforms if p not in supported_platforms]\n    if invalid_platforms:\n        raise InvalidParameterError(\n            f\"不支持的平台: {', '.join(invalid_platforms)}\",\n            suggestion=f\"支持的平台（来自config.yaml）: {', '.join(supported_platforms)}\"\n        )\n\n    return platforms\n\n\ndef validate_limit(limit: Optional[Union[int, str]], default: int = 20, max_limit: int = 1000) -> int:\n    \"\"\"\n    验证数量限制参数\n\n    Args:\n        limit: 限制数量（整数或字符串）\n        default: 默认值\n        max_limit: 最大限制\n\n    Returns:\n        验证后的限制值\n\n    Raises:\n        InvalidParameterError: 参数无效\n    \"\"\"\n    if limit is None:\n        return default\n\n    # 支持字符串形式的整数（某些 MCP 客户端会将数字序列化为字符串）\n    if isinstance(limit, str):\n        limit = _parse_string_to_int(limit, \"limit\")\n\n    if not isinstance(limit, int):\n        raise InvalidParameterError(\"limit 参数必须是整数类型\")\n\n    if limit <= 0:\n        raise InvalidParameterError(\"limit 必须大于0\")\n\n    if limit > max_limit:\n        raise InvalidParameterError(\n            f\"limit 不能超过 {max_limit}\",\n            suggestion=f\"请使用分页或降低limit值\"\n        )\n\n    return limit\n\n\ndef validate_date(date_str: str) -> datetime:\n    \"\"\"\n    验证日期格式\n\n    Args:\n        date_str: 日期字符串 (YYYY-MM-DD)\n\n    Returns:\n        datetime对象\n\n    Raises:\n        InvalidParameterError: 日期格式错误\n    \"\"\"\n    try:\n        return datetime.strptime(date_str, \"%Y-%m-%d\")\n    except ValueError:\n        raise InvalidParameterError(\n            f\"日期格式错误: {date_str}\",\n            suggestion=\"请使用 YYYY-MM-DD 格式，例如: 2025-10-11\"\n        )\n\n\ndef normalize_date_range(date_range: Optional[Union[dict, str]]) -> Optional[Union[dict, str]]:\n    \"\"\"\n    规范化 date_range 参数\n\n    某些 MCP 客户端（特别是 HTTP 方式）会将 JSON 对象序列化为字符串传入。\n    此函数尝试将 JSON 字符串解析为 dict，如果不是 JSON 格式则保持原样。\n\n    Args:\n        date_range: 日期范围，可能是:\n            - dict: {\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"}\n            - JSON 字符串: '{\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"}'\n            - 普通字符串: \"今天\", \"昨天\", \"2025-01-01\"\n            - None\n\n    Returns:\n        规范化后的 date_range（dict 或普通字符串）\n\n    Examples:\n        >>> normalize_date_range('{\"start\":\"2025-01-01\",\"end\":\"2025-01-07\"}')\n        {\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"}\n        >>> normalize_date_range(\"今天\")\n        \"今天\"\n        >>> normalize_date_range({\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"})\n        {\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"}\n    \"\"\"\n    if date_range is None:\n        return None\n\n    # 如果已经是 dict，直接返回\n    if isinstance(date_range, dict):\n        return date_range\n\n    # 如果是字符串，尝试解析为 JSON\n    if isinstance(date_range, str):\n        # 检查是否看起来像 JSON 对象\n        stripped = date_range.strip()\n        if stripped.startswith('{') and stripped.endswith('}'):\n            try:\n                parsed = json.loads(stripped)\n                if isinstance(parsed, dict):\n                    return parsed\n            except json.JSONDecodeError:\n                pass  # 解析失败，当作普通字符串处理\n\n    return date_range\n\n\ndef validate_date_range(date_range: Optional[Union[dict, str]]) -> Optional[tuple]:\n    \"\"\"\n    验证日期范围\n\n    Args:\n        date_range: 日期范围，支持多种格式：\n            - dict: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}\n            - JSON 字符串: '{\"start\": \"2025-01-01\", \"end\": \"2025-01-07\"}'\n            - 单日字符串: \"2025-01-01\"（自动转为同一天的范围）\n            - 自然语言: \"今天\", \"昨天\", \"本周\", \"最近7天\" 等\n\n    Returns:\n        (start_date, end_date) 元组，或 None\n\n    Raises:\n        InvalidParameterError: 日期范围无效\n    \"\"\"\n    if date_range is None:\n        return None\n\n    # 支持字符串形式的输入\n    if isinstance(date_range, str):\n        stripped = date_range.strip()\n\n        # 1. 检查是否是 JSON 对象格式\n        if stripped.startswith('{') and stripped.endswith('}'):\n            try:\n                date_range = json.loads(stripped)\n            except json.JSONDecodeError as e:\n                raise InvalidParameterError(\n                    f\"date_range JSON 解析失败: {e}\",\n                    suggestion='请使用正确的JSON格式: {\"start\": \"YYYY-MM-DD\", \"end\": \"YYYY-MM-DD\"}'\n                )\n        # 2. 检查是否是单日字符串格式 YYYY-MM-DD\n        elif len(stripped) == 10 and stripped[4] == '-' and stripped[7] == '-':\n            try:\n                single_date = datetime.strptime(stripped, \"%Y-%m-%d\")\n                return (single_date, single_date)\n            except ValueError:\n                raise InvalidParameterError(\n                    f\"日期格式错误: {stripped}\",\n                    suggestion=\"请使用 YYYY-MM-DD 格式，例如: 2025-10-11\"\n                )\n        # 3. 尝试自然语言解析\n        else:\n            try:\n                result = DateParser.resolve_date_range_expression(stripped)\n                if result.get(\"success\"):\n                    dr = result[\"date_range\"]\n                    start_date = datetime.strptime(dr[\"start\"], \"%Y-%m-%d\")\n                    end_date = datetime.strptime(dr[\"end\"], \"%Y-%m-%d\")\n                    return (start_date, end_date)\n                else:\n                    raise InvalidParameterError(\n                        f\"无法识别的日期表达式: {stripped}\",\n                        suggestion=\"支持格式: YYYY-MM-DD, {\\\"start\\\": \\\"...\\\", \\\"end\\\": \\\"...\\\"}, 或自然语言（今天、本周、最近7天等）\"\n                    )\n            except InvalidParameterError:\n                raise\n            except Exception:\n                raise InvalidParameterError(\n                    f\"日期解析失败: {stripped}\",\n                    suggestion=\"支持格式: YYYY-MM-DD, {\\\"start\\\": \\\"...\\\", \\\"end\\\": \\\"...\\\"}, 或自然语言（今天、本周、最近7天等）\"\n                )\n\n    if not isinstance(date_range, dict):\n        raise InvalidParameterError(\n            \"date_range 必须是字典类型、日期字符串或有效的JSON字符串\",\n            suggestion='例如: {\"start\": \"2025-10-01\", \"end\": \"2025-10-11\"} 或 \"2025-10-01\"'\n        )\n\n    start_str = date_range.get(\"start\")\n    end_str = date_range.get(\"end\")\n\n    if not start_str or not end_str:\n        raise InvalidParameterError(\n            \"date_range 必须包含 start 和 end 字段\",\n            suggestion='例如: {\"start\": \"2025-10-01\", \"end\": \"2025-10-11\"}'\n        )\n\n    start_date = validate_date(start_str)\n    end_date = validate_date(end_str)\n\n    if start_date > end_date:\n        raise InvalidParameterError(\n            \"开始日期不能晚于结束日期\",\n            suggestion=f\"start: {start_str}, end: {end_str}\"\n        )\n\n    # 检查日期是否在未来\n    today = datetime.now().date()\n    if start_date.date() > today or end_date.date() > today:\n        # 获取可用日期范围提示\n        try:\n            from ..services.data_service import DataService\n            data_service = DataService()\n            earliest, latest = data_service.get_available_date_range()\n\n            if earliest and latest:\n                available_range = f\"{earliest.strftime('%Y-%m-%d')} 至 {latest.strftime('%Y-%m-%d')}\"\n            else:\n                available_range = \"无可用数据\"\n        except Exception:\n            available_range = \"未知（请检查 output 目录）\"\n\n        future_dates = []\n        if start_date.date() > today:\n            future_dates.append(start_str)\n        if end_date.date() > today and end_str != start_str:\n            future_dates.append(end_str)\n\n        raise InvalidParameterError(\n            f\"不允许查询未来日期: {', '.join(future_dates)}（当前日期: {today.strftime('%Y-%m-%d')}）\",\n            suggestion=f\"当前可用数据范围: {available_range}\"\n        )\n\n    return (start_date, end_date)\n\n\ndef validate_keyword(keyword: str) -> str:\n    \"\"\"\n    验证关键词\n\n    Args:\n        keyword: 搜索关键词\n\n    Returns:\n        处理后的关键词\n\n    Raises:\n        InvalidParameterError: 关键词无效\n    \"\"\"\n    if not keyword:\n        raise InvalidParameterError(\"keyword 不能为空\")\n\n    if not isinstance(keyword, str):\n        raise InvalidParameterError(\"keyword 必须是字符串类型\")\n\n    keyword = keyword.strip()\n\n    if not keyword:\n        raise InvalidParameterError(\"keyword 不能为空白字符\")\n\n    if len(keyword) > 100:\n        raise InvalidParameterError(\n            \"keyword 长度不能超过100个字符\",\n            suggestion=\"请使用更简洁的关键词\"\n        )\n\n    return keyword\n\n\ndef validate_top_n(top_n: Optional[Union[int, str]], default: int = 10) -> int:\n    \"\"\"\n    验证TOP N参数\n\n    Args:\n        top_n: TOP N数量（整数或字符串）\n        default: 默认值\n\n    Returns:\n        验证后的值\n\n    Raises:\n        InvalidParameterError: 参数无效\n    \"\"\"\n    return validate_limit(top_n, default=default, max_limit=100)\n\n\ndef validate_mode(mode: Optional[str], valid_modes: List[str], default: str) -> str:\n    \"\"\"\n    验证模式参数\n\n    Args:\n        mode: 模式字符串\n        valid_modes: 有效模式列表\n        default: 默认模式\n\n    Returns:\n        验证后的模式\n\n    Raises:\n        InvalidParameterError: 模式无效\n    \"\"\"\n    if mode is None:\n        return default\n\n    if not isinstance(mode, str):\n        raise InvalidParameterError(\"mode 必须是字符串类型\")\n\n    if mode not in valid_modes:\n        raise InvalidParameterError(\n            f\"无效的模式: {mode}\",\n            suggestion=f\"支持的模式: {', '.join(valid_modes)}\"\n        )\n\n    return mode\n\n\ndef validate_config_section(section: Optional[str]) -> str:\n    \"\"\"\n    验证配置节参数\n\n    Args:\n        section: 配置节名称\n\n    Returns:\n        验证后的配置节\n\n    Raises:\n        InvalidParameterError: 配置节无效\n    \"\"\"\n    valid_sections = [\"all\", \"crawler\", \"push\", \"keywords\", \"weights\"]\n    return validate_mode(section, valid_sections, \"all\")\n\n\ndef validate_threshold(\n    threshold: Optional[Union[float, int, str]],\n    default: float = 0.6,\n    min_value: float = 0.0,\n    max_value: float = 1.0,\n    param_name: str = \"threshold\"\n) -> float:\n    \"\"\"\n    验证阈值参数（浮点数）\n\n    Args:\n        threshold: 阈值（浮点数、整数或字符串）\n        default: 默认值\n        min_value: 最小值\n        max_value: 最大值\n        param_name: 参数名（用于错误消息）\n\n    Returns:\n        验证后的阈值\n\n    Raises:\n        InvalidParameterError: 参数无效\n    \"\"\"\n    if threshold is None:\n        return default\n\n    # 支持字符串形式的数字（某些 MCP 客户端会将数字序列化为字符串）\n    if isinstance(threshold, str):\n        threshold = _parse_string_to_float(threshold, param_name)\n\n    # 整数转浮点数\n    if isinstance(threshold, int):\n        threshold = float(threshold)\n\n    if not isinstance(threshold, float):\n        raise InvalidParameterError(\n            f\"{param_name} 必须是数字类型\",\n            suggestion=f\"请提供 {min_value} 到 {max_value} 之间的数字\"\n        )\n\n    if threshold < min_value or threshold > max_value:\n        raise InvalidParameterError(\n            f\"{param_name} 必须在 {min_value} 到 {max_value} 之间，当前值: {threshold}\",\n            suggestion=f\"推荐值: {default}\"\n        )\n\n    return threshold\n\n\ndef validate_date_query(\n    date_query: str,\n    allow_future: bool = False,\n    max_days_ago: int = 365\n) -> datetime:\n    \"\"\"\n    验证并解析日期查询字符串\n\n    Args:\n        date_query: 日期查询字符串\n        allow_future: 是否允许未来日期\n        max_days_ago: 允许查询的最大天数\n\n    Returns:\n        解析后的datetime对象\n\n    Raises:\n        InvalidParameterError: 日期查询无效\n\n    Examples:\n        >>> validate_date_query(\"昨天\")\n        datetime(2025, 10, 10)\n        >>> validate_date_query(\"2025-10-10\")\n        datetime(2025, 10, 10)\n    \"\"\"\n    if not date_query:\n        raise InvalidParameterError(\n            \"日期查询字符串不能为空\",\n            suggestion=\"请提供日期查询，如：今天、昨天、2025-10-10\"\n        )\n\n    # 使用DateParser解析日期\n    parsed_date = DateParser.parse_date_query(date_query)\n\n    # 验证日期不在未来\n    if not allow_future:\n        DateParser.validate_date_not_future(parsed_date)\n\n    # 验证日期不太久远\n    DateParser.validate_date_not_too_old(parsed_date, max_days=max_days_ago)\n\n    return parsed_date\n\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"trendradar\"\nversion = \"6.5.0\"\ndescription = \"TrendRadar - 热点新闻聚合与分析工具\"\nrequires-python = \">=3.10\"\ndependencies = [\n    \"requests>=2.32.5,<3.0.0\",\n    \"pytz>=2025.2,<2026.0\",\n    \"PyYAML>=6.0.3,<7.0.0\",\n    \"fastmcp>=2.12.0,<2.14.0\",\n    \"websockets>=13.0,<14.0\",\n    \"feedparser>=6.0.0,<7.0.0\",\n    \"boto3>=1.35.0,<2.0.0\",\n    \"litellm>=1.57.0,<2.0.0\",\n    \"json-repair>=0.58.3,<1.0.0\",\n    \"tenacity==8.5.0\"\n]\n\n[project.scripts]\ntrendradar = \"trendradar.__main__:main\"\ntrendradar-mcp = \"mcp_server.server:run_server\"\n\n[dependency-groups]\ndev = []\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"trendradar\", \"mcp_server\"]\n"
  },
  {
    "path": "requirements.txt",
    "content": "requests>=2.32.5,<3.0.0\npytz>=2025.2,<2026.0\nPyYAML>=6.0.3,<7.0.0\nfastmcp>=2.12.0,<2.14.0\nwebsockets>=13.0,<14.0\nboto3>=1.35.0,<2.0.0\nfeedparser>=6.0.0,<7.0.0\nlitellm>=1.57.0,<2.0.0\ntenacity==8.5.0\n"
  },
  {
    "path": "setup-mac.sh",
    "content": "#!/bin/bash\n\n# 颜色定义\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nBOLD='\\033[1m'\nNC='\\033[0m' # No Color\n\necho -e \"${BOLD}╔════════════════════════════════════════╗${NC}\"\necho -e \"${BOLD}║  TrendRadar MCP 一键部署 (Mac)        ║${NC}\"\necho -e \"${BOLD}╚════════════════════════════════════════╝${NC}\"\necho \"\"\n\n# 获取项目根目录\nPROJECT_ROOT=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\necho -e \"📍 项目目录: ${BLUE}${PROJECT_ROOT}${NC}\"\necho \"\"\n\n# 检查 UV 是否已安装\nif ! command -v uv &> /dev/null; then\n    echo -e \"${YELLOW}[1/3] 🔧 UV 未安装，正在自动安装...${NC}\"\n    echo \"提示: UV 是一个快速的 Python 包管理器，只需安装一次\"\n    echo \"\"\n    curl -LsSf https://astral.sh/uv/install.sh | sh\n\n    echo \"\"\n    echo \"正在刷新 PATH 环境变量...\"\n    echo \"\"\n\n    # 添加 UV 到 PATH\n    export PATH=\"$HOME/.cargo/bin:$PATH\"\n\n    # 验证 UV 是否真正可用\n    if ! command -v uv &> /dev/null; then\n        echo -e \"${RED}❌ [错误] UV 安装失败${NC}\"\n        echo \"\"\n        echo \"可能的原因：\"\n        echo \"  1. 网络连接问题，无法下载安装脚本\"\n        echo \"  2. 安装路径权限不足\"\n        echo \"  3. 安装脚本执行异常\"\n        echo \"\"\n        echo \"解决方案：\"\n        echo \"  1. 检查网络连接是否正常\"\n        echo \"  2. 手动安装: https://docs.astral.sh/uv/getting-started/installation/\"\n        echo \"  3. 或运行: curl -LsSf https://astral.sh/uv/install.sh | sh\"\n        exit 1\n    fi\n\n    echo -e \"${GREEN}✅ [成功] UV 已安装${NC}\"\n    echo -e \"${YELLOW}⚠️  请重新运行此脚本以继续${NC}\"\n    exit 0\nelse\n    echo -e \"${GREEN}[1/3] ✅ UV 已安装${NC}\"\n    uv --version\nfi\n\necho \"\"\necho \"[2/3] 📦 安装项目依赖...\"\necho \"提示: 这可能需要 1-2 分钟，请耐心等待\"\necho \"\"\n\n# 创建虚拟环境并安装依赖\nuv sync\n\nif [ $? -ne 0 ]; then\n    echo \"\"\n    echo -e \"${RED}❌ [错误] 依赖安装失败${NC}\"\n    echo \"请检查网络连接后重试\"\n    exit 1\nfi\n\necho \"\"\necho -e \"${GREEN}[3/3] ✅ 检查配置文件...${NC}\"\necho \"\"\n\n# 检查配置文件\nif [ ! -f \"config/config.yaml\" ]; then\n    echo -e \"${YELLOW}⚠️  [警告] 未找到配置文件: config/config.yaml${NC}\"\n    echo \"请确保配置文件存在\"\n    echo \"\"\nfi\n\n# 添加执行权限\nchmod +x start-http.sh 2>/dev/null || true\n\n# 获取 UV 路径\nUV_PATH=$(which uv)\n\necho \"\"\necho -e \"${BOLD}╔════════════════════════════════════════╗${NC}\"\necho -e \"${BOLD}║           部署完成！                   ║${NC}\"\necho -e \"${BOLD}╚════════════════════════════════════════╝${NC}\"\necho \"\"\necho \"📋 下一步操作:\"\necho \"\"\necho \"  1️⃣  打开 Cherry Studio\"\necho \"  2️⃣  进入 设置 > MCP Servers > 添加服务器\"\necho \"  3️⃣  填入以下配置:\"\necho \"\"\necho \"      名称: TrendRadar\"\necho \"      描述: 新闻热点聚合工具\"\necho \"      类型: STDIO\"\necho -e \"      命令: ${BLUE}${UV_PATH}${NC}\"\necho \"      参数（每个占一行）:\"\necho -e \"        ${BLUE}--directory${NC}\"\necho -e \"        ${BLUE}${PROJECT_ROOT}${NC}\"\necho -e \"        ${BLUE}run${NC}\"\necho -e \"        ${BLUE}python${NC}\"\necho -e \"        ${BLUE}-m${NC}\"\necho -e \"        ${BLUE}mcp_server.server${NC}\"\necho \"\"\necho \"  4️⃣  保存并启用 MCP 开关\"\necho \"\"\necho \"📖 详细教程请查看: README-Cherry-Studio.md，本窗口别关，待会儿用于填入参数\"\necho \"\"\n"
  },
  {
    "path": "setup-windows-en.bat",
    "content": "@echo off\nsetlocal enabledelayedexpansion\n\necho ==========================================\necho   TrendRadar MCP Setup (Windows)\necho ==========================================\necho:\n\nREM Fix: Use script location instead of current working directory\nset \"PROJECT_ROOT=%~dp0\"\nREM Remove trailing backslash\nif \"%PROJECT_ROOT:~-1%\"==\"\\\" set \"PROJECT_ROOT=%PROJECT_ROOT:~0,-1%\"\n\necho Project Directory: %PROJECT_ROOT%\necho:\n\nREM Change to project directory\ncd /d \"%PROJECT_ROOT%\"\nif %errorlevel% neq 0 (\n    echo [ERROR] Cannot access project directory\n    pause\n    exit /b 1\n)\n\nREM Validate project structure\necho [0/4] Validating project structure...\nif not exist \"pyproject.toml\" (\n    echo [ERROR] pyproject.toml not found in: %PROJECT_ROOT%\n    echo:\n    echo This should not happen! Please check:\n    echo   1. Is setup-windows.bat in the project root?\n    echo   2. Was the project properly cloned/downloaded?\n    echo:\n    echo Files in current directory:\n    dir /b\n    echo:\n    pause\n    exit /b 1\n)\necho [OK] pyproject.toml found\necho:\n\nREM Check Python\necho [1/4] Checking Python...\npython --version >nul 2>&1\nif %errorlevel% neq 0 (\n    echo [ERROR] Python not detected. Please install Python 3.10+\n    echo Download: https://www.python.org/downloads/\n    pause\n    exit /b 1\n)\nfor /f \"tokens=*\" %%i in ('python --version') do echo [OK] %%i\necho:\n\nREM Check UV\necho [2/4] Checking UV...\nwhere uv >nul 2>&1\nif %errorlevel% neq 0 (\n    echo UV not installed, installing automatically...\n    echo:\n    \n    echo Trying installation method 1: PowerShell...\n    powershell -ExecutionPolicy Bypass -Command \"try { irm https://astral.sh/uv/install.ps1 | iex; exit 0 } catch { Write-Host 'PowerShell method failed'; exit 1 }\"\n    \n    if %errorlevel% neq 0 (\n        echo:\n        echo Method 1 failed. Trying method 2: pip...\n        python -m pip install --upgrade uv\n        \n        if %errorlevel% neq 0 (\n            echo:\n            echo [ERROR] Automatic installation failed\n            echo:\n            echo Please install UV manually using one of these methods:\n            echo:\n            echo   Method 1 - pip:\n            echo     python -m pip install uv\n            echo:\n            echo   Method 2 - pipx:\n            echo     pip install pipx\n            echo     pipx install uv\n            echo:\n            echo   Method 3 - Manual download:\n            echo     Visit: https://docs.astral.sh/uv/getting-started/installation/\n            echo:\n            pause\n            exit /b 1\n        )\n    )\n    \n    echo:\n    echo [SUCCESS] UV installed successfully!\n    echo:\n    echo [IMPORTANT] Please restart your terminal:\n    echo   1. Close this window\n    echo   2. Open a new Command Prompt\n    echo   3. Navigate to: %PROJECT_ROOT%\n    echo   4. Run: setup-windows.bat\n    echo:\n    pause\n    exit /b 0\n) else (\n    for /f \"tokens=*\" %%i in ('uv --version') do echo [OK] %%i\n)\necho:\n\necho [3/4] Installing dependencies...\necho Working directory: %PROJECT_ROOT%\necho:\n\nREM Ensure we're in the project directory\ncd /d \"%PROJECT_ROOT%\"\nuv sync\nif %errorlevel% neq 0 (\n    echo:\n    echo [ERROR] Dependency installation failed\n    echo:\n    echo Troubleshooting steps:\n    echo   1. Check your internet connection\n    echo   2. Verify Python version ^>= 3.10: python --version\n    echo   3. Try with verbose output: uv sync --verbose\n    echo   4. Check if pyproject.toml is valid\n    echo:\n    echo Project directory: %PROJECT_ROOT%\n    echo:\n    pause\n    exit /b 1\n)\necho:\necho [OK] Dependencies installed successfully\necho:\n\necho [4/4] Checking configuration file...\nif not exist \"config\\config.yaml\" (\n    echo [WARNING] config\\config.yaml not found\n    if exist \"config\\config.example.yaml\" (\n        echo:\n        echo To create your configuration:\n        echo   1. Copy: copy config\\config.example.yaml config\\config.yaml\n        echo   2. Edit: notepad config\\config.yaml\n        echo   3. Add your API keys\n    )\n    echo:\n) else (\n    echo [OK] config\\config.yaml exists\n)\necho:\n\nREM Get UV path\nfor /f \"tokens=*\" %%i in ('where uv 2^>nul') do set \"UV_PATH=%%i\"\nif not defined UV_PATH (\n    set \"UV_PATH=uv\"\n)\n\necho:\necho ==========================================\necho   Setup Complete!\necho ==========================================\necho:\necho MCP Server Configuration for Claude Desktop:\necho:\necho   Command: %UV_PATH%\necho   Working Directory: %PROJECT_ROOT%\necho:\necho   Arguments (one per line):\necho     --directory\necho     %PROJECT_ROOT%\necho     run\necho     python\necho     -m\necho     mcp_server.server\necho:\necho Configuration guide: README-Cherry-Studio.md\necho:\necho:\npause"
  },
  {
    "path": "setup-windows.bat",
    "content": "@echo off\nchcp 65001 >nul\nsetlocal enabledelayedexpansion\n\necho ==========================================\necho   TrendRadar MCP 一键部署 (Windows)\necho ==========================================\necho.\n\nREM 修复：使用脚本所在目录，而不是当前工作目录\nset \"PROJECT_ROOT=%~dp0\"\nREM 移除末尾的反斜杠\nif \"%PROJECT_ROOT:~-1%\"==\"\\\" set \"PROJECT_ROOT=%PROJECT_ROOT:~0,-1%\"\n\necho 📍 项目目录: %PROJECT_ROOT%\necho.\n\nREM 切换到项目目录\ncd /d \"%PROJECT_ROOT%\"\nif %errorlevel% neq 0 (\n    echo ❌ 无法访问项目目录\n    pause\n    exit /b 1\n)\n\nREM 验证项目结构\necho [0/4] 🔍 验证项目结构...\nif not exist \"pyproject.toml\" (\n    echo ❌ 未找到 pyproject.toml 文件: %PROJECT_ROOT%\n    echo.\n    echo 请检查:\n    echo   1. setup-windows.bat 是否在项目根目录?\n    echo   2. 项目文件是否完整?\n    echo.\n    echo 当前目录内容:\n    dir /b\n    echo.\n    pause\n    exit /b 1\n)\necho ✅ pyproject.toml 已找到\necho.\n\nREM 检查 Python\necho [1/4] 🐍 检查 Python...\npython --version >nul 2>&1\nif %errorlevel% neq 0 (\n    echo ❌ 未检测到 Python，请先安装 Python 3.10+\n    echo 下载地址: https://www.python.org/downloads/\n    pause\n    exit /b 1\n)\nfor /f \"tokens=*\" %%i in ('python --version') do echo ✅ %%i\necho.\n\nREM 检查 UV\necho [2/4] 🔧 检查 UV...\nwhere uv >nul 2>&1\nif %errorlevel% neq 0 (\n    echo UV 未安装，正在自动安装...\n    echo.\n    \n    echo 尝试方法1: PowerShell 安装...\n    powershell -ExecutionPolicy Bypass -Command \"try { irm https://astral.sh/uv/install.ps1 | iex; exit 0 } catch { Write-Host 'PowerShell 安装失败'; exit 1 }\"\n    \n    if %errorlevel% neq 0 (\n        echo.\n        echo 方法1失败，尝试方法2: pip 安装...\n        python -m pip install --upgrade uv\n        \n        if %errorlevel% neq 0 (\n            echo.\n            echo ❌ 自动安装失败\n            echo.\n            echo 请手动安装 UV，可选方法:\n            echo.\n            echo   方法1 - pip:\n            echo     python -m pip install uv\n            echo.\n            echo   方法2 - pipx:\n            echo     pip install pipx\n            echo     pipx install uv\n            echo.\n            echo   方法3 - 手动下载:\n            echo     访问: https://docs.astral.sh/uv/getting-started/installation/\n            echo.\n            pause\n            exit /b 1\n        )\n    )\n    \n    echo.\n    echo ✅ UV 安装完成！\n    echo.\n    echo ⚠️  重要: 请按照以下步骤操作:\n    echo   1. 关闭此窗口\n    echo   2. 重新打开命令提示符（或 PowerShell）\n    echo   3. 回到项目目录: %PROJECT_ROOT%\n    echo   4. 重新运行此脚本: setup-windows.bat\n    echo.\n    pause\n    exit /b 0\n) else (\n    for /f \"tokens=*\" %%i in ('uv --version') do echo ✅ %%i\n)\necho.\n\necho [3/4] 📦 安装项目依赖...\necho 工作目录: %PROJECT_ROOT%\necho.\n\nREM 确保在项目目录下执行\ncd /d \"%PROJECT_ROOT%\"\nuv sync\nif %errorlevel% neq 0 (\n    echo.\n    echo ❌ 依赖安装失败\n    echo.\n    echo 可能的原因:\n    echo   1. 网络连接问题\n    echo   2. Python 版本不兼容（需要 ^>= 3.10）\n    echo   3. pyproject.toml 文件格式错误\n    echo.\n    echo 故障排查:\n    echo   - 检查网络连接\n    echo   - 验证 Python 版本: python --version\n    echo   - 尝试详细输出: uv sync --verbose\n    echo.\n    echo 项目目录: %PROJECT_ROOT%\n    echo.\n    pause\n    exit /b 1\n)\necho.\necho ✅ 依赖安装成功\necho.\n\necho [4/4] ⚙️  检查配置文件...\nif not exist \"config\\config.yaml\" (\n    echo ⚠️  配置文件不存在: config\\config.yaml\n    if exist \"config\\config.example.yaml\" (\n        echo.\n        echo 创建配置文件:\n        echo   1. 复制: copy config\\config.example.yaml config\\config.yaml\n        echo   2. 编辑: notepad config\\config.yaml\n        echo   3. 填入 API 密钥\n    )\n    echo.\n) else (\n    echo ✅ config\\config.yaml 已存在\n)\necho.\n\nREM 获取 UV 路径\nfor /f \"tokens=*\" %%i in ('where uv 2^>nul') do set \"UV_PATH=%%i\"\nif not defined UV_PATH (\n    set \"UV_PATH=uv\"\n)\n\necho.\necho ==========================================\necho            部署完成！\necho ==========================================\necho.\necho 📋 MCP 服务器配置信息（用于 Claude Desktop）:\necho.\necho   命令: %UV_PATH%\necho   工作目录: %PROJECT_ROOT%\necho.\necho   参数（逐行填入）:\necho     --directory\necho     %PROJECT_ROOT%\necho     run\necho     python\necho     -m\necho     mcp_server.server\necho.\necho 📖 详细教程: README-Cherry-Studio.md\necho.\necho.\npause"
  },
  {
    "path": "start-http.bat",
    "content": "@echo off\nchcp 65001 >nul\n\necho ============================================================\necho   TrendRadar MCP Server (HTTP 模式)\necho ============================================================\necho.\n\nREM 检查虚拟环境\nif not exist \".venv\\Scripts\\python.exe\" (\n    echo ❌ [错误] 虚拟环境未找到\n    echo 请先运行 setup-windows.bat 或 setup-windows-en.bat 进行部署\n    echo.\n    pause\n    exit /b 1\n)\n\necho [模式] HTTP (适合远程访问)\necho [地址] http://localhost:3333/mcp\necho [提示] 按 Ctrl+C 停止服务\necho.\n\nuv run python -m mcp_server.server --transport http --host 0.0.0.0 --port 3333\n\npause\n"
  },
  {
    "path": "start-http.sh",
    "content": "#!/bin/bash\n\necho \"╔════════════════════════════════════════╗\"\necho \"║  TrendRadar MCP Server (HTTP 模式)    ║\"\necho \"╚════════════════════════════════════════╝\"\necho \"\"\n\n# 检查虚拟环境\nif [ ! -d \".venv\" ]; then\n    echo \"❌ [错误] 虚拟环境未找到\"\n    echo \"请先运行 ./setup-mac.sh 进行部署\"\n    echo \"\"\n    exit 1\nfi\n\necho \"[模式] HTTP (适合远程访问)\"\necho \"[地址] http://localhost:3333/mcp\"\necho \"[提示] 按 Ctrl+C 停止服务\"\necho \"\"\n\nuv run python -m mcp_server.server --transport http --host 0.0.0.0 --port 3333\n"
  },
  {
    "path": "trendradar/__init__.py",
    "content": "# coding=utf-8\n\"\"\"\nTrendRadar - 热点新闻聚合与分析工具\n\n使用方式:\n  python -m trendradar        # 模块执行\n  trendradar                  # 安装后执行\n\"\"\"\n\nfrom trendradar.context import AppContext\n\n__version__ = \"6.5.0\"\n__all__ = [\"AppContext\", \"__version__\"]\n"
  },
  {
    "path": "trendradar/__main__.py",
    "content": "# coding=utf-8\n\"\"\"\nTrendRadar 主程序\n\n热点新闻聚合与分析工具\n支持: python -m trendradar\n\"\"\"\n\nimport argparse\nimport copy\nimport json\nimport os\nimport re\nimport sys\nimport webbrowser\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Dict, List, Tuple, Optional\n\nimport requests\n\nfrom trendradar.context import AppContext\nfrom trendradar import __version__\nfrom trendradar.core import load_config, parse_multi_account_config, validate_paired_configs\nfrom trendradar.core.analyzer import convert_keyword_stats_to_platform_stats\nfrom trendradar.crawler import DataFetcher\nfrom trendradar.storage import convert_crawl_results_to_news_data\nfrom trendradar.utils.time import DEFAULT_TIMEZONE, is_within_days, calculate_days_old\nfrom trendradar.ai import AIAnalyzer, AIAnalysisResult\nfrom trendradar.core.scheduler import ResolvedSchedule\n\n\ndef _parse_version(version_str: str) -> Tuple[int, int, int]:\n    \"\"\"解析版本号字符串为元组\"\"\"\n    try:\n        parts = version_str.strip().split(\".\")\n        if len(parts) >= 3:\n            return int(parts[0]), int(parts[1]), int(parts[2])\n        return 0, 0, 0\n    except:\n        return 0, 0, 0\n\n\ndef _compare_version(local: str, remote: str) -> str:\n    \"\"\"比较版本号，返回状态文字\"\"\"\n    local_tuple = _parse_version(local)\n    remote_tuple = _parse_version(remote)\n\n    if local_tuple < remote_tuple:\n        return \"⚠️ 需要更新\"\n    elif local_tuple > remote_tuple:\n        return \"🔮 超前版本\"\n    else:\n        return \"✅ 已是最新\"\n\n\ndef _fetch_remote_version(version_url: str, proxy_url: Optional[str] = None) -> Optional[str]:\n    \"\"\"获取远程版本号\"\"\"\n    try:\n        proxies = None\n        if proxy_url:\n            proxies = {\"http\": proxy_url, \"https\": proxy_url}\n\n        headers = {\n            \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\",\n            \"Accept\": \"text/plain, */*\",\n            \"Cache-Control\": \"no-cache\",\n        }\n\n        response = requests.get(version_url, proxies=proxies, headers=headers, timeout=10)\n        response.raise_for_status()\n        return response.text.strip()\n    except Exception as e:\n        print(f\"[版本检查] 获取远程版本失败: {e}\")\n        return None\n\n\ndef _parse_config_versions(content: str) -> Dict[str, str]:\n    \"\"\"解析配置文件版本内容为字典\"\"\"\n    versions = {}\n    try:\n        if not content:\n            return versions\n        for line in content.splitlines():\n            line = line.strip()\n            if not line or \"=\" not in line:\n                continue\n            name, version = line.split(\"=\", 1)\n            versions[name.strip()] = version.strip()\n    except Exception as e:\n        print(f\"[版本检查] 解析配置版本失败: {e}\")\n    return versions\n\n\ndef check_all_versions(\n    version_url: str,\n    configs_version_url: Optional[str] = None,\n    proxy_url: Optional[str] = None\n) -> Tuple[bool, Optional[str]]:\n    \"\"\"\n    统一版本检查：程序版本 + 配置文件版本\n\n    Args:\n        version_url: 远程程序版本检查 URL\n        configs_version_url: 远程配置文件版本检查 URL (返回格式: filename=version)\n        proxy_url: 代理 URL\n\n    Returns:\n        (need_update, remote_version): 程序是否需要更新及远程版本号\n    \"\"\"\n    # 获取远程版本\n    remote_version = _fetch_remote_version(version_url, proxy_url)\n\n    # 获取远程配置版本（如果有提供 URL）\n    remote_config_versions = {}\n    if configs_version_url:\n        content = _fetch_remote_version(configs_version_url, proxy_url)\n        if content:\n            remote_config_versions = _parse_config_versions(content)\n\n    print(\"=\" * 60)\n    print(\"版本检查\")\n    print(\"=\" * 60)\n\n    if remote_version:\n        print(f\"远程程序版本: {remote_version}\")\n    else:\n        print(\"远程程序版本: 获取失败\")\n\n    if configs_version_url:\n        if remote_config_versions:\n            print(f\"远程配置清单: 获取成功 ({len(remote_config_versions)} 个文件)\")\n        else:\n            print(\"远程配置清单: 获取失败或为空\")\n\n    print(\"-\" * 60)\n\n    program_status = _compare_version(__version__, remote_version) if remote_version else \"(无法比较)\"\n    print(f\"  主程序版本: {__version__} {program_status}\")\n\n    config_files = [\n        Path(\"config/config.yaml\"),\n        Path(\"config/timeline.yaml\"),\n        Path(\"config/frequency_words.txt\"),\n        Path(\"config/ai_interests.txt\"),\n        Path(\"config/ai_analysis_prompt.txt\"),\n        Path(\"config/ai_translation_prompt.txt\"),\n    ]\n\n    version_pattern = re.compile(r\"Version:\\s*(\\d+\\.\\d+\\.\\d+)\", re.IGNORECASE)\n\n    for config_file in config_files:\n        if not config_file.exists():\n            print(f\"  {config_file.name}: 文件不存在\")\n            continue\n\n        try:\n            with open(config_file, \"r\", encoding=\"utf-8\") as f:\n                local_version = None\n                for i, line in enumerate(f):\n                    if i >= 20:\n                        break\n                    match = version_pattern.search(line)\n                    if match:\n                        local_version = match.group(1)\n                        break\n\n                # 获取该文件的远程版本\n                target_remote_version = remote_config_versions.get(config_file.name)\n\n                if local_version:\n                    if target_remote_version:\n                        status = _compare_version(local_version, target_remote_version)\n                        print(f\"  {config_file.name}: {local_version} {status}\")\n                    else:\n                        print(f\"  {config_file.name}: {local_version} (未找到远程版本)\")\n                else:\n                    print(f\"  {config_file.name}: 未找到本地版本号\")\n        except Exception as e:\n            print(f\"  {config_file.name}: 读取失败 - {e}\")\n\n    print(\"=\" * 60)\n\n    # 返回程序版本的更新状态\n    if remote_version:\n        need_update = _parse_version(__version__) < _parse_version(remote_version)\n        return need_update, remote_version if need_update else None\n    return False, None\n\n\n# === 主分析器 ===\nclass NewsAnalyzer:\n    \"\"\"新闻分析器\"\"\"\n\n    # 模式策略定义\n    MODE_STRATEGIES = {\n        \"incremental\": {\n            \"mode_name\": \"增量模式\",\n            \"description\": \"增量模式（只关注新增新闻，无新增时不推送）\",\n            \"report_type\": \"增量分析\",\n            \"should_send_notification\": True,\n        },\n        \"current\": {\n            \"mode_name\": \"当前榜单模式\",\n            \"description\": \"当前榜单模式（当前榜单匹配新闻 + 新增新闻区域 + 按时推送）\",\n            \"report_type\": \"当前榜单\",\n            \"should_send_notification\": True,\n        },\n        \"daily\": {\n            \"mode_name\": \"全天汇总模式\",\n            \"description\": \"全天汇总模式（所有匹配新闻 + 新增新闻区域 + 按时推送）\",\n            \"report_type\": \"全天汇总\",\n            \"should_send_notification\": True,\n        },\n    }\n\n    def __init__(self, config: Optional[Dict] = None):\n        # 使用传入的配置或加载新配置\n        if config is None:\n            print(\"正在加载配置...\")\n            config = load_config()\n        print(f\"TrendRadar v{__version__} 配置加载完成\")\n        print(f\"监控平台数量: {len(config['PLATFORMS'])}\")\n        print(f\"时区: {config.get('TIMEZONE', DEFAULT_TIMEZONE)}\")\n\n        # 创建应用上下文\n        self.ctx = AppContext(config)\n\n        self.request_interval = self.ctx.config[\"REQUEST_INTERVAL\"]\n        self.report_mode = self.ctx.config[\"REPORT_MODE\"]\n        self.frequency_file = None\n        self.filter_method = None  # None=使用全局配置 ctx.filter_method\n        self.interests_file = None  # None=使用全局配置 ai_filter.interests_file\n        self.rank_threshold = self.ctx.rank_threshold\n        self.is_github_actions = os.environ.get(\"GITHUB_ACTIONS\") == \"true\"\n        self.is_docker_container = self._detect_docker_environment()\n        self.update_info = None\n        self.proxy_url = None\n        self._setup_proxy()\n        self.data_fetcher = DataFetcher(self.proxy_url)\n\n        # 初始化存储管理器（使用 AppContext）\n        self._init_storage_manager()\n        # 注意：update_info 由 main() 函数设置，避免重复请求远程版本\n\n    def _init_storage_manager(self) -> None:\n        \"\"\"初始化存储管理器（使用 AppContext）\"\"\"\n        # 获取数据保留天数（支持环境变量覆盖）\n        env_retention = os.environ.get(\"STORAGE_RETENTION_DAYS\", \"\").strip()\n        if env_retention:\n            # 环境变量覆盖配置\n            self.ctx.config[\"STORAGE\"][\"RETENTION_DAYS\"] = int(env_retention)\n\n        self.storage_manager = self.ctx.get_storage_manager()\n        print(f\"存储后端: {self.storage_manager.backend_name}\")\n\n        retention_days = self.ctx.config.get(\"STORAGE\", {}).get(\"RETENTION_DAYS\", 0)\n        if retention_days > 0:\n            print(f\"数据保留天数: {retention_days} 天\")\n\n    def _detect_docker_environment(self) -> bool:\n        \"\"\"检测是否运行在 Docker 容器中\"\"\"\n        try:\n            if os.environ.get(\"DOCKER_CONTAINER\") == \"true\":\n                return True\n\n            if os.path.exists(\"/.dockerenv\"):\n                return True\n\n            return False\n        except Exception:\n            return False\n\n    def _should_open_browser(self) -> bool:\n        \"\"\"判断是否应该打开浏览器\"\"\"\n        return not self.is_github_actions and not self.is_docker_container\n\n    def _setup_proxy(self) -> None:\n        \"\"\"设置代理配置\"\"\"\n        if not self.is_github_actions and self.ctx.config[\"USE_PROXY\"]:\n            self.proxy_url = self.ctx.config[\"DEFAULT_PROXY\"]\n            print(\"本地环境，使用代理\")\n        elif not self.is_github_actions and not self.ctx.config[\"USE_PROXY\"]:\n            print(\"本地环境，未启用代理\")\n        else:\n            print(\"GitHub Actions环境，不使用代理\")\n\n    def _set_update_info_from_config(self) -> None:\n        \"\"\"从已缓存的远程版本设置更新信息（不再重复请求）\"\"\"\n        try:\n            version_url = self.ctx.config.get(\"VERSION_CHECK_URL\", \"\")\n            if not version_url:\n                return\n\n            remote_version = _fetch_remote_version(version_url, self.proxy_url)\n            if remote_version:\n                need_update = _parse_version(__version__) < _parse_version(remote_version)\n                if need_update:\n                    self.update_info = {\n                        \"current_version\": __version__,\n                        \"remote_version\": remote_version,\n                    }\n        except Exception as e:\n            print(f\"版本检查出错: {e}\")\n\n    def _get_mode_strategy(self) -> Dict:\n        \"\"\"获取当前模式的策略配置\"\"\"\n        return self.MODE_STRATEGIES.get(self.report_mode, self.MODE_STRATEGIES[\"daily\"])\n\n    def _has_notification_configured(self) -> bool:\n        \"\"\"检查是否配置了任何通知渠道\"\"\"\n        cfg = self.ctx.config\n        return any(\n            [\n                cfg[\"FEISHU_WEBHOOK_URL\"],\n                cfg[\"DINGTALK_WEBHOOK_URL\"],\n                cfg[\"WEWORK_WEBHOOK_URL\"],\n                (cfg[\"TELEGRAM_BOT_TOKEN\"] and cfg[\"TELEGRAM_CHAT_ID\"]),\n                (\n                    cfg[\"EMAIL_FROM\"]\n                    and cfg[\"EMAIL_PASSWORD\"]\n                    and cfg[\"EMAIL_TO\"]\n                ),\n                (cfg[\"NTFY_SERVER_URL\"] and cfg[\"NTFY_TOPIC\"]),\n                cfg[\"BARK_URL\"],\n                cfg[\"SLACK_WEBHOOK_URL\"],\n                cfg[\"GENERIC_WEBHOOK_URL\"],\n            ]\n        )\n\n    def _has_valid_content(\n        self, stats: List[Dict], new_titles: Optional[Dict] = None\n    ) -> bool:\n        \"\"\"检查是否有有效的新闻内容\"\"\"\n        if self.report_mode == \"incremental\":\n            # 增量模式：只要有匹配的新闻就推送\n            # count_word_frequency 已经确保只处理新增的新闻（包括当天第一次爬取的情况）\n            has_matched_news = any(stat[\"count\"] > 0 for stat in stats)\n            return has_matched_news\n        elif self.report_mode == \"current\":\n            # current模式：只要stats有内容就说明有匹配的新闻\n            return any(stat[\"count\"] > 0 for stat in stats)\n        else:\n            # 当日汇总模式下，检查是否有匹配的频率词新闻或新增新闻\n            has_matched_news = any(stat[\"count\"] > 0 for stat in stats)\n            has_new_news = bool(\n                new_titles and any(len(titles) > 0 for titles in new_titles.values())\n            )\n            return has_matched_news or has_new_news\n\n    def _prepare_ai_analysis_data(\n        self,\n        ai_mode: str,\n        current_results: Optional[Dict] = None,\n        current_id_to_name: Optional[Dict] = None,\n    ) -> Tuple[List[Dict], Optional[Dict]]:\n        \"\"\"\n        为 AI 分析准备指定模式的数据\n\n        Args:\n            ai_mode: AI 分析模式 (daily/current/incremental)\n            current_results: 当前抓取的结果（用于 incremental 模式）\n            current_id_to_name: 当前的平台映射（用于 incremental 模式）\n\n        Returns:\n            Tuple[stats, id_to_name]: 统计数据和平台映射\n        \"\"\"\n        try:\n            word_groups, filter_words, global_filters = self.ctx.load_frequency_words(self.frequency_file)\n\n            if ai_mode == \"incremental\":\n                # incremental 模式：使用当前抓取的数据\n                if not current_results or not current_id_to_name:\n                    print(\"[AI] incremental 模式需要当前抓取数据，但未提供\")\n                    return [], None\n\n                # 准备当前时间信息\n                time_info = self.ctx.format_time()\n                title_info = self._prepare_current_title_info(current_results, time_info)\n\n                # 检测新增标题\n                new_titles = self.ctx.detect_new_titles(list(current_results.keys()))\n\n                # 统计计算\n                stats, _ = self.ctx.count_frequency(\n                    current_results,\n                    word_groups,\n                    filter_words,\n                    current_id_to_name,\n                    title_info,\n                    new_titles,\n                    mode=\"incremental\",\n                    global_filters=global_filters,\n                    quiet=True,\n                )\n\n                # 如果是 platform 模式，转换数据结构\n                if self.ctx.display_mode == \"platform\" and stats:\n                    stats = convert_keyword_stats_to_platform_stats(\n                        stats,\n                        self.ctx.weight_config,\n                        self.ctx.rank_threshold,\n                    )\n\n                return stats, current_id_to_name\n\n            elif ai_mode in [\"daily\", \"current\"]:\n                # 加载历史数据\n                analysis_data = self._load_analysis_data(quiet=True)\n                if not analysis_data:\n                    print(f\"[AI] 无法加载历史数据用于 {ai_mode} 模式分析\")\n                    return [], None\n\n                (\n                    all_results,\n                    id_to_name,\n                    title_info,\n                    new_titles,\n                    _,\n                    _,\n                    _,\n                ) = analysis_data\n\n                # 统计计算\n                stats, _ = self.ctx.count_frequency(\n                    all_results,\n                    word_groups,\n                    filter_words,\n                    id_to_name,\n                    title_info,\n                    new_titles,\n                    mode=ai_mode,\n                    global_filters=global_filters,\n                    quiet=True,\n                )\n\n                # 如果是 platform 模式，转换数据结构\n                if self.ctx.display_mode == \"platform\" and stats:\n                    stats = convert_keyword_stats_to_platform_stats(\n                        stats,\n                        self.ctx.weight_config,\n                        self.ctx.rank_threshold,\n                    )\n\n                return stats, id_to_name\n            else:\n                print(f\"[AI] 未知的 AI 模式: {ai_mode}\")\n                return [], None\n\n        except Exception as e:\n            print(f\"[AI] 准备 {ai_mode} 模式数据时出错: {e}\")\n            if self.ctx.config.get(\"DEBUG\", False):\n                import traceback\n                traceback.print_exc()\n            return [], None\n\n    def _run_ai_analysis(\n        self,\n        stats: List[Dict],\n        rss_items: Optional[List[Dict]],\n        mode: str,\n        report_type: str,\n        id_to_name: Optional[Dict],\n        current_results: Optional[Dict] = None,\n        schedule: ResolvedSchedule = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> Optional[AIAnalysisResult]:\n        \"\"\"执行 AI 分析\"\"\"\n        analysis_config = self.ctx.config.get(\"AI_ANALYSIS\", {})\n        if not analysis_config.get(\"ENABLED\", False):\n            return None\n\n        # 调度系统决策\n        if not schedule.analyze:\n            print(\"[AI] 调度器: 当前时间段不执行 AI 分析\")\n            return None\n\n        if schedule.once_analyze and schedule.period_key:\n            scheduler = self.ctx.create_scheduler()\n            date_str = self.ctx.format_date()\n            if scheduler.already_executed(schedule.period_key, \"analyze\", date_str):\n                print(f\"[AI] 调度器: 时间段 {schedule.period_name or schedule.period_key} 今天已分析过，跳过\")\n                return None\n            else:\n                print(f\"[AI] 调度器: 时间段 {schedule.period_name or schedule.period_key} 今天首次分析\")\n\n        print(\"[AI] 正在进行 AI 分析...\")\n        try:\n            ai_config = self.ctx.config.get(\"AI\", {})\n            debug_mode = self.ctx.config.get(\"DEBUG\", False)\n            analyzer = AIAnalyzer(ai_config, analysis_config, self.ctx.get_time, debug=debug_mode)\n\n            # 确定 AI 分析使用的模式\n            ai_mode_config = analysis_config.get(\"MODE\", \"follow_report\")\n            if ai_mode_config == \"follow_report\":\n                # 跟随推送报告模式\n                ai_mode = mode\n                ai_stats = stats\n                ai_id_to_name = id_to_name\n            elif ai_mode_config in [\"daily\", \"current\", \"incremental\"]:\n                # 使用独立配置的模式，需要重新准备数据\n                ai_mode = ai_mode_config\n                if ai_mode != mode:\n                    print(f\"[AI] 使用独立分析模式: {ai_mode} (推送模式: {mode})\")\n                    print(f\"[AI] 正在准备 {ai_mode} 模式的数据...\")\n\n                    # 根据 AI 模式重新准备数据\n                    ai_stats, ai_id_to_name = self._prepare_ai_analysis_data(\n                        ai_mode, current_results, id_to_name\n                    )\n                    if not ai_stats:\n                        print(f\"[AI] 警告: 无法准备 {ai_mode} 模式的数据，回退到推送模式数据\")\n                        ai_stats = stats\n                        ai_id_to_name = id_to_name\n                        ai_mode = mode\n                else:\n                    ai_stats = stats\n                    ai_id_to_name = id_to_name\n            else:\n                # 配置错误，回退到跟随模式\n                print(f\"[AI] 警告: 无效的 ai_analysis.mode 配置 '{ai_mode_config}'，使用推送模式 '{mode}'\")\n                ai_mode = mode\n                ai_stats = stats\n                ai_id_to_name = id_to_name\n\n            # 提取平台列表\n            platforms = list(ai_id_to_name.values()) if ai_id_to_name else []\n\n            # 提取关键词列表\n            keywords = [s.get(\"word\", \"\") for s in ai_stats if s.get(\"word\")] if ai_stats else []\n\n            # 确定报告类型\n            if ai_mode != mode:\n                # 根据 AI 模式确定报告类型\n                ai_report_type = {\n                    \"daily\": \"当日汇总\",\n                    \"current\": \"当前榜单\",\n                    \"incremental\": \"增量更新\"\n                }.get(ai_mode, report_type)\n            else:\n                ai_report_type = report_type\n\n            result = analyzer.analyze(\n                stats=ai_stats,\n                rss_stats=rss_items,\n                report_mode=ai_mode,\n                report_type=ai_report_type,\n                platforms=platforms,\n                keywords=keywords,\n                standalone_data=standalone_data,\n            )\n\n            # 设置 AI 分析使用的模式\n            if result.success:\n                result.ai_mode = ai_mode\n                if result.error:\n                    # 成功但有警告（如 JSON 解析问题但使用了原始文本）\n                    print(f\"[AI] 分析完成（有警告: {result.error}）\")\n                else:\n                    print(\"[AI] 分析完成\")\n\n                # 记录 AI 分析\n                if schedule.once_analyze and schedule.period_key:\n                    scheduler = self.ctx.create_scheduler()\n                    date_str = self.ctx.format_date()\n                    scheduler.record_execution(schedule.period_key, \"analyze\", date_str)\n            else:\n                print(f\"[AI] 分析失败: {result.error}\")\n\n            return result\n        except Exception as e:\n            import traceback\n            error_type = type(e).__name__\n            error_msg = str(e)\n            # 截断过长的错误消息\n            if len(error_msg) > 200:\n                error_msg = error_msg[:200] + \"...\"\n            print(f\"[AI] 分析出错 ({error_type}): {error_msg}\")\n            # 详细错误日志到 stderr\n            import sys\n            print(f\"[AI] 详细错误堆栈:\", file=sys.stderr)\n            traceback.print_exc(file=sys.stderr)\n            return AIAnalysisResult(success=False, error=f\"{error_type}: {error_msg}\")\n\n    def _load_analysis_data(\n        self,\n        quiet: bool = False,\n    ) -> Optional[Tuple[Dict, Dict, Dict, Dict, List, List]]:\n        \"\"\"统一的数据加载和预处理，使用当前监控平台列表过滤历史数据\"\"\"\n        try:\n            # 获取当前配置的监控平台ID列表\n            current_platform_ids = self.ctx.platform_ids\n            if not quiet:\n                print(f\"当前监控平台: {current_platform_ids}\")\n\n            all_results, id_to_name, title_info = self.ctx.read_today_titles(\n                current_platform_ids, quiet=quiet\n            )\n\n            if not all_results:\n                print(\"没有找到当天的数据\")\n                return None\n\n            total_titles = sum(len(titles) for titles in all_results.values())\n            if not quiet:\n                print(f\"读取到 {total_titles} 个标题（已按当前监控平台过滤）\")\n\n            new_titles = self.ctx.detect_new_titles(current_platform_ids, quiet=quiet)\n            word_groups, filter_words, global_filters = self.ctx.load_frequency_words(self.frequency_file)\n\n            return (\n                all_results,\n                id_to_name,\n                title_info,\n                new_titles,\n                word_groups,\n                filter_words,\n                global_filters,\n            )\n        except Exception as e:\n            print(f\"数据加载失败: {e}\")\n            return None\n\n    def _prepare_current_title_info(self, results: Dict, time_info: str) -> Dict:\n        \"\"\"从当前抓取结果构建标题信息\"\"\"\n        title_info = {}\n        for source_id, titles_data in results.items():\n            title_info[source_id] = {}\n            for title, title_data in titles_data.items():\n                ranks = title_data.get(\"ranks\", [])\n                url = title_data.get(\"url\", \"\")\n                mobile_url = title_data.get(\"mobileUrl\", \"\")\n\n                title_info[source_id][title] = {\n                    \"first_time\": time_info,\n                    \"last_time\": time_info,\n                    \"count\": 1,\n                    \"ranks\": ranks,\n                    \"url\": url,\n                    \"mobileUrl\": mobile_url,\n                }\n        return title_info\n\n    def _prepare_standalone_data(\n        self,\n        results: Dict,\n        id_to_name: Dict,\n        title_info: Optional[Dict] = None,\n        rss_items: Optional[List[Dict]] = None,\n    ) -> Optional[Dict]:\n        \"\"\"\n        从原始数据中提取独立展示区数据\n\n        纯数据准备方法，不检查 display.regions.standalone 开关。\n        各消费者自行决定是否使用：\n        - AI 分析：由 ai.include_standalone 控制\n        - 通知推送：由 display.regions.standalone 控制（在 dispatcher 层门控）\n        - HTML 报告：始终包含（如果有数据）\n\n        Args:\n            results: 原始爬取结果 {platform_id: {title: title_data}}\n            id_to_name: 平台 ID 到名称的映射\n            title_info: 标题元信息（含排名历史、时间等）\n            rss_items: RSS 条目列表\n\n        Returns:\n            独立展示数据字典，如果未配置数据源返回 None\n        \"\"\"\n        display_config = self.ctx.config.get(\"DISPLAY\", {})\n        standalone_config = display_config.get(\"STANDALONE\", {})\n\n        platform_ids = standalone_config.get(\"PLATFORMS\", [])\n        rss_feed_ids = standalone_config.get(\"RSS_FEEDS\", [])\n        max_items = standalone_config.get(\"MAX_ITEMS\", 20)\n\n        if not platform_ids and not rss_feed_ids:\n            return None\n\n        standalone_data = {\n            \"platforms\": [],\n            \"rss_feeds\": [],\n        }\n\n        # 找出最新批次时间（类似 current 模式的过滤逻辑）\n        latest_time = None\n        if title_info:\n            for source_titles in title_info.values():\n                for title_data in source_titles.values():\n                    last_time = title_data.get(\"last_time\", \"\")\n                    if last_time:\n                        if latest_time is None or last_time > latest_time:\n                            latest_time = last_time\n\n        # 提取热榜平台数据\n        for platform_id in platform_ids:\n            if platform_id not in results:\n                continue\n\n            platform_name = id_to_name.get(platform_id, platform_id)\n            platform_titles = results[platform_id]\n\n            items = []\n            for title, title_data in platform_titles.items():\n                # 获取元信息（如果有 title_info）\n                meta = {}\n                if title_info and platform_id in title_info and title in title_info[platform_id]:\n                    meta = title_info[platform_id][title]\n\n                # 只保留当前在榜的话题（last_time 等于最新时间）\n                if latest_time and meta:\n                    if meta.get(\"last_time\") != latest_time:\n                        continue\n\n                # 使用当前热榜的排名数据（title_data）进行排序\n                # title_data 包含的是爬虫返回的当前排名，用于保证独立展示区的顺序与热榜一致\n                current_ranks = title_data.get(\"ranks\", [])\n                current_rank = current_ranks[-1] if current_ranks else 0\n\n                # 用于显示的排名范围：合并历史排名和当前排名\n                historical_ranks = meta.get(\"ranks\", []) if meta else []\n                # 合并去重，保持顺序\n                all_ranks = historical_ranks.copy()\n                for rank in current_ranks:\n                    if rank not in all_ranks:\n                        all_ranks.append(rank)\n                display_ranks = all_ranks if all_ranks else current_ranks\n\n                item = {\n                    \"title\": title,\n                    \"url\": title_data.get(\"url\", \"\"),\n                    \"mobileUrl\": title_data.get(\"mobileUrl\", \"\"),\n                    \"rank\": current_rank,  # 用于排序的当前排名\n                    \"ranks\": display_ranks,  # 用于显示的排名范围（历史+当前）\n                    \"first_time\": meta.get(\"first_time\", \"\"),\n                    \"last_time\": meta.get(\"last_time\", \"\"),\n                    \"count\": meta.get(\"count\", 1),\n                    \"rank_timeline\": meta.get(\"rank_timeline\", []),\n                }\n                items.append(item)\n\n            # 按当前排名排序\n            items.sort(key=lambda x: x[\"rank\"] if x[\"rank\"] > 0 else 9999)\n\n            # 限制条数\n            if max_items > 0:\n                items = items[:max_items]\n\n            if items:\n                standalone_data[\"platforms\"].append({\n                    \"id\": platform_id,\n                    \"name\": platform_name,\n                    \"items\": items,\n                })\n\n        # 提取 RSS 数据\n        if rss_items and rss_feed_ids:\n            # 按 feed_id 分组\n            feed_items_map = {}\n            for item in rss_items:\n                feed_id = item.get(\"feed_id\", \"\")\n                if feed_id in rss_feed_ids:\n                    if feed_id not in feed_items_map:\n                        feed_items_map[feed_id] = {\n                            \"name\": item.get(\"feed_name\", feed_id),\n                            \"items\": [],\n                        }\n                    feed_items_map[feed_id][\"items\"].append({\n                        \"title\": item.get(\"title\", \"\"),\n                        \"url\": item.get(\"url\", \"\"),\n                        \"published_at\": item.get(\"published_at\", \"\"),\n                        \"author\": item.get(\"author\", \"\"),\n                    })\n\n            # 限制条数并添加到结果\n            for feed_id in rss_feed_ids:\n                if feed_id in feed_items_map:\n                    feed_data = feed_items_map[feed_id]\n                    items = feed_data[\"items\"]\n                    if max_items > 0:\n                        items = items[:max_items]\n                    if items:\n                        standalone_data[\"rss_feeds\"].append({\n                            \"id\": feed_id,\n                            \"name\": feed_data[\"name\"],\n                            \"items\": items,\n                        })\n\n        # 如果没有任何数据，返回 None\n        if not standalone_data[\"platforms\"] and not standalone_data[\"rss_feeds\"]:\n            return None\n\n        return standalone_data\n\n    def _run_analysis_pipeline(\n        self,\n        data_source: Dict,\n        mode: str,\n        title_info: Dict,\n        new_titles: Dict,\n        word_groups: List[Dict],\n        filter_words: List[str],\n        id_to_name: Dict,\n        failed_ids: Optional[List] = None,\n        global_filters: Optional[List[str]] = None,\n        quiet: bool = False,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        standalone_data: Optional[Dict] = None,\n        schedule: ResolvedSchedule = None,\n        rss_new_urls: Optional[set] = None,\n    ) -> Tuple[List[Dict], Optional[str], Optional[AIAnalysisResult], Optional[List[Dict]]]:\n        \"\"\"统一的分析流水线：数据处理 → 统计计算（关键词/AI筛选）→ AI分析 → HTML生成\"\"\"\n\n        # 根据筛选策略选择数据处理方式\n        if self.filter_method == \"ai\":\n            # === AI 筛选策略 ===\n            print(\"[筛选] 使用 AI 智能筛选策略\")\n            ai_filter_result = self.ctx.run_ai_filter(interests_file=self.interests_file)\n\n            if ai_filter_result and ai_filter_result.success:\n                print(f\"[筛选] AI 筛选完成: {ai_filter_result.total_matched} 条匹配, {len(ai_filter_result.tags)} 个标签\")\n                # 转换为与关键词匹配相同的数据结构\n                stats, ai_rss_stats = self.ctx.convert_ai_filter_to_report_data(\n                    ai_filter_result, mode=mode,\n                    new_titles=new_titles, rss_new_urls=rss_new_urls,\n                )\n                total_titles = sum(len(titles) for titles in data_source.values())\n\n                # AI 筛选的 RSS 结果替换关键词匹配的 RSS 结果\n                if ai_rss_stats:\n                    rss_items = ai_rss_stats\n            else:\n                # AI 筛选失败，回退到关键词匹配\n                error_msg = ai_filter_result.error if ai_filter_result else \"未知错误\"\n                print(f\"[筛选] AI 筛选失败: {error_msg}，回退到关键词匹配\")\n                stats, total_titles = self.ctx.count_frequency(\n                    data_source, word_groups, filter_words,\n                    id_to_name, title_info, new_titles,\n                    mode=mode, global_filters=global_filters, quiet=quiet,\n                )\n        else:\n            # === 关键词匹配策略（默认）===\n            stats, total_titles = self.ctx.count_frequency(\n                data_source, word_groups, filter_words,\n                id_to_name, title_info, new_titles,\n                mode=mode, global_filters=global_filters, quiet=quiet,\n            )\n\n        # 如果是 platform 模式，转换数据结构\n        if self.ctx.display_mode == \"platform\" and stats:\n            stats = convert_keyword_stats_to_platform_stats(\n                stats,\n                self.ctx.weight_config,\n                self.ctx.rank_threshold,\n            )\n\n        # AI 分析（如果启用，用于 HTML 报告）\n        ai_result = None\n        ai_config = self.ctx.config.get(\"AI_ANALYSIS\", {})\n        if ai_config.get(\"ENABLED\", False) and stats:\n            # 获取模式策略来确定报告类型\n            mode_strategy = self._get_mode_strategy()\n            report_type = mode_strategy[\"report_type\"]\n            ai_result = self._run_ai_analysis(\n                stats, rss_items, mode, report_type, id_to_name,\n                current_results=data_source, schedule=schedule,\n                standalone_data=standalone_data\n            )\n\n        # HTML生成（如果启用）\n        html_file = None\n        if self.ctx.config[\"STORAGE\"][\"FORMATS\"][\"HTML\"]:\n            html_file = self.ctx.generate_html(\n                stats,\n                total_titles,\n                failed_ids=failed_ids,\n                new_titles=new_titles,\n                id_to_name=id_to_name,\n                mode=mode,\n                update_info=self.update_info if self.ctx.config[\"SHOW_VERSION_UPDATE\"] else None,\n                rss_items=rss_items,\n                rss_new_items=rss_new_items,\n                ai_analysis=ai_result,\n                standalone_data=standalone_data,\n                frequency_file=self.frequency_file,\n            )\n\n        return stats, html_file, ai_result, rss_items\n\n    def _send_notification_if_needed(\n        self,\n        stats: List[Dict],\n        report_type: str,\n        mode: str,\n        failed_ids: Optional[List] = None,\n        new_titles: Optional[Dict] = None,\n        id_to_name: Optional[Dict] = None,\n        html_file_path: Optional[str] = None,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        standalone_data: Optional[Dict] = None,\n        ai_result: Optional[AIAnalysisResult] = None,\n        current_results: Optional[Dict] = None,\n        schedule: ResolvedSchedule = None,\n    ) -> bool:\n        \"\"\"统一的通知发送逻辑，包含所有判断条件，支持热榜+RSS合并推送+AI分析+独立展示区\"\"\"\n        has_notification = self._has_notification_configured()\n        cfg = self.ctx.config\n\n        # 检查是否有有效内容（热榜或RSS）\n        has_news_content = self._has_valid_content(stats, new_titles)\n        has_rss_content = bool(rss_items and len(rss_items) > 0)\n        has_any_content = has_news_content or has_rss_content\n\n        # 计算热榜匹配条数\n        news_count = sum(len(stat.get(\"titles\", [])) for stat in stats) if stats else 0\n        rss_count = sum(stat.get(\"count\", 0) for stat in rss_items) if rss_items else 0\n\n        if (\n            cfg[\"ENABLE_NOTIFICATION\"]\n            and has_notification\n            and has_any_content\n        ):\n            # 输出推送内容统计\n            content_parts = []\n            if news_count > 0:\n                content_parts.append(f\"热榜 {news_count} 条\")\n            if rss_count > 0:\n                content_parts.append(f\"RSS {rss_count} 条\")\n            total_count = news_count + rss_count\n            print(f\"[推送] 准备发送：{' + '.join(content_parts)}，合计 {total_count} 条\")\n\n            # 调度系统决策\n            if not schedule.push:\n                print(\"[推送] 调度器: 当前时间段不执行推送\")\n                return False\n\n            if schedule.once_push and schedule.period_key:\n                scheduler = self.ctx.create_scheduler()\n                date_str = self.ctx.format_date()\n                if scheduler.already_executed(schedule.period_key, \"push\", date_str):\n                    print(f\"[推送] 调度器: 时间段 {schedule.period_name or schedule.period_key} 今天已推送过，跳过\")\n                    return False\n                else:\n                    print(f\"[推送] 调度器: 时间段 {schedule.period_name or schedule.period_key} 今天首次推送\")\n\n            # AI 分析：优先使用传入的结果，避免重复分析\n            if ai_result is None:\n                ai_config = cfg.get(\"AI_ANALYSIS\", {})\n                if ai_config.get(\"ENABLED\", False):\n                    ai_result = self._run_ai_analysis(\n                        stats, rss_items, mode, report_type, id_to_name,\n                        current_results=current_results, schedule=schedule\n                    )\n\n            # 准备报告数据\n            report_data = self.ctx.prepare_report(stats, failed_ids, new_titles, id_to_name, mode, frequency_file=self.frequency_file)\n\n            # 是否发送版本更新信息\n            update_info_to_send = self.update_info if cfg[\"SHOW_VERSION_UPDATE\"] else None\n\n            # 使用 NotificationDispatcher 发送到所有渠道\n            dispatcher = self.ctx.create_notification_dispatcher()\n            results = dispatcher.dispatch_all(\n                report_data=report_data,\n                report_type=report_type,\n                update_info=update_info_to_send,\n                proxy_url=self.proxy_url,\n                mode=mode,\n                html_file_path=html_file_path,\n                rss_items=rss_items,\n                rss_new_items=rss_new_items,\n                ai_analysis=ai_result,\n                standalone_data=standalone_data,\n            )\n\n            if not results:\n                print(\"未配置任何通知渠道，跳过通知发送\")\n                return False\n\n            # 记录推送成功\n            if any(results.values()):\n                if schedule.once_push and schedule.period_key:\n                    scheduler = self.ctx.create_scheduler()\n                    date_str = self.ctx.format_date()\n                    scheduler.record_execution(schedule.period_key, \"push\", date_str)\n\n            return True\n\n        elif cfg[\"ENABLE_NOTIFICATION\"] and not has_notification:\n            print(\"⚠️ 警告：通知功能已启用但未配置任何通知渠道，将跳过通知发送\")\n        elif not cfg[\"ENABLE_NOTIFICATION\"]:\n            print(f\"跳过{report_type}通知：通知功能已禁用\")\n        elif (\n            cfg[\"ENABLE_NOTIFICATION\"]\n            and has_notification\n            and not has_any_content\n        ):\n            mode_strategy = self._get_mode_strategy()\n            if self.report_mode == \"incremental\":\n                if not has_rss_content:\n                    print(\"跳过通知：增量模式下未检测到匹配的新闻和RSS\")\n                else:\n                    print(\"跳过通知：增量模式下新闻未匹配到关键词\")\n            else:\n                print(\n                    f\"跳过通知：{mode_strategy['mode_name']}下未检测到匹配的新闻\"\n                )\n\n        return False\n\n    def _initialize_and_check_config(self) -> None:\n        \"\"\"通用初始化和配置检查\"\"\"\n        now = self.ctx.get_time()\n        print(f\"当前北京时间: {now.strftime('%Y-%m-%d %H:%M:%S')}\")\n\n        if not self.ctx.config[\"ENABLE_CRAWLER\"]:\n            print(\"爬虫功能已禁用（ENABLE_CRAWLER=False），程序退出\")\n            return\n\n        has_notification = self._has_notification_configured()\n        if not self.ctx.config[\"ENABLE_NOTIFICATION\"]:\n            print(\"通知功能已禁用（ENABLE_NOTIFICATION=False），将只进行数据抓取\")\n        elif not has_notification:\n            print(\"未配置任何通知渠道，将只进行数据抓取，不发送通知\")\n        else:\n            print(\"通知功能已启用，将发送通知\")\n\n        mode_strategy = self._get_mode_strategy()\n        print(f\"报告模式: {self.report_mode}\")\n        print(f\"运行模式: {mode_strategy['description']}\")\n\n    def _crawl_data(self) -> Tuple[Dict, Dict, List]:\n        \"\"\"执行数据爬取\"\"\"\n        ids = []\n        for platform in self.ctx.platforms:\n            if \"name\" in platform:\n                ids.append((platform[\"id\"], platform[\"name\"]))\n            else:\n                ids.append(platform[\"id\"])\n\n        print(\n            f\"配置的监控平台: {[p.get('name', p['id']) for p in self.ctx.platforms]}\"\n        )\n        print(f\"开始爬取数据，请求间隔 {self.request_interval} 毫秒\")\n        Path(\"output\").mkdir(parents=True, exist_ok=True)\n\n        results, id_to_name, failed_ids = self.data_fetcher.crawl_websites(\n            ids, self.request_interval\n        )\n\n        # 转换为 NewsData 格式并保存到存储后端\n        crawl_time = self.ctx.format_time()\n        crawl_date = self.ctx.format_date()\n        news_data = convert_crawl_results_to_news_data(\n            results, id_to_name, failed_ids, crawl_time, crawl_date\n        )\n\n        # 保存到存储后端（SQLite）\n        if self.storage_manager.save_news_data(news_data):\n            print(f\"数据已保存到存储后端: {self.storage_manager.backend_name}\")\n\n        # 保存 TXT 快照（如果启用）\n        txt_file = self.storage_manager.save_txt_snapshot(news_data)\n        if txt_file:\n            print(f\"TXT 快照已保存: {txt_file}\")\n\n        return results, id_to_name, failed_ids\n\n    def _crawl_rss_data(self) -> Tuple[Optional[List[Dict]], Optional[List[Dict]], Optional[List[Dict]], set]:\n        \"\"\"\n        执行 RSS 数据抓取\n\n        Returns:\n            (rss_items, rss_new_items, raw_rss_items, rss_new_urls) 元组：\n            - rss_items: 统计条目列表（按模式处理，用于统计区块）\n            - rss_new_items: 新增条目列表（用于新增区块）\n            - raw_rss_items: 原始 RSS 条目列表（用于独立展示区）\n            - rss_new_urls: 原始新增 RSS 条目的 URL 集合（用于 AI 模式 is_new 检测）\n            如果未启用或失败返回 (None, None, None, set())\n        \"\"\"\n        if not self.ctx.rss_enabled:\n            return None, None, None, set()\n\n        rss_feeds = self.ctx.rss_feeds\n        if not rss_feeds:\n            print(\"[RSS] 未配置任何 RSS 源\")\n            return None, None, None, set()\n\n        try:\n            from trendradar.crawler.rss import RSSFetcher, RSSFeedConfig\n\n            # 构建 RSS 源配置\n            feeds = []\n            for feed_config in rss_feeds:\n                # 读取并验证单个 feed 的 max_age_days（可选）\n                max_age_days_raw = feed_config.get(\"max_age_days\")\n                max_age_days = None\n                if max_age_days_raw is not None:\n                    try:\n                        max_age_days = int(max_age_days_raw)\n                        if max_age_days < 0:\n                            feed_id = feed_config.get(\"id\", \"unknown\")\n                            print(f\"[警告] RSS feed '{feed_id}' 的 max_age_days 为负数，将使用全局默认值\")\n                            max_age_days = None\n                    except (ValueError, TypeError):\n                        feed_id = feed_config.get(\"id\", \"unknown\")\n                        print(f\"[警告] RSS feed '{feed_id}' 的 max_age_days 格式错误：{max_age_days_raw}\")\n                        max_age_days = None\n\n                feed = RSSFeedConfig(\n                    id=feed_config.get(\"id\", \"\"),\n                    name=feed_config.get(\"name\", \"\"),\n                    url=feed_config.get(\"url\", \"\"),\n                    max_items=feed_config.get(\"max_items\", 50),\n                    enabled=feed_config.get(\"enabled\", True),\n                    max_age_days=max_age_days,  # None=使用全局，0=禁用，>0=覆盖\n                )\n                if feed.id and feed.url and feed.enabled:\n                    feeds.append(feed)\n\n            if not feeds:\n                print(\"[RSS] 没有启用的 RSS 源\")\n                return None, None, None, set()\n\n            # 创建抓取器\n            rss_config = self.ctx.rss_config\n            # RSS 代理：优先使用 RSS 专属代理，否则使用爬虫默认代理\n            rss_proxy_url = rss_config.get(\"PROXY_URL\", \"\") or self.proxy_url or \"\"\n            # 获取配置的时区\n            timezone = self.ctx.config.get(\"TIMEZONE\", DEFAULT_TIMEZONE)\n            # 获取新鲜度过滤配置\n            freshness_config = rss_config.get(\"FRESHNESS_FILTER\", {})\n            freshness_enabled = freshness_config.get(\"ENABLED\", True)\n            default_max_age_days = freshness_config.get(\"MAX_AGE_DAYS\", 3)\n\n            fetcher = RSSFetcher(\n                feeds=feeds,\n                request_interval=rss_config.get(\"REQUEST_INTERVAL\", 2000),\n                timeout=rss_config.get(\"TIMEOUT\", 15),\n                use_proxy=rss_config.get(\"USE_PROXY\", False),\n                proxy_url=rss_proxy_url,\n                timezone=timezone,\n                freshness_enabled=freshness_enabled,\n                default_max_age_days=default_max_age_days,\n            )\n\n            # 抓取数据\n            rss_data = fetcher.fetch_all()\n\n            # 保存到存储后端\n            if self.storage_manager.save_rss_data(rss_data):\n                print(f\"[RSS] 数据已保存到存储后端\")\n\n                # 处理 RSS 数据（按模式过滤）并返回用于合并推送\n                return self._process_rss_data_by_mode(rss_data)\n            else:\n                print(f\"[RSS] 数据保存失败\")\n                return None, None, None, set()\n\n        except ImportError as e:\n            print(f\"[RSS] 缺少依赖: {e}\")\n            print(\"[RSS] 请安装 feedparser: pip install feedparser\")\n            return None, None, None, set()\n        except Exception as e:\n            print(f\"[RSS] 抓取失败: {e}\")\n            return None, None, None, set()\n\n    def _process_rss_data_by_mode(self, rss_data) -> Tuple[Optional[List[Dict]], Optional[List[Dict]], Optional[List[Dict]], set]:\n        \"\"\"\n        按报告模式处理 RSS 数据，返回与热榜相同格式的统计结构\n\n        三种模式：\n        - daily: 当日汇总，统计=当天所有条目，新增=本次新增条目\n        - current: 当前榜单，统计=当前榜单条目，新增=本次新增条目\n        - incremental: 增量模式，统计=新增条目，新增=无\n\n        Args:\n            rss_data: 当前抓取的 RSSData 对象\n\n        Returns:\n            (rss_stats, rss_new_stats, raw_rss_items, rss_new_urls) 元组：\n            - rss_stats: RSS 关键词统计列表（与热榜 stats 格式一致）\n            - rss_new_stats: RSS 新增关键词统计列表（与热榜 stats 格式一致）\n            - raw_rss_items: 原始 RSS 条目列表（用于独立展示区）\n            - rss_new_urls: 原始新增 RSS 条目的 URL 集合（未经关键词过滤，用于 AI 模式 is_new 检测）\n        \"\"\"\n        from trendradar.core.analyzer import count_rss_frequency\n\n        # 从 display.regions.rss 统一控制 RSS 分析和展示\n        rss_display_enabled = self.ctx.config.get(\"DISPLAY\", {}).get(\"REGIONS\", {}).get(\"RSS\", True)\n\n        # 加载关键词配置\n        try:\n            word_groups, filter_words, global_filters = self.ctx.load_frequency_words(self.frequency_file)\n        except FileNotFoundError:\n            word_groups, filter_words, global_filters = [], [], []\n\n        timezone = self.ctx.timezone\n        max_news_per_keyword = self.ctx.config.get(\"MAX_NEWS_PER_KEYWORD\", 0)\n        sort_by_position_first = self.ctx.config.get(\"SORT_BY_POSITION_FIRST\", False)\n\n        rss_stats = None\n        rss_new_stats = None\n        raw_rss_items = None  # 原始 RSS 条目列表（用于独立展示区）\n        rss_new_urls = set()  # 原始新增 RSS URLs（未经关键词过滤）\n\n        # 1. 首先获取原始条目（用于独立展示区，不受 display.regions.rss 影响）\n        # 根据模式获取原始条目\n        if self.report_mode == \"incremental\":\n            new_items_dict = self.storage_manager.detect_new_rss_items(rss_data)\n            if new_items_dict:\n                raw_rss_items = self._convert_rss_items_to_list(new_items_dict, rss_data.id_to_name)\n        elif self.report_mode == \"current\":\n            latest_data = self.storage_manager.get_latest_rss_data(rss_data.date)\n            if latest_data:\n                raw_rss_items = self._convert_rss_items_to_list(latest_data.items, latest_data.id_to_name)\n        else:  # daily\n            all_data = self.storage_manager.get_rss_data(rss_data.date)\n            if all_data:\n                raw_rss_items = self._convert_rss_items_to_list(all_data.items, all_data.id_to_name)\n\n        # 如果 RSS 展示未启用，跳过关键词分析，只返回原始条目用于独立展示区\n        if not rss_display_enabled:\n            return None, None, raw_rss_items, rss_new_urls\n\n        # 2. 获取新增条目（用于统计）\n        new_items_dict = self.storage_manager.detect_new_rss_items(rss_data)\n        new_items_list = None\n        if new_items_dict:\n            new_items_list = self._convert_rss_items_to_list(new_items_dict, rss_data.id_to_name)\n            if new_items_list:\n                print(f\"[RSS] 检测到 {len(new_items_list)} 条新增\")\n                # 收集原始新增 URLs（未经关键词过滤，用于 AI 模式 is_new 检测）\n                rss_new_urls = {item[\"url\"] for item in new_items_list if item.get(\"url\")}\n\n        # 3. 根据模式获取统计条目\n        if self.report_mode == \"incremental\":\n            # 增量模式：统计条目就是新增条目\n            if not new_items_list:\n                print(\"[RSS] 增量模式：没有新增 RSS 条目\")\n                return None, None, raw_rss_items, rss_new_urls\n\n            rss_stats, total = count_rss_frequency(\n                rss_items=new_items_list,\n                word_groups=word_groups,\n                filter_words=filter_words,\n                global_filters=global_filters,\n                new_items=new_items_list,  # 增量模式所有都是新增\n                max_news_per_keyword=max_news_per_keyword,\n                sort_by_position_first=sort_by_position_first,\n                timezone=timezone,\n                rank_threshold=self.rank_threshold,\n                quiet=False,\n            )\n            if not rss_stats:\n                print(\"[RSS] 增量模式：关键词匹配后没有内容\")\n                # 即使关键词匹配为空，也返回原始条目用于独立展示区\n                return None, None, raw_rss_items, rss_new_urls\n\n        elif self.report_mode == \"current\":\n            # 当前榜单模式：统计=当前榜单所有条目\n            # raw_rss_items 已在前面获取\n            if not raw_rss_items:\n                print(\"[RSS] 当前榜单模式：没有 RSS 数据\")\n                return None, None, None, rss_new_urls\n\n            rss_stats, total = count_rss_frequency(\n                rss_items=raw_rss_items,\n                word_groups=word_groups,\n                filter_words=filter_words,\n                global_filters=global_filters,\n                new_items=new_items_list,  # 标记新增\n                max_news_per_keyword=max_news_per_keyword,\n                sort_by_position_first=sort_by_position_first,\n                timezone=timezone,\n                rank_threshold=self.rank_threshold,\n                quiet=False,\n            )\n            if not rss_stats:\n                print(\"[RSS] 当前榜单模式：关键词匹配后没有内容\")\n                # 即使关键词匹配为空，也返回原始条目用于独立展示区\n                return None, None, raw_rss_items, rss_new_urls\n\n            # 生成新增统计\n            if new_items_list:\n                rss_new_stats, _ = count_rss_frequency(\n                    rss_items=new_items_list,\n                    word_groups=word_groups,\n                    filter_words=filter_words,\n                    global_filters=global_filters,\n                    new_items=new_items_list,\n                    max_news_per_keyword=max_news_per_keyword,\n                    sort_by_position_first=sort_by_position_first,\n                    timezone=timezone,\n                    rank_threshold=self.rank_threshold,\n                    quiet=True,\n                )\n\n        else:\n            # daily 模式：统计=当天所有条目\n            # raw_rss_items 已在前面获取\n            if not raw_rss_items:\n                print(\"[RSS] 当日汇总模式：没有 RSS 数据\")\n                return None, None, None, rss_new_urls\n\n            rss_stats, total = count_rss_frequency(\n                rss_items=raw_rss_items,\n                word_groups=word_groups,\n                filter_words=filter_words,\n                global_filters=global_filters,\n                new_items=new_items_list,  # 标记新增\n                max_news_per_keyword=max_news_per_keyword,\n                sort_by_position_first=sort_by_position_first,\n                timezone=timezone,\n                rank_threshold=self.rank_threshold,\n                quiet=False,\n            )\n            if not rss_stats:\n                print(\"[RSS] 当日汇总模式：关键词匹配后没有内容\")\n                # 即使关键词匹配为空，也返回原始条目用于独立展示区\n                return None, None, raw_rss_items, rss_new_urls\n\n            # 生成新增统计\n            if new_items_list:\n                rss_new_stats, _ = count_rss_frequency(\n                    rss_items=new_items_list,\n                    word_groups=word_groups,\n                    filter_words=filter_words,\n                    global_filters=global_filters,\n                    new_items=new_items_list,\n                    max_news_per_keyword=max_news_per_keyword,\n                    sort_by_position_first=sort_by_position_first,\n                    timezone=timezone,\n                    rank_threshold=self.rank_threshold,\n                    quiet=True,\n                )\n\n        return rss_stats, rss_new_stats, raw_rss_items, rss_new_urls\n\n    def _convert_rss_items_to_list(self, items_dict: Dict, id_to_name: Dict) -> List[Dict]:\n        \"\"\"将 RSS 条目字典转换为列表格式，并应用新鲜度过滤（用于推送）\"\"\"\n        rss_items = []\n        filtered_count = 0\n        filtered_details = []  # 用于 DEBUG 模式下的详细日志\n\n        # 获取新鲜度过滤配置\n        rss_config = self.ctx.rss_config\n        freshness_config = rss_config.get(\"FRESHNESS_FILTER\", {})\n        freshness_enabled = freshness_config.get(\"ENABLED\", True)\n        default_max_age_days = freshness_config.get(\"MAX_AGE_DAYS\", 3)\n        timezone = self.ctx.config.get(\"TIMEZONE\", DEFAULT_TIMEZONE)\n        debug_mode = self.ctx.config.get(\"DEBUG\", False)\n\n        # 构建 feed_id -> max_age_days 的映射\n        feed_max_age_map = {}\n        for feed_cfg in self.ctx.rss_feeds:\n            feed_id = feed_cfg.get(\"id\", \"\")\n            max_age = feed_cfg.get(\"max_age_days\")\n            if max_age is not None:\n                try:\n                    feed_max_age_map[feed_id] = int(max_age)\n                except (ValueError, TypeError):\n                    pass\n\n        for feed_id, items in items_dict.items():\n            # 确定此 feed 的 max_age_days\n            max_days = feed_max_age_map.get(feed_id)\n            if max_days is None:\n                max_days = default_max_age_days\n\n            for item in items:\n                # 应用新鲜度过滤（仅在启用时）\n                if freshness_enabled and max_days > 0:\n                    if item.published_at and not is_within_days(item.published_at, max_days, timezone):\n                        filtered_count += 1\n                        # 记录详细信息用于 DEBUG 模式\n                        if debug_mode:\n                            days_old = calculate_days_old(item.published_at, timezone)\n                            feed_name = id_to_name.get(feed_id, feed_id)\n                            filtered_details.append({\n                                \"title\": item.title[:50] + \"...\" if len(item.title) > 50 else item.title,\n                                \"feed\": feed_name,\n                                \"days_old\": days_old,\n                                \"max_days\": max_days,\n                            })\n                        continue  # 跳过超过指定天数的文章\n\n                rss_items.append({\n                    \"title\": item.title,\n                    \"feed_id\": feed_id,\n                    \"feed_name\": id_to_name.get(feed_id, feed_id),\n                    \"url\": item.url,\n                    \"published_at\": item.published_at,\n                    \"summary\": item.summary,\n                    \"author\": item.author,\n                })\n\n        # 输出过滤统计\n        if filtered_count > 0:\n            print(f\"[RSS] 新鲜度过滤：跳过 {filtered_count} 篇超过指定天数的旧文章（仍保留在数据库中）\")\n            # DEBUG 模式下显示详细信息\n            if debug_mode and filtered_details:\n                print(f\"[RSS] 被过滤的文章详情（共 {len(filtered_details)} 篇）：\")\n                for detail in filtered_details[:10]:  # 最多显示 10 条\n                    days_str = f\"{detail['days_old']:.1f}\" if detail['days_old'] else \"未知\"\n                    print(f\"  - [{days_str}天前] [{detail['feed']}] {detail['title']} (限制: {detail['max_days']}天)\")\n                if len(filtered_details) > 10:\n                    print(f\"  ... 还有 {len(filtered_details) - 10} 篇被过滤\")\n\n        return rss_items\n\n    def _filter_rss_by_keywords(self, rss_items: List[Dict]) -> List[Dict]:\n        \"\"\"使用关键词文件过滤 RSS 条目\"\"\"\n        try:\n            word_groups, filter_words, global_filters = self.ctx.load_frequency_words(self.frequency_file)\n            if word_groups or filter_words or global_filters:\n                from trendradar.core.frequency import matches_word_groups\n                filtered_items = []\n                for item in rss_items:\n                    title = item.get(\"title\", \"\")\n                    if matches_word_groups(title, word_groups, filter_words, global_filters):\n                        filtered_items.append(item)\n\n                original_count = len(rss_items)\n                rss_items = filtered_items\n                print(f\"[RSS] 关键词过滤后剩余 {len(rss_items)}/{original_count} 条\")\n\n                if not rss_items:\n                    print(\"[RSS] 关键词过滤后没有匹配内容\")\n                    return []\n        except FileNotFoundError:\n            # 关键词文件不存在时跳过过滤\n            pass\n        return rss_items\n\n    def _generate_rss_html_report(self, rss_items: list, feeds_info: dict) -> str:\n        \"\"\"生成 RSS HTML 报告\"\"\"\n        try:\n            from trendradar.report.rss_html import render_rss_html_content\n            from pathlib import Path\n\n            html_content = render_rss_html_content(\n                rss_items=rss_items,\n                total_count=len(rss_items),\n                feeds_info=feeds_info,\n                get_time_func=self.ctx.get_time,\n            )\n\n            # 保存 HTML 文件（扁平化结构：output/html/日期/）\n            date_folder = self.ctx.format_date()\n            time_filename = self.ctx.format_time()\n            output_dir = Path(\"output\") / \"html\" / date_folder\n            output_dir.mkdir(parents=True, exist_ok=True)\n\n            file_path = output_dir / f\"rss_{time_filename}.html\"\n            with open(file_path, \"w\", encoding=\"utf-8\") as f:\n                f.write(html_content)\n\n            print(f\"[RSS] HTML 报告已生成: {file_path}\")\n            return str(file_path)\n\n        except Exception as e:\n            print(f\"[RSS] 生成 HTML 报告失败: {e}\")\n            return None\n\n    def _execute_mode_strategy(\n        self, mode_strategy: Dict, results: Dict, id_to_name: Dict, failed_ids: List,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        raw_rss_items: Optional[List[Dict]] = None,\n        rss_new_urls: Optional[set] = None,\n    ) -> Optional[str]:\n        \"\"\"执行模式特定逻辑，支持热榜+RSS合并推送\n\n        简化后的逻辑：\n        - 每次运行都生成 HTML 报告（时间戳快照 + latest/{mode}.html + index.html）\n        - 根据模式发送通知\n        \"\"\"\n        # 调度系统\n        scheduler = self.ctx.create_scheduler()\n        schedule = scheduler.resolve()\n\n        # 使用 schedule 决定的 report_mode 覆盖全局配置\n        effective_mode = schedule.report_mode\n        if effective_mode != self.report_mode:\n            print(f\"[调度] 报告模式覆盖: {self.report_mode} -> {effective_mode}\")\n        self.report_mode = effective_mode\n\n        # 重新获取 mode_strategy，确保 report_type 与覆盖后的 report_mode 一致\n        mode_strategy = self._get_mode_strategy()\n\n        # 使用 schedule 决定的 frequency_file 覆盖默认值\n        self.frequency_file = schedule.frequency_file\n\n        # 使用 schedule 决定的筛选策略覆盖默认值\n        self.filter_method = schedule.filter_method or self.ctx.filter_method\n\n        # 使用 schedule 决定的 AI 筛选兴趣文件覆盖默认值\n        self.interests_file = schedule.interests_file\n\n        # 如果调度器说不采集，则直接跳过\n        if not schedule.collect:\n            print(\"[调度] 当前时间段不执行数据采集，跳过分析流水线\")\n            return None\n        # 获取当前监控平台ID列表\n        current_platform_ids = self.ctx.platform_ids\n\n        new_titles = self.ctx.detect_new_titles(current_platform_ids)\n        time_info = self.ctx.format_time()\n        word_groups, filter_words, global_filters = self.ctx.load_frequency_words(self.frequency_file)\n\n        html_file = None\n        stats = []\n        ai_result = None\n        title_info = None\n\n        # current 模式需要使用完整的历史数据\n        if self.report_mode == \"current\":\n            analysis_data = self._load_analysis_data()\n            if analysis_data:\n                (\n                    all_results,\n                    historical_id_to_name,\n                    historical_title_info,\n                    historical_new_titles,\n                    _,\n                    _,\n                    _,\n                ) = analysis_data\n\n                print(\n                    f\"current模式：使用过滤后的历史数据，包含平台：{list(all_results.keys())}\"\n                )\n\n                # 使用历史数据准备独立展示区数据（包含完整的 title_info）\n                standalone_data = self._prepare_standalone_data(\n                    all_results, historical_id_to_name, historical_title_info, raw_rss_items\n                )\n\n                stats, html_file, ai_result, rss_items = self._run_analysis_pipeline(\n                    all_results,\n                    self.report_mode,\n                    historical_title_info,\n                    historical_new_titles,\n                    word_groups,\n                    filter_words,\n                    historical_id_to_name,\n                    failed_ids=failed_ids,\n                    global_filters=global_filters,\n                    rss_items=rss_items,\n                    rss_new_items=rss_new_items,\n                    standalone_data=standalone_data,\n                    schedule=schedule,\n                    rss_new_urls=rss_new_urls,\n                )\n\n                combined_id_to_name = {**historical_id_to_name, **id_to_name}\n                new_titles = historical_new_titles\n                id_to_name = combined_id_to_name\n                title_info = historical_title_info\n                results = all_results\n            else:\n                print(\"❌ 严重错误：无法读取刚保存的数据文件\")\n                raise RuntimeError(\"数据一致性检查失败：保存后立即读取失败\")\n        elif self.report_mode == \"daily\":\n            # daily 模式：使用全天累计数据\n            analysis_data = self._load_analysis_data()\n            if analysis_data:\n                (\n                    all_results,\n                    historical_id_to_name,\n                    historical_title_info,\n                    historical_new_titles,\n                    _,\n                    _,\n                    _,\n                ) = analysis_data\n\n                # 使用历史数据准备独立展示区数据（包含完整的 title_info）\n                standalone_data = self._prepare_standalone_data(\n                    all_results, historical_id_to_name, historical_title_info, raw_rss_items\n                )\n\n                stats, html_file, ai_result, rss_items = self._run_analysis_pipeline(\n                    all_results,\n                    self.report_mode,\n                    historical_title_info,\n                    historical_new_titles,\n                    word_groups,\n                    filter_words,\n                    historical_id_to_name,\n                    failed_ids=failed_ids,\n                    global_filters=global_filters,\n                    rss_items=rss_items,\n                    rss_new_items=rss_new_items,\n                    standalone_data=standalone_data,\n                    schedule=schedule,\n                    rss_new_urls=rss_new_urls,\n                )\n\n                combined_id_to_name = {**historical_id_to_name, **id_to_name}\n                new_titles = historical_new_titles\n                id_to_name = combined_id_to_name\n                title_info = historical_title_info\n                results = all_results\n            else:\n                # 没有历史数据时使用当前数据\n                title_info = self._prepare_current_title_info(results, time_info)\n                standalone_data = self._prepare_standalone_data(\n                    results, id_to_name, title_info, raw_rss_items\n                )\n                stats, html_file, ai_result, rss_items = self._run_analysis_pipeline(\n                    results,\n                    self.report_mode,\n                    title_info,\n                    new_titles,\n                    word_groups,\n                    filter_words,\n                    id_to_name,\n                    failed_ids=failed_ids,\n                    global_filters=global_filters,\n                    rss_items=rss_items,\n                    rss_new_items=rss_new_items,\n                    standalone_data=standalone_data,\n                    schedule=schedule,\n                    rss_new_urls=rss_new_urls,\n                )\n        else:\n            # incremental 模式：只使用当前抓取的数据\n            title_info = self._prepare_current_title_info(results, time_info)\n            standalone_data = self._prepare_standalone_data(\n                results, id_to_name, title_info, raw_rss_items\n            )\n            stats, html_file, ai_result, rss_items = self._run_analysis_pipeline(\n                results,\n                self.report_mode,\n                title_info,\n                new_titles,\n                word_groups,\n                filter_words,\n                id_to_name,\n                failed_ids=failed_ids,\n                global_filters=global_filters,\n                rss_items=rss_items,\n                rss_new_items=rss_new_items,\n                standalone_data=standalone_data,\n                schedule=schedule,\n                rss_new_urls=rss_new_urls,\n            )\n\n        if html_file:\n            print(f\"HTML报告已生成: {html_file}\")\n            print(f\"最新报告已更新: output/html/latest/{self.report_mode}.html\")\n\n        # 发送通知\n        if mode_strategy[\"should_send_notification\"]:\n            standalone_data = self._prepare_standalone_data(\n                results, id_to_name, title_info, raw_rss_items\n            )\n            self._send_notification_if_needed(\n                stats,\n                mode_strategy[\"report_type\"],\n                self.report_mode,\n                failed_ids=failed_ids,\n                new_titles=new_titles,\n                id_to_name=id_to_name,\n                html_file_path=html_file,\n                rss_items=rss_items,\n                rss_new_items=rss_new_items,\n                standalone_data=standalone_data,\n                ai_result=ai_result,\n                current_results=results,\n                schedule=schedule,\n            )\n\n        # 打开浏览器（仅在非容器环境）\n        if self._should_open_browser() and html_file:\n            file_url = \"file://\" + str(Path(html_file).resolve())\n            print(f\"正在打开HTML报告: {file_url}\")\n            webbrowser.open(file_url)\n        elif self.is_docker_container and html_file:\n            print(f\"HTML报告已生成（Docker环境）: {html_file}\")\n\n        return html_file\n\n    def run(self) -> None:\n        \"\"\"执行分析流程\"\"\"\n        try:\n            self._initialize_and_check_config()\n\n            mode_strategy = self._get_mode_strategy()\n\n            # 抓取热榜数据\n            results, id_to_name, failed_ids = self._crawl_data()\n\n            # 抓取 RSS 数据（如果启用），返回统计条目、新增条目和原始条目\n            rss_items, rss_new_items, raw_rss_items, rss_new_urls = self._crawl_rss_data()\n\n            # 执行模式策略，传递 RSS 数据用于合并推送\n            self._execute_mode_strategy(\n                mode_strategy, results, id_to_name, failed_ids,\n                rss_items=rss_items, rss_new_items=rss_new_items,\n                raw_rss_items=raw_rss_items, rss_new_urls=rss_new_urls\n            )\n\n        except Exception as e:\n            print(f\"分析流程执行出错: {e}\")\n            if self.ctx.config.get(\"DEBUG\", False):\n                raise\n        finally:\n            # 清理资源（包括过期数据清理和数据库连接关闭）\n            self.ctx.cleanup()\n\n\ndef _record_doctor_result(results: List[Tuple[str, str, str]], status: str, item: str, detail: str) -> None:\n    \"\"\"记录并打印 doctor 检查结果\"\"\"\n    icon_map = {\n        \"pass\": \"✅\",\n        \"warn\": \"⚠️\",\n        \"fail\": \"❌\",\n    }\n    icon = icon_map.get(status, \"•\")\n    results.append((status, item, detail))\n    print(f\"{icon} {item}: {detail}\")\n\n\ndef _save_doctor_report(\n    results: List[Tuple[str, str, str]],\n    pass_count: int,\n    warn_count: int,\n    fail_count: int,\n    config_path: Optional[str],\n) -> None:\n    \"\"\"保存 doctor 体检报告到 JSON 文件\"\"\"\n    report = {\n        \"version\": __version__,\n        \"generated_at\": datetime.now(timezone.utc).isoformat(),\n        \"config_path\": config_path or os.environ.get(\"CONFIG_PATH\", \"config/config.yaml\"),\n        \"summary\": {\n            \"pass\": pass_count,\n            \"warn\": warn_count,\n            \"fail\": fail_count,\n            \"ok\": fail_count == 0,\n        },\n        \"checks\": [\n            {\"status\": status, \"item\": item, \"detail\": detail}\n            for status, item, detail in results\n        ],\n    }\n\n    try:\n        output_dir = Path(\"output\") / \"meta\"\n        output_dir.mkdir(parents=True, exist_ok=True)\n        output_path = output_dir / \"doctor_report.json\"\n        output_path.write_text(\n            json.dumps(report, ensure_ascii=False, indent=2),\n            encoding=\"utf-8\",\n        )\n        print(f\"体检报告已保存: {output_path}\")\n    except Exception as e:\n        print(f\"⚠️ 体检报告保存失败: {e}\")\n\n\ndef _run_doctor(config_path: Optional[str] = None) -> bool:\n    \"\"\"运行环境体检\"\"\"\n    print(\"=\" * 60)\n    print(f\"TrendRadar v{__version__} 环境体检\")\n    print(\"=\" * 60)\n\n    results: List[Tuple[str, str, str]] = []\n    config = None\n\n    # 1) Python 版本检查\n    py_ok = sys.version_info >= (3, 10)\n    py_version = f\"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\"\n    if py_ok:\n        _record_doctor_result(results, \"pass\", \"Python版本\", f\"{py_version} (满足 >= 3.10)\")\n    else:\n        _record_doctor_result(results, \"fail\", \"Python版本\", f\"{py_version} (不满足 >= 3.10)\")\n\n    # 2) 关键文件检查\n    if config_path is None:\n        config_path = os.environ.get(\"CONFIG_PATH\", \"config/config.yaml\")\n\n    required_files = [\n        (config_path, \"主配置文件\"),\n        (\"config/frequency_words.txt\", \"关键词文件\"),\n    ]\n    optional_files = [\n        (\"config/timeline.yaml\", \"调度文件\"),\n    ]\n\n    for path_str, desc in required_files:\n        if Path(path_str).exists():\n            _record_doctor_result(results, \"pass\", desc, f\"已找到: {path_str}\")\n        else:\n            _record_doctor_result(results, \"fail\", desc, f\"缺失: {path_str}\")\n\n    for path_str, desc in optional_files:\n        if Path(path_str).exists():\n            _record_doctor_result(results, \"pass\", desc, f\"已找到: {path_str}\")\n        else:\n            _record_doctor_result(results, \"warn\", desc, f\"未找到: {path_str}（将使用默认调度模板）\")\n\n    # 3) 配置加载检查\n    try:\n        config = load_config(config_path)\n        _record_doctor_result(results, \"pass\", \"配置加载\", f\"加载成功: {config_path}\")\n    except Exception as e:\n        _record_doctor_result(results, \"fail\", \"配置加载\", f\"加载失败: {e}\")\n\n    # 后续检查依赖配置对象\n    if config:\n        # 4) 调度配置检查\n        try:\n            ctx = AppContext(config)\n            schedule = ctx.create_scheduler().resolve()\n            detail = f\"调度解析成功（report_mode={schedule.report_mode}, ai_mode={schedule.ai_mode}）\"\n            _record_doctor_result(results, \"pass\", \"调度配置\", detail)\n        except Exception as e:\n            _record_doctor_result(results, \"fail\", \"调度配置\", f\"解析失败: {e}\")\n\n        # 5) AI 配置检查（按功能场景区分严重级别）\n        ai_analysis_enabled = config.get(\"AI_ANALYSIS\", {}).get(\"ENABLED\", False)\n        ai_translation_enabled = config.get(\"AI_TRANSLATION\", {}).get(\"ENABLED\", False)\n        ai_filter_enabled = config.get(\"FILTER\", {}).get(\"METHOD\", \"keyword\") == \"ai\"\n        ai_enabled = ai_analysis_enabled or ai_translation_enabled or ai_filter_enabled\n\n        if ai_enabled:\n            try:\n                from trendradar.ai.client import AIClient\n                valid, message = AIClient(config.get(\"AI\", {})).validate_config()\n                if valid:\n                    _record_doctor_result(results, \"pass\", \"AI配置\", f\"模型: {config.get('AI', {}).get('MODEL', '')}\")\n                else:\n                    # AI 分析/翻译是硬依赖；AI 筛选缺失时会自动回退关键词匹配\n                    if ai_analysis_enabled or ai_translation_enabled:\n                        _record_doctor_result(results, \"fail\", \"AI配置\", message)\n                    else:\n                        _record_doctor_result(results, \"warn\", \"AI配置\", f\"{message}（AI 筛选将回退关键词模式）\")\n            except Exception as e:\n                _record_doctor_result(results, \"fail\", \"AI配置\", f\"校验异常: {e}\")\n        else:\n            _record_doctor_result(results, \"warn\", \"AI配置\", \"未启用 AI 功能，跳过校验\")\n\n        # 6) 存储配置检查\n        try:\n            storage_cfg = config.get(\"STORAGE\", {})\n            backend = storage_cfg.get(\"BACKEND\", \"auto\")\n            remote = storage_cfg.get(\"REMOTE\", {})\n            missing_remote_keys = [\n                k for k in (\"BUCKET_NAME\", \"ACCESS_KEY_ID\", \"SECRET_ACCESS_KEY\", \"ENDPOINT_URL\")\n                if not remote.get(k)\n            ]\n\n            if backend == \"remote\" and missing_remote_keys:\n                _record_doctor_result(\n                    results, \"fail\", \"存储配置\",\n                    f\"remote 模式缺少配置: {', '.join(missing_remote_keys)}\"\n                )\n            elif backend == \"auto\" and os.environ.get(\"GITHUB_ACTIONS\") == \"true\" and missing_remote_keys:\n                _record_doctor_result(\n                    results, \"warn\", \"存储配置\",\n                    \"GitHub Actions + auto 模式未完整配置远程存储，可能导致数据丢失\"\n                )\n            else:\n                sm = AppContext(config).get_storage_manager()\n                _record_doctor_result(results, \"pass\", \"存储配置\", f\"当前后端: {sm.backend_name}\")\n        except Exception as e:\n            _record_doctor_result(results, \"fail\", \"存储配置\", f\"检查失败: {e}\")\n\n        # 7) 通知渠道配置检查\n        channel_details = []\n        channel_issues = []\n        max_accounts = config.get(\"MAX_ACCOUNTS_PER_CHANNEL\", 3)\n\n        # 普通单值/多值渠道\n        for key, name in [\n            (\"FEISHU_WEBHOOK_URL\", \"飞书\"),\n            (\"DINGTALK_WEBHOOK_URL\", \"钉钉\"),\n            (\"WEWORK_WEBHOOK_URL\", \"企业微信\"),\n            (\"BARK_URL\", \"Bark\"),\n            (\"SLACK_WEBHOOK_URL\", \"Slack\"),\n            (\"GENERIC_WEBHOOK_URL\", \"通用Webhook\"),\n        ]:\n            values = parse_multi_account_config(config.get(key, \"\"))\n            if values:\n                channel_details.append(f\"{name}({min(len(values), max_accounts)}个)\")\n\n        # Telegram 配对校验\n        tg_tokens = parse_multi_account_config(config.get(\"TELEGRAM_BOT_TOKEN\", \"\"))\n        tg_chats = parse_multi_account_config(config.get(\"TELEGRAM_CHAT_ID\", \"\"))\n        if tg_tokens or tg_chats:\n            valid, count = validate_paired_configs(\n                {\"bot_token\": tg_tokens, \"chat_id\": tg_chats},\n                \"Telegram\",\n                required_keys=[\"bot_token\", \"chat_id\"],\n            )\n            if valid and count > 0:\n                channel_details.append(f\"Telegram({min(count, max_accounts)}个)\")\n            else:\n                channel_issues.append(\"Telegram bot_token/chat_id 配置不完整或数量不一致\")\n\n        # ntfy 配对校验（token 可选）\n        ntfy_server = config.get(\"NTFY_SERVER_URL\", \"\")\n        ntfy_topics = parse_multi_account_config(config.get(\"NTFY_TOPIC\", \"\"))\n        ntfy_tokens = parse_multi_account_config(config.get(\"NTFY_TOKEN\", \"\"))\n        if ntfy_server and ntfy_topics:\n            if ntfy_tokens:\n                valid, count = validate_paired_configs(\n                    {\"topic\": ntfy_topics, \"token\": ntfy_tokens},\n                    \"ntfy\",\n                )\n                if valid and count > 0:\n                    channel_details.append(f\"ntfy({min(count, max_accounts)}个)\")\n                else:\n                    channel_issues.append(\"ntfy topic/token 数量不一致\")\n            else:\n                channel_details.append(f\"ntfy({min(len(ntfy_topics), max_accounts)}个)\")\n\n        # 邮件配置完整性\n        email_ready = all(\n            [\n                config.get(\"EMAIL_FROM\"),\n                config.get(\"EMAIL_PASSWORD\"),\n                config.get(\"EMAIL_TO\"),\n            ]\n        )\n        if email_ready:\n            channel_details.append(\"邮件\")\n        elif any([config.get(\"EMAIL_FROM\"), config.get(\"EMAIL_PASSWORD\"), config.get(\"EMAIL_TO\")]):\n            channel_issues.append(\"邮件配置不完整（需要 from/password/to 同时配置）\")\n\n        if channel_issues and not channel_details:\n            _record_doctor_result(results, \"fail\", \"通知配置\", \"；\".join(channel_issues))\n        elif channel_issues and channel_details:\n            detail = f\"可用渠道: {', '.join(channel_details)}；问题: {'；'.join(channel_issues)}\"\n            _record_doctor_result(results, \"warn\", \"通知配置\", detail)\n        elif channel_details:\n            _record_doctor_result(results, \"pass\", \"通知配置\", f\"可用渠道: {', '.join(channel_details)}\")\n        else:\n            _record_doctor_result(results, \"warn\", \"通知配置\", \"未配置任何通知渠道\")\n\n        # 8) 输出目录可写检查\n        try:\n            output_dir = Path(\"output\")\n            output_dir.mkdir(parents=True, exist_ok=True)\n            probe_file = output_dir / \".doctor_write_probe\"\n            probe_file.write_text(\"ok\", encoding=\"utf-8\")\n            probe_file.unlink(missing_ok=True)\n            _record_doctor_result(results, \"pass\", \"输出目录\", f\"可写: {output_dir}\")\n        except Exception as e:\n            _record_doctor_result(results, \"fail\", \"输出目录\", f\"不可写: {e}\")\n\n    pass_count = sum(1 for status, _, _ in results if status == \"pass\")\n    warn_count = sum(1 for status, _, _ in results if status == \"warn\")\n    fail_count = sum(1 for status, _, _ in results if status == \"fail\")\n\n    _save_doctor_report(results, pass_count, warn_count, fail_count, config_path)\n\n    print(\"-\" * 60)\n    print(f\"体检结果: ✅ {pass_count} 项通过  ⚠️ {warn_count} 项警告  ❌ {fail_count} 项失败\")\n    print(\"=\" * 60)\n\n    if fail_count == 0:\n        print(\"体检通过。\")\n        return True\n\n    print(\"体检未通过，请先修复失败项。\")\n    return False\n\n\ndef _build_test_report_data(ctx: AppContext) -> Dict:\n    \"\"\"构造通知测试用报告数据\"\"\"\n    now = ctx.get_time()\n    time_display = now.strftime(\"%H:%M\")\n    title = f\"TrendRadar 通知测试消息（{now.strftime('%Y-%m-%d %H:%M:%S')}）\"\n\n    return {\n        \"stats\": [\n            {\n                \"word\": \"连通性测试\",\n                \"count\": 1,\n                \"titles\": [\n                    {\n                        \"title\": title,\n                        \"source_name\": \"TrendRadar\",\n                        \"url\": \"https://github.com/sansan0/TrendRadar\",\n                        \"mobile_url\": \"\",\n                        \"ranks\": [1],\n                        \"rank_threshold\": ctx.rank_threshold,\n                        \"count\": 1,\n                        \"is_new\": True,\n                        \"time_display\": time_display,\n                        \"matched_keyword\": \"连通性测试\",\n                    }\n                ],\n            }\n        ],\n        \"failed_ids\": [],\n        \"new_titles\": [],\n        \"id_to_name\": {},\n    }\n\n\ndef _create_test_html_file(ctx: AppContext) -> Optional[str]:\n    \"\"\"创建邮件测试用 HTML 文件\"\"\"\n    try:\n        now = ctx.get_time()\n        output_dir = Path(\"output\") / \"html\" / ctx.format_date()\n        output_dir.mkdir(parents=True, exist_ok=True)\n        html_path = output_dir / f\"notification_test_{ctx.format_time()}.html\"\n        html_content = f\"\"\"<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head><meta charset=\"UTF-8\"><title>TrendRadar 通知测试</title></head>\n<body>\n<h2>TrendRadar 通知连通性测试</h2>\n<p>测试时间：{now.strftime('%Y-%m-%d %H:%M:%S')} ({ctx.timezone})</p>\n<p>这是一条测试消息，用于验证邮件渠道是否可达。</p>\n</body>\n</html>\"\"\"\n        html_path.write_text(html_content, encoding=\"utf-8\")\n        return str(html_path)\n    except Exception as e:\n        print(f\"[测试通知] 创建测试 HTML 失败: {e}\")\n        return None\n\n\ndef _run_test_notification(config: Dict) -> bool:\n    \"\"\"发送测试通知到已配置渠道\"\"\"\n    from trendradar.notification import NotificationDispatcher\n\n    ctx = AppContext(config)\n\n    try:\n        # 检查是否配置了通知渠道\n        has_notification = any(\n            [\n                config.get(\"FEISHU_WEBHOOK_URL\"),\n                config.get(\"DINGTALK_WEBHOOK_URL\"),\n                config.get(\"WEWORK_WEBHOOK_URL\"),\n                (config.get(\"TELEGRAM_BOT_TOKEN\") and config.get(\"TELEGRAM_CHAT_ID\")),\n                (config.get(\"EMAIL_FROM\") and config.get(\"EMAIL_PASSWORD\") and config.get(\"EMAIL_TO\")),\n                (config.get(\"NTFY_SERVER_URL\") and config.get(\"NTFY_TOPIC\")),\n                config.get(\"BARK_URL\"),\n                config.get(\"SLACK_WEBHOOK_URL\"),\n                config.get(\"GENERIC_WEBHOOK_URL\"),\n            ]\n        )\n        if not has_notification:\n            print(\"未检测到可用通知渠道，请先在 config.yaml 或环境变量中配置。\")\n            return False\n\n        # 测试时固定展示区域，避免用户关闭 HOTLIST 导致测试内容为空\n        test_config = copy.deepcopy(config)\n        test_display = test_config.setdefault(\"DISPLAY\", {})\n        test_regions = test_display.setdefault(\"REGIONS\", {})\n        test_regions.update(\n            {\n                \"HOTLIST\": True,\n                \"NEW_ITEMS\": False,\n                \"RSS\": False,\n                \"STANDALONE\": False,\n                \"AI_ANALYSIS\": False,\n            }\n        )\n\n        # 测试时禁用翻译，避免触发额外 AI 调用\n        if \"AI_TRANSLATION\" in test_config:\n            test_config[\"AI_TRANSLATION\"][\"ENABLED\"] = False\n\n        proxy_url = test_config.get(\"DEFAULT_PROXY\", \"\") if test_config.get(\"USE_PROXY\") else None\n        if proxy_url:\n            print(\"[测试通知] 检测到代理配置，将使用代理发送\")\n\n        dispatcher = NotificationDispatcher(\n            config=test_config,\n            get_time_func=ctx.get_time,\n            split_content_func=ctx.split_content,\n            translator=None,\n        )\n\n        report_data = _build_test_report_data(ctx)\n        html_file_path = _create_test_html_file(ctx)\n\n        print(\"=\" * 60)\n        print(\"通知连通性测试\")\n        print(\"=\" * 60)\n\n        results = dispatcher.dispatch_all(\n            report_data=report_data,\n            report_type=\"通知连通性测试\",\n            proxy_url=proxy_url,\n            mode=\"daily\",\n            html_file_path=html_file_path,\n        )\n\n        if not results:\n            print(\"没有可测试的有效通知渠道（可能配置不完整）。\")\n            return False\n\n        print(\"-\" * 60)\n        success_count = 0\n        for channel, ok in results.items():\n            if ok:\n                success_count += 1\n                print(f\"✅ {channel}: 测试成功\")\n            else:\n                print(f\"❌ {channel}: 测试失败\")\n\n        print(\"-\" * 60)\n        print(f\"测试结果: {success_count}/{len(results)} 个渠道成功\")\n        return success_count > 0\n    finally:\n        ctx.cleanup()\n\n\ndef main():\n    \"\"\"主程序入口\"\"\"\n    # 解析命令行参数\n    parser = argparse.ArgumentParser(\n        description=\"TrendRadar - 热点新闻聚合与分析工具\",\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n        epilog=\"\"\"\n调度状态命令:\n  --show-schedule        显示当前调度状态（时间段、行为开关）\n诊断命令:\n  --doctor               运行环境与配置体检\n  --test-notification    发送测试通知到已配置渠道\n\n示例:\n  python -m trendradar                    # 正常运行\n  python -m trendradar --show-schedule    # 查看当前调度状态\n  python -m trendradar --doctor           # 运行一键体检\n  python -m trendradar --test-notification # 测试通知渠道连通性\n\"\"\"\n    )\n    parser.add_argument(\n        \"--show-schedule\",\n        action=\"store_true\",\n        help=\"显示当前调度状态\"\n    )\n    parser.add_argument(\n        \"--doctor\",\n        action=\"store_true\",\n        help=\"运行环境与配置体检\"\n    )\n    parser.add_argument(\n        \"--test-notification\",\n        action=\"store_true\",\n        help=\"发送测试通知到已配置渠道\"\n    )\n\n    args = parser.parse_args()\n\n    debug_mode = False\n    try:\n        # 处理 doctor 命令（不依赖完整运行流程）\n        if args.doctor:\n            ok = _run_doctor()\n            if not ok:\n                raise SystemExit(1)\n            return\n\n        # 先加载配置\n        config = load_config()\n\n        # 处理状态查看命令\n        if args.show_schedule:\n            _handle_status_commands(config)\n            return\n\n        # 处理通知测试命令\n        if args.test_notification:\n            ok = _run_test_notification(config)\n            if not ok:\n                raise SystemExit(1)\n            return\n\n        version_url = config.get(\"VERSION_CHECK_URL\", \"\")\n        configs_version_url = config.get(\"CONFIGS_VERSION_CHECK_URL\", \"\")\n\n        # 统一版本检查（程序版本 + 配置文件版本，只请求一次远程）\n        need_update = False\n        remote_version = None\n        if version_url:\n            need_update, remote_version = check_all_versions(version_url, configs_version_url)\n\n        # 复用已加载的配置，避免重复加载\n        analyzer = NewsAnalyzer(config=config)\n\n        # 设置更新信息（复用已获取的远程版本，不再重复请求）\n        if analyzer.is_github_actions and need_update and remote_version:\n            analyzer.update_info = {\n                \"current_version\": __version__,\n                \"remote_version\": remote_version,\n            }\n\n        # 获取 debug 配置\n        debug_mode = analyzer.ctx.config.get(\"DEBUG\", False)\n        analyzer.run()\n    except FileNotFoundError as e:\n        print(f\"❌ 配置文件错误: {e}\")\n        print(\"\\n请确保以下文件存在:\")\n        print(\"  • config/config.yaml\")\n        print(\"  • config/frequency_words.txt\")\n        print(\"\\n参考项目文档进行正确配置\")\n    except Exception as e:\n        print(f\"❌ 程序运行错误: {e}\")\n        if debug_mode:\n            raise\n\n\ndef _handle_status_commands(config: Dict) -> None:\n    \"\"\"处理状态查看命令 - 显示当前调度状态\"\"\"\n    from trendradar.context import AppContext\n\n    ctx = AppContext(config)\n\n    print(\"=\" * 60)\n    print(f\"TrendRadar v{__version__} 调度状态\")\n    print(\"=\" * 60)\n\n    try:\n        scheduler = ctx.create_scheduler()\n        schedule = scheduler.resolve()\n\n        now = ctx.get_time()\n        date_str = ctx.format_date()\n\n        print(f\"\\n⏰ 当前时间: {now.strftime('%Y-%m-%d %H:%M:%S')} ({ctx.timezone})\")\n        print(f\"📅 当前日期: {date_str}\")\n\n        print(f\"\\n📋 调度信息:\")\n        print(f\"  日计划: {schedule.day_plan}\")\n        if schedule.period_key:\n            print(f\"  当前时间段: {schedule.period_name or schedule.period_key} ({schedule.period_key})\")\n        else:\n            print(f\"  当前时间段: 无（使用默认配置）\")\n\n        print(f\"\\n🔧 行为开关:\")\n        print(f\"  采集数据: {'✅ 是' if schedule.collect else '❌ 否'}\")\n        print(f\"  AI 分析:  {'✅ 是' if schedule.analyze else '❌ 否'}\")\n        print(f\"  推送通知: {'✅ 是' if schedule.push else '❌ 否'}\")\n        print(f\"  报告模式: {schedule.report_mode}\")\n        print(f\"  AI 模式:  {schedule.ai_mode}\")\n\n        if schedule.period_key:\n            print(f\"\\n🔁 一次性控制:\")\n            if schedule.once_analyze:\n                already_analyzed = scheduler.already_executed(schedule.period_key, \"analyze\", date_str)\n                print(f\"  AI 分析:  仅一次 {'(今日已执行 ⚠️)' if already_analyzed else '(今日未执行 ✅)'}\")\n            else:\n                print(f\"  AI 分析:  不限次数\")\n            if schedule.once_push:\n                already_pushed = scheduler.already_executed(schedule.period_key, \"push\", date_str)\n                print(f\"  推送通知: 仅一次 {'(今日已执行 ⚠️)' if already_pushed else '(今日未执行 ✅)'}\")\n            else:\n                print(f\"  推送通知: 不限次数\")\n\n    except Exception as e:\n        print(f\"\\n❌ 获取调度状态失败: {e}\")\n\n    print(\"\\n\" + \"=\" * 60)\n\n    # 清理资源\n    ctx.cleanup()\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "trendradar/ai/__init__.py",
    "content": "# coding=utf-8\n\"\"\"\nTrendRadar AI 模块\n\n提供 AI 大模型对热点新闻的深度分析和翻译功能\n\"\"\"\n\nfrom .analyzer import AIAnalyzer, AIAnalysisResult\nfrom .filter import AIFilter, AIFilterResult\nfrom .translator import AITranslator, TranslationResult, BatchTranslationResult\nfrom .formatter import (\n    get_ai_analysis_renderer,\n    render_ai_analysis_markdown,\n    render_ai_analysis_feishu,\n    render_ai_analysis_dingtalk,\n    render_ai_analysis_html,\n    render_ai_analysis_html_rich,\n    render_ai_analysis_plain,\n)\n\n__all__ = [\n    # 分析器\n    \"AIAnalyzer\",\n    \"AIAnalysisResult\",\n    # 智能筛选\n    \"AIFilter\",\n    \"AIFilterResult\",\n    # 翻译器\n    \"AITranslator\",\n    \"TranslationResult\",\n    \"BatchTranslationResult\",\n    # 格式化\n    \"get_ai_analysis_renderer\",\n    \"render_ai_analysis_markdown\",\n    \"render_ai_analysis_feishu\",\n    \"render_ai_analysis_dingtalk\",\n    \"render_ai_analysis_html\",\n    \"render_ai_analysis_html_rich\",\n    \"render_ai_analysis_plain\",\n]\n"
  },
  {
    "path": "trendradar/ai/analyzer.py",
    "content": "# coding=utf-8\n\"\"\"\nAI 分析器模块\n\n调用 AI 大模型对热点新闻进行深度分析\n基于 LiteLLM 统一接口，支持 100+ AI 提供商\n\"\"\"\n\nimport json\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Any, Callable, Dict, List, Optional\n\nfrom trendradar.ai.client import AIClient\n\n\n@dataclass\nclass AIAnalysisResult:\n    \"\"\"AI 分析结果\"\"\"\n    # 新版 5 核心板块\n    core_trends: str = \"\"                # 核心热点与舆情态势\n    sentiment_controversy: str = \"\"      # 舆论风向与争议\n    signals: str = \"\"                    # 异动与弱信号\n    rss_insights: str = \"\"               # RSS 深度洞察\n    outlook_strategy: str = \"\"           # 研判与策略建议\n    standalone_summaries: Dict[str, str] = field(default_factory=dict)  # 独立展示区概括 {源ID: 概括}\n\n    # 基础元数据\n    raw_response: str = \"\"               # 原始响应\n    success: bool = False                # 是否成功\n    error: str = \"\"                      # 错误信息\n\n    # 新闻数量统计\n    total_news: int = 0                  # 总新闻数（热榜+RSS）\n    analyzed_news: int = 0               # 实际分析的新闻数\n    max_news_limit: int = 0              # 分析上限配置值\n    hotlist_count: int = 0               # 热榜新闻数\n    rss_count: int = 0                   # RSS 新闻数\n    ai_mode: str = \"\"                    # AI 分析使用的模式 (daily/current/incremental)\n\n\nclass AIAnalyzer:\n    \"\"\"AI 分析器\"\"\"\n\n    def __init__(\n        self,\n        ai_config: Dict[str, Any],\n        analysis_config: Dict[str, Any],\n        get_time_func: Callable,\n        debug: bool = False,\n    ):\n        \"\"\"\n        初始化 AI 分析器\n\n        Args:\n            ai_config: AI 模型配置（LiteLLM 格式）\n            analysis_config: AI 分析功能配置（language, prompt_file 等）\n            get_time_func: 获取当前时间的函数\n            debug: 是否开启调试模式\n        \"\"\"\n        self.ai_config = ai_config\n        self.analysis_config = analysis_config\n        self.get_time_func = get_time_func\n        self.debug = debug\n\n        # 创建 AI 客户端（基于 LiteLLM）\n        self.client = AIClient(ai_config)\n\n        # 验证配置\n        valid, error = self.client.validate_config()\n        if not valid:\n            print(f\"[AI] 配置警告: {error}\")\n\n        # 从分析配置获取功能参数\n        self.max_news = analysis_config.get(\"MAX_NEWS_FOR_ANALYSIS\", 50)\n        self.include_rss = analysis_config.get(\"INCLUDE_RSS\", True)\n        self.include_rank_timeline = analysis_config.get(\"INCLUDE_RANK_TIMELINE\", False)\n        self.include_standalone = analysis_config.get(\"INCLUDE_STANDALONE\", False)\n        self.language = analysis_config.get(\"LANGUAGE\", \"Chinese\")\n\n        # 加载提示词模板\n        self.system_prompt, self.user_prompt_template = self._load_prompt_template(\n            analysis_config.get(\"PROMPT_FILE\", \"ai_analysis_prompt.txt\")\n        )\n\n    def _load_prompt_template(self, prompt_file: str) -> tuple:\n        \"\"\"加载提示词模板\"\"\"\n        config_dir = Path(__file__).parent.parent.parent / \"config\"\n        prompt_path = config_dir / prompt_file\n\n        if not prompt_path.exists():\n            print(f\"[AI] 提示词文件不存在: {prompt_path}\")\n            return \"\", \"\"\n\n        content = prompt_path.read_text(encoding=\"utf-8\")\n\n        # 解析 [system] 和 [user] 部分\n        system_prompt = \"\"\n        user_prompt = \"\"\n\n        if \"[system]\" in content and \"[user]\" in content:\n            parts = content.split(\"[user]\")\n            system_part = parts[0]\n            user_part = parts[1] if len(parts) > 1 else \"\"\n\n            # 提取 system 内容\n            if \"[system]\" in system_part:\n                system_prompt = system_part.split(\"[system]\")[1].strip()\n\n            user_prompt = user_part.strip()\n        else:\n            # 整个文件作为 user prompt\n            user_prompt = content\n\n        return system_prompt, user_prompt\n\n    def analyze(\n        self,\n        stats: List[Dict],\n        rss_stats: Optional[List[Dict]] = None,\n        report_mode: str = \"daily\",\n        report_type: str = \"当日汇总\",\n        platforms: Optional[List[str]] = None,\n        keywords: Optional[List[str]] = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> AIAnalysisResult:\n        \"\"\"\n        执行 AI 分析\n\n        Args:\n            stats: 热榜统计数据\n            rss_stats: RSS 统计数据\n            report_mode: 报告模式\n            report_type: 报告类型\n            platforms: 平台列表\n            keywords: 关键词列表\n\n        Returns:\n            AIAnalysisResult: 分析结果\n        \"\"\"\n        \n        # 打印配置信息方便调试\n        model = self.ai_config.get(\"MODEL\", \"unknown\")\n        api_key = self.client.api_key or \"\"\n        api_base = self.ai_config.get(\"API_BASE\", \"\")\n        masked_key = f\"{api_key[:5]}******\" if len(api_key) >= 5 else \"******\"\n        model_display = model.replace(\"/\", \"/\\u200b\") if model else \"unknown\"\n\n        print(f\"[AI] 模型: {model_display}\")\n        print(f\"[AI] Key : {masked_key}\")\n\n        if api_base:\n            print(f\"[AI] 接口: 存在自定义 API 端点\")\n\n        timeout = self.ai_config.get(\"TIMEOUT\", 120)\n        max_tokens = self.ai_config.get(\"MAX_TOKENS\", 5000)\n        print(f\"[AI] 参数: timeout={timeout}, max_tokens={max_tokens}\")\n\n        if not self.client.api_key:\n            return AIAnalysisResult(\n                success=False,\n                error=\"未配置 AI API Key，请在 config.yaml 或环境变量 AI_API_KEY 中设置\"\n            )\n\n        # 准备新闻内容并获取统计数据\n        news_content, rss_content, hotlist_total, rss_total, analyzed_count = self._prepare_news_content(stats, rss_stats)\n        total_news = hotlist_total + rss_total\n\n        if not news_content and not rss_content:\n            return AIAnalysisResult(\n                success=False,\n                error=\"没有可分析的新闻内容\",\n                total_news=total_news,\n                hotlist_count=hotlist_total,\n                rss_count=rss_total,\n                analyzed_news=0,\n                max_news_limit=self.max_news\n            )\n\n        # 构建提示词\n        current_time = self.get_time_func().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n        # 提取关键词\n        if not keywords:\n            keywords = [s.get(\"word\", \"\") for s in stats if s.get(\"word\")] if stats else []\n\n        # 使用安全的字符串替换，避免模板中其他花括号（如 JSON 示例）被误解析\n        user_prompt = self.user_prompt_template\n        user_prompt = user_prompt.replace(\"{report_mode}\", report_mode)\n        user_prompt = user_prompt.replace(\"{report_type}\", report_type)\n        user_prompt = user_prompt.replace(\"{current_time}\", current_time)\n        user_prompt = user_prompt.replace(\"{news_count}\", str(hotlist_total))\n        user_prompt = user_prompt.replace(\"{rss_count}\", str(rss_total))\n        user_prompt = user_prompt.replace(\"{platforms}\", \", \".join(platforms) if platforms else \"多平台\")\n        user_prompt = user_prompt.replace(\"{keywords}\", \", \".join(keywords[:20]) if keywords else \"无\")\n        user_prompt = user_prompt.replace(\"{news_content}\", news_content)\n        user_prompt = user_prompt.replace(\"{rss_content}\", rss_content)\n        user_prompt = user_prompt.replace(\"{language}\", self.language)\n\n        # 构建独立展示区内容\n        standalone_content = \"\"\n        if self.include_standalone and standalone_data:\n            standalone_content = self._prepare_standalone_content(standalone_data)\n        user_prompt = user_prompt.replace(\"{standalone_content}\", standalone_content)\n\n        if self.debug:\n            print(\"\\n\" + \"=\" * 80)\n            print(\"[AI 调试] 发送给 AI 的完整提示词\")\n            print(\"=\" * 80)\n            if self.system_prompt:\n                print(\"\\n--- System Prompt ---\")\n                print(self.system_prompt)\n            print(\"\\n--- User Prompt ---\")\n            print(user_prompt)\n            print(\"=\" * 80 + \"\\n\")\n\n        # 调用 AI API（使用 LiteLLM）\n        try:\n            response = self._call_ai(user_prompt)\n            result = self._parse_response(response)\n\n            # JSON 解析失败时的重试兜底（仅重试一次）\n            if result.error and \"JSON 解析错误\" in result.error:\n                print(f\"[AI] JSON 解析失败，尝试让 AI 修复...\")\n                retry_result = self._retry_fix_json(response, result.error)\n                if retry_result and retry_result.success and not retry_result.error:\n                    print(\"[AI] JSON 修复成功\")\n                    retry_result.raw_response = response\n                    result = retry_result\n                else:\n                    print(\"[AI] JSON 修复失败，使用原始文本兜底\")\n\n            # 如果配置未启用 RSS 分析，强制清空 AI 返回的 RSS 洞察\n            if not self.include_rss:\n                result.rss_insights = \"\"\n\n            # 如果配置未启用 standalone 分析，强制清空\n            if not self.include_standalone:\n                result.standalone_summaries = {}\n\n            # 填充统计数据\n            result.total_news = total_news\n            result.hotlist_count = hotlist_total\n            result.rss_count = rss_total\n            result.analyzed_news = analyzed_count\n            result.max_news_limit = self.max_news\n            return result\n        except Exception as e:\n            error_type = type(e).__name__\n            error_msg = str(e)\n\n            # 截断过长的错误消息\n            if len(error_msg) > 200:\n                error_msg = error_msg[:200] + \"...\"\n            friendly_msg = f\"AI 分析失败 ({error_type}): {error_msg}\"\n\n            return AIAnalysisResult(\n                success=False,\n                error=friendly_msg\n            )\n\n    def _prepare_news_content(\n        self,\n        stats: List[Dict],\n        rss_stats: Optional[List[Dict]] = None,\n    ) -> tuple:\n        \"\"\"\n        准备新闻内容文本（增强版）\n\n        热榜新闻包含：来源、标题、排名范围、时间范围、出现次数\n        RSS 包含：来源、标题、发布时间\n\n        Returns:\n            tuple: (news_content, rss_content, hotlist_total, rss_total, analyzed_count)\n        \"\"\"\n        news_lines = []\n        rss_lines = []\n        news_count = 0\n        rss_count = 0\n\n        # 计算总新闻数\n        hotlist_total = sum(len(s.get(\"titles\", [])) for s in stats) if stats else 0\n        rss_total = sum(len(s.get(\"titles\", [])) for s in rss_stats) if rss_stats else 0\n\n        # 热榜内容\n        if stats:\n            for stat in stats:\n                word = stat.get(\"word\", \"\")\n                titles = stat.get(\"titles\", [])\n                if word and titles:\n                    news_lines.append(f\"\\n**{word}** ({len(titles)}条)\")\n                    for t in titles:\n                        if not isinstance(t, dict):\n                            continue\n                        title = t.get(\"title\", \"\")\n                        if not title:\n                            continue\n\n                        # 来源\n                        source = t.get(\"source_name\", t.get(\"source\", \"\"))\n\n                        # 构建行\n                        if source:\n                            line = f\"- [{source}] {title}\"\n                        else:\n                            line = f\"- {title}\"\n\n                        # 始终显示简化格式：排名范围 + 时间范围 + 出现次数\n                        ranks = t.get(\"ranks\", [])\n                        if ranks:\n                            min_rank = min(ranks)\n                            max_rank = max(ranks)\n                            rank_str = f\"{min_rank}\" if min_rank == max_rank else f\"{min_rank}-{max_rank}\"\n                        else:\n                            rank_str = \"-\"\n\n                        first_time = t.get(\"first_time\", \"\")\n                        last_time = t.get(\"last_time\", \"\")\n                        time_str = self._format_time_range(first_time, last_time)\n\n                        appear_count = t.get(\"count\", 1)\n\n                        line += f\" | 排名:{rank_str} | 时间:{time_str} | 出现:{appear_count}次\"\n\n                        # 开启完整时间线时，额外添加轨迹\n                        if self.include_rank_timeline:\n                            rank_timeline = t.get(\"rank_timeline\", [])\n                            timeline_str = self._format_rank_timeline(rank_timeline)\n                            line += f\" | 轨迹:{timeline_str}\"\n\n                        news_lines.append(line)\n\n                        news_count += 1\n                        if news_count >= self.max_news:\n                            break\n                if news_count >= self.max_news:\n                    break\n\n        # RSS 内容（仅在启用时构建）\n        if self.include_rss and rss_stats:\n            remaining = self.max_news - news_count\n            for stat in rss_stats:\n                if rss_count >= remaining:\n                    break\n                word = stat.get(\"word\", \"\")\n                titles = stat.get(\"titles\", [])\n                if word and titles:\n                    rss_lines.append(f\"\\n**{word}** ({len(titles)}条)\")\n                    for t in titles:\n                        if not isinstance(t, dict):\n                            continue\n                        title = t.get(\"title\", \"\")\n                        if not title:\n                            continue\n\n                        # 来源\n                        source = t.get(\"source_name\", t.get(\"feed_name\", \"\"))\n\n                        # 发布时间\n                        time_display = t.get(\"time_display\", \"\")\n\n                        # 构建行：[来源] 标题 | 发布时间\n                        if source:\n                            line = f\"- [{source}] {title}\"\n                        else:\n                            line = f\"- {title}\"\n                        if time_display:\n                            line += f\" | {time_display}\"\n                        rss_lines.append(line)\n\n                        rss_count += 1\n                        if rss_count >= remaining:\n                            break\n\n        news_content = \"\\n\".join(news_lines) if news_lines else \"\"\n        rss_content = \"\\n\".join(rss_lines) if rss_lines else \"\"\n        total_count = news_count + rss_count\n\n        return news_content, rss_content, hotlist_total, rss_total, total_count\n\n    def _call_ai(self, user_prompt: str) -> str:\n        \"\"\"调用 AI API（使用 LiteLLM）\"\"\"\n        messages = []\n        if self.system_prompt:\n            messages.append({\"role\": \"system\", \"content\": self.system_prompt})\n        messages.append({\"role\": \"user\", \"content\": user_prompt})\n\n        return self.client.chat(messages)\n\n    def _retry_fix_json(self, original_response: str, error_msg: str) -> Optional[AIAnalysisResult]:\n        \"\"\"\n        JSON 解析失败时，请求 AI 修复 JSON（仅重试一次）\n\n        使用轻量 prompt，不重复原始分析的 system prompt，节省 token。\n\n        Args:\n            original_response: AI 原始响应（JSON 格式有误）\n            error_msg: JSON 解析的错误信息\n\n        Returns:\n            修复后的分析结果，失败时返回 None\n        \"\"\"\n        messages = [\n            {\n                \"role\": \"system\",\n                \"content\": (\n                    \"你是一个 JSON 修复助手。用户会提供一段格式有误的 JSON 和错误信息，\"\n                    \"你需要修复 JSON 格式错误并返回正确的 JSON。\\n\"\n                    \"常见问题：字符串值内的双引号未转义、缺少逗号、字符串未正确闭合等。\\n\"\n                    \"只返回纯 JSON，不要包含 markdown 代码块标记（如 ```json）或任何说明文字。\"\n                ),\n            },\n            {\n                \"role\": \"user\",\n                \"content\": (\n                    f\"以下 JSON 解析失败：\\n\\n\"\n                    f\"错误：{error_msg}\\n\\n\"\n                    f\"原始内容：\\n{original_response}\\n\\n\"\n                    f\"请修复以上 JSON 中的格式问题（如值中的双引号改用中文引号「」或转义 \\\\\\\"、\"\n                    f\"缺少逗号、不完整的字符串等），保持原始内容语义不变，只修复格式。\"\n                    f\"直接返回修复后的纯 JSON。\"\n                ),\n            },\n        ]\n\n        try:\n            response = self.client.chat(messages)\n            return self._parse_response(response)\n        except Exception as e:\n            print(f\"[AI] 重试修复 JSON 异常: {type(e).__name__}: {e}\")\n            return None\n\n    def _format_time_range(self, first_time: str, last_time: str) -> str:\n        \"\"\"格式化时间范围（简化显示，只保留时分）\"\"\"\n        def extract_time(time_str: str) -> str:\n            if not time_str:\n                return \"-\"\n            # 尝试提取 HH:MM 部分\n            if \" \" in time_str:\n                parts = time_str.split(\" \")\n                if len(parts) >= 2:\n                    time_part = parts[1]\n                    if \":\" in time_part:\n                        return time_part[:5]  # HH:MM\n            elif \":\" in time_str:\n                return time_str[:5]\n            # 处理 HH-MM 格式\n            result = time_str[:5] if len(time_str) >= 5 else time_str\n            if len(result) == 5 and result[2] == '-':\n                result = result.replace('-', ':')\n            return result\n\n        first = extract_time(first_time)\n        last = extract_time(last_time)\n\n        if first == last or last == \"-\":\n            return first\n        return f\"{first}~{last}\"\n\n    def _format_rank_timeline(self, rank_timeline: List[Dict]) -> str:\n        \"\"\"格式化排名时间线\"\"\"\n        if not rank_timeline:\n            return \"-\"\n\n        parts = []\n        for item in rank_timeline:\n            time_str = item.get(\"time\", \"\")\n            if len(time_str) == 5 and time_str[2] == '-':\n                time_str = time_str.replace('-', ':')\n            rank = item.get(\"rank\")\n            if rank is None:\n                parts.append(f\"0({time_str})\")\n            else:\n                parts.append(f\"{rank}({time_str})\")\n\n        return \"→\".join(parts)\n\n    def _prepare_standalone_content(self, standalone_data: Dict) -> str:\n        \"\"\"\n        将独立展示区数据转为文本，注入 AI 分析 prompt\n\n        Args:\n            standalone_data: 独立展示区数据 {\"platforms\": [...], \"rss_feeds\": [...]}\n\n        Returns:\n            格式化的文本内容\n        \"\"\"\n        lines = []\n\n        # 热榜平台\n        for platform in standalone_data.get(\"platforms\", []):\n            platform_id = platform.get(\"id\", \"\")\n            platform_name = platform.get(\"name\", platform_id)\n            items = platform.get(\"items\", [])\n            if not items:\n                continue\n\n            lines.append(f\"### [{platform_name}]\")\n            for item in items:\n                title = item.get(\"title\", \"\")\n                if not title:\n                    continue\n\n                line = f\"- {title}\"\n\n                # 排名信息\n                ranks = item.get(\"ranks\", [])\n                if ranks:\n                    min_rank = min(ranks)\n                    max_rank = max(ranks)\n                    rank_str = f\"{min_rank}\" if min_rank == max_rank else f\"{min_rank}-{max_rank}\"\n                    line += f\" | 排名:{rank_str}\"\n\n                # 时间范围\n                first_time = item.get(\"first_time\", \"\")\n                last_time = item.get(\"last_time\", \"\")\n                if first_time:\n                    time_str = self._format_time_range(first_time, last_time)\n                    line += f\" | 时间:{time_str}\"\n\n                # 出现次数\n                count = item.get(\"count\", 1)\n                if count > 1:\n                    line += f\" | 出现:{count}次\"\n\n                # 排名轨迹（如果启用）\n                if self.include_rank_timeline:\n                    rank_timeline = item.get(\"rank_timeline\", [])\n                    if rank_timeline:\n                        timeline_str = self._format_rank_timeline(rank_timeline)\n                        line += f\" | 轨迹:{timeline_str}\"\n\n                lines.append(line)\n            lines.append(\"\")\n\n        # RSS 源\n        for feed in standalone_data.get(\"rss_feeds\", []):\n            feed_id = feed.get(\"id\", \"\")\n            feed_name = feed.get(\"name\", feed_id)\n            items = feed.get(\"items\", [])\n            if not items:\n                continue\n\n            lines.append(f\"### [{feed_name}]\")\n            for item in items:\n                title = item.get(\"title\", \"\")\n                if not title:\n                    continue\n\n                line = f\"- {title}\"\n                published_at = item.get(\"published_at\", \"\")\n                if published_at:\n                    line += f\" | {published_at}\"\n\n                lines.append(line)\n            lines.append(\"\")\n\n        return \"\\n\".join(lines)\n\n    def _parse_response(self, response: str) -> AIAnalysisResult:\n        \"\"\"解析 AI 响应\"\"\"\n        result = AIAnalysisResult(raw_response=response)\n\n        if not response or not response.strip():\n            result.error = \"AI 返回空响应\"\n            return result\n\n        # 提取 JSON 文本（去掉 markdown 代码块标记）\n        json_str = response\n\n        if \"```json\" in response:\n            parts = response.split(\"```json\", 1)\n            if len(parts) > 1:\n                code_block = parts[1]\n                end_idx = code_block.find(\"```\")\n                if end_idx != -1:\n                    json_str = code_block[:end_idx]\n                else:\n                    json_str = code_block\n        elif \"```\" in response:\n            parts = response.split(\"```\", 2)\n            if len(parts) >= 2:\n                json_str = parts[1]\n\n        json_str = json_str.strip()\n        if not json_str:\n            result.error = \"提取的 JSON 内容为空\"\n            result.core_trends = response[:500] + \"...\" if len(response) > 500 else response\n            result.success = True\n            return result\n\n        # 第一步：标准 JSON 解析\n        data = None\n        parse_error = None\n\n        try:\n            data = json.loads(json_str)\n        except json.JSONDecodeError as e:\n            parse_error = e\n\n        # 第二步：json_repair 本地修复\n        if data is None:\n            try:\n                from json_repair import repair_json\n                repaired = repair_json(json_str, return_objects=True)\n                if isinstance(repaired, dict):\n                    data = repaired\n                    print(\"[AI] JSON 本地修复成功（json_repair）\")\n            except Exception:\n                pass\n\n        # 两步都失败，记录错误（后续由 analyze 方法的重试机制处理）\n        if data is None:\n            if parse_error:\n                error_context = json_str[max(0, parse_error.pos - 30):parse_error.pos + 30] if json_str and parse_error.pos else \"\"\n                result.error = f\"JSON 解析错误 (位置 {parse_error.pos}): {parse_error.msg}\"\n                if error_context:\n                    result.error += f\"，上下文: ...{error_context}...\"\n            else:\n                result.error = \"JSON 解析失败\"\n            # 兜底：使用已提取的 json_str（不含 markdown 标记），避免推送中出现 ```json\n            result.core_trends = json_str[:500] + \"...\" if len(json_str) > 500 else json_str\n            result.success = True\n            return result\n\n        # 解析成功，提取字段\n        try:\n            result.core_trends = data.get(\"core_trends\", \"\")\n            result.sentiment_controversy = data.get(\"sentiment_controversy\", \"\")\n            result.signals = data.get(\"signals\", \"\")\n            result.rss_insights = data.get(\"rss_insights\", \"\")\n            result.outlook_strategy = data.get(\"outlook_strategy\", \"\")\n\n            # 解析独立展示区概括\n            summaries = data.get(\"standalone_summaries\", {})\n            if isinstance(summaries, dict):\n                result.standalone_summaries = {\n                    str(k): str(v) for k, v in summaries.items()\n                }\n\n            result.success = True\n        except (KeyError, TypeError, AttributeError) as e:\n            result.error = f\"字段提取错误: {type(e).__name__}: {e}\"\n            result.core_trends = json_str[:500] + \"...\" if len(json_str) > 500 else json_str\n            result.success = True\n\n        return result\n"
  },
  {
    "path": "trendradar/ai/client.py",
    "content": "# coding=utf-8\n\"\"\"\nAI 客户端模块\n\n基于 LiteLLM 的统一 AI 模型接口\n支持 100+ AI 提供商（OpenAI、DeepSeek、Gemini、Claude、国内模型等）\n\"\"\"\n\nimport os\nfrom typing import Any, Dict, List\n\nfrom litellm import completion\n\n\nclass AIClient:\n    \"\"\"统一的 AI 客户端（基于 LiteLLM）\"\"\"\n\n    def __init__(self, config: Dict[str, Any]):\n        \"\"\"\n        初始化 AI 客户端\n\n        Args:\n            config: AI 配置字典\n                - MODEL: 模型标识（格式: provider/model_name）\n                - API_KEY: API 密钥\n                - API_BASE: API 基础 URL（可选）\n                - TEMPERATURE: 采样温度\n                - MAX_TOKENS: 最大生成 token 数\n                - TIMEOUT: 请求超时时间（秒）\n                - NUM_RETRIES: 重试次数（可选）\n                - FALLBACK_MODELS: 备用模型列表（可选）\n        \"\"\"\n        self.model = config.get(\"MODEL\", \"deepseek/deepseek-chat\")\n        self.api_key = config.get(\"API_KEY\") or os.environ.get(\"AI_API_KEY\", \"\")\n        self.api_base = config.get(\"API_BASE\", \"\")\n        self.temperature = config.get(\"TEMPERATURE\", 1.0)\n        self.max_tokens = config.get(\"MAX_TOKENS\", 5000)\n        self.timeout = config.get(\"TIMEOUT\", 120)\n        self.num_retries = config.get(\"NUM_RETRIES\", 2)\n        self.fallback_models = config.get(\"FALLBACK_MODELS\", [])\n\n    def chat(\n        self,\n        messages: List[Dict[str, str]],\n        **kwargs\n    ) -> str:\n        \"\"\"\n        调用 AI 模型进行对话\n\n        Args:\n            messages: 消息列表，格式: [{\"role\": \"system/user/assistant\", \"content\": \"...\"}]\n            **kwargs: 额外参数，会覆盖默认配置\n\n        Returns:\n            str: AI 响应内容\n\n        Raises:\n            Exception: API 调用失败时抛出异常\n        \"\"\"\n        # 构建请求参数\n        params = {\n            \"model\": self.model,\n            \"messages\": messages,\n            \"temperature\": kwargs.get(\"temperature\", self.temperature),\n            \"timeout\": kwargs.get(\"timeout\", self.timeout),\n            \"num_retries\": kwargs.get(\"num_retries\", self.num_retries),\n        }\n\n        # 添加 API Key\n        if self.api_key:\n            params[\"api_key\"] = self.api_key\n\n        # 添加 API Base（如果配置了）\n        if self.api_base:\n            params[\"api_base\"] = self.api_base\n\n        # 添加 max_tokens（如果配置了且不为 0）\n        max_tokens = kwargs.get(\"max_tokens\", self.max_tokens)\n        if max_tokens and max_tokens > 0:\n            params[\"max_tokens\"] = max_tokens\n\n        # 添加 fallback 模型（如果配置了）\n        if self.fallback_models:\n            params[\"fallbacks\"] = self.fallback_models\n\n        # 合并其他额外参数\n        for key, value in kwargs.items():\n            if key not in params:\n                params[key] = value\n\n        # 调用 LiteLLM\n        response = completion(**params)\n\n        # 提取响应内容\n        # 某些模型/提供商返回 list（内容块）而非 str，统一转为 str\n        content = response.choices[0].message.content\n        if isinstance(content, list):\n            content = \"\\n\".join(\n                item.get(\"text\", str(item)) if isinstance(item, dict) else str(item)\n                for item in content\n            )\n        return content or \"\"\n\n    def validate_config(self) -> tuple[bool, str]:\n        \"\"\"\n        验证配置是否有效\n\n        Returns:\n            tuple: (是否有效, 错误信息)\n        \"\"\"\n        if not self.model:\n            return False, \"未配置 AI 模型（model）\"\n\n        if not self.api_key:\n            return False, \"未配置 AI API Key，请在 config.yaml 或环境变量 AI_API_KEY 中设置\"\n\n        # 验证模型格式（应该包含 provider/model）\n        if \"/\" not in self.model:\n            return False, f\"模型格式错误: {self.model}，应为 'provider/model' 格式（如 'deepseek/deepseek-chat'）\"\n\n        return True, \"\"\n"
  },
  {
    "path": "trendradar/ai/filter.py",
    "content": "# coding=utf-8\n\"\"\"\nAI 智能筛选模块\n\n通过 AI 对新闻进行标签分类：\n1. 阶段 A：从用户兴趣描述中提取结构化标签\n2. 阶段 B：对新闻标题按标签进行批量分类\n\"\"\"\n\nimport hashlib\nimport json\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Any, Callable, Dict, List, Optional\n\nfrom trendradar.ai.client import AIClient\n\n\n@dataclass\nclass AIFilterResult:\n    \"\"\"AI 筛选结果，传给报告和通知模块\"\"\"\n    tags: List[Dict] = field(default_factory=list)\n    # [{\"tag\": str, \"description\": str, \"count\": int, \"items\": [\n    #     {\"title\": str, \"source_id\": str, \"source_name\": str,\n    #      \"url\": str, \"mobile_url\": str, \"rank\": int, \"ranks\": [...],\n    #      \"first_time\": str, \"last_time\": str, \"count\": int,\n    #      \"relevance_score\": float, \"source_type\": str}\n    # ]}]\n    total_matched: int = 0       # 匹配新闻总数\n    total_processed: int = 0     # 处理新闻总数\n    success: bool = False\n    error: str = \"\"\n\n\nclass AIFilter:\n    \"\"\"AI 智能筛选器\"\"\"\n\n    def __init__(\n        self,\n        ai_config: Dict[str, Any],\n        filter_config: Dict[str, Any],\n        get_time_func: Callable,\n        debug: bool = False,\n    ):\n        self.client = AIClient(ai_config)\n        self.filter_config = filter_config\n        self.batch_size = filter_config.get(\"BATCH_SIZE\", 200)\n        self.get_time_func = get_time_func\n        self.debug = debug\n\n        # 加载提示词模板\n        self.classify_system, self.classify_user = self._load_prompt(\n            filter_config.get(\"PROMPT_FILE\", \"ai_filter_prompt.txt\")\n        )\n        self.extract_system, self.extract_user = self._load_prompt(\n            filter_config.get(\"EXTRACT_PROMPT_FILE\", \"ai_filter_extract_prompt.txt\")\n        )\n        self.update_tags_system, self.update_tags_user = self._load_prompt(\n            filter_config.get(\"UPDATE_TAGS_PROMPT_FILE\", \"update_tags_prompt.txt\")\n        )\n\n    def _load_prompt(self, filename: str) -> tuple:\n        \"\"\"加载提示词文件，返回 (system_prompt, user_prompt_template)\"\"\"\n        config_dir = Path(__file__).parent.parent.parent / \"config\" / \"ai_filter\"\n        prompt_path = config_dir / filename\n\n        if not prompt_path.exists():\n            print(f\"[AI筛选] 提示词文件不存在: {prompt_path}\")\n            return \"\", \"\"\n\n        content = prompt_path.read_text(encoding=\"utf-8\")\n\n        system_prompt = \"\"\n        user_prompt = \"\"\n\n        if \"[system]\" in content and \"[user]\" in content:\n            parts = content.split(\"[user]\")\n            system_part = parts[0]\n            user_part = parts[1] if len(parts) > 1 else \"\"\n\n            if \"[system]\" in system_part:\n                system_prompt = system_part.split(\"[system]\")[1].strip()\n            user_prompt = user_part.strip()\n        else:\n            user_prompt = content\n\n        return system_prompt, user_prompt\n\n    def compute_interests_hash(self, interests_content: str, filename: str = \"ai_interests.txt\") -> str:\n        \"\"\"计算兴趣描述的 hash，格式为 filename:md5\"\"\"\n        # 去除前后空白和注释行，确保内容变化才改变 hash\n        lines = []\n        for line in interests_content.strip().splitlines():\n            line = line.strip()\n            if line and not line.startswith(\"#\"):\n                lines.append(line)\n        normalized = \"\\n\".join(lines)\n        content_hash = hashlib.md5(normalized.encode(\"utf-8\")).hexdigest()\n        return f\"{filename}:{content_hash}\"\n\n    def load_interests_content(self, interests_file: Optional[str] = None) -> Optional[str]:\n        \"\"\"加载兴趣描述文件内容\n\n        解析逻辑：\n        - interests_file 为 None：使用默认 config/ai_interests.txt\n        - interests_file 有值：仅查 config/custom/ai/{filename}\n\n        注意：调用方（context.py）已完成 config/timeline 的合并决策，\n        此处不再二次读取 filter_config，避免语义冲突。\n        \"\"\"\n        config_dir = Path(__file__).parent.parent.parent / \"config\"\n        configured_file = interests_file\n\n        if configured_file:\n            # 自定义兴趣文件：仅查 custom/ai 目录\n            filename = configured_file\n            interests_path = config_dir / \"custom\" / \"ai\" / filename\n            if not interests_path.exists():\n                print(f\"[AI筛选] 自定义兴趣描述文件不存在: {filename}\")\n                print(f\"[AI筛选]   已查找: {interests_path}\")\n                return None\n        else:\n            # 默认兴趣文件：固定使用 config/ai_interests.txt\n            filename = \"ai_interests.txt\"\n            interests_path = config_dir / filename\n            if not interests_path.exists():\n                print(f\"[AI筛选] 默认兴趣描述文件不存在: {filename}\")\n                print(f\"[AI筛选]   已查找: {interests_path}\")\n                return None\n\n        if not interests_path.exists():\n            print(f\"[AI筛选] 兴趣描述文件不存在: {interests_path}\")\n            return None\n\n        content = interests_path.read_text(encoding=\"utf-8\").strip()\n        if not content:\n            print(\"[AI筛选] 兴趣描述文件为空\")\n            return None\n\n        return content\n\n    def extract_tags(self, interests_content: str) -> List[Dict]:\n        \"\"\"\n        阶段 A：从兴趣描述中提取结构化标签\n\n        Args:\n            interests_content: 用户的兴趣描述文本\n\n        Returns:\n            [{\"tag\": str, \"description\": str}, ...]\n        \"\"\"\n        if not self.extract_user:\n            print(\"[AI筛选] 标签提取提示词模板为空\")\n            return []\n\n        user_prompt = self.extract_user.replace(\"{interests_content}\", interests_content)\n\n        messages = []\n        if self.extract_system:\n            messages.append({\"role\": \"system\", \"content\": self.extract_system})\n        messages.append({\"role\": \"user\", \"content\": user_prompt})\n\n        if self.debug:\n            print(f\"\\n[AI筛选][DEBUG] === 标签提取 Prompt ===\")\n            for m in messages:\n                print(f\"[{m['role']}]\\n{m['content']}\")\n            print(f\"[AI筛选][DEBUG] === Prompt 结束 ===\")\n\n        try:\n            response = self.client.chat(messages)\n\n            if self.debug:\n                print(f\"\\n[AI筛选][DEBUG] === 标签提取 AI 原始响应 ===\")\n                # 尝试格式化 JSON 便于阅读\n                self._print_formatted_json(response)\n                print(f\"[AI筛选][DEBUG] === 响应结束 ===\")\n\n            tags = self._parse_tags_response(response)\n            print(f\"[AI筛选] 提取到 {len(tags)} 个标签\")\n            for t in tags:\n                print(f\"   {t['tag']}: {t.get('description', '')}\")\n\n            if self.debug:\n                json_str = self._extract_json(response)\n                if not json_str:\n                    print(f\"[AI筛选][DEBUG] 无法从响应中提取 JSON\")\n                else:\n                    raw_data = json.loads(json_str)\n                    raw_tags = raw_data.get(\"tags\", [])\n                    skipped = len(raw_tags) - len(tags)\n                    if skipped > 0:\n                        print(f\"[AI筛选][DEBUG] 原始标签 {len(raw_tags)} 个，有效 {len(tags)} 个，跳过 {skipped} 个（缺少 tag 字段或格式无效）\")\n\n            return tags\n        except json.JSONDecodeError as e:\n            print(f\"[AI筛选] 标签提取失败: JSON 解析错误: {e}\")\n            if self.debug:\n                print(f\"[AI筛选][DEBUG] 尝试解析的 JSON 内容: {self._extract_json(response) if response else '(空响应)'}\")\n            return []\n        except Exception as e:\n            print(f\"[AI筛选] 标签提取失败: {type(e).__name__}: {e}\")\n            return []\n\n    def update_tags(self, old_tags: List[Dict], interests_content: str) -> Optional[Dict]:\n        \"\"\"\n        阶段 A'：AI 对比旧标签和新兴趣描述，给出更新方案\n\n        Args:\n            old_tags: [{\"tag\": str, \"description\": str, \"id\": int}, ...]\n            interests_content: 新的兴趣描述文本\n\n        Returns:\n            {\"keep\": [{\"tag\": str, \"description\": str}],\n             \"add\": [{\"tag\": str, \"description\": str}],\n             \"remove\": [str],\n             \"change_ratio\": float}\n            失败返回 None\n        \"\"\"\n        if not self.update_tags_user:\n            print(\"[AI筛选] 标签更新提示词模板为空，回退到重新提取\")\n            return None\n\n        # 构造旧标签 JSON\n        old_tags_json = json.dumps(\n            [{\"tag\": t[\"tag\"], \"description\": t.get(\"description\", \"\")} for t in old_tags],\n            ensure_ascii=False, indent=2\n        )\n\n        user_prompt = self.update_tags_user.replace(\n            \"{old_tags_json}\", old_tags_json\n        ).replace(\n            \"{interests_content}\", interests_content\n        )\n\n        messages = []\n        if self.update_tags_system:\n            messages.append({\"role\": \"system\", \"content\": self.update_tags_system})\n        messages.append({\"role\": \"user\", \"content\": user_prompt})\n\n        if self.debug:\n            print(f\"\\n[AI筛选][DEBUG] === 标签更新 Prompt ===\")\n            for m in messages:\n                print(f\"[{m['role']}]\\n{m['content']}\")\n            print(f\"[AI筛选][DEBUG] === Prompt 结束 ===\")\n\n        try:\n            response = self.client.chat(messages)\n\n            if self.debug:\n                print(f\"\\n[AI筛选][DEBUG] === 标签更新 AI 原始响应 ===\")\n                self._print_formatted_json(response)\n                print(f\"[AI筛选][DEBUG] === 响应结束 ===\")\n\n            result = self._parse_update_tags_response(response)\n            if result is None:\n                return None\n\n            keep_count = len(result.get(\"keep\", []))\n            add_count = len(result.get(\"add\", []))\n            remove_count = len(result.get(\"remove\", []))\n            ratio = result.get(\"change_ratio\", 0)\n            print(f\"[AI筛选] AI 标签更新方案: 保留 {keep_count}, 新增 {add_count}, 移除 {remove_count}, change_ratio={ratio:.2f}\")\n\n            return result\n        except Exception as e:\n            print(f\"[AI筛选] 标签更新失败: {type(e).__name__}: {e}\")\n            return None\n\n    def _parse_update_tags_response(self, response: str) -> Optional[Dict]:\n        \"\"\"解析标签更新的 AI 响应\"\"\"\n        json_str = self._extract_json(response)\n        if not json_str:\n            print(\"[AI筛选] 无法从标签更新响应中提取 JSON\")\n            return None\n\n        data = json.loads(json_str)\n\n        # 校验必需字段\n        keep = data.get(\"keep\", [])\n        add = data.get(\"add\", [])\n        remove = data.get(\"remove\", [])\n        change_ratio = float(data.get(\"change_ratio\", 0))\n\n        # 校验 keep/add 格式\n        validated_keep = []\n        for t in keep:\n            if isinstance(t, dict) and \"tag\" in t:\n                validated_keep.append({\n                    \"tag\": str(t[\"tag\"]).strip(),\n                    \"description\": str(t.get(\"description\", \"\")).strip(),\n                })\n\n        validated_add = []\n        for t in add:\n            if isinstance(t, dict) and \"tag\" in t:\n                validated_add.append({\n                    \"tag\": str(t[\"tag\"]).strip(),\n                    \"description\": str(t.get(\"description\", \"\")).strip(),\n                })\n\n        validated_remove = [str(r).strip() for r in remove if r]\n\n        # change_ratio 限制在 0~1\n        change_ratio = max(0.0, min(1.0, change_ratio))\n\n        return {\n            \"keep\": validated_keep,\n            \"add\": validated_add,\n            \"remove\": validated_remove,\n            \"change_ratio\": change_ratio,\n        }\n\n    def _parse_tags_response(self, response: str) -> List[Dict]:\n        \"\"\"解析标签提取的 AI 响应\"\"\"\n        json_str = self._extract_json(response)\n        if not json_str:\n            return []\n\n        data = json.loads(json_str)\n        tags_raw = data.get(\"tags\", [])\n\n        tags = []\n        for t in tags_raw:\n            if not isinstance(t, dict) or \"tag\" not in t:\n                continue\n            tags.append({\n                \"tag\": str(t[\"tag\"]).strip(),\n                \"description\": str(t.get(\"description\", \"\")).strip(),\n            })\n\n        return tags\n\n    def classify_batch(\n        self,\n        titles: List[Dict],\n        tags: List[Dict],\n        interests_content: str = \"\",\n    ) -> List[Dict]:\n        \"\"\"\n        阶段 B：对一批新闻标题做分类\n\n        Args:\n            titles: [{\"id\": news_item_id, \"title\": str, \"source\": str}]\n            tags: [{\"id\": tag_id, \"tag\": str, \"description\": str}]\n            interests_content: 用户的兴趣描述（含质量过滤要求）\n\n        Returns:\n            [{\"news_item_id\": int, \"tag_id\": int, \"relevance_score\": float}, ...]\n        \"\"\"\n        if not titles or not tags:\n            return []\n\n        if not self.classify_user:\n            print(\"[AI筛选] 分类提示词模板为空\")\n            return []\n\n        # 构建标签列表文本\n        tags_list = \"\\n\".join(\n            f\"{t['id']}. {t['tag']}: {t.get('description', '')}\"\n            for t in tags\n        )\n\n        # 构建新闻列表文本\n        news_list = \"\\n\".join(\n            f\"{t['id']}. [{t.get('source', '')}] {t['title']}\"\n            for t in titles\n        )\n\n        # 填充模板\n        user_prompt = self.classify_user\n        user_prompt = user_prompt.replace(\"{interests_content}\", interests_content)\n        user_prompt = user_prompt.replace(\"{tags_list}\", tags_list)\n        user_prompt = user_prompt.replace(\"{news_count}\", str(len(titles)))\n        user_prompt = user_prompt.replace(\"{news_list}\", news_list)\n\n        messages = []\n        if self.classify_system:\n            messages.append({\"role\": \"system\", \"content\": self.classify_system})\n        messages.append({\"role\": \"user\", \"content\": user_prompt})\n\n        if self.debug:\n            print(f\"\\n[AI筛选][DEBUG] === 分类 Prompt (标题数={len(titles)}, 标签={len(tags)}) ===\")\n            for m in messages:\n                role = m['role']\n                content = m['content']\n                # 截断过长的新闻列表：只显示前5条和后5条\n                lines = content.split('\\n')\n                # 找到新闻列表区域并截断\n                if len(lines) > 30:\n                    # 显示前15行 + 省略提示 + 后10行\n                    head = lines[:15]\n                    tail = lines[-10:]\n                    omitted = len(lines) - 25\n                    truncated = '\\n'.join(head) + f'\\n... (省略 {omitted} 行) ...\\n' + '\\n'.join(tail)\n                    print(f\"[{role}]\\n{truncated}\")\n                else:\n                    print(f\"[{role}]\\n{content}\")\n            print(f\"[AI筛选][DEBUG] === Prompt 结束 (长度: {sum(len(m['content']) for m in messages)} 字符) ===\")\n\n        try:\n            response = self.client.chat(messages)\n\n            return self._parse_classify_response(response, titles, tags)\n        except Exception as e:\n            print(f\"[AI筛选] 分类请求失败: {type(e).__name__}: {e}\")\n            return []\n\n    def _parse_classify_response(\n        self,\n        response: str,\n        titles: List[Dict],\n        tags: List[Dict],\n    ) -> List[Dict]:\n        \"\"\"解析分类的 AI 响应\n\n        支持两种 JSON 格式：\n        - 新格式（扁平）: [{\"id\": 1, \"tag_id\": 1, \"score\": 0.9}, ...]\n        - 旧格式（嵌套）: [{\"id\": 1, \"tags\": [{\"tag_id\": 1, \"score\": 0.9}]}, ...]\n\n        每条新闻只保留一个最高分的 tag，杜绝同一条出现在多个标签下。\n        \"\"\"\n        json_str = self._extract_json(response)\n        if not json_str:\n            if self.debug:\n                print(f\"[AI筛选][DEBUG] 无法从分类响应中提取 JSON，原始响应前 500 字符: {(response or '')[:500]}\")\n            return []\n\n        try:\n            data = json.loads(json_str)\n        except json.JSONDecodeError as e:\n            if self.debug:\n                print(f\"[AI筛选][DEBUG] 分类响应 JSON 解析失败: {e}\")\n                print(f\"[AI筛选][DEBUG] 提取的 JSON 文本前 500 字符: {json_str[:500]}\")\n            return []\n\n        if not isinstance(data, list):\n            if self.debug:\n                print(f\"[AI筛选][DEBUG] 分类响应顶层不是数组，实际类型: {type(data).__name__}\")\n            return []\n\n        # 构建 id 映射\n        title_ids = {t[\"id\"] for t in titles}\n        title_map = {t[\"id\"]: t[\"title\"] for t in titles}\n        tag_id_set = {t[\"id\"] for t in tags}\n        tag_name_map = {t[\"id\"]: t[\"tag\"] for t in tags}\n\n        # 每条新闻只保留一个最高分的 tag\n        best_per_news: Dict[int, Dict] = {}  # news_id -> {\"tag_id\": ..., \"score\": ...}\n        skipped_news_ids = 0\n        skipped_tag_ids = 0\n        skipped_empty = 0\n\n        for item in data:\n            if not isinstance(item, dict):\n                continue\n            news_id = item.get(\"id\")\n            if news_id not in title_ids:\n                skipped_news_ids += 1\n                continue\n\n            # 收集此条新闻的所有候选 tag\n            candidates = []\n\n            if \"tag_id\" in item:\n                # 新格式（扁平）: {\"id\": 1, \"tag_id\": 1, \"score\": 0.9}\n                candidates.append({\"tag_id\": item[\"tag_id\"], \"score\": item.get(\"score\", 0.5)})\n            elif \"tags\" in item:\n                # 旧格式（嵌套）: {\"id\": 1, \"tags\": [{\"tag_id\": 1, \"score\": 0.9}]}\n                matched_tags = item.get(\"tags\", [])\n                if isinstance(matched_tags, list):\n                    if not matched_tags:\n                        skipped_empty += 1\n                        continue\n                    candidates.extend(matched_tags)\n\n            if not candidates:\n                skipped_empty += 1\n                continue\n\n            # 取最高分的有效 tag\n            best_tag_id = None\n            best_score = -1.0\n\n            for tag_match in candidates:\n                if not isinstance(tag_match, dict):\n                    continue\n                tag_id = tag_match.get(\"tag_id\")\n                if tag_id not in tag_id_set:\n                    skipped_tag_ids += 1\n                    continue\n\n                score = tag_match.get(\"score\", 0.5)\n                try:\n                    score = float(score)\n                    score = max(0.0, min(1.0, score))\n                except (ValueError, TypeError):\n                    score = 0.5\n\n                if score > best_score:\n                    best_score = score\n                    best_tag_id = tag_id\n\n            if best_tag_id is not None:\n                # 如果同一条新闻被多次返回，只保留分数更高的\n                existing = best_per_news.get(news_id)\n                if existing is None or best_score > existing[\"relevance_score\"]:\n                    best_per_news[news_id] = {\n                        \"news_item_id\": news_id,\n                        \"tag_id\": best_tag_id,\n                        \"relevance_score\": best_score,\n                    }\n\n        results = list(best_per_news.values())\n\n        if self.debug:\n            ai_returned = len(data)\n            print(f\"[AI筛选][DEBUG] --- 分类解析结果 ---\")\n            print(f\"[AI筛选][DEBUG] AI 返回 {ai_returned} 条, 有效 {len(results)} 条 (每条新闻仅保留最高分 tag)\")\n            if skipped_empty > 0:\n                print(f\"[AI筛选][DEBUG] 跳过空 tags: {skipped_empty} 条\")\n            if skipped_news_ids > 0:\n                print(f\"[AI筛选][DEBUG] !! 跳过无效 news_id: {skipped_news_ids} 条\")\n            if skipped_tag_ids > 0:\n                print(f\"[AI筛选][DEBUG] !! 跳过无效 tag_id: {skipped_tag_ids} 条\")\n\n            # 按标签汇总\n            tag_summary: Dict[int, List[str]] = {}\n            for r in results:\n                tid = r[\"tag_id\"]\n                if tid not in tag_summary:\n                    tag_summary[tid] = []\n                tag_summary[tid].append(\n                    f\"  [{r['news_item_id']}] {title_map.get(r['news_item_id'], '?')[:40]} (score={r['relevance_score']:.2f})\"\n                )\n\n            for tid, items in tag_summary.items():\n                tname = tag_name_map.get(tid, f\"tag_{tid}\")\n                print(f\"[AI筛选][DEBUG] 标签「{tname}」匹配 {len(items)} 条:\")\n                for line in items:\n                    print(line)\n\n        return results\n\n    def _extract_json(self, response: str) -> Optional[str]:\n        \"\"\"从 AI 响应中提取 JSON 字符串\"\"\"\n        if not response or not response.strip():\n            return None\n\n        json_str = response.strip()\n\n        if \"```json\" in json_str:\n            parts = json_str.split(\"```json\", 1)\n            if len(parts) > 1:\n                code_block = parts[1]\n                end_idx = code_block.find(\"```\")\n                json_str = code_block[:end_idx] if end_idx != -1 else code_block\n        elif \"```\" in json_str:\n            parts = json_str.split(\"```\", 2)\n            if len(parts) >= 2:\n                json_str = parts[1]\n\n        json_str = json_str.strip()\n        return json_str if json_str else None\n\n    def _print_formatted_json(self, response: str) -> None:\n        \"\"\"格式化打印 AI 响应中的 JSON，便于 debug 阅读\"\"\"\n        if not response:\n            print(\"(空响应)\")\n            return\n\n        json_str = self._extract_json(response)\n        if json_str:\n            try:\n                data = json.loads(json_str)\n                if isinstance(data, list):\n                    # 数组：每个元素压成一行\n                    lines = [json.dumps(item, ensure_ascii=False) for item in data]\n                    print(\"[\\n  \" + \",\\n  \".join(lines) + \"\\n]\")\n                else:\n                    print(json.dumps(data, ensure_ascii=False, indent=2))\n                return\n            except json.JSONDecodeError:\n                pass\n\n        # JSON 解析失败，直接打印原始响应\n        print(response)\n"
  },
  {
    "path": "trendradar/ai/formatter.py",
    "content": "# coding=utf-8\n\"\"\"\nAI 分析结果格式化模块\n\n将 AI 分析结果格式化为各推送渠道的样式\n\"\"\"\n\nimport html as html_lib\nimport re\nfrom .analyzer import AIAnalysisResult\n\n\ndef _escape_html(text: str) -> str:\n    \"\"\"转义 HTML 特殊字符，防止 XSS 攻击\"\"\"\n    return html_lib.escape(text) if text else \"\"\n\n\ndef _format_list_content(text: str) -> str:\n    \"\"\"\n    格式化列表内容，确保序号前有换行\n    例如将 \"1. xxx 2. yyy\" 转换为:\n    1. xxx\n    2. yyy\n    \"\"\"\n    if not text:\n        return \"\"\n    \n    # 去除首尾空白，防止 AI 返回的内容开头就有换行导致显示空行\n    text = text.strip()\n\n    # 0. 合并序号与紧随的【标签】（防御性处理）\n    # 将 \"1.\\n【投资者】：\" 或 \"1. 【投资者】：\" 合并为 \"1. 投资者：\"\n    text = re.sub(r'(\\d+\\.)\\s*【([^】]+)】([:：]?)', r'\\1 \\2：', text)\n\n    # 1. 规范化：确保 \"1.\" 后面有空格\n    result = re.sub(r'(\\d+)\\.([^ \\d])', r'\\1. \\2', text)\n\n    # 2. 强制换行：匹配 \"数字.\"，且前面不是换行符\n    #    (?!\\d) 排除版本号/小数（如 2.0、3.5），避免将其误判为列表序号\n    result = re.sub(r'(?<=[^\\n])\\s+(\\d+\\.)(?!\\d)', r'\\n\\1', result)\n    \n    # 3. 处理 \"1.**粗体**\" 这种情况（虽然 Prompt 要求不输出 Markdown，但防御性处理）\n    result = re.sub(r'(?<=[^\\n])(\\d+\\.\\*\\*)', r'\\n\\1', result)\n\n    # 4. 处理中文标点后的换行（排除版本号/小数）\n    result = re.sub(r'([：:;,。；，])\\s*(\\d+\\.)(?!\\d)', r'\\1\\n\\2', result)\n\n    # 5. 处理 \"XX方面：\"、\"XX领域：\" 等子标题换行\n    # 只有在中文标点（句号、逗号、分号等）后才触发换行，避免破坏 \"1. XX领域：\" 格式\n    result = re.sub(r'([。！？；，、])\\s*([a-zA-Z0-9\\u4e00-\\u9fa5]+(方面|领域)[:：])', r'\\1\\n\\2', result)\n\n    # 6. 处理 【标签】 格式\n    # 6a. 标签前确保空行分隔（文本开头除外）\n    result = re.sub(r'(?<=\\S)\\n*(【[^】]+】)', r'\\n\\n\\1', result)\n    # 6b. 合并标签与被换行拆开的冒号：【tag】\\n： → 【tag】：\n    result = re.sub(r'(【[^】]+】)\\n+([:：])', r'\\1\\2', result)\n    # 6c. 标签后（含可选冒号），如果紧跟非空白非冒号内容则另起一行\n    # 用 (?=[^\\s:：]) 避免正则回溯将冒号误判为\"内容\"而拆开 【tag】：\n    result = re.sub(r'(【[^】]+】[:：]?)[ \\t]*(?=[^\\s:：])', r'\\1\\n', result)\n\n    # 7. 在列表项之间增加视觉空行（排除版本号/小数）\n    # 排除 【标签】 行（以】结尾）和子标题行（以冒号结尾）之后的情况，避免标题与首项之间出现空行\n    result = re.sub(r'(?<![:：】])\\n(\\d+\\.)(?!\\d)', r'\\n\\n\\1', result)\n\n    return result\n\n\ndef _format_standalone_summaries(summaries: dict) -> str:\n    \"\"\"格式化独立展示区概括为纯文本行，每个源名称单独一行\"\"\"\n    if not summaries:\n        return \"\"\n    lines = []\n    for source_name, summary in summaries.items():\n        if summary:\n            lines.append(f\"[{source_name}]:\\n{summary}\")\n    return \"\\n\\n\".join(lines)\n\n\ndef render_ai_analysis_markdown(result: AIAnalysisResult) -> str:\n    \"\"\"渲染为通用 Markdown 格式（Telegram、企业微信、ntfy、Bark、Slack）\"\"\"\n    if not result.success:\n        return f\"⚠️ AI 分析失败: {result.error}\"\n\n    lines = [\"**✨ AI 热点分析**\", \"\"]\n\n    if result.core_trends:\n        lines.extend([\"**核心热点态势**\", _format_list_content(result.core_trends), \"\"])\n\n    if result.sentiment_controversy:\n        lines.extend(\n            [\"**舆论风向争议**\", _format_list_content(result.sentiment_controversy), \"\"]\n        )\n\n    if result.signals:\n        lines.extend([\"**异动与弱信号**\", _format_list_content(result.signals), \"\"])\n\n    if result.rss_insights:\n        lines.extend(\n            [\"**RSS 深度洞察**\", _format_list_content(result.rss_insights), \"\"]\n        )\n\n    if result.outlook_strategy:\n        lines.extend(\n            [\"**研判策略建议**\", _format_list_content(result.outlook_strategy), \"\"]\n        )\n\n    if result.standalone_summaries:\n        summaries_text = _format_standalone_summaries(result.standalone_summaries)\n        if summaries_text:\n            lines.extend([\"**独立源点速览**\", summaries_text])\n\n    return \"\\n\".join(lines)\n\n\ndef render_ai_analysis_feishu(result: AIAnalysisResult) -> str:\n    \"\"\"渲染为飞书卡片 Markdown 格式\"\"\"\n    if not result.success:\n        return f\"⚠️ AI 分析失败: {result.error}\"\n\n    lines = [\"**✨ AI 热点分析**\", \"\"]\n\n    if result.core_trends:\n        lines.extend([\"**核心热点态势**\", _format_list_content(result.core_trends), \"\"])\n\n    if result.sentiment_controversy:\n        lines.extend(\n            [\"**舆论风向争议**\", _format_list_content(result.sentiment_controversy), \"\"]\n        )\n\n    if result.signals:\n        lines.extend([\"**异动与弱信号**\", _format_list_content(result.signals), \"\"])\n\n    if result.rss_insights:\n        lines.extend(\n            [\"**RSS 深度洞察**\", _format_list_content(result.rss_insights), \"\"]\n        )\n\n    if result.outlook_strategy:\n        lines.extend(\n            [\"**研判策略建议**\", _format_list_content(result.outlook_strategy), \"\"]\n        )\n\n    if result.standalone_summaries:\n        summaries_text = _format_standalone_summaries(result.standalone_summaries)\n        if summaries_text:\n            lines.extend([\"**独立源点速览**\", summaries_text])\n\n    return \"\\n\".join(lines)\n\n\ndef render_ai_analysis_dingtalk(result: AIAnalysisResult) -> str:\n    \"\"\"渲染为钉钉 Markdown 格式\"\"\"\n    if not result.success:\n        return f\"⚠️ AI 分析失败: {result.error}\"\n\n    lines = [\"### ✨ AI 热点分析\", \"\"]\n\n    if result.core_trends:\n        lines.extend(\n            [\"#### 核心热点态势\", _format_list_content(result.core_trends), \"\"]\n        )\n\n    if result.sentiment_controversy:\n        lines.extend(\n            [\n                \"#### 舆论风向争议\",\n                _format_list_content(result.sentiment_controversy),\n                \"\",\n            ]\n        )\n\n    if result.signals:\n        lines.extend([\"#### 异动与弱信号\", _format_list_content(result.signals), \"\"])\n\n    if result.rss_insights:\n        lines.extend(\n            [\"#### RSS 深度洞察\", _format_list_content(result.rss_insights), \"\"]\n        )\n\n    if result.outlook_strategy:\n        lines.extend(\n            [\"#### 研判策略建议\", _format_list_content(result.outlook_strategy), \"\"]\n        )\n\n    if result.standalone_summaries:\n        summaries_text = _format_standalone_summaries(result.standalone_summaries)\n        if summaries_text:\n            lines.extend([\"#### 独立源点速览\", summaries_text])\n\n    return \"\\n\".join(lines)\n\n\ndef render_ai_analysis_html(result: AIAnalysisResult) -> str:\n    \"\"\"渲染为 HTML 格式（邮件）\"\"\"\n    if not result.success:\n        return (\n            f'<div class=\"ai-error\">⚠️ AI 分析失败: {_escape_html(result.error)}</div>'\n        )\n\n    html_parts = ['<div class=\"ai-analysis\">', \"<h3>✨ AI 热点分析</h3>\"]\n\n    if result.core_trends:\n        content = _format_list_content(result.core_trends)\n        content_html = _escape_html(content).replace(\"\\n\", \"<br>\")\n        html_parts.extend(\n            [\n                '<div class=\"ai-section\">',\n                \"<h4>核心热点态势</h4>\",\n                f'<div class=\"ai-content\">{content_html}</div>',\n                \"</div>\",\n            ]\n        )\n\n    if result.sentiment_controversy:\n        content = _format_list_content(result.sentiment_controversy)\n        content_html = _escape_html(content).replace(\"\\n\", \"<br>\")\n        html_parts.extend(\n            [\n                '<div class=\"ai-section\">',\n                \"<h4>舆论风向争议</h4>\",\n                f'<div class=\"ai-content\">{content_html}</div>',\n                \"</div>\",\n            ]\n        )\n\n    if result.signals:\n        content = _format_list_content(result.signals)\n        content_html = _escape_html(content).replace(\"\\n\", \"<br>\")\n        html_parts.extend(\n            [\n                '<div class=\"ai-section\">',\n                \"<h4>异动与弱信号</h4>\",\n                f'<div class=\"ai-content\">{content_html}</div>',\n                \"</div>\",\n            ]\n        )\n\n    if result.rss_insights:\n        content = _format_list_content(result.rss_insights)\n        content_html = _escape_html(content).replace(\"\\n\", \"<br>\")\n        html_parts.extend(\n            [\n                '<div class=\"ai-section\">',\n                \"<h4>RSS 深度洞察</h4>\",\n                f'<div class=\"ai-content\">{content_html}</div>',\n                \"</div>\",\n            ]\n        )\n\n    if result.outlook_strategy:\n        content = _format_list_content(result.outlook_strategy)\n        content_html = _escape_html(content).replace(\"\\n\", \"<br>\")\n        html_parts.extend(\n            [\n                '<div class=\"ai-section ai-conclusion\">',\n                \"<h4>研判策略建议</h4>\",\n                f'<div class=\"ai-content\">{content_html}</div>',\n                \"</div>\",\n            ]\n        )\n\n    if result.standalone_summaries:\n        summaries_text = _format_standalone_summaries(result.standalone_summaries)\n        if summaries_text:\n            summaries_html = _escape_html(summaries_text).replace(\"\\n\", \"<br>\")\n            html_parts.extend(\n                [\n                    '<div class=\"ai-section\">',\n                    \"<h4>独立源点速览</h4>\",\n                    f'<div class=\"ai-content\">{summaries_html}</div>',\n                    \"</div>\",\n                ]\n            )\n\n    html_parts.append(\"</div>\")\n    return \"\\n\".join(html_parts)\n\n\ndef render_ai_analysis_plain(result: AIAnalysisResult) -> str:\n    \"\"\"渲染为纯文本格式\"\"\"\n    if not result.success:\n        return f\"AI 分析失败: {result.error}\"\n\n    lines = [\"【✨ AI 热点分析】\", \"\"]\n\n    if result.core_trends:\n        lines.extend([\"[核心热点态势]\", _format_list_content(result.core_trends), \"\"])\n\n    if result.sentiment_controversy:\n        lines.extend(\n            [\"[舆论风向争议]\", _format_list_content(result.sentiment_controversy), \"\"]\n        )\n\n    if result.signals:\n        lines.extend([\"[异动与弱信号]\", _format_list_content(result.signals), \"\"])\n\n    if result.rss_insights:\n        lines.extend([\"[RSS 深度洞察]\", _format_list_content(result.rss_insights), \"\"])\n\n    if result.outlook_strategy:\n        lines.extend([\"[研判策略建议]\", _format_list_content(result.outlook_strategy), \"\"])\n\n    if result.standalone_summaries:\n        summaries_text = _format_standalone_summaries(result.standalone_summaries)\n        if summaries_text:\n            lines.extend([\"[独立源点速览]\", summaries_text])\n\n    return \"\\n\".join(lines)\n\n\ndef get_ai_analysis_renderer(channel: str):\n    \"\"\"根据渠道获取对应的渲染函数\"\"\"\n    renderers = {\n        \"feishu\": render_ai_analysis_feishu,\n        \"dingtalk\": render_ai_analysis_dingtalk,\n        \"wework\": render_ai_analysis_markdown,\n        \"telegram\": render_ai_analysis_markdown,\n        \"email\": render_ai_analysis_html_rich,  # 邮件使用丰富样式，配合 HTML 报告的 CSS\n        \"ntfy\": render_ai_analysis_markdown,\n        \"bark\": render_ai_analysis_plain,\n        \"slack\": render_ai_analysis_markdown,\n    }\n    return renderers.get(channel, render_ai_analysis_markdown)\n\n\ndef render_ai_analysis_html_rich(result: AIAnalysisResult) -> str:\n    \"\"\"渲染为丰富样式的 HTML 格式（HTML 报告用）\"\"\"\n    if not result:\n        return \"\"\n\n    # 检查是否成功\n    if not result.success:\n        error_msg = result.error or \"未知错误\"\n        return f\"\"\"\n                <div class=\"ai-section\">\n                    <div class=\"ai-error\">⚠️ AI 分析失败: {_escape_html(str(error_msg))}</div>\n                </div>\"\"\"\n\n    ai_html = \"\"\"\n                <div class=\"ai-section\">\n                    <div class=\"ai-section-header\">\n                        <div class=\"ai-section-title\">✨ AI 热点分析</div>\n                        <span class=\"ai-section-badge\">AI</span>\n                    </div>\"\"\"\n\n    if result.core_trends:\n        content = _format_list_content(result.core_trends)\n        content_html = _escape_html(content).replace(\"\\n\", \"<br>\")\n        ai_html += f\"\"\"\n                    <div class=\"ai-block\">\n                        <div class=\"ai-block-title\">核心热点态势</div>\n                        <div class=\"ai-block-content\">{content_html}</div>\n                    </div>\"\"\"\n\n    if result.sentiment_controversy:\n        content = _format_list_content(result.sentiment_controversy)\n        content_html = _escape_html(content).replace(\"\\n\", \"<br>\")\n        ai_html += f\"\"\"\n                    <div class=\"ai-block\">\n                        <div class=\"ai-block-title\">舆论风向争议</div>\n                        <div class=\"ai-block-content\">{content_html}</div>\n                    </div>\"\"\"\n\n    if result.signals:\n        content = _format_list_content(result.signals)\n        content_html = _escape_html(content).replace(\"\\n\", \"<br>\")\n        ai_html += f\"\"\"\n                    <div class=\"ai-block\">\n                        <div class=\"ai-block-title\">异动与弱信号</div>\n                        <div class=\"ai-block-content\">{content_html}</div>\n                    </div>\"\"\"\n\n    if result.rss_insights:\n        content = _format_list_content(result.rss_insights)\n        content_html = _escape_html(content).replace(\"\\n\", \"<br>\")\n        ai_html += f\"\"\"\n                    <div class=\"ai-block\">\n                        <div class=\"ai-block-title\">RSS 深度洞察</div>\n                        <div class=\"ai-block-content\">{content_html}</div>\n                    </div>\"\"\"\n\n    if result.outlook_strategy:\n        content = _format_list_content(result.outlook_strategy)\n        content_html = _escape_html(content).replace(\"\\n\", \"<br>\")\n        ai_html += f\"\"\"\n                    <div class=\"ai-block\">\n                        <div class=\"ai-block-title\">研判策略建议</div>\n                        <div class=\"ai-block-content\">{content_html}</div>\n                    </div>\"\"\"\n\n    if result.standalone_summaries:\n        summaries_text = _format_standalone_summaries(result.standalone_summaries)\n        if summaries_text:\n            summaries_html = _escape_html(summaries_text).replace(\"\\n\", \"<br>\")\n            ai_html += f\"\"\"\n                    <div class=\"ai-block\">\n                        <div class=\"ai-block-title\">独立源点速览</div>\n                        <div class=\"ai-block-content\">{summaries_html}</div>\n                    </div>\"\"\"\n\n    ai_html += \"\"\"\n                </div>\"\"\"\n    return ai_html\n"
  },
  {
    "path": "trendradar/ai/translator.py",
    "content": "# coding=utf-8\n\"\"\"\nAI 翻译器模块\n\n对推送内容进行多语言翻译\n基于 LiteLLM 统一接口，支持 100+ AI 提供商\n\"\"\"\n\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\nfrom typing import Any, Dict, List\n\nfrom trendradar.ai.client import AIClient\n\n\n@dataclass\nclass TranslationResult:\n    \"\"\"翻译结果\"\"\"\n    translated_text: str = \"\"       # 翻译后的文本\n    original_text: str = \"\"         # 原始文本\n    success: bool = False           # 是否成功\n    error: str = \"\"                 # 错误信息\n\n\n@dataclass\nclass BatchTranslationResult:\n    \"\"\"批量翻译结果\"\"\"\n    results: List[TranslationResult] = field(default_factory=list)\n    success_count: int = 0\n    fail_count: int = 0\n    total_count: int = 0\n    prompt: str = \"\"                # debug: 发送给 AI 的完整 prompt\n    raw_response: str = \"\"          # debug: AI 原始响应\n    parsed_count: int = 0           # debug: AI 响应解析出的条目数\n\n\nclass AITranslator:\n    \"\"\"AI 翻译器\"\"\"\n\n    def __init__(self, translation_config: Dict[str, Any], ai_config: Dict[str, Any]):\n        \"\"\"\n        初始化 AI 翻译器\n\n        Args:\n            translation_config: AI 翻译配置 (AI_TRANSLATION)\n            ai_config: AI 模型配置（LiteLLM 格式）\n        \"\"\"\n        self.translation_config = translation_config\n        self.ai_config = ai_config\n\n        # 翻译配置\n        self.enabled = translation_config.get(\"ENABLED\", False)\n        self.target_language = translation_config.get(\"LANGUAGE\", \"English\")\n        self.scope = translation_config.get(\"SCOPE\", {\"HOTLIST\": True, \"RSS\": True, \"STANDALONE\": True})\n\n        # 创建 AI 客户端（基于 LiteLLM）\n        self.client = AIClient(ai_config)\n\n        # 加载提示词模板\n        self.system_prompt, self.user_prompt_template = self._load_prompt_template(\n            translation_config.get(\"PROMPT_FILE\", \"ai_translation_prompt.txt\")\n        )\n\n    def _load_prompt_template(self, prompt_file: str) -> tuple:\n        \"\"\"加载提示词模板\"\"\"\n        config_dir = Path(__file__).parent.parent.parent / \"config\"\n        prompt_path = config_dir / prompt_file\n\n        if not prompt_path.exists():\n            print(f\"[翻译] 提示词文件不存在: {prompt_path}\")\n            return \"\", \"\"\n\n        content = prompt_path.read_text(encoding=\"utf-8\")\n\n        # 解析 [system] 和 [user] 部分\n        system_prompt = \"\"\n        user_prompt = \"\"\n\n        if \"[system]\" in content and \"[user]\" in content:\n            parts = content.split(\"[user]\")\n            system_part = parts[0]\n            user_part = parts[1] if len(parts) > 1 else \"\"\n\n            if \"[system]\" in system_part:\n                system_prompt = system_part.split(\"[system]\")[1].strip()\n\n            user_prompt = user_part.strip()\n        else:\n            user_prompt = content\n\n        return system_prompt, user_prompt\n\n    def translate(self, text: str) -> TranslationResult:\n        \"\"\"\n        翻译单条文本\n\n        Args:\n            text: 要翻译的文本\n\n        Returns:\n            TranslationResult: 翻译结果\n        \"\"\"\n        result = TranslationResult(original_text=text)\n\n        if not self.enabled:\n            result.error = \"翻译功能未启用\"\n            return result\n\n        if not self.client.api_key:\n            result.error = \"未配置 AI API Key\"\n            return result\n\n        if not text or not text.strip():\n            result.translated_text = text\n            result.success = True\n            return result\n\n        try:\n            # 构建提示词\n            user_prompt = self.user_prompt_template\n            user_prompt = user_prompt.replace(\"{target_language}\", self.target_language)\n            user_prompt = user_prompt.replace(\"{content}\", text)\n\n            # 调用 AI API\n            response = self._call_ai(user_prompt)\n            result.translated_text = response.strip()\n            result.success = True\n\n        except Exception as e:\n            error_type = type(e).__name__\n            error_msg = str(e)\n            if len(error_msg) > 100:\n                error_msg = error_msg[:100] + \"...\"\n            result.error = f\"翻译失败 ({error_type}): {error_msg}\"\n\n        return result\n\n    def translate_batch(self, texts: List[str]) -> BatchTranslationResult:\n        \"\"\"\n        批量翻译文本（单次 API 调用）\n\n        Args:\n            texts: 要翻译的文本列表\n\n        Returns:\n            BatchTranslationResult: 批量翻译结果\n        \"\"\"\n        batch_result = BatchTranslationResult(total_count=len(texts))\n\n        if not self.enabled:\n            for text in texts:\n                batch_result.results.append(TranslationResult(\n                    original_text=text,\n                    error=\"翻译功能未启用\"\n                ))\n            batch_result.fail_count = len(texts)\n            return batch_result\n\n        if not self.client.api_key:\n            for text in texts:\n                batch_result.results.append(TranslationResult(\n                    original_text=text,\n                    error=\"未配置 AI API Key\"\n                ))\n            batch_result.fail_count = len(texts)\n            return batch_result\n\n        if not texts:\n            return batch_result\n\n        # 过滤空文本\n        non_empty_indices = []\n        non_empty_texts = []\n        for i, text in enumerate(texts):\n            if text and text.strip():\n                non_empty_indices.append(i)\n                non_empty_texts.append(text)\n\n        # 初始化结果列表\n        for text in texts:\n            batch_result.results.append(TranslationResult(original_text=text))\n\n        # 空文本直接标记成功\n        for i, text in enumerate(texts):\n            if not text or not text.strip():\n                batch_result.results[i].translated_text = text\n                batch_result.results[i].success = True\n                batch_result.success_count += 1\n\n        if not non_empty_texts:\n            return batch_result\n\n        try:\n            # 构建批量翻译内容（使用编号格式）\n            batch_content = self._format_batch_content(non_empty_texts)\n\n            # 构建提示词\n            user_prompt = self.user_prompt_template\n            user_prompt = user_prompt.replace(\"{target_language}\", self.target_language)\n            user_prompt = user_prompt.replace(\"{content}\", batch_content)\n\n            # 记录 debug 信息（包含完整的 system + user prompt）\n            if self.system_prompt:\n                batch_result.prompt = f\"[system]\\n{self.system_prompt}\\n\\n[user]\\n{user_prompt}\"\n            else:\n                batch_result.prompt = user_prompt\n\n            # 调用 AI API\n            response = self._call_ai(user_prompt)\n\n            # 记录 AI 原始响应\n            batch_result.raw_response = response\n\n            # 解析批量翻译结果\n            translated_texts, raw_parsed_count = self._parse_batch_response(response, len(non_empty_texts))\n            batch_result.parsed_count = raw_parsed_count\n\n            # 填充结果\n            for idx, translated in zip(non_empty_indices, translated_texts):\n                batch_result.results[idx].translated_text = translated\n                batch_result.results[idx].success = True\n                batch_result.success_count += 1\n\n        except Exception as e:\n            error_msg = f\"批量翻译失败: {type(e).__name__}: {str(e)[:100]}\"\n            for idx in non_empty_indices:\n                batch_result.results[idx].error = error_msg\n            batch_result.fail_count = len(non_empty_indices)\n\n        return batch_result\n\n    def _format_batch_content(self, texts: List[str]) -> str:\n        \"\"\"格式化批量翻译内容\"\"\"\n        lines = []\n        for i, text in enumerate(texts, 1):\n            lines.append(f\"[{i}] {text}\")\n        return \"\\n\".join(lines)\n\n    def _parse_batch_response(self, response: str, expected_count: int) -> tuple:\n        \"\"\"\n        解析批量翻译响应\n\n        Args:\n            response: AI 响应文本\n            expected_count: 期望的翻译数量\n\n        Returns:\n            tuple: (翻译结果列表, AI 原始解析出的条目数)\n        \"\"\"\n        results = []\n        lines = response.strip().split(\"\\n\")\n\n        current_idx = None\n        current_text = []\n\n        for line in lines:\n            # 尝试匹配 [数字] 格式\n            stripped = line.strip()\n            if stripped.startswith(\"[\") and \"]\" in stripped:\n                bracket_end = stripped.index(\"]\")\n                try:\n                    idx = int(stripped[1:bracket_end])\n                    # 保存之前的内容\n                    if current_idx is not None:\n                        results.append((current_idx, \"\\n\".join(current_text).strip()))\n                    current_idx = idx\n                    current_text = [stripped[bracket_end + 1:].strip()]\n                except ValueError:\n                    if current_idx is not None:\n                        current_text.append(line)\n            else:\n                if current_idx is not None:\n                    current_text.append(line)\n\n        # 保存最后一条\n        if current_idx is not None:\n            results.append((current_idx, \"\\n\".join(current_text).strip()))\n\n        # 按索引排序并提取文本\n        results.sort(key=lambda x: x[0])\n        translated = [text for _, text in results]\n        raw_parsed_count = len(translated)\n\n        # 如果解析结果数量不匹配，尝试简单按行分割\n        if len(translated) != expected_count:\n            # 回退：按行分割（去除编号）\n            translated = []\n            for line in lines:\n                stripped = line.strip()\n                if stripped.startswith(\"[\") and \"]\" in stripped:\n                    bracket_end = stripped.index(\"]\")\n                    translated.append(stripped[bracket_end + 1:].strip())\n                elif stripped:\n                    translated.append(stripped)\n            raw_parsed_count = len(translated)\n\n        # 确保返回正确数量\n        while len(translated) < expected_count:\n            translated.append(\"\")\n\n        return translated[:expected_count], raw_parsed_count\n\n    def _call_ai(self, user_prompt: str) -> str:\n        \"\"\"调用 AI API（使用 LiteLLM）\"\"\"\n        messages = []\n        if self.system_prompt:\n            messages.append({\"role\": \"system\", \"content\": self.system_prompt})\n        messages.append({\"role\": \"user\", \"content\": user_prompt})\n\n        return self.client.chat(messages)\n"
  },
  {
    "path": "trendradar/context.py",
    "content": "# coding=utf-8\n\"\"\"\n应用上下文模块\n\n提供配置上下文类，封装所有依赖配置的操作，消除全局状态和包装函数。\n\"\"\"\n\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional, Tuple\n\nfrom trendradar.utils.time import (\n    DEFAULT_TIMEZONE,\n    get_configured_time,\n    format_date_folder,\n    format_time_filename,\n    get_current_time_display,\n    convert_time_for_display,\n    format_iso_time_friendly,\n    is_within_days,\n)\nfrom trendradar.core import (\n    load_frequency_words,\n    matches_word_groups,\n    read_all_today_titles,\n    detect_latest_new_titles,\n    count_word_frequency,\n    Scheduler,\n)\nfrom trendradar.report import (\n    prepare_report_data,\n    generate_html_report,\n    render_html_content,\n)\nfrom trendradar.notification import (\n    render_feishu_content,\n    render_dingtalk_content,\n    split_content_into_batches,\n    NotificationDispatcher,\n)\nfrom trendradar.ai import AITranslator\nfrom trendradar.ai.filter import AIFilter, AIFilterResult\nfrom trendradar.storage import get_storage_manager\n\n\nclass AppContext:\n    \"\"\"\n    应用上下文类\n\n    封装所有依赖配置的操作，提供统一的接口。\n    消除对全局 CONFIG 的依赖，提高可测试性。\n\n    使用示例:\n        config = load_config()\n        ctx = AppContext(config)\n\n        # 时间操作\n        now = ctx.get_time()\n        date_folder = ctx.format_date()\n\n        # 存储操作\n        storage = ctx.get_storage_manager()\n\n        # 报告生成\n        html = ctx.generate_html_report(stats, total_titles, ...)\n    \"\"\"\n\n    def __init__(self, config: Dict[str, Any]):\n        \"\"\"\n        初始化应用上下文\n\n        Args:\n            config: 完整的配置字典\n        \"\"\"\n        self.config = config\n        self._storage_manager = None\n        self._scheduler = None\n\n    # === 配置访问 ===\n\n    @property\n    def timezone(self) -> str:\n        \"\"\"获取配置的时区\"\"\"\n        return self.config.get(\"TIMEZONE\", DEFAULT_TIMEZONE)\n\n    @property\n    def rank_threshold(self) -> int:\n        \"\"\"获取排名阈值\"\"\"\n        return self.config.get(\"RANK_THRESHOLD\", 50)\n\n    @property\n    def weight_config(self) -> Dict:\n        \"\"\"获取权重配置\"\"\"\n        return self.config.get(\"WEIGHT_CONFIG\", {})\n\n    @property\n    def platforms(self) -> List[Dict]:\n        \"\"\"获取平台配置列表\"\"\"\n        return self.config.get(\"PLATFORMS\", [])\n\n    @property\n    def platform_ids(self) -> List[str]:\n        \"\"\"获取平台ID列表\"\"\"\n        return [p[\"id\"] for p in self.platforms]\n\n    @property\n    def rss_config(self) -> Dict:\n        \"\"\"获取 RSS 配置\"\"\"\n        return self.config.get(\"RSS\", {})\n\n    @property\n    def rss_enabled(self) -> bool:\n        \"\"\"RSS 是否启用\"\"\"\n        return self.rss_config.get(\"ENABLED\", False)\n\n    @property\n    def rss_feeds(self) -> List[Dict]:\n        \"\"\"获取 RSS 源列表\"\"\"\n        return self.rss_config.get(\"FEEDS\", [])\n\n    @property\n    def display_mode(self) -> str:\n        \"\"\"获取显示模式 (keyword | platform)\"\"\"\n        return self.config.get(\"DISPLAY_MODE\", \"keyword\")\n\n    @property\n    def show_new_section(self) -> bool:\n        \"\"\"是否显示新增热点区域\"\"\"\n        return self.config.get(\"DISPLAY\", {}).get(\"REGIONS\", {}).get(\"NEW_ITEMS\", True)\n\n    @property\n    def region_order(self) -> List[str]:\n        \"\"\"获取区域显示顺序\"\"\"\n        default_order = [\"hotlist\", \"rss\", \"new_items\", \"standalone\", \"ai_analysis\"]\n        return self.config.get(\"DISPLAY\", {}).get(\"REGION_ORDER\", default_order)\n\n    @property\n    def filter_method(self) -> str:\n        \"\"\"获取筛选策略: keyword | ai\"\"\"\n        return self.config.get(\"FILTER\", {}).get(\"METHOD\", \"keyword\")\n\n    @property\n    def ai_priority_sort_enabled(self) -> bool:\n        \"\"\"AI 模式标签排序开关（与 keyword 的 sort_by_position_first 解耦）\"\"\"\n        return self.config.get(\"FILTER\", {}).get(\"PRIORITY_SORT_ENABLED\", False)\n\n    @property\n    def ai_filter_config(self) -> Dict:\n        \"\"\"获取 AI 筛选配置\"\"\"\n        return self.config.get(\"AI_FILTER\", {})\n\n    @property\n    def ai_filter_enabled(self) -> bool:\n        \"\"\"AI 筛选是否启用（基于 filter.method 判断）\"\"\"\n        return self.filter_method == \"ai\"\n\n    # === 时间操作 ===\n\n    def get_time(self) -> datetime:\n        \"\"\"获取当前配置时区的时间\"\"\"\n        return get_configured_time(self.timezone)\n\n    def format_date(self) -> str:\n        \"\"\"格式化日期文件夹 (YYYY-MM-DD)\"\"\"\n        return format_date_folder(timezone=self.timezone)\n\n    def format_time(self) -> str:\n        \"\"\"格式化时间文件名 (HH-MM)\"\"\"\n        return format_time_filename(self.timezone)\n\n    def get_time_display(self) -> str:\n        \"\"\"获取时间显示 (HH:MM)\"\"\"\n        return get_current_time_display(self.timezone)\n\n    @staticmethod\n    def convert_time_display(time_str: str) -> str:\n        \"\"\"将 HH-MM 转换为 HH:MM\"\"\"\n        return convert_time_for_display(time_str)\n\n    # === 存储操作 ===\n\n    def get_storage_manager(self):\n        \"\"\"获取存储管理器（延迟初始化，单例）\"\"\"\n        if self._storage_manager is None:\n            storage_config = self.config.get(\"STORAGE\", {})\n            remote_config = storage_config.get(\"REMOTE\", {})\n            local_config = storage_config.get(\"LOCAL\", {})\n            pull_config = storage_config.get(\"PULL\", {})\n\n            self._storage_manager = get_storage_manager(\n                backend_type=storage_config.get(\"BACKEND\", \"auto\"),\n                data_dir=local_config.get(\"DATA_DIR\", \"output\"),\n                enable_txt=storage_config.get(\"FORMATS\", {}).get(\"TXT\", True),\n                enable_html=storage_config.get(\"FORMATS\", {}).get(\"HTML\", True),\n                remote_config={\n                    \"bucket_name\": remote_config.get(\"BUCKET_NAME\", \"\"),\n                    \"access_key_id\": remote_config.get(\"ACCESS_KEY_ID\", \"\"),\n                    \"secret_access_key\": remote_config.get(\"SECRET_ACCESS_KEY\", \"\"),\n                    \"endpoint_url\": remote_config.get(\"ENDPOINT_URL\", \"\"),\n                    \"region\": remote_config.get(\"REGION\", \"\"),\n                },\n                local_retention_days=local_config.get(\"RETENTION_DAYS\", 0),\n                remote_retention_days=remote_config.get(\"RETENTION_DAYS\", 0),\n                pull_enabled=pull_config.get(\"ENABLED\", False),\n                pull_days=pull_config.get(\"DAYS\", 7),\n                timezone=self.timezone,\n            )\n        return self._storage_manager\n\n    def get_output_path(self, subfolder: str, filename: str) -> str:\n        \"\"\"获取输出路径（扁平化结构：output/类型/日期/文件名）\"\"\"\n        output_dir = Path(\"output\") / subfolder / self.format_date()\n        output_dir.mkdir(parents=True, exist_ok=True)\n        return str(output_dir / filename)\n\n    # === 数据处理 ===\n\n    def read_today_titles(\n        self, platform_ids: Optional[List[str]] = None, quiet: bool = False\n    ) -> Tuple[Dict, Dict, Dict]:\n        \"\"\"读取当天所有标题\"\"\"\n        return read_all_today_titles(self.get_storage_manager(), platform_ids, quiet=quiet)\n\n    def detect_new_titles(\n        self, platform_ids: Optional[List[str]] = None, quiet: bool = False\n    ) -> Dict:\n        \"\"\"检测最新批次的新增标题\"\"\"\n        return detect_latest_new_titles(self.get_storage_manager(), platform_ids, quiet=quiet)\n\n    def is_first_crawl(self) -> bool:\n        \"\"\"检测是否是当天第一次爬取\"\"\"\n        return self.get_storage_manager().is_first_crawl_today()\n\n    # === 频率词处理 ===\n\n    def load_frequency_words(\n        self, frequency_file: Optional[str] = None\n    ) -> Tuple[List[Dict], List[str], List[str]]:\n        \"\"\"加载频率词配置\"\"\"\n        return load_frequency_words(frequency_file)\n\n    def matches_word_groups(\n        self,\n        title: str,\n        word_groups: List[Dict],\n        filter_words: List[str],\n        global_filters: Optional[List[str]] = None,\n    ) -> bool:\n        \"\"\"检查标题是否匹配词组规则\"\"\"\n        return matches_word_groups(title, word_groups, filter_words, global_filters)\n\n    # === 统计分析 ===\n\n    def count_frequency(\n        self,\n        results: Dict,\n        word_groups: List[Dict],\n        filter_words: List[str],\n        id_to_name: Dict,\n        title_info: Optional[Dict] = None,\n        new_titles: Optional[Dict] = None,\n        mode: str = \"daily\",\n        global_filters: Optional[List[str]] = None,\n        quiet: bool = False,\n    ) -> Tuple[List[Dict], int]:\n        \"\"\"统计词频\"\"\"\n        return count_word_frequency(\n            results=results,\n            word_groups=word_groups,\n            filter_words=filter_words,\n            id_to_name=id_to_name,\n            title_info=title_info,\n            rank_threshold=self.rank_threshold,\n            new_titles=new_titles,\n            mode=mode,\n            global_filters=global_filters,\n            weight_config=self.weight_config,\n            max_news_per_keyword=self.config.get(\"MAX_NEWS_PER_KEYWORD\", 0),\n            sort_by_position_first=self.config.get(\"SORT_BY_POSITION_FIRST\", False),\n            is_first_crawl_func=self.is_first_crawl,\n            convert_time_func=self.convert_time_display,\n            quiet=quiet,\n        )\n\n    # === 报告生成 ===\n\n    def prepare_report(\n        self,\n        stats: List[Dict],\n        failed_ids: Optional[List] = None,\n        new_titles: Optional[Dict] = None,\n        id_to_name: Optional[Dict] = None,\n        mode: str = \"daily\",\n        frequency_file: Optional[str] = None,\n    ) -> Dict:\n        \"\"\"准备报告数据\"\"\"\n        return prepare_report_data(\n            stats=stats,\n            failed_ids=failed_ids,\n            new_titles=new_titles,\n            id_to_name=id_to_name,\n            mode=mode,\n            rank_threshold=self.rank_threshold,\n            matches_word_groups_func=self.matches_word_groups,\n            load_frequency_words_func=lambda: self.load_frequency_words(frequency_file),\n            show_new_section=self.show_new_section,\n        )\n\n    def generate_html(\n        self,\n        stats: List[Dict],\n        total_titles: int,\n        failed_ids: Optional[List] = None,\n        new_titles: Optional[Dict] = None,\n        id_to_name: Optional[Dict] = None,\n        mode: str = \"daily\",\n        update_info: Optional[Dict] = None,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        ai_analysis: Optional[Any] = None,\n        standalone_data: Optional[Dict] = None,\n        frequency_file: Optional[str] = None,\n    ) -> str:\n        \"\"\"生成HTML报告\"\"\"\n        return generate_html_report(\n            stats=stats,\n            total_titles=total_titles,\n            failed_ids=failed_ids,\n            new_titles=new_titles,\n            id_to_name=id_to_name,\n            mode=mode,\n            update_info=update_info,\n            rank_threshold=self.rank_threshold,\n            output_dir=\"output\",\n            date_folder=self.format_date(),\n            time_filename=self.format_time(),\n            render_html_func=lambda *args, **kwargs: self.render_html(*args, rss_items=rss_items, rss_new_items=rss_new_items, ai_analysis=ai_analysis, standalone_data=standalone_data, **kwargs),\n            matches_word_groups_func=self.matches_word_groups,\n            load_frequency_words_func=lambda: self.load_frequency_words(frequency_file),\n        )\n\n    def render_html(\n        self,\n        report_data: Dict,\n        total_titles: int,\n        mode: str = \"daily\",\n        update_info: Optional[Dict] = None,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        ai_analysis: Optional[Any] = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> str:\n        \"\"\"渲染HTML内容\"\"\"\n        return render_html_content(\n            report_data=report_data,\n            total_titles=total_titles,\n            mode=mode,\n            update_info=update_info,\n            region_order=self.region_order,\n            get_time_func=self.get_time,\n            rss_items=rss_items,\n            rss_new_items=rss_new_items,\n            display_mode=self.display_mode,\n            ai_analysis=ai_analysis,\n            show_new_section=self.show_new_section,\n            standalone_data=standalone_data,\n        )\n\n    # === 通知内容渲染 ===\n\n    def render_feishu(\n        self,\n        report_data: Dict,\n        update_info: Optional[Dict] = None,\n        mode: str = \"daily\",\n    ) -> str:\n        \"\"\"渲染飞书内容\"\"\"\n        return render_feishu_content(\n            report_data=report_data,\n            update_info=update_info,\n            mode=mode,\n            separator=self.config.get(\"FEISHU_MESSAGE_SEPARATOR\", \"---\"),\n            region_order=self.region_order,\n            get_time_func=self.get_time,\n            show_new_section=self.show_new_section,\n        )\n\n    def render_dingtalk(\n        self,\n        report_data: Dict,\n        update_info: Optional[Dict] = None,\n        mode: str = \"daily\",\n    ) -> str:\n        \"\"\"渲染钉钉内容\"\"\"\n        return render_dingtalk_content(\n            report_data=report_data,\n            update_info=update_info,\n            mode=mode,\n            region_order=self.region_order,\n            get_time_func=self.get_time,\n            show_new_section=self.show_new_section,\n        )\n\n    def split_content(\n        self,\n        report_data: Dict,\n        format_type: str,\n        update_info: Optional[Dict] = None,\n        max_bytes: Optional[int] = None,\n        mode: str = \"daily\",\n        rss_items: Optional[list] = None,\n        rss_new_items: Optional[list] = None,\n        ai_content: Optional[str] = None,\n        standalone_data: Optional[Dict] = None,\n        ai_stats: Optional[Dict] = None,\n        report_type: str = \"热点分析报告\",\n    ) -> List[str]:\n        \"\"\"分批处理消息内容（支持热榜+RSS合并+AI分析+独立展示区）\n\n        Args:\n            report_data: 报告数据\n            format_type: 格式类型\n            update_info: 更新信息\n            max_bytes: 最大字节数\n            mode: 报告模式\n            rss_items: RSS 统计条目列表\n            rss_new_items: RSS 新增条目列表\n            ai_content: AI 分析内容（已渲染的字符串）\n            standalone_data: 独立展示区数据\n            ai_stats: AI 分析统计数据\n            report_type: 报告类型\n\n        Returns:\n            分批后的消息内容列表\n        \"\"\"\n        return split_content_into_batches(\n            report_data=report_data,\n            format_type=format_type,\n            update_info=update_info,\n            max_bytes=max_bytes,\n            mode=mode,\n            batch_sizes={\n                \"dingtalk\": self.config.get(\"DINGTALK_BATCH_SIZE\", 20000),\n                \"feishu\": self.config.get(\"FEISHU_BATCH_SIZE\", 29000),\n                \"default\": self.config.get(\"MESSAGE_BATCH_SIZE\", 4000),\n            },\n            feishu_separator=self.config.get(\"FEISHU_MESSAGE_SEPARATOR\", \"---\"),\n            region_order=self.region_order,\n            get_time_func=self.get_time,\n            rss_items=rss_items,\n            rss_new_items=rss_new_items,\n            timezone=self.config.get(\"TIMEZONE\", DEFAULT_TIMEZONE),\n            display_mode=self.display_mode,\n            ai_content=ai_content,\n            standalone_data=standalone_data,\n            rank_threshold=self.rank_threshold,\n            ai_stats=ai_stats,\n            report_type=report_type,\n            show_new_section=self.show_new_section,\n        )\n\n    # === 通知发送 ===\n\n    def create_notification_dispatcher(self) -> NotificationDispatcher:\n        \"\"\"创建通知调度器\"\"\"\n        # 创建翻译器（如果启用）\n        translator = None\n        trans_config = self.config.get(\"AI_TRANSLATION\", {})\n        if trans_config.get(\"ENABLED\", False):\n            ai_config = self.config.get(\"AI\", {})\n            translator = AITranslator(trans_config, ai_config)\n\n        return NotificationDispatcher(\n            config=self.config,\n            get_time_func=self.get_time,\n            split_content_func=self.split_content,\n            translator=translator,\n        )\n\n    def create_scheduler(self) -> Scheduler:\n        \"\"\"\n        创建调度器（延迟初始化，单例）\n\n        基于 config.yaml 的 schedule 段 + timeline.yaml 构建。\n        \"\"\"\n        if self._scheduler is None:\n            schedule_config = self.config.get(\"SCHEDULE\", {})\n            timeline_data = self.config.get(\"_TIMELINE_DATA\", {})\n\n            self._scheduler = Scheduler(\n                schedule_config=schedule_config,\n                timeline_data=timeline_data,\n                storage_backend=self.get_storage_manager(),\n                get_time_func=self.get_time,\n                fallback_report_mode=self.config.get(\"REPORT_MODE\", \"current\"),\n            )\n        return self._scheduler\n\n    # === AI 智能筛选 ===\n\n    @staticmethod\n    def _with_ordered_priorities(tags: List[Dict], start_priority: int = 1) -> List[Dict]:\n        \"\"\"按当前列表顺序补齐优先级（值越小优先级越高）\"\"\"\n        normalized: List[Dict] = []\n        priority = start_priority\n        for tag_data in tags:\n            if not isinstance(tag_data, dict):\n                continue\n            tag_name = str(tag_data.get(\"tag\", \"\")).strip()\n            if not tag_name:\n                continue\n            item = dict(tag_data)\n            item[\"tag\"] = tag_name\n            item[\"priority\"] = priority\n            normalized.append(item)\n            priority += 1\n        return normalized\n\n    def run_ai_filter(self, interests_file: Optional[str] = None) -> Optional[AIFilterResult]:\n        \"\"\"\n        执行 AI 智能筛选完整流程\n\n        Args:\n            interests_file: 兴趣描述文件名（位于 config/custom/ai/），None=使用默认 config/ai_interests.txt\n\n        1. 读取兴趣描述文件，计算 hash\n        2. 对比数据库 prompt_hash，决定是否重新提取标签\n        3. 收集待分类新闻（去重）\n        4. 按 batch_size 分组调用 AI 分类\n        5. 保存结果\n        6. 查询 active 结果，按标签分组返回\n\n        Returns:\n            AIFilterResult 或 None（未启用或出错）\n        \"\"\"\n        if not self.ai_filter_enabled:\n            return None\n\n        filter_config = self.ai_filter_config\n        ai_config = self.config.get(\"AI\", {})\n        debug = self.config.get(\"DEBUG\", False)\n\n        # 创建 AIFilter 实例\n        ai_filter = AIFilter(ai_config, filter_config, self.get_time, debug)\n\n        # 确定实际使用的兴趣文件名\n        # None = 使用默认 config/ai_interests.txt，指定文件名 = config/custom/ai/{name}\n        configured_interests = interests_file or filter_config.get(\"INTERESTS_FILE\")\n        effective_interests_file = configured_interests or \"ai_interests.txt\"\n\n        if debug:\n            print(f\"[AI筛选][DEBUG] === 配置信息 ===\")\n            print(f\"[AI筛选][DEBUG] 存储后端: {self.get_storage_manager().backend_name}\")\n            print(f\"[AI筛选][DEBUG] batch_size={filter_config.get('BATCH_SIZE', 200)}, \"\n                  f\"batch_interval={filter_config.get('BATCH_INTERVAL', 5)}\")\n            print(f\"[AI筛选][DEBUG] interests_file={effective_interests_file}\")\n            print(f\"[AI筛选][DEBUG] prompt_file={filter_config.get('PROMPT_FILE', 'prompt.txt')}\")\n            print(f\"[AI筛选][DEBUG] extract_prompt_file={filter_config.get('EXTRACT_PROMPT_FILE', 'extract_prompt.txt')}\")\n\n        # 1. 读取兴趣描述\n        # 传 configured_interests（可能为 None）给 load_interests_content，\n        # 让它区分\"默认文件(config/ai_interests.txt)\"和\"自定义文件(config/custom/ai/)\"\n        interests_content = ai_filter.load_interests_content(configured_interests)\n        if not interests_content:\n            return AIFilterResult(success=False, error=\"兴趣描述文件为空或不存在\")\n\n        current_hash = ai_filter.compute_interests_hash(interests_content, effective_interests_file)\n        storage = self.get_storage_manager()\n\n        if debug:\n            print(f\"[AI筛选][DEBUG] 兴趣描述 hash: {current_hash}\")\n            print(f\"[AI筛选][DEBUG] 兴趣描述内容 ({len(interests_content)} 字符):\\n{interests_content}\")\n\n        # 2. 开启批量模式（远程后端延迟上传，所有写操作完成后统一上传）\n        storage.begin_batch()\n\n        # 3. 检查提示词是否变更\n        stored_hash = storage.get_latest_prompt_hash(interests_file=effective_interests_file)\n\n        if debug:\n            print(f\"[AI筛选][DEBUG] 数据库存储 hash: {stored_hash}\")\n            print(f\"[AI筛选][DEBUG] hash 对比: stored={stored_hash} vs current={current_hash} → {'匹配' if stored_hash == current_hash else '不匹配'}\")\n\n        if stored_hash != current_hash:\n            new_version = storage.get_latest_ai_filter_tag_version() + 1\n            threshold = filter_config.get(\"RECLASSIFY_THRESHOLD\", 0.6)\n\n            if stored_hash is None:\n                # 首次运行，直接提取并保存全部标签\n                print(f\"[AI筛选] 首次运行 ({effective_interests_file})，提取标签...\")\n                tags_data = ai_filter.extract_tags(interests_content)\n                if not tags_data:\n                    storage.end_batch()\n                    return AIFilterResult(success=False, error=\"标签提取失败\")\n                tags_data = self._with_ordered_priorities(tags_data, start_priority=1)\n                saved_count = storage.save_ai_filter_tags(tags_data, new_version, current_hash, interests_file=effective_interests_file)\n                print(f\"[AI筛选] 已保存 {saved_count} 个标签 (版本 {new_version})\")\n            else:\n                # 兴趣描述已变更，让 AI 对比旧标签和新兴趣，给出更新方案\n                old_tags = storage.get_active_ai_filter_tags(interests_file=effective_interests_file)\n                update_result = ai_filter.update_tags(old_tags, interests_content)\n\n                if update_result is None:\n                    # AI 标签更新失败，回退到重新提取全部标签\n                    print(f\"[AI筛选] AI 标签更新失败，回退到重新提取\")\n                    tags_data = ai_filter.extract_tags(interests_content)\n                    if not tags_data:\n                        storage.end_batch()\n                        return AIFilterResult(success=False, error=\"标签提取失败\")\n                    tags_data = self._with_ordered_priorities(tags_data, start_priority=1)\n                    deprecated_count = storage.deprecate_all_ai_filter_tags(interests_file=effective_interests_file)\n                    storage.clear_analyzed_news(interests_file=effective_interests_file)\n                    saved_count = storage.save_ai_filter_tags(tags_data, new_version, current_hash, interests_file=effective_interests_file)\n                    print(f\"[AI筛选] 废弃 {deprecated_count} 个旧标签, 保存 {saved_count} 个新标签 (版本 {new_version})\")\n                else:\n                    change_ratio = update_result[\"change_ratio\"]\n                    keep_tags = update_result[\"keep\"]\n                    add_tags = update_result[\"add\"]\n                    remove_tags = update_result[\"remove\"]\n\n                    if debug:\n                        print(f\"[AI筛选][DEBUG] AI 标签更新: keep={len(keep_tags)}, add={len(add_tags)}, remove={len(remove_tags)}, change_ratio={change_ratio:.2f}, threshold={threshold:.2f}\")\n\n                    if change_ratio >= threshold:\n                        # 全量重分类：废弃所有旧标签，用 extract_tags 重新提取\n                        print(f\"[AI筛选] 兴趣文件变更: {effective_interests_file} (AI change_ratio={change_ratio:.2f} >= threshold={threshold:.2f} → 全量重分类)\")\n                        tags_data = ai_filter.extract_tags(interests_content)\n                        if not tags_data:\n                            storage.end_batch()\n                            return AIFilterResult(success=False, error=\"标签提取失败\")\n                        tags_data = self._with_ordered_priorities(tags_data, start_priority=1)\n                        deprecated_count = storage.deprecate_all_ai_filter_tags(interests_file=effective_interests_file)\n                        storage.clear_analyzed_news(interests_file=effective_interests_file)\n                        saved_count = storage.save_ai_filter_tags(tags_data, new_version, current_hash, interests_file=effective_interests_file)\n                        print(f\"[AI筛选] 废弃 {deprecated_count} 个旧标签, 保存 {saved_count} 个新标签 (版本 {new_version})\")\n                    else:\n                        # 增量更新：按 AI 指示操作\n                        print(f\"[AI筛选] 兴趣文件变更: {effective_interests_file} (AI change_ratio={change_ratio:.2f} < threshold={threshold:.2f} → 增量更新)\")\n                        print(f\"[AI筛选]   保留 {len(keep_tags)} 个标签, 新增 {len(add_tags)} 个, 废弃 {len(remove_tags)} 个\")\n\n                        # 废弃 AI 标记移除的标签\n                        if remove_tags:\n                            remove_set = set(remove_tags)\n                            removed_ids = [t[\"id\"] for t in old_tags if t[\"tag\"] in remove_set]\n                            if removed_ids:\n                                storage.deprecate_specific_ai_filter_tags(removed_ids)\n                                if debug:\n                                    print(f\"[AI筛选][DEBUG] 废弃标签 IDs: {removed_ids}\")\n\n                        # 更新保留标签的描述\n                        keep_with_priority = []\n                        if keep_tags:\n                            storage.update_ai_filter_tag_descriptions(keep_tags, interests_file=effective_interests_file)\n                            keep_with_priority = self._with_ordered_priorities(keep_tags, start_priority=1)\n                            storage.update_ai_filter_tag_priorities(keep_with_priority, interests_file=effective_interests_file)\n\n                        # 保存新增标签\n                        if add_tags:\n                            add_start = keep_with_priority[-1][\"priority\"] + 1 if keep_with_priority else 1\n                            add_with_priority = self._with_ordered_priorities(add_tags, start_priority=add_start)\n                            saved_count = storage.save_ai_filter_tags(add_with_priority, new_version, current_hash, interests_file=effective_interests_file)\n                            if debug:\n                                print(f\"[AI筛选][DEBUG] 新增保存 {saved_count} 个标签\")\n\n                        # 更新保留标签的 hash（标记为已处理）\n                        storage.update_ai_filter_tags_hash(effective_interests_file, current_hash)\n\n                        # 增量更新：清除不匹配新闻的分析记录，让它们有机会被新标签集重新分析\n                        if add_tags:\n                            cleared = storage.clear_unmatched_analyzed_news(interests_file=effective_interests_file)\n                            if cleared > 0:\n                                print(f\"[AI筛选]   清除 {cleared} 条不匹配记录，将在新标签下重新分析\")\n\n        # 3. 获取当前 active 标签\n        active_tags = storage.get_active_ai_filter_tags(interests_file=effective_interests_file)\n        if debug:\n            print(f\"[AI筛选][DEBUG] 从数据库获取 active 标签: {len(active_tags)} 个\")\n            for t in active_tags:\n                print(f\"[AI筛选][DEBUG]   id={t['id']} tag={t['tag']} priority={t.get('priority', 9999)} version={t.get('version')} hash={t.get('prompt_hash', '')[:8]}...\")\n\n        if not active_tags:\n            storage.end_batch()\n            return AIFilterResult(success=False, error=\"没有可用的标签\")\n\n        print(f\"[AI筛选] 使用 {len(active_tags)} 个标签\")\n\n        # 4. 收集待分类新闻\n        # 热榜\n        all_news = storage.get_all_news_ids()\n        analyzed_hotlist = storage.get_analyzed_news_ids(\"hotlist\", interests_file=effective_interests_file)\n        pending_news = [n for n in all_news if n[\"id\"] not in analyzed_hotlist]\n\n        # RSS（先做新鲜度过滤，再去除已分类的）\n        pending_rss = []\n        freshness_filtered_rss = 0\n        if self.rss_enabled:\n            all_rss = storage.get_all_rss_ids()\n\n            # 应用新鲜度过滤（与推送阶段一致）\n            rss_config = self.rss_config\n            freshness_config = rss_config.get(\"FRESHNESS_FILTER\", {})\n            freshness_enabled = freshness_config.get(\"ENABLED\", True)\n            default_max_age_days = freshness_config.get(\"MAX_AGE_DAYS\", 3)\n            timezone = self.config.get(\"TIMEZONE\", DEFAULT_TIMEZONE)\n\n            # 构建 feed_id -> max_age_days 的映射\n            feed_max_age_map = {}\n            for feed_cfg in self.rss_feeds:\n                feed_id = feed_cfg.get(\"id\", \"\")\n                max_age = feed_cfg.get(\"max_age_days\")\n                if max_age is not None:\n                    try:\n                        feed_max_age_map[feed_id] = int(max_age)\n                    except (ValueError, TypeError):\n                        pass\n\n            fresh_rss = []\n            for n in all_rss:\n                published_at = n.get(\"published_at\", \"\")\n                feed_id = n.get(\"source_id\", \"\")\n                max_days = feed_max_age_map.get(feed_id, default_max_age_days)\n                if freshness_enabled and max_days > 0 and published_at:\n                    if not is_within_days(published_at, max_days, timezone):\n                        freshness_filtered_rss += 1\n                        continue\n                fresh_rss.append(n)\n\n            analyzed_rss = storage.get_analyzed_news_ids(\"rss\", interests_file=effective_interests_file)\n            pending_rss = [n for n in fresh_rss if n[\"id\"] not in analyzed_rss]\n\n        # 始终打印总量/已分析/待分析 的详细数据\n        hotlist_total = len(all_news)\n        hotlist_skipped = len(analyzed_hotlist)\n        hotlist_pending = len(pending_news)\n        print(f\"[AI筛选] 热榜: 总计 {hotlist_total} 条, 已分析跳过 {hotlist_skipped} 条, 本次发送AI分析 {hotlist_pending} 条\")\n        if self.rss_enabled:\n            rss_total = len(all_rss)\n            rss_skipped = len(analyzed_rss)\n            rss_pending = len(pending_rss)\n            freshness_info = f\", 新鲜度过滤 {freshness_filtered_rss} 条\" if freshness_filtered_rss > 0 else \"\"\n            print(f\"[AI筛选] RSS: 总计 {rss_total} 条{freshness_info}, 已分析跳过 {rss_skipped} 条, 本次发送AI分析 {rss_pending} 条\")\n\n        total_pending = len(pending_news) + len(pending_rss)\n        if total_pending == 0:\n            print(\"[AI筛选] 没有新增新闻需要分类\")\n\n        # 5. 批量分类\n        batch_size = filter_config.get(\"BATCH_SIZE\", 200)\n        batch_interval = filter_config.get(\"BATCH_INTERVAL\", 5)\n        total_results = []\n        batch_count = 0  # 跨热榜和 RSS 的全局批次计数\n\n        # 处理热榜\n        for i in range(0, len(pending_news), batch_size):\n            if batch_count > 0 and batch_interval > 0:\n                import time\n                print(f\"[AI筛选] 批次间隔等待 {batch_interval} 秒...\")\n                time.sleep(batch_interval)\n            batch = pending_news[i:i + batch_size]\n            titles_for_ai = [\n                {\"id\": n[\"id\"], \"title\": n[\"title\"], \"source\": n.get(\"source_name\", \"\")}\n                for n in batch\n            ]\n            batch_results = ai_filter.classify_batch(titles_for_ai, active_tags, interests_content)\n            for r in batch_results:\n                r[\"source_type\"] = \"hotlist\"\n            total_results.extend(batch_results)\n            batch_count += 1\n            print(f\"[AI筛选] 热榜批次 {i // batch_size + 1}: {len(batch)} 条 → {len(batch_results)} 条匹配\")\n\n        # 处理 RSS\n        for i in range(0, len(pending_rss), batch_size):\n            if batch_count > 0 and batch_interval > 0:\n                import time\n                print(f\"[AI筛选] 批次间隔等待 {batch_interval} 秒...\")\n                time.sleep(batch_interval)\n            batch = pending_rss[i:i + batch_size]\n            titles_for_ai = [\n                {\"id\": n[\"id\"], \"title\": n[\"title\"], \"source\": n.get(\"source_name\", \"\")}\n                for n in batch\n            ]\n            batch_results = ai_filter.classify_batch(titles_for_ai, active_tags, interests_content)\n            for r in batch_results:\n                r[\"source_type\"] = \"rss\"\n            total_results.extend(batch_results)\n            batch_count += 1\n            print(f\"[AI筛选] RSS 批次 {i // batch_size + 1}: {len(batch)} 条 → {len(batch_results)} 条匹配\")\n\n        # 6. 保存结果\n        if total_results:\n            saved = storage.save_ai_filter_results(total_results)\n            print(f\"[AI筛选] 保存 {saved} 条分类结果\")\n            if debug and saved != len(total_results):\n                print(f\"[AI筛选][DEBUG] !! 保存数量不一致: 期望 {len(total_results)}, 实际 {saved}（可能有重复记录被跳过）\")\n\n        # 6.5 记录所有已分析的新闻（匹配+不匹配，用于去重）\n        matched_hotlist_ids = {r[\"news_item_id\"] for r in total_results if r.get(\"source_type\") == \"hotlist\"}\n        matched_rss_ids = {r[\"news_item_id\"] for r in total_results if r.get(\"source_type\") == \"rss\"}\n\n        if pending_news:\n            hotlist_ids = [n[\"id\"] for n in pending_news]\n            storage.save_analyzed_news(\n                hotlist_ids, \"hotlist\", effective_interests_file,\n                current_hash, matched_hotlist_ids\n            )\n\n        if pending_rss:\n            rss_ids = [n[\"id\"] for n in pending_rss]\n            storage.save_analyzed_news(\n                rss_ids, \"rss\", effective_interests_file,\n                current_hash, matched_rss_ids\n            )\n\n        if pending_news or pending_rss:\n            total_analyzed = len(pending_news) + len(pending_rss)\n            total_matched = len(matched_hotlist_ids) + len(matched_rss_ids)\n            print(f\"[AI筛选] 已记录 {total_analyzed} 条新闻分析状态 (匹配 {total_matched}, 不匹配 {total_analyzed - total_matched})\")\n\n        # 7. 结束批量模式（统一上传数据库到远程存储）\n        storage.end_batch()\n\n        # 8. 查询并组装返回结果\n        all_results = storage.get_active_ai_filter_results(interests_file=effective_interests_file)\n\n        if debug:\n            print(f\"[AI筛选][DEBUG] === 最终汇总 ===\")\n            print(f\"[AI筛选][DEBUG] 数据库 active 分类结果: {len(all_results)} 条\")\n            # 按标签统计\n            tag_counts: dict = {}\n            for r in all_results:\n                tag_name = r.get(\"tag\", \"?\")\n                src_type = r.get(\"source_type\", \"?\")\n                key = f\"{tag_name}({src_type})\"\n                tag_counts[key] = tag_counts.get(key, 0) + 1\n            for key, count in sorted(tag_counts.items()):\n                print(f\"[AI筛选][DEBUG]   {key}: {count} 条\")\n\n        return self._build_filter_result(all_results, active_tags, total_pending)\n\n    def _build_filter_result(\n        self,\n        raw_results: List[Dict],\n        tags: List[Dict],\n        total_processed: int,\n    ) -> AIFilterResult:\n        \"\"\"将数据库查询结果组装为 AIFilterResult\"\"\"\n        priority_sort_enabled = self.ai_priority_sort_enabled\n        tag_priority_map = {}\n        for idx, t in enumerate(tags, start=1):\n            tag_name = str(t.get(\"tag\", \"\")).strip() if isinstance(t, dict) else \"\"\n            if not tag_name:\n                continue\n            try:\n                tag_priority_map[tag_name] = int(t.get(\"priority\", idx))\n            except (TypeError, ValueError):\n                tag_priority_map[tag_name] = idx\n\n        # 按标签分组\n        tag_groups: Dict[str, Dict] = {}\n        seen_titles: Dict[str, set] = {}  # 每个标签下去重\n\n        for r in raw_results:\n            tag_name = r[\"tag\"]\n            if tag_name not in tag_groups:\n                raw_priority = r.get(\"tag_priority\", tag_priority_map.get(tag_name, 9999))\n                try:\n                    tag_position = int(raw_priority)\n                except (TypeError, ValueError):\n                    tag_position = 9999\n                tag_groups[tag_name] = {\n                    \"tag\": tag_name,\n                    \"description\": r.get(\"tag_description\", \"\"),\n                    \"position\": tag_position,\n                    \"count\": 0,\n                    \"items\": [],\n                }\n                seen_titles[tag_name] = set()\n\n            title = r[\"title\"]\n            if title in seen_titles[tag_name]:\n                continue\n            seen_titles[tag_name].add(title)\n\n            tag_groups[tag_name][\"items\"].append({\n                \"title\": title,\n                \"source_id\": r.get(\"source_id\", \"\"),\n                \"source_name\": r.get(\"source_name\", \"\"),\n                \"url\": r.get(\"url\", \"\"),\n                \"mobile_url\": r.get(\"mobile_url\", \"\"),\n                \"rank\": r.get(\"rank\", 0),\n                \"ranks\": r.get(\"ranks\", []),\n                \"first_time\": r.get(\"first_time\", \"\"),\n                \"last_time\": r.get(\"last_time\", \"\"),\n                \"count\": r.get(\"count\", 1),\n                \"relevance_score\": r.get(\"relevance_score\", 0),\n                \"source_type\": r.get(\"source_type\", \"hotlist\"),\n            })\n            tag_groups[tag_name][\"count\"] += 1\n\n        # 根据配置排序：位置优先 / 数量优先\n        if priority_sort_enabled:\n            sorted_tags = sorted(\n                tag_groups.values(),\n                key=lambda x: (x.get(\"position\", 9999), -x[\"count\"], x[\"tag\"]),\n            )\n        else:\n            sorted_tags = sorted(\n                tag_groups.values(),\n                key=lambda x: (-x[\"count\"], x.get(\"position\", 9999), x[\"tag\"]),\n            )\n\n        total_matched = sum(t[\"count\"] for t in sorted_tags)\n\n        return AIFilterResult(\n            tags=sorted_tags,\n            total_matched=total_matched,\n            total_processed=total_processed,\n            success=True,\n        )\n\n    def convert_ai_filter_to_report_data(\n        self,\n        ai_filter_result: AIFilterResult,\n        mode: str = \"daily\",\n        new_titles: Optional[Dict] = None,\n        rss_new_urls: Optional[set] = None,\n    ) -> tuple:\n        \"\"\"\n        将 AI 筛选结果转换为与关键词匹配相同的数据结构\n\n        AIFilterResult.tags 中每个 tag 对应一个 \"word\"（关键词组）。\n        tag.items 中 source_type=\"hotlist\" 的条目进入热榜 stats，\n        source_type=\"rss\" 的条目进入 rss_items stats。\n\n        Args:\n            ai_filter_result: AI 筛选结果\n            mode: 报告模式 (\"daily\" | \"current\" | \"incremental\")\n            new_titles: 热榜新增标题 {source_id: {title: data}}，用于 is_new 检测\n            rss_new_urls: 新增 RSS 条目的 URL 集合，用于 is_new 检测\n\n        Returns:\n            (hotlist_stats, rss_stats):\n            - hotlist_stats: 与 count_word_frequency() 产出格式一致\n            - rss_stats: 与 rss_items 格式一致\n        \"\"\"\n        hotlist_stats = []\n        rss_stats = []\n        max_news = self.config.get(\"MAX_NEWS_PER_KEYWORD\", 0)\n        min_score = self.ai_filter_config.get(\"MIN_SCORE\", 0)\n\n        # current 模式：计算最新时间，只保留当前在榜的热榜新闻\n        # 与 count_word_frequency(mode=\"current\") 的过滤逻辑对齐\n        latest_time = None\n        if mode == \"current\":\n            for tag_data in ai_filter_result.tags:\n                for item in tag_data.get(\"items\", []):\n                    if item.get(\"source_type\", \"hotlist\") == \"hotlist\":\n                        last_time = item.get(\"last_time\", \"\")\n                        if last_time and (latest_time is None or last_time > latest_time):\n                            latest_time = last_time\n            if latest_time:\n                print(f\"[AI筛选] current 模式：最新时间 {latest_time}，过滤已下榜新闻\")\n\n        # RSS 新鲜度过滤配置（与推送阶段一致）\n        rss_config = self.rss_config\n        freshness_config = rss_config.get(\"FRESHNESS_FILTER\", {})\n        freshness_enabled = freshness_config.get(\"ENABLED\", True)\n        default_max_age_days = freshness_config.get(\"MAX_AGE_DAYS\", 3)\n        timezone = self.config.get(\"TIMEZONE\", DEFAULT_TIMEZONE)\n\n        feed_max_age_map = {}\n        for feed_cfg in self.rss_feeds:\n            feed_id = feed_cfg.get(\"id\", \"\")\n            max_age = feed_cfg.get(\"max_age_days\")\n            if max_age is not None:\n                try:\n                    feed_max_age_map[feed_id] = int(max_age)\n                except (ValueError, TypeError):\n                    pass\n\n        filtered_count = 0\n        for tag_data in ai_filter_result.tags:\n            tag_name = tag_data.get(\"tag\", \"\")\n            items = tag_data.get(\"items\", [])\n            if not items:\n                continue\n\n            hotlist_titles = []\n            rss_titles = []\n\n            for item in items:\n                source_type = item.get(\"source_type\", \"hotlist\")\n\n                # current 模式：跳过已下榜的热榜新闻\n                if mode == \"current\" and latest_time and source_type == \"hotlist\":\n                    if item.get(\"last_time\", \"\") != latest_time:\n                        filtered_count += 1\n                        continue\n\n                # 分数阈值过滤：跳过相关度低于 min_score 的新闻\n                if min_score > 0:\n                    score = item.get(\"relevance_score\", 0)\n                    if score < min_score:\n                        continue\n\n                # 构建时间显示\n                first_time = item.get(\"first_time\", \"\")\n                last_time = item.get(\"last_time\", \"\")\n                if source_type == \"rss\":\n                    # RSS 新鲜度过滤：跳过超过 max_age_days 的旧文章\n                    if freshness_enabled and first_time:\n                        feed_id = item.get(\"source_id\", \"\")\n                        max_days = feed_max_age_map.get(feed_id, default_max_age_days)\n                        if max_days > 0 and not is_within_days(first_time, max_days, timezone):\n                            continue\n\n                    # RSS 条目：first_time 是 ISO 格式，用友好格式显示\n                    if first_time:\n                        time_display = format_iso_time_friendly(first_time, timezone, include_date=True)\n                    else:\n                        time_display = \"\"\n                else:\n                    # 热榜条目：使用 [HH:MM ~ HH:MM] 格式（与 keyword 模式一致）\n                    if first_time and last_time and first_time != last_time:\n                        first_display = convert_time_for_display(first_time)\n                        last_display = convert_time_for_display(last_time)\n                        time_display = f\"[{first_display} ~ {last_display}]\"\n                    elif first_time:\n                        time_display = convert_time_for_display(first_time)\n                    else:\n                        time_display = \"\"\n\n                # 计算 is_new（与 keyword 模式 core/analyzer.py:335-342 对齐）\n                if source_type == \"rss\":\n                    is_new = False\n                    if rss_new_urls:\n                        item_url = item.get(\"url\", \"\")\n                        is_new = item_url in rss_new_urls if item_url else False\n                else:\n                    is_new = False\n                    if new_titles:\n                        item_source_id = item.get(\"source_id\", \"\")\n                        item_title = item.get(\"title\", \"\")\n                        if item_source_id in new_titles:\n                            is_new = item_title in new_titles[item_source_id]\n\n                # incremental 模式下仅保留本轮新增命中的条目。\n                # run_ai_filter() 返回的是 active 结果集合，因此这里需要\n                # 显式过滤掉历史已命中的旧条目，才能与 keyword 模式行为对齐。\n                if mode == \"incremental\" and not is_new:\n                    continue\n\n                title_entry = {\n                    \"title\": item.get(\"title\", \"\"),\n                    \"source_name\": item.get(\"source_name\", \"\"),\n                    \"url\": item.get(\"url\", \"\"),\n                    \"mobile_url\": item.get(\"mobile_url\", \"\"),\n                    \"ranks\": item.get(\"ranks\", []),\n                    \"rank_threshold\": self.rank_threshold,\n                    \"count\": item.get(\"count\", 1),\n                    \"is_new\": is_new,\n                    \"time_display\": time_display,\n                    \"matched_keyword\": tag_name,\n                }\n\n                if source_type == \"rss\":\n                    rss_titles.append(title_entry)\n                else:\n                    hotlist_titles.append(title_entry)\n\n            if hotlist_titles:\n                if max_news > 0:\n                    hotlist_titles = hotlist_titles[:max_news]\n                hotlist_stats.append({\n                    \"word\": tag_name,\n                    \"count\": len(hotlist_titles),\n                    \"position\": tag_data.get(\"position\", 9999),\n                    \"titles\": hotlist_titles,\n                })\n\n            if rss_titles:\n                if max_news > 0:\n                    rss_titles = rss_titles[:max_news]\n                rss_stats.append({\n                    \"word\": tag_name,\n                    \"count\": len(rss_titles),\n                    \"position\": tag_data.get(\"position\", 9999),\n                    \"titles\": rss_titles,\n                })\n\n        if mode == \"current\" and filtered_count > 0:\n            total_kept = sum(s[\"count\"] for s in hotlist_stats)\n            print(f\"[AI筛选] current 模式：过滤 {filtered_count} 条已下榜新闻，保留 {total_kept} 条当前在榜\")\n\n        if min_score > 0:\n            hotlist_kept = sum(s[\"count\"] for s in hotlist_stats)\n            rss_kept = sum(s[\"count\"] for s in rss_stats)\n            total_kept = hotlist_kept + rss_kept\n            parts = [f\"热榜 {hotlist_kept} 条\"]\n            if rss_kept > 0:\n                parts.append(f\"RSS {rss_kept} 条\")\n            print(f\"[AI筛选] 分数过滤：min_score={min_score}，保留 {total_kept} 条 score≥{min_score} ({', '.join(parts)})\")\n\n        priority_sort_enabled = self.ai_priority_sort_enabled\n        if priority_sort_enabled:\n            hotlist_stats.sort(key=lambda x: (x.get(\"position\", 9999), -x[\"count\"], x[\"word\"]))\n            rss_stats.sort(key=lambda x: (x.get(\"position\", 9999), -x[\"count\"], x[\"word\"]))\n        else:\n            hotlist_stats.sort(key=lambda x: (-x[\"count\"], x.get(\"position\", 9999), x[\"word\"]))\n            rss_stats.sort(key=lambda x: (-x[\"count\"], x.get(\"position\", 9999), x[\"word\"]))\n\n        return hotlist_stats, rss_stats\n\n    # === 资源清理 ===\n\n    def cleanup(self):\n        \"\"\"清理资源\"\"\"\n        if self._storage_manager:\n            self._storage_manager.cleanup_old_data()\n            self._storage_manager.cleanup()\n            self._storage_manager = None\n"
  },
  {
    "path": "trendradar/core/__init__.py",
    "content": "# coding=utf-8\n\"\"\"\n核心模块 - 配置管理和核心工具\n\"\"\"\n\nfrom trendradar.core.config import (\n    parse_multi_account_config,\n    validate_paired_configs,\n    limit_accounts,\n    get_account_at_index,\n)\nfrom trendradar.core.loader import load_config\nfrom trendradar.core.frequency import load_frequency_words, matches_word_groups\nfrom trendradar.core.scheduler import Scheduler, ResolvedSchedule\nfrom trendradar.core.data import (\n    read_all_today_titles_from_storage,\n    read_all_today_titles,\n    detect_latest_new_titles_from_storage,\n    detect_latest_new_titles,\n)\nfrom trendradar.core.analyzer import (\n    calculate_news_weight,\n    format_time_display,\n    count_word_frequency,\n    count_rss_frequency,\n)\n\n__all__ = [\n    \"parse_multi_account_config\",\n    \"validate_paired_configs\",\n    \"limit_accounts\",\n    \"get_account_at_index\",\n    \"load_config\",\n    \"load_frequency_words\",\n    \"matches_word_groups\",\n    # 数据处理\n    \"read_all_today_titles_from_storage\",\n    \"read_all_today_titles\",\n    \"detect_latest_new_titles_from_storage\",\n    \"detect_latest_new_titles\",\n    # 统计分析\n    \"calculate_news_weight\",\n    \"format_time_display\",\n    \"count_word_frequency\",\n    \"count_rss_frequency\",\n    # 调度器\n    \"Scheduler\",\n    \"ResolvedSchedule\",\n]\n"
  },
  {
    "path": "trendradar/core/analyzer.py",
    "content": "# coding=utf-8\n\"\"\"\n统计分析模块\n\n提供新闻统计和分析功能：\n- calculate_news_weight: 计算新闻权重\n- format_time_display: 格式化时间显示\n- count_word_frequency: 统计词频\n\"\"\"\n\nfrom typing import Dict, List, Tuple, Optional, Callable\n\nfrom trendradar.core.frequency import matches_word_groups, _word_matches\nfrom trendradar.utils.time import DEFAULT_TIMEZONE\n\n\ndef calculate_news_weight(\n    title_data: Dict,\n    rank_threshold: int,\n    weight_config: Dict,\n) -> float:\n    \"\"\"\n    计算新闻权重，用于排序\n\n    Args:\n        title_data: 标题数据，包含 ranks 和 count\n        rank_threshold: 排名阈值\n        weight_config: 权重配置 {RANK_WEIGHT, FREQUENCY_WEIGHT, HOTNESS_WEIGHT}\n\n    Returns:\n        float: 计算出的权重值\n    \"\"\"\n    ranks = title_data.get(\"ranks\", [])\n    if not ranks:\n        return 0.0\n\n    count = title_data.get(\"count\", len(ranks))\n\n    # 排名权重：Σ(11 - min(rank, 10)) / 出现次数\n    rank_scores = []\n    for rank in ranks:\n        score = 11 - min(rank, 10)\n        rank_scores.append(score)\n\n    rank_weight = sum(rank_scores) / len(ranks) if ranks else 0\n\n    # 频次权重：min(出现次数, 10) × 10\n    frequency_weight = min(count, 10) * 10\n\n    # 热度加成：高排名次数 / 总出现次数 × 100\n    high_rank_count = sum(1 for rank in ranks if rank <= rank_threshold)\n    hotness_ratio = high_rank_count / len(ranks) if ranks else 0\n    hotness_weight = hotness_ratio * 100\n\n    total_weight = (\n        rank_weight * weight_config[\"RANK_WEIGHT\"]\n        + frequency_weight * weight_config[\"FREQUENCY_WEIGHT\"]\n        + hotness_weight * weight_config[\"HOTNESS_WEIGHT\"]\n    )\n\n    return total_weight\n\n\ndef format_time_display(\n    first_time: str,\n    last_time: str,\n    convert_time_func: Callable[[str], str],\n) -> str:\n    \"\"\"\n    格式化时间显示（将 HH-MM 转换为 HH:MM）\n\n    Args:\n        first_time: 首次出现时间\n        last_time: 最后出现时间\n        convert_time_func: 时间格式转换函数\n\n    Returns:\n        str: 格式化后的时间显示字符串\n    \"\"\"\n    if not first_time:\n        return \"\"\n    # 转换为显示格式\n    first_display = convert_time_func(first_time)\n    last_display = convert_time_func(last_time)\n    if first_display == last_display or not last_display:\n        return first_display\n    else:\n        return f\"[{first_display} ~ {last_display}]\"\n\n\ndef count_word_frequency(\n    results: Dict,\n    word_groups: List[Dict],\n    filter_words: List[str],\n    id_to_name: Dict,\n    title_info: Optional[Dict] = None,\n    rank_threshold: int = 3,\n    new_titles: Optional[Dict] = None,\n    mode: str = \"daily\",\n    global_filters: Optional[List[str]] = None,\n    weight_config: Optional[Dict] = None,\n    max_news_per_keyword: int = 0,\n    sort_by_position_first: bool = False,\n    is_first_crawl_func: Optional[Callable[[], bool]] = None,\n    convert_time_func: Optional[Callable[[str], str]] = None,\n    quiet: bool = False,\n) -> Tuple[List[Dict], int]:\n    \"\"\"\n    统计词频，支持必须词、频率词、过滤词、全局过滤词，并标记新增标题\n\n    Args:\n        results: 抓取结果 {source_id: {title: title_data}}\n        word_groups: 词组配置列表\n        filter_words: 过滤词列表\n        id_to_name: ID 到名称的映射\n        title_info: 标题统计信息（可选）\n        rank_threshold: 排名阈值\n        new_titles: 新增标题（可选）\n        mode: 报告模式 (daily/incremental/current)\n        global_filters: 全局过滤词（可选）\n        weight_config: 权重配置\n        max_news_per_keyword: 每个关键词最大显示数量\n        sort_by_position_first: 是否优先按配置位置排序\n        is_first_crawl_func: 检测是否是当天第一次爬取的函数\n        convert_time_func: 时间格式转换函数\n        quiet: 是否静默模式（不打印日志）\n\n    Returns:\n        Tuple[List[Dict], int]: (统计结果列表, 总标题数)\n    \"\"\"\n    # 默认权重配置\n    if weight_config is None:\n        weight_config = {\n            \"RANK_WEIGHT\": 0.4,\n            \"FREQUENCY_WEIGHT\": 0.3,\n            \"HOTNESS_WEIGHT\": 0.3,\n        }\n\n    # 默认时间转换函数\n    if convert_time_func is None:\n        convert_time_func = lambda x: x\n\n    # 默认首次爬取检测函数\n    if is_first_crawl_func is None:\n        is_first_crawl_func = lambda: True\n\n    # 如果没有配置词组，创建一个包含所有新闻的虚拟词组\n    if not word_groups:\n        print(\"频率词配置为空，将显示所有新闻\")\n        word_groups = [{\"required\": [], \"normal\": [], \"group_key\": \"全部新闻\"}]\n        filter_words = []  # 清空过滤词，显示所有新闻\n\n    is_first_today = is_first_crawl_func()\n\n    # 确定处理的数据源和新增标记逻辑\n    if mode == \"incremental\":\n        if is_first_today:\n            # 增量模式 + 当天第一次：处理所有新闻，都标记为新增\n            results_to_process = results\n            all_news_are_new = True\n        else:\n            # 增量模式 + 当天非第一次：只处理新增的新闻\n            results_to_process = new_titles if new_titles else {}\n            all_news_are_new = True\n    elif mode == \"current\":\n        # current 模式：只处理当前时间批次的新闻，但统计信息来自全部历史\n        if title_info:\n            latest_time = None\n            for source_titles in title_info.values():\n                for title_data in source_titles.values():\n                    last_time = title_data.get(\"last_time\", \"\")\n                    if last_time:\n                        if latest_time is None or last_time > latest_time:\n                            latest_time = last_time\n\n            # 只处理 last_time 等于最新时间的新闻\n            if latest_time:\n                results_to_process = {}\n                for source_id, source_titles in results.items():\n                    if source_id in title_info:\n                        filtered_titles = {}\n                        for title, title_data in source_titles.items():\n                            if title in title_info[source_id]:\n                                info = title_info[source_id][title]\n                                if info.get(\"last_time\") == latest_time:\n                                    filtered_titles[title] = title_data\n                        if filtered_titles:\n                            results_to_process[source_id] = filtered_titles\n\n                if not quiet:\n                    print(\n                        f\"当前榜单模式：最新时间 {latest_time}，筛选出 {sum(len(titles) for titles in results_to_process.values())} 条当前榜单新闻\"\n                    )\n            else:\n                results_to_process = results\n        else:\n            results_to_process = results\n        all_news_are_new = False\n    else:\n        # 当日汇总模式：处理所有新闻\n        results_to_process = results\n        all_news_are_new = False\n        total_input_news = sum(len(titles) for titles in results.values())\n        filter_status = (\n            \"全部显示\"\n            if len(word_groups) == 1 and word_groups[0][\"group_key\"] == \"全部新闻\"\n            else \"频率词过滤\"\n        )\n        print(f\"当日汇总模式：处理 {total_input_news} 条新闻，模式：{filter_status}\")\n\n    word_stats = {}\n    total_titles = 0\n    processed_titles = {}\n    matched_new_count = 0\n\n    if title_info is None:\n        title_info = {}\n    if new_titles is None:\n        new_titles = {}\n\n    for group in word_groups:\n        group_key = group[\"group_key\"]\n        word_stats[group_key] = {\"count\": 0, \"titles\": {}}\n\n    for source_id, titles_data in results_to_process.items():\n        total_titles += len(titles_data)\n\n        if source_id not in processed_titles:\n            processed_titles[source_id] = {}\n\n        for title, title_data in titles_data.items():\n            if title in processed_titles.get(source_id, {}):\n                continue\n\n            # 使用统一的匹配逻辑\n            matches_frequency_words = matches_word_groups(\n                title, word_groups, filter_words, global_filters\n            )\n\n            if not matches_frequency_words:\n                continue\n\n            # 如果是增量模式或 current 模式第一次，统计匹配的新增新闻数量\n            if (mode == \"incremental\" and all_news_are_new) or (\n                mode == \"current\" and is_first_today\n            ):\n                matched_new_count += 1\n\n            source_ranks = title_data.get(\"ranks\", [])\n            source_url = title_data.get(\"url\", \"\")\n            source_mobile_url = title_data.get(\"mobileUrl\", \"\")\n\n            # 找到匹配的词组（防御性转换确保类型安全）\n            title_lower = str(title).lower() if not isinstance(title, str) else title.lower()\n            for group in word_groups:\n                required_words = group[\"required\"]\n                normal_words = group[\"normal\"]\n\n                # 如果是\"全部新闻\"模式，所有标题都匹配第一个（唯一的）词组\n                if len(word_groups) == 1 and word_groups[0][\"group_key\"] == \"全部新闻\":\n                    group_key = group[\"group_key\"]\n                    word_stats[group_key][\"count\"] += 1\n                    if source_id not in word_stats[group_key][\"titles\"]:\n                        word_stats[group_key][\"titles\"][source_id] = []\n                else:\n                    # 原有的匹配逻辑（支持正则语法）\n                    if required_words:\n                        all_required_present = all(\n                            _word_matches(req_item, title_lower)\n                            for req_item in required_words\n                        )\n                        if not all_required_present:\n                            continue\n\n                    if normal_words:\n                        any_normal_present = any(\n                            _word_matches(normal_item, title_lower)\n                            for normal_item in normal_words\n                        )\n                        if not any_normal_present:\n                            continue\n\n                    group_key = group[\"group_key\"]\n                    word_stats[group_key][\"count\"] += 1\n                    if source_id not in word_stats[group_key][\"titles\"]:\n                        word_stats[group_key][\"titles\"][source_id] = []\n\n                first_time = \"\"\n                last_time = \"\"\n                count_info = 1\n                ranks = source_ranks if source_ranks else []\n                url = source_url\n                mobile_url = source_mobile_url\n                rank_timeline = []\n\n                # 对于 current 模式，从历史统计信息中获取完整数据\n                if (\n                    mode == \"current\"\n                    and title_info\n                    and source_id in title_info\n                    and title in title_info[source_id]\n                ):\n                    info = title_info[source_id][title]\n                    first_time = info.get(\"first_time\", \"\")\n                    last_time = info.get(\"last_time\", \"\")\n                    count_info = info.get(\"count\", 1)\n                    if \"ranks\" in info and info[\"ranks\"]:\n                        ranks = info[\"ranks\"]\n                    url = info.get(\"url\", source_url)\n                    mobile_url = info.get(\"mobileUrl\", source_mobile_url)\n                    rank_timeline = info.get(\"rank_timeline\", [])\n                elif (\n                    title_info\n                    and source_id in title_info\n                    and title in title_info[source_id]\n                ):\n                    info = title_info[source_id][title]\n                    first_time = info.get(\"first_time\", \"\")\n                    last_time = info.get(\"last_time\", \"\")\n                    count_info = info.get(\"count\", 1)\n                    if \"ranks\" in info and info[\"ranks\"]:\n                        ranks = info[\"ranks\"]\n                    url = info.get(\"url\", source_url)\n                    mobile_url = info.get(\"mobileUrl\", source_mobile_url)\n                    rank_timeline = info.get(\"rank_timeline\", [])\n\n                if not ranks:\n                    ranks = [99]\n\n                time_display = format_time_display(first_time, last_time, convert_time_func)\n\n                source_name = id_to_name.get(source_id, source_id)\n\n                # 判断是否为新增\n                is_new = False\n                if all_news_are_new:\n                    # 增量模式下所有处理的新闻都是新增，或者当天第一次的所有新闻都是新增\n                    is_new = True\n                elif new_titles and source_id in new_titles:\n                    # 检查是否在新增列表中\n                    new_titles_for_source = new_titles[source_id]\n                    is_new = title in new_titles_for_source\n\n                word_stats[group_key][\"titles\"][source_id].append(\n                    {\n                        \"title\": title,\n                        \"source_name\": source_name,\n                        \"first_time\": first_time,\n                        \"last_time\": last_time,\n                        \"time_display\": time_display,\n                        \"count\": count_info,\n                        \"ranks\": ranks,\n                        \"rank_threshold\": rank_threshold,\n                        \"url\": url,\n                        \"mobileUrl\": mobile_url,\n                        \"is_new\": is_new,\n                        \"rank_timeline\": rank_timeline,\n                    }\n                )\n\n                if source_id not in processed_titles:\n                    processed_titles[source_id] = {}\n                processed_titles[source_id][title] = True\n\n                break\n\n    # 最后统一打印汇总信息\n    if mode == \"incremental\":\n        if is_first_today:\n            total_input_news = sum(len(titles) for titles in results.values())\n            filter_status = (\n                \"全部显示\"\n                if len(word_groups) == 1 and word_groups[0][\"group_key\"] == \"全部新闻\"\n                else \"频率词匹配\"\n            )\n            if not quiet:\n                print(\n                    f\"增量模式：当天第一次爬取，{total_input_news} 条新闻中有 {matched_new_count} 条{filter_status}\"\n                )\n        else:\n            if new_titles:\n                total_new_count = sum(len(titles) for titles in new_titles.values())\n                filter_status = (\n                    \"全部显示\"\n                    if len(word_groups) == 1\n                    and word_groups[0][\"group_key\"] == \"全部新闻\"\n                    else \"匹配频率词\"\n                )\n                if not quiet:\n                    print(\n                        f\"增量模式：{total_new_count} 条新增新闻中，有 {matched_new_count} 条{filter_status}\"\n                    )\n                    if matched_new_count == 0 and len(word_groups) > 1:\n                        print(\"增量模式：没有新增新闻匹配频率词，将不会发送通知\")\n            else:\n                if not quiet:\n                    print(\"增量模式：未检测到新增新闻\")\n    elif mode == \"current\":\n        total_input_news = sum(len(titles) for titles in results_to_process.values())\n        if is_first_today:\n            filter_status = (\n                \"全部显示\"\n                if len(word_groups) == 1 and word_groups[0][\"group_key\"] == \"全部新闻\"\n                else \"频率词匹配\"\n            )\n            if not quiet:\n                print(\n                    f\"当前榜单模式：当天第一次爬取，{total_input_news} 条当前榜单新闻中有 {matched_new_count} 条{filter_status}\"\n                )\n        else:\n            matched_count = sum(stat[\"count\"] for stat in word_stats.values())\n            filter_status = (\n                \"全部显示\"\n                if len(word_groups) == 1 and word_groups[0][\"group_key\"] == \"全部新闻\"\n                else \"频率词匹配\"\n            )\n            if not quiet:\n                print(\n                    f\"当前榜单模式：{total_input_news} 条当前榜单新闻中有 {matched_count} 条{filter_status}\"\n                )\n\n    stats = []\n    # 创建 group_key 到位置、最大数量、显示名称的映射\n    group_key_to_position = {\n        group[\"group_key\"]: idx for idx, group in enumerate(word_groups)\n    }\n    group_key_to_max_count = {\n        group[\"group_key\"]: group.get(\"max_count\", 0) for group in word_groups\n    }\n    group_key_to_display_name = {\n        group[\"group_key\"]: group.get(\"display_name\") for group in word_groups\n    }\n\n    for group_key, data in word_stats.items():\n        all_titles = []\n        for source_id, title_list in data[\"titles\"].items():\n            all_titles.extend(title_list)\n\n        # 按权重排序\n        sorted_titles = sorted(\n            all_titles,\n            key=lambda x: (\n                -calculate_news_weight(x, rank_threshold, weight_config),\n                min(x[\"ranks\"]) if x[\"ranks\"] else 999,\n                -x[\"count\"],\n            ),\n        )\n\n        # 应用最大显示数量限制（优先级：单独配置 > 全局配置）\n        group_max_count = group_key_to_max_count.get(group_key, 0)\n        if group_max_count == 0:\n            # 使用全局配置\n            group_max_count = max_news_per_keyword\n\n        if group_max_count > 0:\n            sorted_titles = sorted_titles[:group_max_count]\n\n        # 优先使用 display_name，否则使用 group_key\n        display_word = group_key_to_display_name.get(group_key) or group_key\n\n        stats.append(\n            {\n                \"word\": display_word,\n                \"count\": data[\"count\"],\n                \"position\": group_key_to_position.get(group_key, 999),\n                \"titles\": sorted_titles,\n                \"percentage\": (\n                    round(data[\"count\"] / total_titles * 100, 2)\n                    if total_titles > 0\n                    else 0\n                ),\n            }\n        )\n\n    # 根据配置选择排序优先级\n    if sort_by_position_first:\n        # 先按配置位置，再按热点条数\n        stats.sort(key=lambda x: (x[\"position\"], -x[\"count\"]))\n    else:\n        # 先按热点条数，再按配置位置（原逻辑）\n        stats.sort(key=lambda x: (-x[\"count\"], x[\"position\"]))\n\n    # 打印过滤后的匹配新闻数\n    matched_news_count = sum(len(stat[\"titles\"]) for stat in stats if stat[\"count\"] > 0)\n    if not quiet and mode == \"daily\":\n        print(f\"当日汇总模式：处理 {total_titles} 条新闻，模式：频率词过滤\")\n        print(f\"频率词过滤后：{matched_news_count} 条新闻匹配\")\n\n    return stats, total_titles\n\n\ndef count_rss_frequency(\n    rss_items: List[Dict],\n    word_groups: List[Dict],\n    filter_words: List[str],\n    global_filters: Optional[List[str]] = None,\n    new_items: Optional[List[Dict]] = None,\n    max_news_per_keyword: int = 0,\n    sort_by_position_first: bool = False,\n    timezone: str = DEFAULT_TIMEZONE,\n    rank_threshold: int = 5,\n    quiet: bool = False,\n) -> Tuple[List[Dict], int]:\n    \"\"\"\n    按关键词分组统计 RSS 条目（与热榜统计格式一致）\n\n    Args:\n        rss_items: RSS 条目列表，每个条目包含：\n            - title: 标题\n            - feed_id: RSS 源 ID\n            - feed_name: RSS 源名称\n            - url: 文章链接\n            - published_at: 发布时间（ISO 格式）\n        word_groups: 词组配置列表\n        filter_words: 过滤词列表\n        global_filters: 全局过滤词（可选）\n        new_items: 新增条目列表（可选，用于标记 is_new）\n        max_news_per_keyword: 每个关键词最大显示数量\n        sort_by_position_first: 是否优先按配置位置排序\n        timezone: 时区名称（用于时间格式化）\n        quiet: 是否静默模式\n\n    Returns:\n        Tuple[List[Dict], int]: (统计结果列表, 总条目数)\n        统计结果格式与热榜一致：\n        [\n            {\n                \"word\": \"关键词\",\n                \"count\": 5,\n                \"position\": 0,\n                \"titles\": [\n                    {\n                        \"title\": \"标题\",\n                        \"source_name\": \"Hacker News\",\n                        \"time_display\": \"12-29 08:20\",\n                        \"count\": 1,\n                        \"ranks\": [1],  # RSS 用发布时间顺序作为排名\n                        \"rank_threshold\": 50,\n                        \"url\": \"...\",\n                        \"mobile_url\": \"\",\n                        \"is_new\": True/False\n                    }\n                ],\n                \"percentage\": 10.0\n            }\n        ]\n    \"\"\"\n    from trendradar.utils.time import format_iso_time_friendly\n\n    if not rss_items:\n        return [], 0\n\n    # 如果没有配置词组，创建一个包含所有条目的虚拟词组\n    if not word_groups:\n        if not quiet:\n            print(\"[RSS] 频率词配置为空，将显示所有 RSS 条目\")\n        word_groups = [{\"required\": [], \"normal\": [], \"group_key\": \"全部 RSS\"}]\n        filter_words = []\n\n    # 创建新增条目的 URL 集合，用于快速查找\n    new_urls = set()\n    if new_items:\n        for item in new_items:\n            if item.get(\"url\"):\n                new_urls.add(item[\"url\"])\n\n    # 初始化词组统计\n    word_stats = {}\n    for group in word_groups:\n        group_key = group[\"group_key\"]\n        word_stats[group_key] = {\"count\": 0, \"titles\": []}\n\n    total_items = len(rss_items)\n    processed_urls = set()  # 用于去重\n\n    # 为每个条目分配一个基于发布时间的\"排名\"\n    # 按发布时间排序，最新的排在前面\n    sorted_items = sorted(\n        rss_items,\n        key=lambda x: x.get(\"published_at\", \"\"),\n        reverse=True\n    )\n    url_to_rank = {item.get(\"url\", \"\"): idx + 1 for idx, item in enumerate(sorted_items)}\n\n    for item in rss_items:\n        title = item.get(\"title\", \"\")\n        url = item.get(\"url\", \"\")\n\n        # 去重\n        if url and url in processed_urls:\n            continue\n        if url:\n            processed_urls.add(url)\n\n        # 使用统一的匹配逻辑\n        if not matches_word_groups(title, word_groups, filter_words, global_filters):\n            continue\n\n        # 找到匹配的词组\n        title_lower = title.lower()\n        for group in word_groups:\n            required_words = group[\"required\"]\n            normal_words = group[\"normal\"]\n            group_key = group[\"group_key\"]\n\n            # \"全部 RSS\" 模式：所有条目都匹配\n            if len(word_groups) == 1 and word_groups[0][\"group_key\"] == \"全部 RSS\":\n                matched = True\n            else:\n                # 检查必须词（支持正则语法）\n                if required_words:\n                    all_required_present = all(\n                        _word_matches(req_item, title_lower)\n                        for req_item in required_words\n                    )\n                    if not all_required_present:\n                        continue\n\n                # 检查普通词（支持正则语法）\n                if normal_words:\n                    any_normal_present = any(\n                        _word_matches(normal_item, title_lower)\n                        for normal_item in normal_words\n                    )\n                    if not any_normal_present:\n                        continue\n\n                matched = True\n\n            if matched:\n                word_stats[group_key][\"count\"] += 1\n\n                # 格式化时间显示\n                published_at = item.get(\"published_at\", \"\")\n                time_display = format_iso_time_friendly(published_at, timezone, include_date=True) if published_at else \"\"\n\n                # 判断是否为新增\n                is_new = url in new_urls if url else False\n\n                # 获取排名（基于发布时间顺序）\n                rank = url_to_rank.get(url, 99) if url else 99\n\n                title_data = {\n                    \"title\": title,\n                    \"source_name\": item.get(\"feed_name\", item.get(\"feed_id\", \"RSS\")),\n                    \"time_display\": time_display,\n                    \"count\": 1,  # RSS 条目通常只出现一次\n                    \"ranks\": [rank],\n                    \"rank_threshold\": rank_threshold,\n                    \"url\": url,\n                    \"mobile_url\": \"\",\n                    \"is_new\": is_new,\n                }\n                word_stats[group_key][\"titles\"].append(title_data)\n                break  # 一个条目只匹配第一个词组\n\n    # 构建统计结果\n    stats = []\n    group_key_to_position = {\n        group[\"group_key\"]: idx for idx, group in enumerate(word_groups)\n    }\n    group_key_to_max_count = {\n        group[\"group_key\"]: group.get(\"max_count\", 0) for group in word_groups\n    }\n    group_key_to_display_name = {\n        group[\"group_key\"]: group.get(\"display_name\") for group in word_groups\n    }\n\n    for group_key, data in word_stats.items():\n        if data[\"count\"] == 0:\n            continue\n\n        # 按发布时间排序（最新在前）\n        sorted_titles = sorted(\n            data[\"titles\"],\n            key=lambda x: x[\"ranks\"][0] if x[\"ranks\"] else 999\n        )\n\n        # 应用最大显示数量限制\n        group_max_count = group_key_to_max_count.get(group_key, 0)\n        if group_max_count == 0:\n            group_max_count = max_news_per_keyword\n        if group_max_count > 0:\n            sorted_titles = sorted_titles[:group_max_count]\n\n        # 优先使用 display_name，否则使用 group_key\n        display_word = group_key_to_display_name.get(group_key) or group_key\n\n        stats.append({\n            \"word\": display_word,\n            \"count\": data[\"count\"],\n            \"position\": group_key_to_position.get(group_key, 999),\n            \"titles\": sorted_titles,\n            \"percentage\": round(data[\"count\"] / total_items * 100, 2) if total_items > 0 else 0,\n        })\n\n    # 排序\n    if sort_by_position_first:\n        stats.sort(key=lambda x: (x[\"position\"], -x[\"count\"]))\n    else:\n        stats.sort(key=lambda x: (-x[\"count\"], x[\"position\"]))\n\n    matched_count = sum(stat[\"count\"] for stat in stats)\n    if not quiet:\n        print(f\"[RSS] 关键词分组统计：{matched_count}/{total_items} 条匹配\")\n\n    return stats, total_items\n\n\ndef convert_keyword_stats_to_platform_stats(\n    keyword_stats: List[Dict],\n    weight_config: Dict,\n    rank_threshold: int = 5,\n) -> List[Dict]:\n    \"\"\"\n    将按关键词分组的统计数据转换为按平台分组的统计数据\n\n    Args:\n        keyword_stats: 原始按关键词分组的统计数据\n        weight_config: 权重配置\n        rank_threshold: 排名阈值\n\n    Returns:\n        按平台分组的统计数据，格式与原 stats 一致\n    \"\"\"\n    # 1. 收集所有新闻，按平台分组\n    platform_map: Dict[str, List[Dict]] = {}\n\n    for stat in keyword_stats:\n        keyword = stat[\"word\"]\n        for title_data in stat[\"titles\"]:\n            source_name = title_data[\"source_name\"]\n\n            if source_name not in platform_map:\n                platform_map[source_name] = []\n\n            # 复制 title_data 并添加匹配的关键词\n            title_with_keyword = title_data.copy()\n            title_with_keyword[\"matched_keyword\"] = keyword\n            platform_map[source_name].append(title_with_keyword)\n\n    # 2. 去重（同一平台下相同标题只保留一条，保留第一个匹配的关键词）\n    for source_name, titles in platform_map.items():\n        seen_titles: Dict[str, bool] = {}\n        unique_titles = []\n        for title_data in titles:\n            title_text = title_data[\"title\"]\n            if title_text not in seen_titles:\n                seen_titles[title_text] = True\n                unique_titles.append(title_data)\n        platform_map[source_name] = unique_titles\n\n    # 3. 按权重排序每个平台内的新闻\n    for source_name, titles in platform_map.items():\n        platform_map[source_name] = sorted(\n            titles,\n            key=lambda x: (\n                -calculate_news_weight(x, rank_threshold, weight_config),\n                min(x[\"ranks\"]) if x[\"ranks\"] else 999,\n                -x[\"count\"],\n            ),\n        )\n\n    # 4. 构建平台统计结果\n    platform_stats = []\n    for source_name, titles in platform_map.items():\n        platform_stats.append({\n            \"word\": source_name,  # 平台名作为分组标识\n            \"count\": len(titles),\n            \"titles\": titles,\n            \"percentage\": 0,  # 可后续计算\n        })\n\n    # 5. 按新闻条数排序平台\n    platform_stats.sort(key=lambda x: -x[\"count\"])\n\n    return platform_stats\n"
  },
  {
    "path": "trendradar/core/config.py",
    "content": "# coding=utf-8\n\"\"\"\n配置工具模块 - 多账号配置解析和验证\n\n提供多账号推送配置的解析、验证和限制功能\n\"\"\"\n\nfrom typing import Dict, List, Optional, Tuple\n\n\ndef parse_multi_account_config(config_value: str, separator: str = \";\") -> List[str]:\n    \"\"\"\n    解析多账号配置，返回账号列表\n\n    Args:\n        config_value: 配置值字符串，多个账号用分隔符分隔\n        separator: 分隔符，默认为 ;\n\n    Returns:\n        账号列表，空字符串会被保留（用于占位）\n\n    Examples:\n        >>> parse_multi_account_config(\"url1;url2;url3\")\n        ['url1', 'url2', 'url3']\n        >>> parse_multi_account_config(\";token2\")  # 第一个账号无token\n        ['', 'token2']\n        >>> parse_multi_account_config(\"\")\n        []\n    \"\"\"\n    if not config_value:\n        return []\n    # 保留空字符串用于占位（如 \";token2\" 表示第一个账号无token）\n    accounts = [acc.strip() for acc in config_value.split(separator)]\n    # 过滤掉全部为空的情况\n    if all(not acc for acc in accounts):\n        return []\n    return accounts\n\n\ndef validate_paired_configs(\n    configs: Dict[str, List[str]],\n    channel_name: str,\n    required_keys: Optional[List[str]] = None\n) -> Tuple[bool, int]:\n    \"\"\"\n    验证配对配置的数量是否一致\n\n    对于需要多个配置项配对的渠道（如 Telegram 的 token 和 chat_id），\n    验证所有配置项的账号数量是否一致。\n\n    Args:\n        configs: 配置字典，key 为配置名，value 为账号列表\n        channel_name: 渠道名称，用于日志输出\n        required_keys: 必须有值的配置项列表\n\n    Returns:\n        (是否验证通过, 账号数量)\n\n    Examples:\n        >>> validate_paired_configs({\n        ...     \"token\": [\"t1\", \"t2\"],\n        ...     \"chat_id\": [\"c1\", \"c2\"]\n        ... }, \"Telegram\", [\"token\", \"chat_id\"])\n        (True, 2)\n\n        >>> validate_paired_configs({\n        ...     \"token\": [\"t1\", \"t2\"],\n        ...     \"chat_id\": [\"c1\"]  # 数量不匹配\n        ... }, \"Telegram\", [\"token\", \"chat_id\"])\n        (False, 0)\n    \"\"\"\n    # 过滤掉空列表\n    non_empty_configs = {k: v for k, v in configs.items() if v}\n\n    if not non_empty_configs:\n        return True, 0\n\n    # 检查必须项\n    if required_keys:\n        for key in required_keys:\n            if key not in non_empty_configs or not non_empty_configs[key]:\n                return True, 0  # 必须项为空，视为未配置\n\n    # 获取所有非空配置的长度\n    lengths = {k: len(v) for k, v in non_empty_configs.items()}\n    unique_lengths = set(lengths.values())\n\n    if len(unique_lengths) > 1:\n        print(f\"❌ {channel_name} 配置错误：配对配置数量不一致，将跳过该渠道推送\")\n        for key, length in lengths.items():\n            print(f\"   - {key}: {length} 个\")\n        return False, 0\n\n    return True, list(unique_lengths)[0] if unique_lengths else 0\n\n\ndef limit_accounts(\n    accounts: List[str],\n    max_count: int,\n    channel_name: str\n) -> List[str]:\n    \"\"\"\n    限制账号数量\n\n    当配置的账号数量超过最大限制时，只使用前 N 个账号，\n    并输出警告信息。\n\n    Args:\n        accounts: 账号列表\n        max_count: 最大账号数量\n        channel_name: 渠道名称，用于日志输出\n\n    Returns:\n        限制后的账号列表\n\n    Examples:\n        >>> limit_accounts([\"a1\", \"a2\", \"a3\"], 2, \"飞书\")\n        ⚠️ 飞书 配置了 3 个账号，超过最大限制 2，只使用前 2 个\n        ['a1', 'a2']\n    \"\"\"\n    if len(accounts) > max_count:\n        print(f\"⚠️ {channel_name} 配置了 {len(accounts)} 个账号，超过最大限制 {max_count}，只使用前 {max_count} 个\")\n        print(f\"   ⚠️ 警告：如果你是 fork 用户，过多账号可能导致 GitHub Actions 运行时间过长，存在账号风险\")\n        return accounts[:max_count]\n    return accounts\n\n\ndef get_account_at_index(accounts: List[str], index: int, default: str = \"\") -> str:\n    \"\"\"\n    安全获取指定索引的账号值\n\n    当索引超出范围或账号值为空时，返回默认值。\n\n    Args:\n        accounts: 账号列表\n        index: 索引\n        default: 默认值\n\n    Returns:\n        账号值或默认值\n\n    Examples:\n        >>> get_account_at_index([\"a\", \"b\", \"c\"], 1)\n        'b'\n        >>> get_account_at_index([\"a\", \"\", \"c\"], 1, \"default\")\n        'default'\n        >>> get_account_at_index([\"a\"], 5, \"default\")\n        'default'\n    \"\"\"\n    if index < len(accounts):\n        return accounts[index] if accounts[index] else default\n    return default\n"
  },
  {
    "path": "trendradar/core/data.py",
    "content": "# coding=utf-8\n\"\"\"\n数据处理模块\n\n提供数据读取和检测功能：\n- read_all_today_titles: 从存储后端读取当天所有标题\n- detect_latest_new_titles: 检测最新批次的新增标题\n\nAuthor: TrendRadar Team\n\"\"\"\n\nfrom typing import Dict, List, Tuple, Optional\n\n\ndef read_all_today_titles_from_storage(\n    storage_manager,\n    current_platform_ids: Optional[List[str]] = None,\n) -> Tuple[Dict, Dict, Dict]:\n    \"\"\"\n    从存储后端读取当天所有标题（SQLite 数据）\n\n    Args:\n        storage_manager: 存储管理器实例\n        current_platform_ids: 当前监控的平台 ID 列表（用于过滤）\n\n    Returns:\n        Tuple[Dict, Dict, Dict]: (all_results, id_to_name, title_info)\n    \"\"\"\n    try:\n        news_data = storage_manager.get_today_all_data()\n\n        if not news_data or not news_data.items:\n            return {}, {}, {}\n\n        all_results = {}\n        final_id_to_name = {}\n        title_info = {}\n\n        for source_id, news_list in news_data.items.items():\n            # 按平台过滤\n            if current_platform_ids is not None and source_id not in current_platform_ids:\n                continue\n\n            # 获取来源名称\n            source_name = news_data.id_to_name.get(source_id, source_id)\n            final_id_to_name[source_id] = source_name\n\n            if source_id not in all_results:\n                all_results[source_id] = {}\n                title_info[source_id] = {}\n\n            for item in news_list:\n                title = item.title\n                ranks = item.ranks or [item.rank]\n                first_time = item.first_time or item.crawl_time\n                last_time = item.last_time or item.crawl_time\n                count = item.count\n                rank_timeline = item.rank_timeline\n\n                all_results[source_id][title] = {\n                    \"ranks\": ranks,\n                    \"url\": item.url or \"\",\n                    \"mobileUrl\": item.mobile_url or \"\",\n                }\n\n                title_info[source_id][title] = {\n                    \"first_time\": first_time,\n                    \"last_time\": last_time,\n                    \"count\": count,\n                    \"ranks\": ranks,\n                    \"url\": item.url or \"\",\n                    \"mobileUrl\": item.mobile_url or \"\",\n                    \"rank_timeline\": rank_timeline,\n                }\n\n        return all_results, final_id_to_name, title_info\n\n    except Exception as e:\n        print(f\"[存储] 从存储后端读取数据失败: {e}\")\n        return {}, {}, {}\n\n\ndef read_all_today_titles(\n    storage_manager,\n    current_platform_ids: Optional[List[str]] = None,\n    quiet: bool = False,\n) -> Tuple[Dict, Dict, Dict]:\n    \"\"\"\n    读取当天所有标题（从存储后端）\n\n    Args:\n        storage_manager: 存储管理器实例\n        current_platform_ids: 当前监控的平台 ID 列表（用于过滤）\n        quiet: 是否静默模式（不打印日志）\n\n    Returns:\n        Tuple[Dict, Dict, Dict]: (all_results, id_to_name, title_info)\n    \"\"\"\n    all_results, final_id_to_name, title_info = read_all_today_titles_from_storage(\n        storage_manager, current_platform_ids\n    )\n\n    if not quiet:\n        if all_results:\n            total_count = sum(len(titles) for titles in all_results.values())\n            print(f\"[存储] 已从存储后端读取 {total_count} 条标题\")\n        else:\n            print(\"[存储] 当天暂无数据\")\n\n    return all_results, final_id_to_name, title_info\n\n\ndef detect_latest_new_titles_from_storage(\n    storage_manager,\n    current_platform_ids: Optional[List[str]] = None,\n) -> Dict:\n    \"\"\"\n    从存储后端检测最新批次的新增标题\n\n    Args:\n        storage_manager: 存储管理器实例\n        current_platform_ids: 当前监控的平台 ID 列表（用于过滤）\n\n    Returns:\n        Dict: 新增标题 {source_id: {title: title_data}}\n    \"\"\"\n    try:\n        # 获取最新抓取数据\n        latest_data = storage_manager.get_latest_crawl_data()\n        if not latest_data or not latest_data.items:\n            return {}\n\n        # 获取所有历史数据\n        all_data = storage_manager.get_today_all_data()\n        if not all_data or not all_data.items:\n            # 没有历史数据（第一次抓取），不应该有\"新增\"标题\n            return {}\n\n        # 获取最新批次时间\n        latest_time = latest_data.crawl_time\n\n        # 步骤1：收集最新批次的标题（last_crawl_time = latest_time 的标题）\n        latest_titles = {}\n        for source_id, news_list in latest_data.items.items():\n            if current_platform_ids is not None and source_id not in current_platform_ids:\n                continue\n            latest_titles[source_id] = {}\n            for item in news_list:\n                latest_titles[source_id][item.title] = {\n                    \"ranks\": [item.rank],\n                    \"url\": item.url or \"\",\n                    \"mobileUrl\": item.mobile_url or \"\",\n                }\n\n        # 步骤2：收集历史标题\n        # 关键逻辑：一个标题只要其 first_crawl_time < latest_time，就是历史标题\n        # 这样即使同一标题有多条记录（URL 不同），只要任何一条是历史的，该标题就算历史\n        historical_titles = {}\n        for source_id, news_list in all_data.items.items():\n            if current_platform_ids is not None and source_id not in current_platform_ids:\n                continue\n\n            historical_titles[source_id] = set()\n            for item in news_list:\n                first_time = item.first_time or item.crawl_time\n                # 如果该记录的首次出现时间早于最新批次，则该标题是历史标题\n                if first_time < latest_time:\n                    historical_titles[source_id].add(item.title)\n\n        # 检查是否是当天第一次抓取（没有任何历史标题）\n        # 如果所有平台的历史标题集合都为空，说明只有一个抓取批次\n        # 在这种情况下，将所有最新批次的标题视为\"新增\"（用于增量模式的第一次推送）\n        has_historical_data = any(len(titles) > 0 for titles in historical_titles.values())\n        if not has_historical_data:\n            # 第一次爬取：返回所有最新标题作为\"新增\"\n            return latest_titles\n\n        # 步骤3：找出新增标题 = 最新批次标题 - 历史标题\n        new_titles = {}\n        for source_id, source_latest_titles in latest_titles.items():\n            historical_set = historical_titles.get(source_id, set())\n            source_new_titles = {}\n\n            for title, title_data in source_latest_titles.items():\n                if title not in historical_set:\n                    source_new_titles[title] = title_data\n\n            if source_new_titles:\n                new_titles[source_id] = source_new_titles\n\n        return new_titles\n\n    except Exception as e:\n        print(f\"[存储] 从存储后端检测新标题失败: {e}\")\n        return {}\n\n\ndef detect_latest_new_titles(\n    storage_manager,\n    current_platform_ids: Optional[List[str]] = None,\n    quiet: bool = False,\n) -> Dict:\n    \"\"\"\n    检测当日最新批次的新增标题（从存储后端）\n\n    Args:\n        storage_manager: 存储管理器实例\n        current_platform_ids: 当前监控的平台 ID 列表（用于过滤）\n        quiet: 是否静默模式（不打印日志）\n\n    Returns:\n        Dict: 新增标题 {source_id: {title: title_data}}\n    \"\"\"\n    new_titles = detect_latest_new_titles_from_storage(storage_manager, current_platform_ids)\n    if new_titles and not quiet:\n        total_new = sum(len(titles) for titles in new_titles.values())\n        print(f\"[存储] 从存储后端检测到 {total_new} 条新增标题\")\n    return new_titles\n"
  },
  {
    "path": "trendradar/core/frequency.py",
    "content": "# coding=utf-8\n\"\"\"\n频率词配置加载模块\n\n负责从配置文件加载频率词规则，支持：\n- 普通词组\n- 必须词（+前缀）\n- 过滤词（!前缀）\n- 全局过滤词（[GLOBAL_FILTER] 区域）\n- 最大显示数量（@前缀）\n- 正则表达式（/pattern/ 语法）\n- 显示名称（=> 别名 语法）\n- 组别名（[组别名] 语法，作为词组第一行）\n\"\"\"\n\nimport os\nimport re\nfrom pathlib import Path\nfrom typing import Dict, List, Tuple, Optional, Union\n\n\ndef _parse_word(word: str) -> Dict:\n    \"\"\"\n    解析单个词，识别是否为正则表达式，支持显示名称\n\n    Args:\n        word: 原始配置行 (e.g. \"/京东|刘强东/ => 京东\")\n\n    Returns:\n        Dict: 包含 word, is_regex, pattern, display_name\n    \"\"\"\n    display_name = None\n\n    # 1. 优先处理显示名称 (=>)\n    # 先切分出 \"配置内容\" 和 \"显示名称\"\n    if '=>' in word:\n        parts = re.split(r'\\s*=>\\s*', word, 1)\n        word_config = parts[0].strip()\n        # 只有当 => 右边有内容时才作为 display_name\n        if len(parts) > 1 and parts[1].strip():\n            display_name = parts[1].strip()\n    else:\n        word_config = word.strip()\n\n    # 2. 解析正则表达式\n    # 规则：以 / 开头，以 / 结尾(可能跟 flags)，中间内容贪婪提取\n    # [a-z]*$ 表示允许末尾有 flags (如 i, g)，但在下面代码中会被忽略\n    regex_match = re.match(r'^/(.+)/[a-z]*$', word_config)\n\n    if regex_match:\n        pattern_str = regex_match.group(1)\n        try:\n            pattern = re.compile(pattern_str, re.IGNORECASE)\n            \n            return {\n                \"word\": pattern_str,\n                \"is_regex\": True,\n                \"pattern\": pattern,\n                \"display_name\": display_name,\n            }\n        except re.error as e:\n            print(f\"Warning: Invalid regex pattern '/{pattern_str}/': {e}\")\n            pass\n\n    return {\n        \"word\": word_config, \n        \"is_regex\": False, \n        \"pattern\": None, \n        \"display_name\": display_name\n    }\n\n\ndef _word_matches(word_config: Union[str, Dict], title_lower: str) -> bool:\n    \"\"\"\n    检查词是否在标题中匹配\n\n    Args:\n        word_config: 词配置（字符串或字典）\n        title_lower: 小写的标题\n\n    Returns:\n        是否匹配\n    \"\"\"\n    if isinstance(word_config, str):\n        # 向后兼容：纯字符串\n        return word_config.lower() in title_lower\n\n    if word_config.get(\"is_regex\") and word_config.get(\"pattern\"):\n        # 正则匹配\n        return bool(word_config[\"pattern\"].search(title_lower))\n    else:\n        # 子字符串匹配\n        return word_config[\"word\"].lower() in title_lower\n\n\ndef load_frequency_words(\n    frequency_file: Optional[str] = None,\n) -> Tuple[List[Dict], List[str], List[str]]:\n    \"\"\"\n    加载频率词配置\n\n    配置文件格式说明：\n    - 每个词组由空行分隔\n    - [GLOBAL_FILTER] 区域定义全局过滤词\n    - [WORD_GROUPS] 区域定义词组（默认）\n\n    词组语法：\n    - 普通词：直接写入，任意匹配即可\n    - +词：必须词，所有必须词都要匹配\n    - !词：过滤词，匹配则排除\n    - @数字：该词组最多显示的条数\n\n    Args:\n        frequency_file: 频率词配置文件路径，默认从环境变量 FREQUENCY_WORDS_PATH 获取或使用 config/frequency_words.txt，短文件名从 config/custom/keyword/ 查找\n\n    Returns:\n        (词组列表, 词组内过滤词, 全局过滤词)\n\n    Raises:\n        FileNotFoundError: 频率词文件不存在\n    \"\"\"\n    if frequency_file is None:\n        frequency_file = os.environ.get(\n            \"FREQUENCY_WORDS_PATH\", \"config/frequency_words.txt\"\n        )\n\n    frequency_path = Path(frequency_file)\n    if not frequency_path.exists():\n        # 尝试作为短文件名，拼接 config/custom/keyword/ 前缀\n        custom_path = Path(\"config/custom/keyword\") / frequency_file\n        if custom_path.exists():\n            frequency_path = custom_path\n        else:\n            raise FileNotFoundError(f\"频率词文件 {frequency_file} 不存在\")\n\n    with open(frequency_path, \"r\", encoding=\"utf-8\") as f:\n        content = f.read()\n\n    word_groups = [group.strip() for group in content.split(\"\\n\\n\") if group.strip()]\n\n    processed_groups = []\n    filter_words = []\n    global_filters = []\n\n    # 默认区域（向后兼容）\n    current_section = \"WORD_GROUPS\"\n\n    for group in word_groups:\n        # 过滤空行和注释行（# 开头）\n        lines = [line.strip() for line in group.split(\"\\n\") if line.strip() and not line.strip().startswith(\"#\")]\n\n        if not lines:\n            continue\n\n        # 检查是否为区域标记\n        if lines[0].startswith(\"[\") and lines[0].endswith(\"]\"):\n            section_name = lines[0][1:-1].upper()\n            if section_name in (\"GLOBAL_FILTER\", \"WORD_GROUPS\"):\n                current_section = section_name\n                lines = lines[1:]  # 移除标记行\n\n        # 处理全局过滤区域\n        if current_section == \"GLOBAL_FILTER\":\n            # 直接添加所有非空行到全局过滤列表\n            for line in lines:\n                # 忽略特殊语法前缀，只提取纯文本\n                if line.startswith((\"!\", \"+\", \"@\")):\n                    continue  # 全局过滤区不支持特殊语法\n                if line:\n                    global_filters.append(line)\n            continue\n\n        # 处理词组区域\n        words = lines\n        group_alias = None  # 组别名（[别名] 语法）\n\n        # 检查第一行是否为组别名（非区域标记）\n        if words and words[0].startswith(\"[\") and words[0].endswith(\"]\"):\n            potential_alias = words[0][1:-1].strip()\n            # 排除区域标记（GLOBAL_FILTER, WORD_GROUPS）\n            if potential_alias.upper() not in (\"GLOBAL_FILTER\", \"WORD_GROUPS\"):\n                group_alias = potential_alias\n                words = words[1:]  # 移除组别名行\n\n        group_required_words = []\n        group_normal_words = []\n        group_max_count = 0  # 默认不限制\n\n        for word in words:\n            if word.startswith(\"@\"):\n                # 解析最大显示数量（只接受正整数）\n                try:\n                    count = int(word[1:])\n                    if count > 0:\n                        group_max_count = count\n                except (ValueError, IndexError):\n                    pass  # 忽略无效的@数字格式\n            elif word.startswith(\"!\"):\n                # 过滤词（支持正则语法）\n                filter_word = word[1:]\n                parsed = _parse_word(filter_word)\n                filter_words.append(parsed)\n            elif word.startswith(\"+\"):\n                # 必须词（支持正则语法）\n                req_word = word[1:]\n                group_required_words.append(_parse_word(req_word))\n            else:\n                # 普通词（支持正则语法）\n                group_normal_words.append(_parse_word(word))\n\n        if group_required_words or group_normal_words:\n            if group_normal_words:\n                group_key = \" \".join(w[\"word\"] for w in group_normal_words)\n            else:\n                group_key = \" \".join(w[\"word\"] for w in group_required_words)\n\n            # 生成显示名称\n            # 优先级：组别名 > 行别名拼接 > 关键词拼接\n            if group_alias:\n                # 有组别名，直接使用\n                display_name = group_alias\n            else:\n                # 没有组别名，拼接每行的显示名（行别名或关键词本身）\n                all_words = group_normal_words + group_required_words\n                display_parts = []\n                for w in all_words:\n                    # 优先使用行别名，否则使用关键词本身\n                    part = w.get(\"display_name\") or w[\"word\"]\n                    display_parts.append(part)\n                # 用 \" / \" 拼接多个词\n                display_name = \" / \".join(display_parts) if display_parts else None\n\n            processed_groups.append(\n                {\n                    \"required\": group_required_words,\n                    \"normal\": group_normal_words,\n                    \"group_key\": group_key,\n                    \"display_name\": display_name,  # 可能为 None\n                    \"max_count\": group_max_count,\n                }\n            )\n\n    return processed_groups, filter_words, global_filters\n\n\ndef matches_word_groups(\n    title: str,\n    word_groups: List[Dict],\n    filter_words: List,\n    global_filters: Optional[List[str]] = None\n) -> bool:\n    \"\"\"\n    检查标题是否匹配词组规则\n\n    Args:\n        title: 标题文本\n        word_groups: 词组列表\n        filter_words: 过滤词列表（可以是字符串列表或字典列表）\n        global_filters: 全局过滤词列表\n\n    Returns:\n        是否匹配\n    \"\"\"\n    # 防御性类型检查：确保 title 是有效字符串\n    if not isinstance(title, str):\n        title = str(title) if title is not None else \"\"\n    if not title.strip():\n        return False\n\n    title_lower = title.lower()\n\n    # 全局过滤检查（优先级最高）\n    if global_filters:\n        if any(global_word.lower() in title_lower for global_word in global_filters):\n            return False\n\n    # 如果没有配置词组，则匹配所有标题（支持显示全部新闻）\n    if not word_groups:\n        return True\n\n    # 过滤词检查（兼容新旧格式）\n    for filter_item in filter_words:\n        if _word_matches(filter_item, title_lower):\n            return False\n\n    # 词组匹配检查\n    for group in word_groups:\n        required_words = group[\"required\"]\n        normal_words = group[\"normal\"]\n\n        # 必须词检查\n        if required_words:\n            all_required_present = all(\n                _word_matches(req_item, title_lower) for req_item in required_words\n            )\n            if not all_required_present:\n                continue\n\n        # 普通词检查\n        if normal_words:\n            any_normal_present = any(\n                _word_matches(normal_item, title_lower) for normal_item in normal_words\n            )\n            if not any_normal_present:\n                continue\n\n        return True\n\n    return False\n"
  },
  {
    "path": "trendradar/core/loader.py",
    "content": "# coding=utf-8\n\"\"\"\n配置加载模块\n\n负责从 YAML 配置文件和环境变量加载配置。\n\"\"\"\n\nimport os\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional\n\nimport yaml\n\nfrom .config import parse_multi_account_config, validate_paired_configs\nfrom trendradar.utils.time import DEFAULT_TIMEZONE\n\n\ndef _get_env_bool(key: str) -> Optional[bool]:\n    \"\"\"从环境变量获取布尔值，如果未设置返回 None\"\"\"\n    value = os.environ.get(key, \"\").strip().lower()\n    if not value:\n        return None\n    return value in (\"true\", \"1\")\n\n\ndef _get_env_int(key: str, default: int = 0) -> int:\n    \"\"\"从环境变量获取整数值\"\"\"\n    value = os.environ.get(key, \"\").strip()\n    if not value:\n        return default\n    try:\n        return int(value)\n    except ValueError:\n        return default\n\n\ndef _get_env_int_or_none(key: str) -> Optional[int]:\n    \"\"\"从环境变量获取整数值，未设置时返回 None\"\"\"\n    value = os.environ.get(key, \"\").strip()\n    if not value:\n        return None\n    try:\n        return int(value)\n    except ValueError:\n        return None\n\n\ndef _get_env_str(key: str, default: str = \"\") -> str:\n    \"\"\"从环境变量获取字符串值\"\"\"\n    return os.environ.get(key, \"\").strip() or default\n\n\ndef _load_app_config(config_data: Dict) -> Dict:\n    \"\"\"加载应用配置\"\"\"\n    app_config = config_data.get(\"app\", {})\n    advanced = config_data.get(\"advanced\", {})\n    return {\n        \"VERSION_CHECK_URL\": advanced.get(\"version_check_url\", \"\"),\n        \"CONFIGS_VERSION_CHECK_URL\": advanced.get(\"configs_version_check_url\", \"\"),\n        \"SHOW_VERSION_UPDATE\": app_config.get(\"show_version_update\", True),\n        \"TIMEZONE\": _get_env_str(\"TIMEZONE\") or app_config.get(\"timezone\", DEFAULT_TIMEZONE),\n        \"DEBUG\": _get_env_bool(\"DEBUG\") if _get_env_bool(\"DEBUG\") is not None else advanced.get(\"debug\", False),\n    }\n\n\ndef _load_crawler_config(config_data: Dict) -> Dict:\n    \"\"\"加载爬虫配置\"\"\"\n    advanced = config_data.get(\"advanced\", {})\n    crawler_config = advanced.get(\"crawler\", {})\n    platforms_config = config_data.get(\"platforms\", {})\n    return {\n        \"REQUEST_INTERVAL\": crawler_config.get(\"request_interval\", 100),\n        \"USE_PROXY\": crawler_config.get(\"use_proxy\", False),\n        \"DEFAULT_PROXY\": crawler_config.get(\"default_proxy\", \"\"),\n        \"ENABLE_CRAWLER\": platforms_config.get(\"enabled\", True),\n    }\n\n\ndef _load_report_config(config_data: Dict) -> Dict:\n    \"\"\"加载报告配置\"\"\"\n    report_config = config_data.get(\"report\", {})\n\n    # 环境变量覆盖\n    sort_by_position_env = _get_env_bool(\"SORT_BY_POSITION_FIRST\")\n    max_news_env = _get_env_int(\"MAX_NEWS_PER_KEYWORD\")\n\n    return {\n        \"REPORT_MODE\": report_config.get(\"mode\", \"daily\"),\n        \"DISPLAY_MODE\": report_config.get(\"display_mode\", \"keyword\"),\n        \"RANK_THRESHOLD\": report_config.get(\"rank_threshold\", 10),\n        \"SORT_BY_POSITION_FIRST\": sort_by_position_env if sort_by_position_env is not None else report_config.get(\"sort_by_position_first\", False),\n        \"MAX_NEWS_PER_KEYWORD\": max_news_env or report_config.get(\"max_news_per_keyword\", 0),\n    }\n\n\ndef _load_notification_config(config_data: Dict) -> Dict:\n    \"\"\"加载通知配置\"\"\"\n    notification = config_data.get(\"notification\", {})\n    advanced = config_data.get(\"advanced\", {})\n    batch_size = advanced.get(\"batch_size\", {})\n\n    return {\n        \"ENABLE_NOTIFICATION\": notification.get(\"enabled\", True),\n        \"MESSAGE_BATCH_SIZE\": batch_size.get(\"default\", 4000),\n        \"DINGTALK_BATCH_SIZE\": batch_size.get(\"dingtalk\", 20000),\n        \"FEISHU_BATCH_SIZE\": batch_size.get(\"feishu\", 29000),\n        \"BARK_BATCH_SIZE\": batch_size.get(\"bark\", 3600),\n        \"SLACK_BATCH_SIZE\": batch_size.get(\"slack\", 4000),\n        \"BATCH_SEND_INTERVAL\": advanced.get(\"batch_send_interval\", 1.0),\n        \"FEISHU_MESSAGE_SEPARATOR\": advanced.get(\"feishu_message_separator\", \"---\"),\n        \"MAX_ACCOUNTS_PER_CHANNEL\": _get_env_int(\"MAX_ACCOUNTS_PER_CHANNEL\") or advanced.get(\"max_accounts_per_channel\", 3),\n    }\n\n\ndef _load_schedule_config(config_data: Dict) -> Dict:\n    \"\"\"\n    加载统一调度配置\n\n    从 config.yaml 的 schedule 段读取，支持环境变量覆盖。\n    \"\"\"\n    schedule = config_data.get(\"schedule\", {})\n\n    # 环境变量覆盖\n    enabled_env = _get_env_bool(\"SCHEDULE_ENABLED\")\n    preset_env = _get_env_str(\"SCHEDULE_PRESET\")\n\n    enabled = enabled_env if enabled_env is not None else schedule.get(\"enabled\", False)\n    preset = preset_env or schedule.get(\"preset\", \"always_on\")\n\n    return {\n        \"enabled\": enabled,\n        \"preset\": preset,\n    }\n\n\ndef _load_timeline_data(config_dir: str = \"config\") -> Dict:\n    \"\"\"\n    加载 timeline.yaml\n\n    Args:\n        config_dir: 配置目录路径\n\n    Returns:\n        timeline.yaml 的完整数据，找不到时返回空模板\n    \"\"\"\n    timeline_path = Path(config_dir) / \"timeline.yaml\"\n    if not timeline_path.exists():\n        print(f\"[调度] timeline.yaml 未找到: {timeline_path}，使用空模板\")\n        return {\n            \"presets\": {},\n            \"custom\": {\n                \"default\": {\n                    \"collect\": True,\n                    \"analyze\": False,\n                    \"push\": False,\n                    \"report_mode\": \"current\",\n                    \"ai_mode\": \"follow_report\",\n                    \"once\": {\"analyze\": False, \"push\": False},\n                },\n                \"periods\": {},\n                \"day_plans\": {\"all_day\": {\"periods\": []}},\n                \"week_map\": {i: \"all_day\" for i in range(1, 8)},\n            },\n        }\n\n    with open(timeline_path, \"r\", encoding=\"utf-8\") as f:\n        data = yaml.safe_load(f)\n\n    print(f\"[调度] timeline.yaml 加载成功: {timeline_path}\")\n    return data or {}\n\n\ndef _load_weight_config(config_data: Dict) -> Dict:\n    \"\"\"加载权重配置\"\"\"\n    advanced = config_data.get(\"advanced\", {})\n    weight = advanced.get(\"weight\", {})\n    return {\n        \"RANK_WEIGHT\": weight.get(\"rank\", 0.6),\n        \"FREQUENCY_WEIGHT\": weight.get(\"frequency\", 0.3),\n        \"HOTNESS_WEIGHT\": weight.get(\"hotness\", 0.1),\n    }\n\n\ndef _load_rss_config(config_data: Dict) -> Dict:\n    \"\"\"加载 RSS 配置\"\"\"\n    rss = config_data.get(\"rss\", {})\n    advanced = config_data.get(\"advanced\", {})\n    advanced_rss = advanced.get(\"rss\", {})\n    advanced_crawler = advanced.get(\"crawler\", {})\n\n    # RSS 代理配置：优先使用 RSS 专属代理，否则复用 crawler 的 default_proxy\n    rss_proxy_url = advanced_rss.get(\"proxy_url\", \"\") or advanced_crawler.get(\"default_proxy\", \"\")\n\n    # 新鲜度过滤配置\n    freshness_filter = rss.get(\"freshness_filter\", {})\n\n    # 验证并设置 max_age_days 默认值\n    raw_max_age = freshness_filter.get(\"max_age_days\", 3)\n    try:\n        max_age_days = int(raw_max_age)\n        if max_age_days < 0:\n            print(f\"[警告] RSS freshness_filter.max_age_days 为负数 ({max_age_days})，使用默认值 3\")\n            max_age_days = 3\n    except (ValueError, TypeError):\n        print(f\"[警告] RSS freshness_filter.max_age_days 格式错误 ({raw_max_age})，使用默认值 3\")\n        max_age_days = 3\n\n    # RSS 配置直接从 config.yaml 读取，不再支持环境变量\n    return {\n        \"ENABLED\": rss.get(\"enabled\", False),\n        \"REQUEST_INTERVAL\": advanced_rss.get(\"request_interval\", 2000),\n        \"TIMEOUT\": advanced_rss.get(\"timeout\", 15),\n        \"USE_PROXY\": advanced_rss.get(\"use_proxy\", False),\n        \"PROXY_URL\": rss_proxy_url,\n        \"FEEDS\": rss.get(\"feeds\", []),\n        \"FRESHNESS_FILTER\": {\n            \"ENABLED\": freshness_filter.get(\"enabled\", True),  # 默认启用\n            \"MAX_AGE_DAYS\": max_age_days,\n        },\n    }\n\n\ndef _load_display_config(config_data: Dict) -> Dict:\n    \"\"\"加载推送内容显示配置\"\"\"\n    display = config_data.get(\"display\", {})\n    regions = display.get(\"regions\", {})\n    standalone = display.get(\"standalone\", {})\n\n    # 默认区域顺序\n    default_region_order = [\"hotlist\", \"rss\", \"new_items\", \"standalone\", \"ai_analysis\"]\n    region_order = display.get(\"region_order\", default_region_order)\n\n    # 验证 region_order 中的值是否合法\n    valid_regions = {\"hotlist\", \"rss\", \"new_items\", \"standalone\", \"ai_analysis\"}\n    region_order = [r for r in region_order if r in valid_regions]\n\n    # 如果过滤后为空，使用默认顺序\n    if not region_order:\n        region_order = default_region_order\n\n    return {\n        # 区域显示顺序\n        \"REGION_ORDER\": region_order,\n        # 区域开关\n        \"REGIONS\": {\n            \"HOTLIST\": regions.get(\"hotlist\", True),\n            \"NEW_ITEMS\": regions.get(\"new_items\", True),\n            \"RSS\": regions.get(\"rss\", True),\n            \"STANDALONE\": regions.get(\"standalone\", False),\n            \"AI_ANALYSIS\": regions.get(\"ai_analysis\", True),\n        },\n        # 独立展示区配置\n        \"STANDALONE\": {\n            \"PLATFORMS\": standalone.get(\"platforms\", []),\n            \"RSS_FEEDS\": standalone.get(\"rss_feeds\", []),\n            \"MAX_ITEMS\": standalone.get(\"max_items\", 20),\n        },\n    }\n\n\ndef _load_ai_config(config_data: Dict) -> Dict:\n    \"\"\"加载 AI 模型配置（LiteLLM 格式）\"\"\"\n    ai_config = config_data.get(\"ai\", {})\n\n    timeout_env = _get_env_int_or_none(\"AI_TIMEOUT\")\n\n    return {\n        # LiteLLM 核心配置\n        \"MODEL\": _get_env_str(\"AI_MODEL\") or ai_config.get(\"model\", \"\"),\n        \"API_KEY\": _get_env_str(\"AI_API_KEY\") or ai_config.get(\"api_key\", \"\"),\n        \"API_BASE\": _get_env_str(\"AI_API_BASE\") or ai_config.get(\"api_base\", \"\"),\n\n        # 生成参数\n        \"TIMEOUT\": timeout_env if timeout_env is not None else ai_config.get(\"timeout\", 120),\n        \"TEMPERATURE\": ai_config.get(\"temperature\", 1.0),\n        \"MAX_TOKENS\": ai_config.get(\"max_tokens\", 5000),\n\n        # LiteLLM 高级选项\n        \"NUM_RETRIES\": ai_config.get(\"num_retries\", 2),\n        \"FALLBACK_MODELS\": ai_config.get(\"fallback_models\", []),\n        \"EXTRA_PARAMS\": ai_config.get(\"extra_params\", {}),\n    }\n\n\ndef _load_ai_analysis_config(config_data: Dict) -> Dict:\n    \"\"\"加载 AI 分析配置（功能配置，模型配置见 _load_ai_config）\"\"\"\n    ai_config = config_data.get(\"ai_analysis\", {})\n\n    enabled_env = _get_env_bool(\"AI_ANALYSIS_ENABLED\")\n\n    return {\n        \"ENABLED\": enabled_env if enabled_env is not None else ai_config.get(\"enabled\", False),\n        \"LANGUAGE\": ai_config.get(\"language\", \"Chinese\"),\n        \"PROMPT_FILE\": ai_config.get(\"prompt_file\", \"ai_analysis_prompt.txt\"),\n        \"MODE\": ai_config.get(\"mode\", \"follow_report\"),\n        \"MAX_NEWS_FOR_ANALYSIS\": ai_config.get(\"max_news_for_analysis\", 50),\n        \"INCLUDE_RSS\": ai_config.get(\"include_rss\", True),\n        \"INCLUDE_RANK_TIMELINE\": ai_config.get(\"include_rank_timeline\", False),\n        \"INCLUDE_STANDALONE\": ai_config.get(\"include_standalone\", False),\n    }\n\n\ndef _load_ai_translation_config(config_data: Dict) -> Dict:\n    \"\"\"加载 AI 翻译配置（功能配置，模型配置见 _load_ai_config）\"\"\"\n    trans_config = config_data.get(\"ai_translation\", {})\n\n    enabled_env = _get_env_bool(\"AI_TRANSLATION_ENABLED\")\n\n    scope = trans_config.get(\"scope\", {})\n\n    return {\n        \"ENABLED\": enabled_env if enabled_env is not None else trans_config.get(\"enabled\", False),\n        \"LANGUAGE\": _get_env_str(\"AI_TRANSLATION_LANGUAGE\") or trans_config.get(\"language\", \"English\"),\n        \"PROMPT_FILE\": trans_config.get(\"prompt_file\", \"ai_translation_prompt.txt\"),\n        \"SCOPE\": {\n            \"HOTLIST\": scope.get(\"hotlist\", True),\n            \"RSS\": scope.get(\"rss\", True),\n            \"STANDALONE\": scope.get(\"standalone\", True),\n        },\n    }\n\n\ndef _load_ai_filter_config(config_data: Dict) -> Dict:\n    \"\"\"加载 AI 智能筛选配置（由 filter.method 控制是否启用）\"\"\"\n    ai_filter = config_data.get(\"ai_filter\", {})\n\n    return {\n        \"BATCH_SIZE\": ai_filter.get(\"batch_size\", 200),\n        \"BATCH_INTERVAL\": ai_filter.get(\"batch_interval\", 5),\n        \"INTERESTS_FILE\": ai_filter.get(\"interests_file\"),  # None = 使用默认 config/ai_interests.txt\n        \"PROMPT_FILE\": ai_filter.get(\"prompt_file\", \"prompt.txt\"),\n        \"EXTRACT_PROMPT_FILE\": ai_filter.get(\"extract_prompt_file\", \"extract_prompt.txt\"),\n        \"UPDATE_TAGS_PROMPT_FILE\": ai_filter.get(\"update_tags_prompt_file\", \"update_tags_prompt.txt\"),\n        \"RECLASSIFY_THRESHOLD\": ai_filter.get(\"reclassify_threshold\", 0.6),\n        \"MIN_SCORE\": float(ai_filter.get(\"min_score\", 0)),\n    }\n\n\ndef _load_filter_config(config_data: Dict) -> Dict:\n    \"\"\"加载筛选策略配置\"\"\"\n    filter_cfg = config_data.get(\"filter\", {})\n\n    # 环境变量兼容：AI_FILTER_ENABLED=true → method=ai\n    env_ai_filter = _get_env_bool(\"AI_FILTER_ENABLED\")\n\n    method = filter_cfg.get(\"method\", \"keyword\")\n    if env_ai_filter is True:\n        method = \"ai\"\n\n    # 兼容旧配置：如果 ai_filter.enabled=true 且未显式设置 filter.method\n    if method == \"keyword\" and not filter_cfg.get(\"method\"):\n        ai_filter = config_data.get(\"ai_filter\", {})\n        if ai_filter.get(\"enabled\", False):\n            method = \"ai\"\n\n    return {\n        \"METHOD\": method,  # \"keyword\" | \"ai\"\n        \"PRIORITY_SORT_ENABLED\": filter_cfg.get(\"priority_sort_enabled\", False),  # AI 模式标签优先级排序开关\n    }\n\n\ndef _load_storage_config(config_data: Dict) -> Dict:\n    \"\"\"加载存储配置\"\"\"\n    storage = config_data.get(\"storage\", {})\n    formats = storage.get(\"formats\", {})\n    local = storage.get(\"local\", {})\n    remote = storage.get(\"remote\", {})\n    pull = storage.get(\"pull\", {})\n\n    txt_enabled_env = _get_env_bool(\"STORAGE_TXT_ENABLED\")\n    html_enabled_env = _get_env_bool(\"STORAGE_HTML_ENABLED\")\n    pull_enabled_env = _get_env_bool(\"PULL_ENABLED\")\n\n    return {\n        \"BACKEND\": _get_env_str(\"STORAGE_BACKEND\") or storage.get(\"backend\", \"auto\"),\n        \"FORMATS\": {\n            \"SQLITE\": formats.get(\"sqlite\", True),\n            \"TXT\": txt_enabled_env if txt_enabled_env is not None else formats.get(\"txt\", True),\n            \"HTML\": html_enabled_env if html_enabled_env is not None else formats.get(\"html\", True),\n        },\n        \"LOCAL\": {\n            \"DATA_DIR\": local.get(\"data_dir\", \"output\"),\n            \"RETENTION_DAYS\": _get_env_int(\"LOCAL_RETENTION_DAYS\") or local.get(\"retention_days\", 0),\n        },\n        \"REMOTE\": {\n            \"ENDPOINT_URL\": _get_env_str(\"S3_ENDPOINT_URL\") or remote.get(\"endpoint_url\", \"\"),\n            \"BUCKET_NAME\": _get_env_str(\"S3_BUCKET_NAME\") or remote.get(\"bucket_name\", \"\"),\n            \"ACCESS_KEY_ID\": _get_env_str(\"S3_ACCESS_KEY_ID\") or remote.get(\"access_key_id\", \"\"),\n            \"SECRET_ACCESS_KEY\": _get_env_str(\"S3_SECRET_ACCESS_KEY\") or remote.get(\"secret_access_key\", \"\"),\n            \"REGION\": _get_env_str(\"S3_REGION\") or remote.get(\"region\", \"\"),\n            \"RETENTION_DAYS\": _get_env_int(\"REMOTE_RETENTION_DAYS\") or remote.get(\"retention_days\", 0),\n        },\n        \"PULL\": {\n            \"ENABLED\": pull_enabled_env if pull_enabled_env is not None else pull.get(\"enabled\", False),\n            \"DAYS\": _get_env_int(\"PULL_DAYS\") or pull.get(\"days\", 7),\n        },\n    }\n\n\ndef _load_webhook_config(config_data: Dict) -> Dict:\n    \"\"\"加载 Webhook 配置\"\"\"\n    notification = config_data.get(\"notification\", {})\n    channels = notification.get(\"channels\", {})\n\n    # 各渠道配置\n    feishu = channels.get(\"feishu\", {})\n    dingtalk = channels.get(\"dingtalk\", {})\n    wework = channels.get(\"wework\", {})\n    telegram = channels.get(\"telegram\", {})\n    email = channels.get(\"email\", {})\n    ntfy = channels.get(\"ntfy\", {})\n    bark = channels.get(\"bark\", {})\n    slack = channels.get(\"slack\", {})\n    generic = channels.get(\"generic_webhook\", {})\n\n    return {\n        # 飞书\n        \"FEISHU_WEBHOOK_URL\": _get_env_str(\"FEISHU_WEBHOOK_URL\") or feishu.get(\"webhook_url\", \"\"),\n        # 钉钉\n        \"DINGTALK_WEBHOOK_URL\": _get_env_str(\"DINGTALK_WEBHOOK_URL\") or dingtalk.get(\"webhook_url\", \"\"),\n        # 企业微信\n        \"WEWORK_WEBHOOK_URL\": _get_env_str(\"WEWORK_WEBHOOK_URL\") or wework.get(\"webhook_url\", \"\"),\n        \"WEWORK_MSG_TYPE\": _get_env_str(\"WEWORK_MSG_TYPE\") or wework.get(\"msg_type\", \"markdown\"),\n        # Telegram\n        \"TELEGRAM_BOT_TOKEN\": _get_env_str(\"TELEGRAM_BOT_TOKEN\") or telegram.get(\"bot_token\", \"\"),\n        \"TELEGRAM_CHAT_ID\": _get_env_str(\"TELEGRAM_CHAT_ID\") or telegram.get(\"chat_id\", \"\"),\n        # 邮件\n        \"EMAIL_FROM\": _get_env_str(\"EMAIL_FROM\") or email.get(\"from\", \"\"),\n        \"EMAIL_PASSWORD\": _get_env_str(\"EMAIL_PASSWORD\") or email.get(\"password\", \"\"),\n        \"EMAIL_TO\": _get_env_str(\"EMAIL_TO\") or email.get(\"to\", \"\"),\n        \"EMAIL_SMTP_SERVER\": _get_env_str(\"EMAIL_SMTP_SERVER\") or email.get(\"smtp_server\", \"\"),\n        \"EMAIL_SMTP_PORT\": _get_env_str(\"EMAIL_SMTP_PORT\") or email.get(\"smtp_port\", \"\"),\n        # ntfy\n        \"NTFY_SERVER_URL\": _get_env_str(\"NTFY_SERVER_URL\") or ntfy.get(\"server_url\") or \"https://ntfy.sh\",\n        \"NTFY_TOPIC\": _get_env_str(\"NTFY_TOPIC\") or ntfy.get(\"topic\", \"\"),\n        \"NTFY_TOKEN\": _get_env_str(\"NTFY_TOKEN\") or ntfy.get(\"token\", \"\"),\n        # Bark\n        \"BARK_URL\": _get_env_str(\"BARK_URL\") or bark.get(\"url\", \"\"),\n        # Slack\n        \"SLACK_WEBHOOK_URL\": _get_env_str(\"SLACK_WEBHOOK_URL\") or slack.get(\"webhook_url\", \"\"),\n        # 通用 Webhook\n        \"GENERIC_WEBHOOK_URL\": _get_env_str(\"GENERIC_WEBHOOK_URL\") or generic.get(\"webhook_url\", \"\"),\n        \"GENERIC_WEBHOOK_TEMPLATE\": _get_env_str(\"GENERIC_WEBHOOK_TEMPLATE\") or generic.get(\"payload_template\", \"\"),\n    }\n\n\ndef _print_notification_sources(config: Dict) -> None:\n    \"\"\"打印通知渠道配置来源信息\"\"\"\n    notification_sources = []\n    max_accounts = config[\"MAX_ACCOUNTS_PER_CHANNEL\"]\n\n    if config[\"FEISHU_WEBHOOK_URL\"]:\n        accounts = parse_multi_account_config(config[\"FEISHU_WEBHOOK_URL\"])\n        count = min(len(accounts), max_accounts)\n        source = \"环境变量\" if os.environ.get(\"FEISHU_WEBHOOK_URL\") else \"配置文件\"\n        notification_sources.append(f\"飞书({source}, {count}个账号)\")\n\n    if config[\"DINGTALK_WEBHOOK_URL\"]:\n        accounts = parse_multi_account_config(config[\"DINGTALK_WEBHOOK_URL\"])\n        count = min(len(accounts), max_accounts)\n        source = \"环境变量\" if os.environ.get(\"DINGTALK_WEBHOOK_URL\") else \"配置文件\"\n        notification_sources.append(f\"钉钉({source}, {count}个账号)\")\n\n    if config[\"WEWORK_WEBHOOK_URL\"]:\n        accounts = parse_multi_account_config(config[\"WEWORK_WEBHOOK_URL\"])\n        count = min(len(accounts), max_accounts)\n        source = \"环境变量\" if os.environ.get(\"WEWORK_WEBHOOK_URL\") else \"配置文件\"\n        notification_sources.append(f\"企业微信({source}, {count}个账号)\")\n\n    if config[\"TELEGRAM_BOT_TOKEN\"] and config[\"TELEGRAM_CHAT_ID\"]:\n        tokens = parse_multi_account_config(config[\"TELEGRAM_BOT_TOKEN\"])\n        chat_ids = parse_multi_account_config(config[\"TELEGRAM_CHAT_ID\"])\n        valid, count = validate_paired_configs(\n            {\"bot_token\": tokens, \"chat_id\": chat_ids},\n            \"Telegram\",\n            required_keys=[\"bot_token\", \"chat_id\"]\n        )\n        if valid and count > 0:\n            count = min(count, max_accounts)\n            token_source = \"环境变量\" if os.environ.get(\"TELEGRAM_BOT_TOKEN\") else \"配置文件\"\n            notification_sources.append(f\"Telegram({token_source}, {count}个账号)\")\n\n    if config[\"EMAIL_FROM\"] and config[\"EMAIL_PASSWORD\"] and config[\"EMAIL_TO\"]:\n        from_source = \"环境变量\" if os.environ.get(\"EMAIL_FROM\") else \"配置文件\"\n        notification_sources.append(f\"邮件({from_source})\")\n\n    if config[\"NTFY_SERVER_URL\"] and config[\"NTFY_TOPIC\"]:\n        topics = parse_multi_account_config(config[\"NTFY_TOPIC\"])\n        tokens = parse_multi_account_config(config[\"NTFY_TOKEN\"])\n        if tokens:\n            valid, count = validate_paired_configs(\n                {\"topic\": topics, \"token\": tokens},\n                \"ntfy\"\n            )\n            if valid and count > 0:\n                count = min(count, max_accounts)\n                server_source = \"环境变量\" if os.environ.get(\"NTFY_SERVER_URL\") else \"配置文件\"\n                notification_sources.append(f\"ntfy({server_source}, {count}个账号)\")\n        else:\n            count = min(len(topics), max_accounts)\n            server_source = \"环境变量\" if os.environ.get(\"NTFY_SERVER_URL\") else \"配置文件\"\n            notification_sources.append(f\"ntfy({server_source}, {count}个账号)\")\n\n    if config[\"BARK_URL\"]:\n        accounts = parse_multi_account_config(config[\"BARK_URL\"])\n        count = min(len(accounts), max_accounts)\n        bark_source = \"环境变量\" if os.environ.get(\"BARK_URL\") else \"配置文件\"\n        notification_sources.append(f\"Bark({bark_source}, {count}个账号)\")\n\n    if config[\"SLACK_WEBHOOK_URL\"]:\n        accounts = parse_multi_account_config(config[\"SLACK_WEBHOOK_URL\"])\n        count = min(len(accounts), max_accounts)\n        slack_source = \"环境变量\" if os.environ.get(\"SLACK_WEBHOOK_URL\") else \"配置文件\"\n        notification_sources.append(f\"Slack({slack_source}, {count}个账号)\")\n\n    if config.get(\"GENERIC_WEBHOOK_URL\"):\n        accounts = parse_multi_account_config(config[\"GENERIC_WEBHOOK_URL\"])\n        count = min(len(accounts), max_accounts)\n        source = \"环境变量\" if os.environ.get(\"GENERIC_WEBHOOK_URL\") else \"配置文件\"\n        notification_sources.append(f\"通用Webhook({source}, {count}个账号)\")\n\n    if notification_sources:\n        print(f\"通知渠道配置来源: {', '.join(notification_sources)}\")\n        print(f\"每个渠道最大账号数: {max_accounts}\")\n    else:\n        print(\"未配置任何通知渠道\")\n\n\ndef load_config(config_path: Optional[str] = None) -> Dict[str, Any]:\n    \"\"\"\n    加载配置文件\n\n    Args:\n        config_path: 配置文件路径，默认从环境变量 CONFIG_PATH 获取或使用 config/config.yaml\n\n    Returns:\n        包含所有配置的字典\n\n    Raises:\n        FileNotFoundError: 配置文件不存在\n    \"\"\"\n    if config_path is None:\n        config_path = os.environ.get(\"CONFIG_PATH\", \"config/config.yaml\")\n\n    if not Path(config_path).exists():\n        raise FileNotFoundError(f\"配置文件 {config_path} 不存在\")\n\n    with open(config_path, \"r\", encoding=\"utf-8\") as f:\n        config_data = yaml.safe_load(f)\n\n    print(f\"配置文件加载成功: {config_path}\")\n\n    # 合并所有配置\n    config = {}\n\n    # 应用配置\n    config.update(_load_app_config(config_data))\n\n    # 爬虫配置\n    config.update(_load_crawler_config(config_data))\n\n    # 报告配置\n    config.update(_load_report_config(config_data))\n\n    # 通知配置\n    config.update(_load_notification_config(config_data))\n\n    # 统一调度配置\n    config[\"SCHEDULE\"] = _load_schedule_config(config_data)\n    config[\"_TIMELINE_DATA\"] = _load_timeline_data(\n        str(Path(config_path).parent) if config_path else \"config\"\n    )\n\n    # 权重配置\n    config[\"WEIGHT_CONFIG\"] = _load_weight_config(config_data)\n\n    # 平台配置\n    platforms_config = config_data.get(\"platforms\", {})\n    config[\"PLATFORMS\"] = platforms_config.get(\"sources\", [])\n\n    # RSS 配置\n    config[\"RSS\"] = _load_rss_config(config_data)\n\n    # AI 模型共享配置\n    config[\"AI\"] = _load_ai_config(config_data)\n\n    # AI 分析配置\n    config[\"AI_ANALYSIS\"] = _load_ai_analysis_config(config_data)\n\n    # AI 翻译配置\n    config[\"AI_TRANSLATION\"] = _load_ai_translation_config(config_data)\n\n    # AI 智能筛选配置\n    config[\"AI_FILTER\"] = _load_ai_filter_config(config_data)\n\n    # 筛选策略配置\n    config[\"FILTER\"] = _load_filter_config(config_data)\n\n    # 推送内容显示配置\n    config[\"DISPLAY\"] = _load_display_config(config_data)\n\n    # 存储配置\n    config[\"STORAGE\"] = _load_storage_config(config_data)\n\n    # Webhook 配置\n    config.update(_load_webhook_config(config_data))\n\n    # 打印通知渠道配置来源\n    _print_notification_sources(config)\n\n    return config\n"
  },
  {
    "path": "trendradar/core/scheduler.py",
    "content": "# coding=utf-8\n\"\"\"\n时间线调度器\n\n统一的时间线调度系统，替代分散的 push_window / analysis_window 逻辑。\n基于 periods + day_plans + week_map 模型实现灵活的时间段调度。\n\"\"\"\n\nimport copy\nimport re\nfrom dataclasses import dataclass\nfrom typing import Any, Callable, Dict, List, Optional\n\nfrom datetime import datetime\n\n\n@dataclass\nclass ResolvedSchedule:\n    \"\"\"当前时间解析后的调度结果\"\"\"\n    period_key: Optional[str]       # 命中的 period key，None=默认配置\n    period_name: Optional[str]      # 命中的展示名称\n    day_plan: str                   # 当前日计划\n    collect: bool\n    analyze: bool\n    push: bool\n    report_mode: str\n    ai_mode: str\n    once_analyze: bool\n    once_push: bool\n    frequency_file: Optional[str] = None  # 频率词文件路径，None=使用默认\n    filter_method: Optional[str] = None   # 筛选策略: \"keyword\"|\"ai\"，None=使用全局配置\n    interests_file: Optional[str] = None  # AI 筛选兴趣文件，None=使用默认\n\n\nclass Scheduler:\n    \"\"\"\n    时间线调度器\n\n    根据 timeline 配置（periods + day_plans + week_map）解析当前时间应执行的行为。\n    支持：\n    - 预设模板 + 自定义模式\n    - 跨日时间段（如 22:00-07:00）\n    - 每天 / 每周差异化配置\n    - once 执行去重（analyze / push 独立维度）\n    - 冲突策略（error_on_overlap / last_wins）\n    \"\"\"\n\n    def __init__(\n        self,\n        schedule_config: Dict[str, Any],\n        timeline_data: Dict[str, Any],\n        storage_backend: Any,\n        get_time_func: Callable[[], datetime],\n        fallback_report_mode: str = \"current\",\n    ):\n        \"\"\"\n        初始化调度器\n\n        Args:\n            schedule_config: config.yaml 中的 schedule 段（含 preset 等）\n            timeline_data: timeline.yaml 的完整数据\n            storage_backend: 存储后端（用于 once 去重记录）\n            get_time_func: 获取当前时间的函数（应使用配置的时区）\n            fallback_report_mode: 调度未启用时回退使用的 report_mode（来自 config.yaml 的 report.mode）\n        \"\"\"\n        self.schedule_config = schedule_config\n        self.storage = storage_backend\n        self.get_time = get_time_func\n        self.enabled = schedule_config.get(\"enabled\", True)\n        self.fallback_report_mode = fallback_report_mode\n\n        # 加载并构建最终 timeline\n        self.timeline = self._build_timeline(schedule_config, timeline_data)\n        if self.enabled:\n            self._validate_timeline(self.timeline)\n\n    def _build_timeline(\n        self,\n        schedule_config: Dict[str, Any],\n        timeline_data: Dict[str, Any],\n    ) -> Dict[str, Any]:\n        \"\"\"从 preset 或 custom 构建 timeline\"\"\"\n        preset = schedule_config.get(\"preset\", \"always_on\")\n\n        if preset == \"custom\":\n            timeline = copy.deepcopy(timeline_data.get(\"custom\", {}))\n        else:\n            presets = timeline_data.get(\"presets\", {})\n            if preset not in presets:\n                raise ValueError(\n                    f\"未知的预设模板: '{preset}'，可选值: \"\n                    f\"{', '.join(presets.keys())}, custom\"\n                )\n            timeline = copy.deepcopy(presets[preset])\n\n        # 确保 periods 是 dict（可能为空 {}）\n        if timeline.get(\"periods\") is None:\n            timeline[\"periods\"] = {}\n\n        return timeline\n\n    def resolve(self) -> ResolvedSchedule:\n        \"\"\"\n        解析当前时间对应的调度配置\n\n        Returns:\n            ResolvedSchedule 包含当前应执行的行为\n        \"\"\"\n        if not self.enabled:\n            # 调度未启用时返回默认的全功能配置，report_mode 回退使用 config.yaml 的 report.mode\n            return ResolvedSchedule(\n                period_key=None,\n                period_name=None,\n                day_plan=\"disabled\",\n                collect=True,\n                analyze=True,\n                push=True,\n                report_mode=self.fallback_report_mode,\n                ai_mode=\"follow_report\",\n                once_analyze=False,\n                once_push=False,\n            )\n\n        now = self.get_time()\n        weekday = now.isoweekday()  # 1=周一 ... 7=周日\n        now_hhmm = now.strftime(\"%H:%M\")\n\n        # 查找当天的日计划\n        day_plan_key = self.timeline[\"week_map\"].get(weekday)\n        if day_plan_key is None:\n            raise ValueError(f\"week_map 缺少星期映射: {weekday}\")\n\n        day_plan = self.timeline[\"day_plans\"].get(day_plan_key)\n        if day_plan is None:\n            raise ValueError(f\"week_map[{weekday}] 引用了不存在的 day_plan: {day_plan_key}\")\n\n        # 查找当前活跃的时间段\n        period_key = self._find_active_period(now_hhmm, day_plan)\n\n        # 合并默认配置和时间段配置\n        merged = self._merge_with_default(period_key)\n\n        # 打印调度日志\n        weekday_names = {1: \"一\", 2: \"二\", 3: \"三\", 4: \"四\", 5: \"五\", 6: \"六\", 7: \"日\"}\n        period_display = \"默认配置（未命中任何时间段）\"\n        if period_key:\n            period_cfg = self.timeline[\"periods\"][period_key]\n            period_name = period_cfg.get(\"name\", period_key)\n            start = period_cfg.get(\"start\", \"?\")\n            end = period_cfg.get(\"end\", \"?\")\n            period_display = f\"{period_name} ({start}-{end})\"\n\n        print(f\"[调度] 星期{weekday_names.get(weekday, '?')}，日计划: {day_plan_key}\")\n        print(f\"[调度] 当前时间段: {period_display}\")\n\n        resolved = ResolvedSchedule(\n            period_key=period_key,\n            period_name=(\n                self.timeline[\"periods\"][period_key].get(\"name\")\n                if period_key\n                else None\n            ),\n            day_plan=day_plan_key,\n            collect=merged.get(\"collect\", True),\n            analyze=merged.get(\"analyze\", False),\n            push=merged.get(\"push\", False),\n            report_mode=merged.get(\"report_mode\", \"current\"),\n            ai_mode=self._resolve_ai_mode(merged),\n            once_analyze=merged.get(\"once\", {}).get(\"analyze\", False),\n            once_push=merged.get(\"once\", {}).get(\"push\", False),\n            frequency_file=merged.get(\"frequency_file\"),\n            filter_method=merged.get(\"filter_method\"),\n            interests_file=merged.get(\"interests_file\"),\n        )\n\n        # 打印行为摘要\n        actions = []\n        if resolved.collect:\n            actions.append(\"采集\")\n        if resolved.analyze:\n            actions.append(f\"分析(AI:{resolved.ai_mode})\")\n        if resolved.push:\n            actions.append(f\"推送(模式:{resolved.report_mode})\")\n        print(f\"[调度] 行为: {', '.join(actions) if actions else '无'}\")\n        if resolved.frequency_file:\n            print(f\"[调度] 频率词文件: {resolved.frequency_file}\")\n\n        return resolved\n\n    def _find_active_period(\n        self, now_hhmm: str, day_plan: Dict[str, Any]\n    ) -> Optional[str]:\n        \"\"\"\n        查找当前时间命中的活跃时间段\n\n        Args:\n            now_hhmm: 当前时间 HH:MM\n            day_plan: 日计划配置\n\n        Returns:\n            命中的 period key，或 None\n        \"\"\"\n        candidates = []\n        for idx, key in enumerate(day_plan.get(\"periods\", [])):\n            period = self.timeline[\"periods\"].get(key)\n            if period is None:\n                continue\n            if self._in_range(now_hhmm, period[\"start\"], period[\"end\"]):\n                candidates.append((idx, key))\n\n        if not candidates:\n            return None\n\n        # 检查冲突\n        if len(candidates) > 1:\n            policy = self.timeline.get(\"overlap\", {}).get(\"policy\", \"error_on_overlap\")\n            conflicting = [c[1] for c in candidates]\n\n            if policy == \"error_on_overlap\":\n                raise ValueError(\n                    f\"检测到时间段重叠冲突: {', '.join(conflicting)} 在 {now_hhmm} 重叠。\"\n                    f\"请调整时间段配置，或将 overlap.policy 设为 'last_wins'\"\n                )\n\n            # last_wins：输出重叠警告，列表中后面的优先\n            print(\n                f\"[调度] 检测到时间段重叠: {', '.join(conflicting)} 在 {now_hhmm} 重叠\"\n            )\n            winner = candidates[-1]\n            print(f\"[调度] 冲突策略: last_wins，生效时间段: {winner[1]}\")\n            return winner[1]\n\n        return candidates[0][1]\n\n    @staticmethod\n    def _in_range(now_hhmm: str, start: str, end: str) -> bool:\n        \"\"\"\n        检查时间是否在范围内（支持跨日）\n\n        Args:\n            now_hhmm: 当前时间 HH:MM\n            start: 开始时间 HH:MM\n            end: 结束时间 HH:MM\n\n        Returns:\n            是否在范围内\n        \"\"\"\n        if start <= end:\n            # 正常范围，如 08:00-09:00\n            return start <= now_hhmm <= end\n        else:\n            # 跨日范围，如 22:00-07:00\n            return now_hhmm >= start or now_hhmm <= end\n\n    def _merge_with_default(self, period_key: Optional[str]) -> Dict[str, Any]:\n        \"\"\"合并默认配置和时间段配置\"\"\"\n        base = copy.deepcopy(self.timeline.get(\"default\", {}))\n        if not period_key:\n            return base\n\n        period = copy.deepcopy(self.timeline[\"periods\"][period_key])\n\n        # 先合并 once 子对象\n        merged_once = dict(base.get(\"once\", {}))\n        merged_once.update(period.get(\"once\", {}))\n\n        # 标量字段覆盖\n        base.update(period)\n\n        # 恢复合并后的 once\n        if merged_once:\n            base[\"once\"] = merged_once\n\n        return base\n\n    @staticmethod\n    def _resolve_ai_mode(cfg: Dict[str, Any]) -> str:\n        \"\"\"解析最终的 AI 模式\"\"\"\n        ai_mode = cfg.get(\"ai_mode\", \"follow_report\")\n        if ai_mode == \"follow_report\":\n            return cfg.get(\"report_mode\", \"current\")\n        return ai_mode\n\n    def already_executed(self, period_key: str, action: str, date_str: str) -> bool:\n        \"\"\"\n        检查指定时间段的某个 action 今天是否已执行\n\n        Args:\n            period_key: 时间段 key\n            action: 动作类型 (analyze / push)\n            date_str: 日期 YYYY-MM-DD\n\n        Returns:\n            是否已执行\n        \"\"\"\n        return self.storage.has_period_executed(date_str, period_key, action)\n\n    def record_execution(self, period_key: str, action: str, date_str: str) -> None:\n        \"\"\"\n        记录时间段的 action 执行\n\n        Args:\n            period_key: 时间段 key\n            action: 动作类型 (analyze / push)\n            date_str: 日期 YYYY-MM-DD\n        \"\"\"\n        self.storage.record_period_execution(date_str, period_key, action)\n\n    # ========================================\n    # 校验\n    # ========================================\n\n    def _validate_timeline(self, timeline: Dict[str, Any]) -> None:\n        \"\"\"\n        启动时校验 timeline 配置\n\n        Raises:\n            ValueError: 配置不合法时抛出\n        \"\"\"\n        required_top_keys = [\"default\", \"periods\", \"day_plans\", \"week_map\"]\n        for key in required_top_keys:\n            if key not in timeline:\n                raise ValueError(f\"timeline 缺少必须字段: {key}\")\n\n        # week_map 必须覆盖 1..7\n        for day in range(1, 8):\n            if day not in timeline[\"week_map\"]:\n                raise ValueError(f\"week_map 缺少星期映射: {day}\")\n\n        # day_plan 引用完整性\n        for day, plan_key in timeline[\"week_map\"].items():\n            if plan_key not in timeline[\"day_plans\"]:\n                raise ValueError(\n                    f\"week_map[{day}] 引用了不存在的 day_plan: {plan_key}\"\n                )\n\n        # period 引用完整性\n        for plan_key, plan in timeline[\"day_plans\"].items():\n            for period_key in plan.get(\"periods\", []):\n                if period_key not in timeline[\"periods\"]:\n                    raise ValueError(\n                        f\"day_plan[{plan_key}] 引用了不存在的 period: {period_key}\"\n                    )\n\n        # 时间格式校验\n        for period_key, period in timeline[\"periods\"].items():\n            if \"start\" not in period or \"end\" not in period:\n                raise ValueError(\n                    f\"period '{period_key}' 缺少 start 或 end 字段\"\n                )\n            self._validate_hhmm(period[\"start\"], f\"{period_key}.start\")\n            self._validate_hhmm(period[\"end\"], f\"{period_key}.end\")\n            if period[\"start\"] == period[\"end\"]:\n                raise ValueError(\n                    f\"period '{period_key}' 的 start 与 end 不能相同: {period['start']}\"\n                )\n\n        # 检查冲突策略下的重叠\n        policy = timeline.get(\"overlap\", {}).get(\"policy\", \"error_on_overlap\")\n        if policy == \"error_on_overlap\":\n            self._check_period_overlaps(timeline)\n\n    def _check_period_overlaps(self, timeline: Dict[str, Any]) -> None:\n        \"\"\"\n        检查每个日计划中的时间段是否存在重叠\n\n        仅在 overlap.policy == \"error_on_overlap\" 时调用\n        \"\"\"\n        periods = timeline.get(\"periods\", {})\n\n        for plan_key, plan in timeline[\"day_plans\"].items():\n            period_keys = plan.get(\"periods\", [])\n            if len(period_keys) <= 1:\n                continue\n\n            # 收集每个时间段的范围\n            ranges = []\n            for pk in period_keys:\n                p = periods.get(pk, {})\n                if \"start\" in p and \"end\" in p:\n                    ranges.append((pk, p[\"start\"], p[\"end\"]))\n\n            # 两两检查重叠\n            for i in range(len(ranges)):\n                for j in range(i + 1, len(ranges)):\n                    if self._ranges_overlap(\n                        ranges[i][1], ranges[i][2],\n                        ranges[j][1], ranges[j][2],\n                    ):\n                        raise ValueError(\n                            f\"day_plan '{plan_key}' 中时间段 '{ranges[i][0]}' \"\n                            f\"({ranges[i][1]}-{ranges[i][2]}) 与 '{ranges[j][0]}' \"\n                            f\"({ranges[j][1]}-{ranges[j][2]}) 存在重叠。\"\n                            f\"请调整时间段，或将 overlap.policy 设为 'last_wins'\"\n                        )\n\n    @staticmethod\n    def _ranges_overlap(s1: str, e1: str, s2: str, e2: str) -> bool:\n        \"\"\"检查两个时间范围是否重叠（支持跨日）\"\"\"\n        def to_minutes(t: str) -> int:\n            h, m = t.split(\":\")\n            return int(h) * 60 + int(m)\n\n        def expand_range(start: str, end: str) -> List[tuple]:\n            \"\"\"将时间范围展开为分钟段列表，跨日时拆分为两段\"\"\"\n            s = to_minutes(start)\n            e = to_minutes(end)\n            if s <= e:\n                return [(s, e)]\n            else:\n                # 跨日：拆分为 [start, 23:59] 和 [00:00, end]\n                return [(s, 24 * 60 - 1), (0, e)]\n\n        segs1 = expand_range(s1, e1)\n        segs2 = expand_range(s2, e2)\n\n        for a_start, a_end in segs1:\n            for b_start, b_end in segs2:\n                # 两个区间有重叠的条件\n                if a_start <= b_end and b_start <= a_end:\n                    return True\n        return False\n\n    @staticmethod\n    def _validate_hhmm(value: str, field_name: str) -> None:\n        \"\"\"校验 HH:MM 格式\"\"\"\n        if not re.match(r\"^\\d{2}:\\d{2}$\", value):\n            raise ValueError(f\"{field_name} 格式错误: '{value}'，期望 HH:MM\")\n        h, m = value.split(\":\")\n        if not (0 <= int(h) <= 23 and 0 <= int(m) <= 59):\n            raise ValueError(f\"{field_name} 时间值超出范围: '{value}'\")\n"
  },
  {
    "path": "trendradar/crawler/__init__.py",
    "content": "# coding=utf-8\n\"\"\"\n爬虫模块 - 数据抓取功能\n\"\"\"\n\nfrom trendradar.crawler.fetcher import DataFetcher\n\n__all__ = [\"DataFetcher\"]\n"
  },
  {
    "path": "trendradar/crawler/fetcher.py",
    "content": "# coding=utf-8\n\"\"\"\n数据获取器模块\n\n负责从 NewsNow API 抓取新闻数据，支持：\n- 单个平台数据获取\n- 批量平台数据爬取\n- 自动重试机制\n- 代理支持\n\"\"\"\n\nimport json\nimport random\nimport time\nfrom typing import Dict, List, Tuple, Optional, Union\n\nimport requests\n\n\nclass DataFetcher:\n    \"\"\"数据获取器\"\"\"\n\n    # 默认 API 地址\n    DEFAULT_API_URL = \"https://newsnow.busiyi.world/api/s\"\n\n    # 默认请求头\n    DEFAULT_HEADERS = {\n        \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\",\n        \"Accept\": \"application/json, text/plain, */*\",\n        \"Accept-Language\": \"zh-CN,zh;q=0.9,en;q=0.8\",\n        \"Connection\": \"keep-alive\",\n        \"Cache-Control\": \"no-cache\",\n    }\n\n    def __init__(\n        self,\n        proxy_url: Optional[str] = None,\n        api_url: Optional[str] = None,\n    ):\n        \"\"\"\n        初始化数据获取器\n\n        Args:\n            proxy_url: 代理服务器 URL（可选）\n            api_url: API 基础 URL（可选，默认使用 DEFAULT_API_URL）\n        \"\"\"\n        self.proxy_url = proxy_url\n        self.api_url = api_url or self.DEFAULT_API_URL\n\n    def fetch_data(\n        self,\n        id_info: Union[str, Tuple[str, str]],\n        max_retries: int = 2,\n        min_retry_wait: int = 3,\n        max_retry_wait: int = 5,\n    ) -> Tuple[Optional[str], str, str]:\n        \"\"\"\n        获取指定ID数据，支持重试\n\n        Args:\n            id_info: 平台ID 或 (平台ID, 别名) 元组\n            max_retries: 最大重试次数\n            min_retry_wait: 最小重试等待时间（秒）\n            max_retry_wait: 最大重试等待时间（秒）\n\n        Returns:\n            (响应文本, 平台ID, 别名) 元组，失败时响应文本为 None\n        \"\"\"\n        if isinstance(id_info, tuple):\n            id_value, alias = id_info\n        else:\n            id_value = id_info\n            alias = id_value\n\n        url = f\"{self.api_url}?id={id_value}&latest\"\n\n        proxies = None\n        if self.proxy_url:\n            proxies = {\"http\": self.proxy_url, \"https\": self.proxy_url}\n\n        retries = 0\n        while retries <= max_retries:\n            try:\n                response = requests.get(\n                    url,\n                    proxies=proxies,\n                    headers=self.DEFAULT_HEADERS,\n                    timeout=10,\n                )\n                response.raise_for_status()\n\n                data_text = response.text\n                data_json = json.loads(data_text)\n\n                status = data_json.get(\"status\", \"未知\")\n                if status not in [\"success\", \"cache\"]:\n                    raise ValueError(f\"响应状态异常: {status}\")\n\n                status_info = \"最新数据\" if status == \"success\" else \"缓存数据\"\n                print(f\"获取 {id_value} 成功（{status_info}）\")\n                return data_text, id_value, alias\n\n            except Exception as e:\n                retries += 1\n                if retries <= max_retries:\n                    base_wait = random.uniform(min_retry_wait, max_retry_wait)\n                    additional_wait = (retries - 1) * random.uniform(1, 2)\n                    wait_time = base_wait + additional_wait\n                    print(f\"请求 {id_value} 失败: {e}. {wait_time:.2f}秒后重试...\")\n                    time.sleep(wait_time)\n                else:\n                    print(f\"请求 {id_value} 失败: {e}\")\n                    return None, id_value, alias\n\n        return None, id_value, alias\n\n    def crawl_websites(\n        self,\n        ids_list: List[Union[str, Tuple[str, str]]],\n        request_interval: int = 100,\n    ) -> Tuple[Dict, Dict, List]:\n        \"\"\"\n        爬取多个网站数据\n\n        Args:\n            ids_list: 平台ID列表，每个元素可以是字符串或 (平台ID, 别名) 元组\n            request_interval: 请求间隔（毫秒）\n\n        Returns:\n            (结果字典, ID到名称的映射, 失败ID列表) 元组\n        \"\"\"\n        results = {}\n        id_to_name = {}\n        failed_ids = []\n\n        for i, id_info in enumerate(ids_list):\n            if isinstance(id_info, tuple):\n                id_value, name = id_info\n            else:\n                id_value = id_info\n                name = id_value\n\n            id_to_name[id_value] = name\n            response, _, _ = self.fetch_data(id_info)\n\n            if response:\n                try:\n                    data = json.loads(response)\n                    results[id_value] = {}\n\n                    for index, item in enumerate(data.get(\"items\", []), 1):\n                        title = item.get(\"title\")\n                        # 跳过无效标题（None、float、空字符串）\n                        if title is None or isinstance(title, float) or not str(title).strip():\n                            continue\n                        title = str(title).strip()\n                        url = item.get(\"url\", \"\")\n                        mobile_url = item.get(\"mobileUrl\", \"\")\n\n                        if title in results[id_value]:\n                            results[id_value][title][\"ranks\"].append(index)\n                        else:\n                            results[id_value][title] = {\n                                \"ranks\": [index],\n                                \"url\": url,\n                                \"mobileUrl\": mobile_url,\n                            }\n                except json.JSONDecodeError:\n                    print(f\"解析 {id_value} 响应失败\")\n                    failed_ids.append(id_value)\n                except Exception as e:\n                    print(f\"处理 {id_value} 数据出错: {e}\")\n                    failed_ids.append(id_value)\n            else:\n                failed_ids.append(id_value)\n\n            # 请求间隔（除了最后一个）\n            if i < len(ids_list) - 1:\n                actual_interval = request_interval + random.randint(-10, 20)\n                actual_interval = max(50, actual_interval)\n                time.sleep(actual_interval / 1000)\n\n        print(f\"成功: {list(results.keys())}, 失败: {failed_ids}\")\n        return results, id_to_name, failed_ids\n"
  },
  {
    "path": "trendradar/crawler/rss/__init__.py",
    "content": "# coding=utf-8\n\"\"\"\nRSS 抓取模块\n\n提供 RSS 2.0、Atom 和 JSON Feed 1.1 订阅源的解析和抓取功能\n\"\"\"\n\nfrom .parser import RSSParser\nfrom .fetcher import RSSFetcher, RSSFeedConfig\n\n__all__ = [\"RSSParser\", \"RSSFetcher\", \"RSSFeedConfig\"]\n"
  },
  {
    "path": "trendradar/crawler/rss/fetcher.py",
    "content": "# coding=utf-8\n\"\"\"\nRSS 抓取器\n\n负责从配置的 RSS 源抓取数据并转换为标准格式\n\"\"\"\n\nimport time\nimport random\nfrom dataclasses import dataclass\nfrom typing import List, Dict, Optional, Tuple\n\nimport requests\n\nfrom .parser import RSSParser\nfrom trendradar.storage.base import RSSItem, RSSData\nfrom trendradar.utils.time import get_configured_time, is_within_days, DEFAULT_TIMEZONE\n\n\n@dataclass\nclass RSSFeedConfig:\n    \"\"\"RSS 源配置\"\"\"\n    id: str                     # 源 ID\n    name: str                   # 显示名称\n    url: str                    # RSS URL\n    max_items: int = 0          # 最大条目数（0=不限制）\n    enabled: bool = True        # 是否启用\n    max_age_days: Optional[int] = None  # 文章最大年龄（天），覆盖全局设置；None=使用全局，0=禁用过滤\n\n\nclass RSSFetcher:\n    \"\"\"RSS 抓取器\"\"\"\n\n    def __init__(\n        self,\n        feeds: List[RSSFeedConfig],\n        request_interval: int = 2000,\n        timeout: int = 15,\n        use_proxy: bool = False,\n        proxy_url: str = \"\",\n        timezone: str = DEFAULT_TIMEZONE,\n        freshness_enabled: bool = True,\n        default_max_age_days: int = 3,\n    ):\n        \"\"\"\n        初始化抓取器\n\n        Args:\n            feeds: RSS 源配置列表\n            request_interval: 请求间隔（毫秒）\n            timeout: 请求超时（秒）\n            use_proxy: 是否使用代理\n            proxy_url: 代理 URL\n            timezone: 时区配置（如 'Asia/Shanghai'）\n            freshness_enabled: 是否启用新鲜度过滤\n            default_max_age_days: 默认最大文章年龄（天）\n        \"\"\"\n        self.feeds = [f for f in feeds if f.enabled]\n        self.request_interval = request_interval\n        self.timeout = timeout\n        self.use_proxy = use_proxy\n        self.proxy_url = proxy_url\n        self.timezone = timezone\n        self.freshness_enabled = freshness_enabled\n        self.default_max_age_days = default_max_age_days\n\n        self.parser = RSSParser()\n        self.session = self._create_session()\n\n    def _create_session(self) -> requests.Session:\n        \"\"\"创建请求会话\"\"\"\n        session = requests.Session()\n        session.headers.update({\n            \"User-Agent\": \"TrendRadar/2.0 RSS Reader (https://github.com/trendradar)\",\n            \"Accept\": \"application/feed+json, application/json, application/rss+xml, application/atom+xml, application/xml, text/xml, */*\",\n            \"Accept-Language\": \"zh-CN,zh;q=0.9,en;q=0.8\",\n        })\n\n        if self.use_proxy and self.proxy_url:\n            session.proxies = {\n                \"http\": self.proxy_url,\n                \"https\": self.proxy_url,\n            }\n\n        return session\n\n    def _filter_by_freshness(\n        self,\n        items: List[RSSItem],\n        feed: RSSFeedConfig,\n    ) -> Tuple[List[RSSItem], int]:\n        \"\"\"\n        根据新鲜度过滤文章\n\n        Args:\n            items: 待过滤的文章列表\n            feed: RSS 源配置\n\n        Returns:\n            (过滤后的文章列表, 被过滤的文章数)\n        \"\"\"\n        # 如果全局禁用，直接返回\n        if not self.freshness_enabled:\n            return items, 0\n\n        # 确定此 feed 的 max_age_days\n        max_days = feed.max_age_days\n        if max_days is None:\n            max_days = self.default_max_age_days\n\n        # 如果设为 0，禁用此 feed 的过滤\n        if max_days == 0:\n            return items, 0\n\n        # 过滤逻辑：无发布时间的文章保留\n        filtered = []\n        for item in items:\n            if not item.published_at:\n                # 无发布时间，保留\n                filtered.append(item)\n            elif is_within_days(item.published_at, max_days, self.timezone):\n                # 在指定天数内，保留\n                filtered.append(item)\n            # 否则过滤掉\n\n        filtered_count = len(items) - len(filtered)\n        return filtered, filtered_count\n\n    def fetch_feed(self, feed: RSSFeedConfig) -> Tuple[List[RSSItem], Optional[str]]:\n        \"\"\"\n        抓取单个 RSS 源\n\n        Args:\n            feed: RSS 源配置\n\n        Returns:\n            (条目列表, 错误信息) 元组\n        \"\"\"\n        try:\n            response = self.session.get(feed.url, timeout=self.timeout)\n            response.raise_for_status()\n\n            parsed_items = self.parser.parse(response.text, feed.url)\n\n            # 限制条目数量（0=不限制）\n            if feed.max_items > 0:\n                parsed_items = parsed_items[:feed.max_items]\n\n            # 转换为 RSSItem（使用配置的时区）\n            now = get_configured_time(self.timezone)\n            crawl_time = now.strftime(\"%H:%M\")\n            items = []\n\n            for parsed in parsed_items:\n                item = RSSItem(\n                    title=parsed.title,\n                    feed_id=feed.id,\n                    feed_name=feed.name,\n                    url=parsed.url,\n                    published_at=parsed.published_at or \"\",\n                    summary=parsed.summary or \"\",\n                    author=parsed.author or \"\",\n                    crawl_time=crawl_time,\n                    first_time=crawl_time,\n                    last_time=crawl_time,\n                    count=1,\n                )\n                items.append(item)\n\n            # 注意：新鲜度过滤已移至推送阶段（_convert_rss_items_to_list）\n            # 这样所有文章都会存入数据库，但旧文章不会推送\n            print(f\"[RSS] {feed.name}: 获取 {len(items)} 条\")\n            return items, None\n\n        except requests.Timeout:\n            error = f\"请求超时 ({self.timeout}s)\"\n            print(f\"[RSS] {feed.name}: {error}\")\n            return [], error\n\n        except requests.RequestException as e:\n            error = f\"请求失败: {e}\"\n            print(f\"[RSS] {feed.name}: {error}\")\n            return [], error\n\n        except ValueError as e:\n            error = f\"解析失败: {e}\"\n            print(f\"[RSS] {feed.name}: {error}\")\n            return [], error\n\n        except Exception as e:\n            error = f\"未知错误: {e}\"\n            print(f\"[RSS] {feed.name}: {error}\")\n            return [], error\n\n    def fetch_all(self) -> RSSData:\n        \"\"\"\n        抓取所有 RSS 源\n\n        Returns:\n            RSSData 对象\n        \"\"\"\n        all_items: Dict[str, List[RSSItem]] = {}\n        id_to_name: Dict[str, str] = {}\n        failed_ids: List[str] = []\n\n        # 使用配置的时区\n        now = get_configured_time(self.timezone)\n        crawl_time = now.strftime(\"%H:%M\")\n        crawl_date = now.strftime(\"%Y-%m-%d\")\n\n        print(f\"[RSS] 开始抓取 {len(self.feeds)} 个 RSS 源...\")\n\n        for i, feed in enumerate(self.feeds):\n            # 请求间隔（带随机波动）\n            if i > 0:\n                interval = self.request_interval / 1000\n                jitter = random.uniform(-0.2, 0.2) * interval\n                time.sleep(interval + jitter)\n\n            items, error = self.fetch_feed(feed)\n\n            id_to_name[feed.id] = feed.name\n\n            if error:\n                failed_ids.append(feed.id)\n            else:\n                all_items[feed.id] = items\n\n        total_items = sum(len(items) for items in all_items.values())\n        print(f\"[RSS] 抓取完成: {len(all_items)} 个源成功, {len(failed_ids)} 个失败, 共 {total_items} 条\")\n\n        return RSSData(\n            date=crawl_date,\n            crawl_time=crawl_time,\n            items=all_items,\n            id_to_name=id_to_name,\n            failed_ids=failed_ids,\n        )\n\n    @classmethod\n    def from_config(cls, config: Dict) -> \"RSSFetcher\":\n        \"\"\"\n        从配置字典创建抓取器\n\n        Args:\n            config: 配置字典，格式如下：\n                {\n                    \"enabled\": true,\n                    \"request_interval\": 2000,\n                    \"freshness_filter\": {\n                        \"enabled\": true,\n                        \"max_age_days\": 3\n                    },\n                    \"feeds\": [\n                        {\"id\": \"hacker-news\", \"name\": \"Hacker News\", \"url\": \"...\", \"max_age_days\": 1}\n                    ]\n                }\n\n        Returns:\n            RSSFetcher 实例\n        \"\"\"\n        # 读取新鲜度过滤配置\n        freshness_config = config.get(\"freshness_filter\", {})\n        freshness_enabled = freshness_config.get(\"enabled\", True)  # 默认启用\n        default_max_age_days = freshness_config.get(\"max_age_days\", 3)  # 默认3天\n\n        feeds = []\n        for feed_config in config.get(\"feeds\", []):\n            # 读取并验证单个 feed 的 max_age_days（可选）\n            max_age_days_raw = feed_config.get(\"max_age_days\")\n            max_age_days = None\n            if max_age_days_raw is not None:\n                try:\n                    max_age_days = int(max_age_days_raw)\n                    if max_age_days < 0:\n                        feed_id = feed_config.get(\"id\", \"unknown\")\n                        print(f\"[警告] RSS feed '{feed_id}' 的 max_age_days 为负数，将使用全局默认值\")\n                        max_age_days = None\n                except (ValueError, TypeError):\n                    feed_id = feed_config.get(\"id\", \"unknown\")\n                    print(f\"[警告] RSS feed '{feed_id}' 的 max_age_days 格式错误：{max_age_days_raw}\")\n                    max_age_days = None\n\n            feed = RSSFeedConfig(\n                id=feed_config.get(\"id\", \"\"),\n                name=feed_config.get(\"name\", \"\"),\n                url=feed_config.get(\"url\", \"\"),\n                max_items=feed_config.get(\"max_items\", 0),  # 0=不限制\n                enabled=feed_config.get(\"enabled\", True),\n                max_age_days=max_age_days,  # None=使用全局，0=禁用，>0=覆盖\n            )\n            if feed.id and feed.url:\n                feeds.append(feed)\n\n        return cls(\n            feeds=feeds,\n            request_interval=config.get(\"request_interval\", 2000),\n            timeout=config.get(\"timeout\", 15),\n            use_proxy=config.get(\"use_proxy\", False),\n            proxy_url=config.get(\"proxy_url\", \"\"),\n            timezone=config.get(\"timezone\", DEFAULT_TIMEZONE),\n            freshness_enabled=freshness_enabled,\n            default_max_age_days=default_max_age_days,\n        )\n"
  },
  {
    "path": "trendradar/crawler/rss/parser.py",
    "content": "# coding=utf-8\n\"\"\"\nRSS 解析器\n\n支持 RSS 2.0、Atom 和 JSON Feed 1.1 格式的解析\n\"\"\"\n\nimport re\nimport html\nimport json\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom typing import List, Optional, Dict, Any\nfrom email.utils import parsedate_to_datetime\n\ntry:\n    import feedparser\n    HAS_FEEDPARSER = True\nexcept ImportError:\n    HAS_FEEDPARSER = False\n    feedparser = None\n\n\n@dataclass\nclass ParsedRSSItem:\n    \"\"\"解析后的 RSS 条目\"\"\"\n    title: str\n    url: str\n    published_at: Optional[str] = None\n    summary: Optional[str] = None\n    author: Optional[str] = None\n    guid: Optional[str] = None\n\n\nclass RSSParser:\n    \"\"\"RSS 解析器\"\"\"\n\n    def __init__(self, max_summary_length: int = 500):\n        \"\"\"\n        初始化解析器\n\n        Args:\n            max_summary_length: 摘要最大长度\n        \"\"\"\n        if not HAS_FEEDPARSER:\n            raise ImportError(\"RSS 解析需要安装 feedparser: pip install feedparser\")\n\n        self.max_summary_length = max_summary_length\n\n    def parse(self, content: str, feed_url: str = \"\") -> List[ParsedRSSItem]:\n        \"\"\"\n        解析 RSS/Atom/JSON Feed 内容\n\n        Args:\n            content: Feed 内容（XML 或 JSON）\n            feed_url: Feed URL（用于错误提示）\n\n        Returns:\n            解析后的条目列表\n        \"\"\"\n        # 先尝试检测 JSON Feed\n        if self._is_json_feed(content):\n            return self._parse_json_feed(content, feed_url)\n\n        # 使用 feedparser 解析 RSS/Atom\n        feed = feedparser.parse(content)\n\n        if feed.bozo and not feed.entries:\n            raise ValueError(f\"RSS 解析失败 ({feed_url}): {feed.bozo_exception}\")\n\n        items = []\n        for entry in feed.entries:\n            item = self._parse_entry(entry)\n            if item:\n                items.append(item)\n\n        return items\n\n    def _is_json_feed(self, content: str) -> bool:\n        \"\"\"\n        检测内容是否为 JSON Feed 格式\n\n        JSON Feed 必须包含 version 字段，值为 https://jsonfeed.org/version/1 或 1.1\n        \"\"\"\n        content = content.strip()\n        if not content.startswith(\"{\"):\n            return False\n\n        try:\n            data = json.loads(content)\n            version = data.get(\"version\", \"\")\n            return \"jsonfeed.org\" in version\n        except (json.JSONDecodeError, TypeError):\n            return False\n\n    def _parse_json_feed(self, content: str, feed_url: str = \"\") -> List[ParsedRSSItem]:\n        \"\"\"\n        解析 JSON Feed 1.1 格式\n\n        JSON Feed 规范: https://www.jsonfeed.org/version/1.1/\n\n        Args:\n            content: JSON Feed 内容\n            feed_url: Feed URL（用于错误提示）\n\n        Returns:\n            解析后的条目列表\n        \"\"\"\n        try:\n            data = json.loads(content)\n        except json.JSONDecodeError as e:\n            raise ValueError(f\"JSON Feed 解析失败 ({feed_url}): {e}\")\n\n        items_data = data.get(\"items\", [])\n        if not items_data:\n            return []\n\n        items = []\n        for item_data in items_data:\n            item = self._parse_json_feed_item(item_data)\n            if item:\n                items.append(item)\n\n        return items\n\n    def _parse_json_feed_item(self, item_data: Dict[str, Any]) -> Optional[ParsedRSSItem]:\n        \"\"\"解析单个 JSON Feed 条目\"\"\"\n        # 标题：优先 title，否则使用 content_text 的前 100 字符\n        title = item_data.get(\"title\", \"\")\n        if not title:\n            content_text = item_data.get(\"content_text\", \"\")\n            if content_text:\n                title = content_text[:100] + (\"...\" if len(content_text) > 100 else \"\")\n\n        title = self._clean_text(title)\n        if not title:\n            return None\n\n        # URL\n        url = item_data.get(\"url\", \"\") or item_data.get(\"external_url\", \"\")\n\n        # 发布时间（ISO 8601 格式）\n        published_at = None\n        date_str = item_data.get(\"date_published\") or item_data.get(\"date_modified\")\n        if date_str:\n            published_at = self._parse_iso_date(date_str)\n\n        # 摘要：优先 summary，否则使用 content_text\n        summary = item_data.get(\"summary\", \"\")\n        if not summary:\n            content_text = item_data.get(\"content_text\", \"\")\n            content_html = item_data.get(\"content_html\", \"\")\n            summary = content_text or self._clean_text(content_html)\n\n        if summary:\n            summary = self._clean_text(summary)\n            if len(summary) > self.max_summary_length:\n                summary = summary[:self.max_summary_length] + \"...\"\n\n        # 作者\n        author = None\n        authors = item_data.get(\"authors\", [])\n        if authors:\n            names = [a.get(\"name\", \"\") for a in authors if isinstance(a, dict) and a.get(\"name\")]\n            if names:\n                author = \", \".join(names)\n\n        # GUID\n        guid = item_data.get(\"id\", \"\") or url\n\n        return ParsedRSSItem(\n            title=title,\n            url=url,\n            published_at=published_at,\n            summary=summary or None,\n            author=author,\n            guid=guid,\n        )\n\n    def _parse_iso_date(self, date_str: str) -> Optional[str]:\n        \"\"\"解析 ISO 8601 日期格式\"\"\"\n        if not date_str:\n            return None\n\n        try:\n            # 处理常见的 ISO 8601 格式\n            # 替换 Z 为 +00:00\n            date_str = date_str.replace(\"Z\", \"+00:00\")\n            dt = datetime.fromisoformat(date_str)\n            return dt.isoformat()\n        except (ValueError, TypeError):\n            pass\n\n        return None\n\n    def parse_url(self, url: str, timeout: int = 10) -> List[ParsedRSSItem]:\n        \"\"\"\n        从 URL 解析 RSS\n\n        Args:\n            url: RSS URL\n            timeout: 超时时间（秒）\n\n        Returns:\n            解析后的条目列表\n        \"\"\"\n        import requests\n\n        response = requests.get(url, timeout=timeout, headers={\n            \"User-Agent\": \"TrendRadar/2.0 RSS Reader\"\n        })\n        response.raise_for_status()\n\n        return self.parse(response.text, url)\n\n    def _parse_entry(self, entry: Any) -> Optional[ParsedRSSItem]:\n        \"\"\"解析单个条目\"\"\"\n        title = self._clean_text(entry.get(\"title\", \"\"))\n        if not title:\n            return None\n\n        url = entry.get(\"link\", \"\")\n        if not url:\n            # 尝试从 links 中获取\n            links = entry.get(\"links\", [])\n            for link in links:\n                if link.get(\"rel\") == \"alternate\" or link.get(\"type\", \"\").startswith(\"text/html\"):\n                    url = link.get(\"href\", \"\")\n                    break\n            if not url and links:\n                url = links[0].get(\"href\", \"\")\n\n        published_at = self._parse_date(entry)\n        summary = self._parse_summary(entry)\n        author = self._parse_author(entry)\n        guid = entry.get(\"id\") or entry.get(\"guid\", {}).get(\"value\") or url\n\n        return ParsedRSSItem(\n            title=title,\n            url=url,\n            published_at=published_at,\n            summary=summary,\n            author=author,\n            guid=guid,\n        )\n\n    def _clean_text(self, text: str) -> str:\n        \"\"\"清理文本\"\"\"\n        if not text:\n            return \"\"\n\n        # 解码 HTML 实体\n        text = html.unescape(text)\n\n        # 移除 HTML 标签\n        text = re.sub(r'<[^>]+>', '', text)\n\n        # 移除多余空白\n        text = re.sub(r'\\s+', ' ', text)\n\n        return text.strip()\n\n    def _parse_date(self, entry: Any) -> Optional[str]:\n        \"\"\"解析发布日期\"\"\"\n        # feedparser 会自动解析日期到 published_parsed\n        date_struct = entry.get(\"published_parsed\") or entry.get(\"updated_parsed\")\n\n        if date_struct:\n            try:\n                dt = datetime(*date_struct[:6])\n                return dt.isoformat()\n            except (ValueError, TypeError):\n                pass\n\n        # 尝试手动解析\n        date_str = entry.get(\"published\") or entry.get(\"updated\")\n        if date_str:\n            try:\n                dt = parsedate_to_datetime(date_str)\n                return dt.isoformat()\n            except (ValueError, TypeError):\n                pass\n\n            # 尝试 ISO 格式\n            try:\n                dt = datetime.fromisoformat(date_str.replace(\"Z\", \"+00:00\"))\n                return dt.isoformat()\n            except (ValueError, TypeError):\n                pass\n\n        return None\n\n    def _parse_summary(self, entry: Any) -> Optional[str]:\n        \"\"\"解析摘要\"\"\"\n        summary = entry.get(\"summary\") or entry.get(\"description\", \"\")\n\n        if not summary:\n            # 尝试从 content 获取\n            content = entry.get(\"content\", [])\n            if content and isinstance(content, list):\n                summary = content[0].get(\"value\", \"\")\n\n        if not summary:\n            return None\n\n        summary = self._clean_text(summary)\n\n        # 截断过长的摘要\n        if len(summary) > self.max_summary_length:\n            summary = summary[:self.max_summary_length] + \"...\"\n\n        return summary\n\n    def _parse_author(self, entry: Any) -> Optional[str]:\n        \"\"\"解析作者\"\"\"\n        author = entry.get(\"author\")\n        if author:\n            return self._clean_text(author)\n\n        # 尝试从 dc:creator 获取\n        author = entry.get(\"dc_creator\")\n        if author:\n            return self._clean_text(author)\n\n        # 尝试从 authors 列表获取\n        authors = entry.get(\"authors\", [])\n        if authors:\n            names = [a.get(\"name\", \"\") for a in authors if a.get(\"name\")]\n            if names:\n                return \", \".join(names)\n\n        return None\n"
  },
  {
    "path": "trendradar/notification/__init__.py",
    "content": "# coding=utf-8\n\"\"\"\n通知推送模块\n\n提供多渠道通知推送功能，包括：\n- 飞书、钉钉、企业微信\n- Telegram、Slack\n- Email、ntfy、Bark\n\n模块结构：\n- formatters: 内容格式转换\n- batch: 批次处理工具\n- renderer: 通知内容渲染\n- splitter: 消息分批拆分\n- senders: 消息发送器（各渠道发送函数）\n- dispatcher: 多账号通知调度器\n\"\"\"\n\nfrom trendradar.notification.formatters import (\n    strip_markdown,\n    convert_markdown_to_mrkdwn,\n)\nfrom trendradar.notification.batch import (\n    get_batch_header,\n    get_max_batch_header_size,\n    truncate_to_bytes,\n    add_batch_headers,\n)\nfrom trendradar.notification.renderer import (\n    render_feishu_content,\n    render_dingtalk_content,\n)\nfrom trendradar.notification.splitter import (\n    split_content_into_batches,\n    DEFAULT_BATCH_SIZES,\n)\nfrom trendradar.notification.senders import (\n    send_to_feishu,\n    send_to_dingtalk,\n    send_to_wework,\n    send_to_telegram,\n    send_to_email,\n    send_to_ntfy,\n    send_to_bark,\n    send_to_slack,\n    SMTP_CONFIGS,\n)\nfrom trendradar.notification.dispatcher import NotificationDispatcher\n\n__all__ = [\n    # 格式转换\n    \"strip_markdown\",\n    \"convert_markdown_to_mrkdwn\",\n    # 批次处理\n    \"get_batch_header\",\n    \"get_max_batch_header_size\",\n    \"truncate_to_bytes\",\n    \"add_batch_headers\",\n    # 内容渲染\n    \"render_feishu_content\",\n    \"render_dingtalk_content\",\n    # 消息分批\n    \"split_content_into_batches\",\n    \"DEFAULT_BATCH_SIZES\",\n    # 消息发送器\n    \"send_to_feishu\",\n    \"send_to_dingtalk\",\n    \"send_to_wework\",\n    \"send_to_telegram\",\n    \"send_to_email\",\n    \"send_to_ntfy\",\n    \"send_to_bark\",\n    \"send_to_slack\",\n    \"SMTP_CONFIGS\",\n    # 通知调度器\n    \"NotificationDispatcher\",\n]\n"
  },
  {
    "path": "trendradar/notification/batch.py",
    "content": "# coding=utf-8\n\"\"\"\n批次处理模块\n\n提供消息分批发送的辅助函数\n\"\"\"\n\nfrom typing import List\n\n\ndef get_batch_header(format_type: str, batch_num: int, total_batches: int) -> str:\n    \"\"\"根据 format_type 生成对应格式的批次头部\n\n    Args:\n        format_type: 推送类型（telegram, slack, wework_text, bark, feishu, dingtalk, ntfy, wework）\n        batch_num: 当前批次编号\n        total_batches: 总批次数\n\n    Returns:\n        格式化的批次头部字符串\n    \"\"\"\n    if format_type == \"telegram\":\n        return f\"<b>[第 {batch_num}/{total_batches} 批次]</b>\\n\\n\"\n    elif format_type == \"slack\":\n        return f\"*[第 {batch_num}/{total_batches} 批次]*\\n\\n\"\n    elif format_type in (\"wework_text\", \"bark\"):\n        # 企业微信文本模式和 Bark 使用纯文本格式\n        return f\"[第 {batch_num}/{total_batches} 批次]\\n\\n\"\n    else:\n        # 飞书、钉钉、ntfy、企业微信 markdown 模式\n        return f\"**[第 {batch_num}/{total_batches} 批次]**\\n\\n\"\n\n\ndef get_max_batch_header_size(format_type: str) -> int:\n    \"\"\"估算批次头部的最大字节数（假设最多 99 批次）\n\n    用于在分批时预留空间，避免事后截断破坏内容完整性。\n\n    Args:\n        format_type: 推送类型\n\n    Returns:\n        最大头部字节数\n    \"\"\"\n    # 生成最坏情况的头部（99/99 批次）\n    max_header = get_batch_header(format_type, 99, 99)\n    return len(max_header.encode(\"utf-8\"))\n\n\ndef truncate_to_bytes(text: str, max_bytes: int) -> str:\n    \"\"\"安全截断字符串到指定字节数，避免截断多字节字符\n\n    Args:\n        text: 要截断的文本\n        max_bytes: 最大字节数\n\n    Returns:\n        截断后的文本\n    \"\"\"\n    text_bytes = text.encode(\"utf-8\")\n    if len(text_bytes) <= max_bytes:\n        return text\n\n    # 截断到指定字节数\n    truncated = text_bytes[:max_bytes]\n\n    # 处理可能的不完整 UTF-8 字符\n    for i in range(min(4, len(truncated))):\n        try:\n            return truncated[: len(truncated) - i].decode(\"utf-8\")\n        except UnicodeDecodeError:\n            continue\n\n    # 极端情况：返回空字符串\n    return \"\"\n\n\ndef add_batch_headers(\n    batches: List[str], format_type: str, max_bytes: int\n) -> List[str]:\n    \"\"\"为批次添加头部，动态计算确保总大小不超过限制\n\n    Args:\n        batches: 原始批次列表\n        format_type: 推送类型（bark, telegram, feishu 等）\n        max_bytes: 该推送类型的最大字节限制\n\n    Returns:\n        添加头部后的批次列表\n    \"\"\"\n    if len(batches) <= 1:\n        return batches\n\n    total = len(batches)\n    result = []\n\n    for i, content in enumerate(batches, 1):\n        # 生成批次头部\n        header = get_batch_header(format_type, i, total)\n        header_size = len(header.encode(\"utf-8\"))\n\n        # 动态计算允许的最大内容大小\n        max_content_size = max_bytes - header_size\n        content_size = len(content.encode(\"utf-8\"))\n\n        # 如果超出，截断到安全大小\n        if content_size > max_content_size:\n            print(\n                f\"警告：{format_type} 第 {i}/{total} 批次内容({content_size}字节) + 头部({header_size}字节) 超出限制({max_bytes}字节)，截断到 {max_content_size} 字节\"\n            )\n            content = truncate_to_bytes(content, max_content_size)\n\n        result.append(header + content)\n\n    return result\n"
  },
  {
    "path": "trendradar/notification/dispatcher.py",
    "content": "# coding=utf-8\n\"\"\"\n通知调度器模块\n\n提供统一的通知分发接口。\n支持所有通知渠道的多账号配置，使用 `;` 分隔多个账号。\n\n使用示例:\n    dispatcher = NotificationDispatcher(config, get_time_func, split_content_func)\n    results = dispatcher.dispatch_all(report_data, report_type, ...)\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional\n\nfrom trendradar.core.config import (\n    get_account_at_index,\n    limit_accounts,\n    parse_multi_account_config,\n    validate_paired_configs,\n)\n\nfrom .senders import (\n    send_to_bark,\n    send_to_dingtalk,\n    send_to_email,\n    send_to_feishu,\n    send_to_ntfy,\n    send_to_slack,\n    send_to_telegram,\n    send_to_wework,\n    send_to_generic_webhook,\n)\nfrom .renderer import (\n    render_rss_feishu_content,\n    render_rss_dingtalk_content,\n    render_rss_markdown_content,\n)\n\n# 类型检查时导入，运行时不导入（避免循环导入）\nif TYPE_CHECKING:\n    from trendradar.ai import AIAnalysisResult, AITranslator\n\n\nclass NotificationDispatcher:\n    \"\"\"\n    统一的多账号通知调度器\n\n    将多账号发送逻辑封装，提供简洁的 dispatch_all 接口。\n    内部处理账号解析、数量限制、配对验证等逻辑。\n    \"\"\"\n\n    def __init__(\n        self,\n        config: Dict[str, Any],\n        get_time_func: Callable,\n        split_content_func: Callable,\n        translator: Optional[\"AITranslator\"] = None,\n    ):\n        \"\"\"\n        初始化通知调度器\n\n        Args:\n            config: 完整的配置字典，包含所有通知渠道的配置\n            get_time_func: 获取当前时间的函数\n            split_content_func: 内容分批函数\n            translator: AI 翻译器实例（可选）\n        \"\"\"\n        self.config = config\n        self.get_time_func = get_time_func\n        self.split_content_func = split_content_func\n        self.max_accounts = config.get(\"MAX_ACCOUNTS_PER_CHANNEL\", 3)\n        self.translator = translator\n\n    def _translate_content(\n        self,\n        report_data: Dict,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        standalone_data: Optional[Dict] = None,\n        display_regions: Optional[Dict] = None,\n    ) -> tuple:\n        \"\"\"\n        翻译推送内容\n\n        Args:\n            report_data: 报告数据\n            rss_items: RSS 统计条目\n            rss_new_items: RSS 新增条目\n            standalone_data: 独立展示区数据\n            display_regions: 区域显示配置（不展示的区域跳过翻译）\n\n        Returns:\n            tuple: (翻译后的 report_data, rss_items, rss_new_items, standalone_data)\n        \"\"\"\n        if not self.translator or not self.translator.enabled:\n            return report_data, rss_items, rss_new_items, standalone_data\n\n        import copy\n        print(f\"[翻译] 开始翻译内容到 {self.translator.target_language}...\")\n\n        scope = self.translator.scope\n        display_regions = display_regions or {}\n\n        # 深拷贝避免修改原始数据\n        report_data = copy.deepcopy(report_data)\n        rss_items = copy.deepcopy(rss_items) if rss_items else None\n        rss_new_items = copy.deepcopy(rss_new_items) if rss_new_items else None\n        standalone_data = copy.deepcopy(standalone_data) if standalone_data else None\n\n        # 收集所有需要翻译的标题\n        titles_to_translate = []\n        title_locations = []  # 记录标题位置，用于回填\n\n        # 1. 热榜标题（scope 开启 且 区域展示）\n        if scope.get(\"HOTLIST\", True) and display_regions.get(\"HOTLIST\", True):\n            for stat_idx, stat in enumerate(report_data.get(\"stats\", [])):\n                for title_idx, title_data in enumerate(stat.get(\"titles\", [])):\n                    titles_to_translate.append(title_data.get(\"title\", \"\"))\n                    title_locations.append((\"stats\", stat_idx, title_idx))\n\n            # 2. 新增热点标题\n            for source_idx, source in enumerate(report_data.get(\"new_titles\", [])):\n                for title_idx, title_data in enumerate(source.get(\"titles\", [])):\n                    titles_to_translate.append(title_data.get(\"title\", \"\"))\n                    title_locations.append((\"new_titles\", source_idx, title_idx))\n\n        # 3. RSS 统计标题（结构与 stats 一致：[{word, count, titles: [{title, ...}]}]）\n        if rss_items and scope.get(\"RSS\", True) and display_regions.get(\"RSS\", True):\n            for stat_idx, stat in enumerate(rss_items):\n                for title_idx, title_data in enumerate(stat.get(\"titles\", [])):\n                    titles_to_translate.append(title_data.get(\"title\", \"\"))\n                    title_locations.append((\"rss_items\", stat_idx, title_idx))\n\n        # 4. RSS 新增标题（结构与 stats 一致）\n        if rss_new_items and scope.get(\"RSS\", True) and display_regions.get(\"RSS\", True) and display_regions.get(\"NEW_ITEMS\", True):\n            for stat_idx, stat in enumerate(rss_new_items):\n                for title_idx, title_data in enumerate(stat.get(\"titles\", [])):\n                    titles_to_translate.append(title_data.get(\"title\", \"\"))\n                    title_locations.append((\"rss_new_items\", stat_idx, title_idx))\n\n        # 5. 独立展示区 - 热榜平台\n        if standalone_data and scope.get(\"STANDALONE\", True) and display_regions.get(\"STANDALONE\", False):\n            for plat_idx, platform in enumerate(standalone_data.get(\"platforms\", [])):\n                for item_idx, item in enumerate(platform.get(\"items\", [])):\n                    titles_to_translate.append(item.get(\"title\", \"\"))\n                    title_locations.append((\"standalone_platforms\", plat_idx, item_idx))\n\n            # 6. 独立展示区 - RSS 源\n            for feed_idx, feed in enumerate(standalone_data.get(\"rss_feeds\", [])):\n                for item_idx, item in enumerate(feed.get(\"items\", [])):\n                    titles_to_translate.append(item.get(\"title\", \"\"))\n                    title_locations.append((\"standalone_rss\", feed_idx, item_idx))\n\n        if not titles_to_translate:\n            print(\"[翻译] 没有需要翻译的内容\")\n            return report_data, rss_items, rss_new_items, standalone_data\n\n        print(f\"[翻译] 共 {len(titles_to_translate)} 条标题待翻译\")\n\n        # 批量翻译\n        result = self.translator.translate_batch(titles_to_translate)\n\n        if result.success_count == 0:\n            print(f\"[翻译] 翻译失败: {result.results[0].error if result.results else '未知错误'}\")\n            return report_data, rss_items, rss_new_items, standalone_data\n\n        print(f\"[翻译] 翻译完成: {result.success_count}/{result.total_count} 成功\")\n\n        # debug 模式：输出完整 prompt、AI 原始响应、逐条对照\n        if self.config.get(\"DEBUG\", False):\n            if result.prompt:\n                print(f\"[翻译][DEBUG] === 发送给 AI 的 Prompt ===\")\n                print(result.prompt)\n                print(f\"[翻译][DEBUG] === Prompt 结束 ===\")\n            if result.raw_response:\n                print(f\"[翻译][DEBUG] === AI 原始响应 ===\")\n                print(result.raw_response)\n                print(f\"[翻译][DEBUG] === 响应结束 ===\")\n            # 行数不匹配警告\n            expected = len(titles_to_translate)\n            if result.parsed_count != expected:\n                print(f\"[翻译][DEBUG] ⚠️ 行数不匹配：期望 {expected} 条，AI 返回 {result.parsed_count} 条\")\n            # 逐条对照\n            unchanged_count = 0\n            for i, res in enumerate(result.results):\n                if not res.success and res.error:\n                    print(f\"[翻译][DEBUG] [{i+1}] !! 失败: {res.error}\")\n                elif res.original_text == res.translated_text:\n                    unchanged_count += 1\n                else:\n                    print(f\"[翻译][DEBUG] [{i+1}] {res.original_text} => {res.translated_text}\")\n            if unchanged_count > 0:\n                print(f\"[翻译][DEBUG] （另有 {unchanged_count} 条未变化，已省略）\")\n\n        # 回填翻译结果\n        for i, (loc_type, idx1, idx2) in enumerate(title_locations):\n            if i < len(result.results) and result.results[i].success:\n                translated = result.results[i].translated_text\n                if loc_type == \"stats\":\n                    report_data[\"stats\"][idx1][\"titles\"][idx2][\"title\"] = translated\n                elif loc_type == \"new_titles\":\n                    report_data[\"new_titles\"][idx1][\"titles\"][idx2][\"title\"] = translated\n                elif loc_type == \"rss_items\" and rss_items:\n                    rss_items[idx1][\"titles\"][idx2][\"title\"] = translated\n                elif loc_type == \"rss_new_items\" and rss_new_items:\n                    rss_new_items[idx1][\"titles\"][idx2][\"title\"] = translated\n                elif loc_type == \"standalone_platforms\" and standalone_data:\n                    standalone_data[\"platforms\"][idx1][\"items\"][idx2][\"title\"] = translated\n                elif loc_type == \"standalone_rss\" and standalone_data:\n                    standalone_data[\"rss_feeds\"][idx1][\"items\"][idx2][\"title\"] = translated\n\n        return report_data, rss_items, rss_new_items, standalone_data\n\n    def dispatch_all(\n        self,\n        report_data: Dict,\n        report_type: str,\n        update_info: Optional[Dict] = None,\n        proxy_url: Optional[str] = None,\n        mode: str = \"daily\",\n        html_file_path: Optional[str] = None,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        ai_analysis: Optional[AIAnalysisResult] = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> Dict[str, bool]:\n        \"\"\"\n        分发通知到所有已配置的渠道（支持热榜+RSS合并推送+AI分析+独立展示区）\n\n        Args:\n            report_data: 报告数据（由 prepare_report_data 生成）\n            report_type: 报告类型（如 \"全天汇总\"、\"当前榜单\"、\"增量分析\"）\n            update_info: 版本更新信息（可选）\n            proxy_url: 代理 URL（可选）\n            mode: 报告模式 (daily/current/incremental)\n            html_file_path: HTML 报告文件路径（邮件使用）\n            rss_items: RSS 统计条目列表（用于 RSS 统计区块）\n            rss_new_items: RSS 新增条目列表（用于 RSS 新增区块）\n            ai_analysis: AI 分析结果（可选）\n            standalone_data: 独立展示区数据（可选）\n\n        Returns:\n            Dict[str, bool]: 每个渠道的发送结果，key 为渠道名，value 为是否成功\n        \"\"\"\n        results = {}\n\n        # 获取区域显示配置\n        display_regions = self.config.get(\"DISPLAY\", {}).get(\"REGIONS\", {})\n\n        # 执行翻译（如果启用，根据 display_regions 跳过不展示的区域）\n        report_data, rss_items, rss_new_items, standalone_data = self._translate_content(\n            report_data, rss_items, rss_new_items, standalone_data, display_regions\n        )\n\n        # 飞书\n        if self.config.get(\"FEISHU_WEBHOOK_URL\"):\n            results[\"feishu\"] = self._send_feishu(\n                report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,\n                ai_analysis, display_regions, standalone_data\n            )\n\n        # 钉钉\n        if self.config.get(\"DINGTALK_WEBHOOK_URL\"):\n            results[\"dingtalk\"] = self._send_dingtalk(\n                report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,\n                ai_analysis, display_regions, standalone_data\n            )\n\n        # 企业微信\n        if self.config.get(\"WEWORK_WEBHOOK_URL\"):\n            results[\"wework\"] = self._send_wework(\n                report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,\n                ai_analysis, display_regions, standalone_data\n            )\n\n        # Telegram（需要配对验证）\n        if self.config.get(\"TELEGRAM_BOT_TOKEN\") and self.config.get(\"TELEGRAM_CHAT_ID\"):\n            results[\"telegram\"] = self._send_telegram(\n                report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,\n                ai_analysis, display_regions, standalone_data\n            )\n\n        # ntfy（需要配对验证）\n        if self.config.get(\"NTFY_SERVER_URL\") and self.config.get(\"NTFY_TOPIC\"):\n            results[\"ntfy\"] = self._send_ntfy(\n                report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,\n                ai_analysis, display_regions, standalone_data\n            )\n\n        # Bark\n        if self.config.get(\"BARK_URL\"):\n            results[\"bark\"] = self._send_bark(\n                report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,\n                ai_analysis, display_regions, standalone_data\n            )\n\n        # Slack\n        if self.config.get(\"SLACK_WEBHOOK_URL\"):\n            results[\"slack\"] = self._send_slack(\n                report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,\n                ai_analysis, display_regions, standalone_data\n            )\n\n        # 通用 Webhook\n        if self.config.get(\"GENERIC_WEBHOOK_URL\"):\n            results[\"generic_webhook\"] = self._send_generic_webhook(\n                report_data, report_type, update_info, proxy_url, mode, rss_items, rss_new_items,\n                ai_analysis, display_regions, standalone_data\n            )\n\n        # 邮件（保持原有逻辑，已支持多收件人，AI 分析已嵌入 HTML）\n        if (\n            self.config.get(\"EMAIL_FROM\")\n            and self.config.get(\"EMAIL_PASSWORD\")\n            and self.config.get(\"EMAIL_TO\")\n        ):\n            results[\"email\"] = self._send_email(report_type, html_file_path)\n\n        return results\n\n    def _send_to_multi_accounts(\n        self,\n        channel_name: str,\n        config_value: str,\n        send_func: Callable[..., bool],\n        **kwargs,\n    ) -> bool:\n        \"\"\"\n        通用多账号发送逻辑\n\n        Args:\n            channel_name: 渠道名称（用于日志和账号数量限制提示）\n            config_value: 配置值（可能包含多个账号，用 ; 分隔）\n            send_func: 发送函数，签名为 (account, account_label=..., **kwargs) -> bool\n            **kwargs: 传递给发送函数的其他参数\n\n        Returns:\n            bool: 任一账号发送成功则返回 True\n        \"\"\"\n        accounts = parse_multi_account_config(config_value)\n        if not accounts:\n            return False\n\n        accounts = limit_accounts(accounts, self.max_accounts, channel_name)\n        results = []\n\n        for i, account in enumerate(accounts):\n            if account:\n                account_label = f\"账号{i+1}\" if len(accounts) > 1 else \"\"\n                result = send_func(account, account_label=account_label, **kwargs)\n                results.append(result)\n\n        return any(results) if results else False\n\n    def _send_feishu(\n        self,\n        report_data: Dict,\n        report_type: str,\n        update_info: Optional[Dict],\n        proxy_url: Optional[str],\n        mode: str,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        ai_analysis: Optional[AIAnalysisResult] = None,\n        display_regions: Optional[Dict] = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> bool:\n        \"\"\"发送到飞书（多账号，支持热榜+RSS合并+AI分析+独立展示区）\"\"\"\n        display_regions = display_regions or {}\n        if not display_regions.get(\"HOTLIST\", True):\n            report_data = {\"stats\": [], \"failed_ids\": [], \"new_titles\": [], \"id_to_name\": {}}\n\n        return self._send_to_multi_accounts(\n            channel_name=\"飞书\",\n            config_value=self.config[\"FEISHU_WEBHOOK_URL\"],\n            send_func=lambda url, account_label: send_to_feishu(\n                webhook_url=url,\n                report_data=report_data,\n                report_type=report_type,\n                update_info=update_info,\n                proxy_url=proxy_url,\n                mode=mode,\n                account_label=account_label,\n                batch_size=self.config.get(\"FEISHU_BATCH_SIZE\", 29000),\n                batch_interval=self.config.get(\"BATCH_SEND_INTERVAL\", 1.0),\n                split_content_func=self.split_content_func,\n                get_time_func=self.get_time_func,\n                rss_items=rss_items if display_regions.get(\"RSS\", True) else None,\n                rss_new_items=rss_new_items if (display_regions.get(\"RSS\", True) and display_regions.get(\"NEW_ITEMS\", True)) else None,\n                ai_analysis=ai_analysis if display_regions.get(\"AI_ANALYSIS\", True) else None,\n                display_regions=display_regions,\n                standalone_data=standalone_data if display_regions.get(\"STANDALONE\", False) else None,\n            ),\n        )\n\n    def _send_dingtalk(\n        self,\n        report_data: Dict,\n        report_type: str,\n        update_info: Optional[Dict],\n        proxy_url: Optional[str],\n        mode: str,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        ai_analysis: Optional[AIAnalysisResult] = None,\n        display_regions: Optional[Dict] = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> bool:\n        \"\"\"发送到钉钉（多账号，支持热榜+RSS合并+AI分析+独立展示区）\"\"\"\n        display_regions = display_regions or {}\n        if not display_regions.get(\"HOTLIST\", True):\n            report_data = {\"stats\": [], \"failed_ids\": [], \"new_titles\": [], \"id_to_name\": {}}\n\n        return self._send_to_multi_accounts(\n            channel_name=\"钉钉\",\n            config_value=self.config[\"DINGTALK_WEBHOOK_URL\"],\n            send_func=lambda url, account_label: send_to_dingtalk(\n                webhook_url=url,\n                report_data=report_data,\n                report_type=report_type,\n                update_info=update_info,\n                proxy_url=proxy_url,\n                mode=mode,\n                account_label=account_label,\n                batch_size=self.config.get(\"DINGTALK_BATCH_SIZE\", 20000),\n                batch_interval=self.config.get(\"BATCH_SEND_INTERVAL\", 1.0),\n                split_content_func=self.split_content_func,\n                rss_items=rss_items if display_regions.get(\"RSS\", True) else None,\n                rss_new_items=rss_new_items if (display_regions.get(\"RSS\", True) and display_regions.get(\"NEW_ITEMS\", True)) else None,\n                ai_analysis=ai_analysis if display_regions.get(\"AI_ANALYSIS\", True) else None,\n                display_regions=display_regions,\n                standalone_data=standalone_data if display_regions.get(\"STANDALONE\", False) else None,\n            ),\n        )\n\n    def _send_wework(\n        self,\n        report_data: Dict,\n        report_type: str,\n        update_info: Optional[Dict],\n        proxy_url: Optional[str],\n        mode: str,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        ai_analysis: Optional[AIAnalysisResult] = None,\n        display_regions: Optional[Dict] = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> bool:\n        \"\"\"发送到企业微信（多账号，支持热榜+RSS合并+AI分析+独立展示区）\"\"\"\n        display_regions = display_regions or {}\n        if not display_regions.get(\"HOTLIST\", True):\n            report_data = {\"stats\": [], \"failed_ids\": [], \"new_titles\": [], \"id_to_name\": {}}\n\n        return self._send_to_multi_accounts(\n            channel_name=\"企业微信\",\n            config_value=self.config[\"WEWORK_WEBHOOK_URL\"],\n            send_func=lambda url, account_label: send_to_wework(\n                webhook_url=url,\n                report_data=report_data,\n                report_type=report_type,\n                update_info=update_info,\n                proxy_url=proxy_url,\n                mode=mode,\n                account_label=account_label,\n                batch_size=self.config.get(\"MESSAGE_BATCH_SIZE\", 4000),\n                batch_interval=self.config.get(\"BATCH_SEND_INTERVAL\", 1.0),\n                msg_type=self.config.get(\"WEWORK_MSG_TYPE\", \"markdown\"),\n                split_content_func=self.split_content_func,\n                rss_items=rss_items if display_regions.get(\"RSS\", True) else None,\n                rss_new_items=rss_new_items if (display_regions.get(\"RSS\", True) and display_regions.get(\"NEW_ITEMS\", True)) else None,\n                ai_analysis=ai_analysis if display_regions.get(\"AI_ANALYSIS\", True) else None,\n                display_regions=display_regions,\n                standalone_data=standalone_data if display_regions.get(\"STANDALONE\", False) else None,\n            ),\n        )\n\n    def _send_telegram(\n        self,\n        report_data: Dict,\n        report_type: str,\n        update_info: Optional[Dict],\n        proxy_url: Optional[str],\n        mode: str,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        ai_analysis: Optional[AIAnalysisResult] = None,\n        display_regions: Optional[Dict] = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> bool:\n        \"\"\"发送到 Telegram（多账号，需验证 token 和 chat_id 配对，支持热榜+RSS合并+AI分析+独立展示区）\"\"\"\n        display_regions = display_regions or {}\n        if not display_regions.get(\"HOTLIST\", True):\n            report_data = {\"stats\": [], \"failed_ids\": [], \"new_titles\": [], \"id_to_name\": {}}\n\n        telegram_tokens = parse_multi_account_config(self.config[\"TELEGRAM_BOT_TOKEN\"])\n        telegram_chat_ids = parse_multi_account_config(self.config[\"TELEGRAM_CHAT_ID\"])\n\n        if not telegram_tokens or not telegram_chat_ids:\n            return False\n\n        valid, count = validate_paired_configs(\n            {\"bot_token\": telegram_tokens, \"chat_id\": telegram_chat_ids},\n            \"Telegram\",\n            required_keys=[\"bot_token\", \"chat_id\"],\n        )\n        if not valid or count == 0:\n            return False\n\n        telegram_tokens = limit_accounts(telegram_tokens, self.max_accounts, \"Telegram\")\n        telegram_chat_ids = telegram_chat_ids[: len(telegram_tokens)]\n\n        results = []\n        for i in range(len(telegram_tokens)):\n            token = telegram_tokens[i]\n            chat_id = telegram_chat_ids[i]\n            if token and chat_id:\n                account_label = f\"账号{i+1}\" if len(telegram_tokens) > 1 else \"\"\n                result = send_to_telegram(\n                    bot_token=token,\n                    chat_id=chat_id,\n                    report_data=report_data,\n                    report_type=report_type,\n                    update_info=update_info,\n                    proxy_url=proxy_url,\n                    mode=mode,\n                    account_label=account_label,\n                    batch_size=self.config.get(\"MESSAGE_BATCH_SIZE\", 4000),\n                    batch_interval=self.config.get(\"BATCH_SEND_INTERVAL\", 1.0),\n                    split_content_func=self.split_content_func,\n                    rss_items=rss_items if display_regions.get(\"RSS\", True) else None,\n                    rss_new_items=rss_new_items if (display_regions.get(\"RSS\", True) and display_regions.get(\"NEW_ITEMS\", True)) else None,\n                    ai_analysis=ai_analysis if display_regions.get(\"AI_ANALYSIS\", True) else None,\n                    display_regions=display_regions,\n                    standalone_data=standalone_data if display_regions.get(\"STANDALONE\", False) else None,\n                )\n                results.append(result)\n\n        return any(results) if results else False\n\n    def _send_ntfy(\n        self,\n        report_data: Dict,\n        report_type: str,\n        update_info: Optional[Dict],\n        proxy_url: Optional[str],\n        mode: str,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        ai_analysis: Optional[AIAnalysisResult] = None,\n        display_regions: Optional[Dict] = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> bool:\n        \"\"\"发送到 ntfy（多账号，需验证 topic 和 token 配对，支持热榜+RSS合并+AI分析+独立展示区）\"\"\"\n        display_regions = display_regions or {}\n        if not display_regions.get(\"HOTLIST\", True):\n            report_data = {\"stats\": [], \"failed_ids\": [], \"new_titles\": [], \"id_to_name\": {}}\n\n        ntfy_server_url = self.config[\"NTFY_SERVER_URL\"]\n        ntfy_topics = parse_multi_account_config(self.config[\"NTFY_TOPIC\"])\n        ntfy_tokens = parse_multi_account_config(self.config.get(\"NTFY_TOKEN\", \"\"))\n\n        if not ntfy_server_url or not ntfy_topics:\n            return False\n\n        if ntfy_tokens and len(ntfy_tokens) != len(ntfy_topics):\n            print(\n                f\"❌ ntfy 配置错误：topic 数量({len(ntfy_topics)})与 token 数量({len(ntfy_tokens)})不一致，跳过 ntfy 推送\"\n            )\n            return False\n\n        ntfy_topics = limit_accounts(ntfy_topics, self.max_accounts, \"ntfy\")\n        if ntfy_tokens:\n            ntfy_tokens = ntfy_tokens[: len(ntfy_topics)]\n\n        results = []\n        for i, topic in enumerate(ntfy_topics):\n            if topic:\n                token = get_account_at_index(ntfy_tokens, i, \"\") if ntfy_tokens else \"\"\n                account_label = f\"账号{i+1}\" if len(ntfy_topics) > 1 else \"\"\n                result = send_to_ntfy(\n                    server_url=ntfy_server_url,\n                    topic=topic,\n                    token=token,\n                    report_data=report_data,\n                    report_type=report_type,\n                    update_info=update_info,\n                    proxy_url=proxy_url,\n                    mode=mode,\n                    account_label=account_label,\n                    batch_size=3800,\n                    split_content_func=self.split_content_func,\n                    rss_items=rss_items if display_regions.get(\"RSS\", True) else None,\n                    rss_new_items=rss_new_items if (display_regions.get(\"RSS\", True) and display_regions.get(\"NEW_ITEMS\", True)) else None,\n                    ai_analysis=ai_analysis if display_regions.get(\"AI_ANALYSIS\", True) else None,\n                    display_regions=display_regions,\n                    standalone_data=standalone_data if display_regions.get(\"STANDALONE\", False) else None,\n                )\n                results.append(result)\n\n        return any(results) if results else False\n\n    def _send_bark(\n        self,\n        report_data: Dict,\n        report_type: str,\n        update_info: Optional[Dict],\n        proxy_url: Optional[str],\n        mode: str,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        ai_analysis: Optional[AIAnalysisResult] = None,\n        display_regions: Optional[Dict] = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> bool:\n        \"\"\"发送到 Bark（多账号，支持热榜+RSS合并+AI分析+独立展示区）\"\"\"\n        display_regions = display_regions or {}\n        if not display_regions.get(\"HOTLIST\", True):\n            report_data = {\"stats\": [], \"failed_ids\": [], \"new_titles\": [], \"id_to_name\": {}}\n\n        return self._send_to_multi_accounts(\n            channel_name=\"Bark\",\n            config_value=self.config[\"BARK_URL\"],\n            send_func=lambda url, account_label: send_to_bark(\n                bark_url=url,\n                report_data=report_data,\n                report_type=report_type,\n                update_info=update_info,\n                proxy_url=proxy_url,\n                mode=mode,\n                account_label=account_label,\n                batch_size=self.config.get(\"BARK_BATCH_SIZE\", 3600),\n                batch_interval=self.config.get(\"BATCH_SEND_INTERVAL\", 1.0),\n                split_content_func=self.split_content_func,\n                rss_items=rss_items if display_regions.get(\"RSS\", True) else None,\n                rss_new_items=rss_new_items if (display_regions.get(\"RSS\", True) and display_regions.get(\"NEW_ITEMS\", True)) else None,\n                ai_analysis=ai_analysis if display_regions.get(\"AI_ANALYSIS\", True) else None,\n                display_regions=display_regions,\n                standalone_data=standalone_data if display_regions.get(\"STANDALONE\", False) else None,\n            ),\n        )\n\n    def _send_slack(\n        self,\n        report_data: Dict,\n        report_type: str,\n        update_info: Optional[Dict],\n        proxy_url: Optional[str],\n        mode: str,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        ai_analysis: Optional[AIAnalysisResult] = None,\n        display_regions: Optional[Dict] = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> bool:\n        \"\"\"发送到 Slack（多账号，支持热榜+RSS合并+AI分析+独立展示区）\"\"\"\n        display_regions = display_regions or {}\n        if not display_regions.get(\"HOTLIST\", True):\n            report_data = {\"stats\": [], \"failed_ids\": [], \"new_titles\": [], \"id_to_name\": {}}\n\n        return self._send_to_multi_accounts(\n            channel_name=\"Slack\",\n            config_value=self.config[\"SLACK_WEBHOOK_URL\"],\n            send_func=lambda url, account_label: send_to_slack(\n                webhook_url=url,\n                report_data=report_data,\n                report_type=report_type,\n                update_info=update_info,\n                proxy_url=proxy_url,\n                mode=mode,\n                account_label=account_label,\n                batch_size=self.config.get(\"SLACK_BATCH_SIZE\", 4000),\n                batch_interval=self.config.get(\"BATCH_SEND_INTERVAL\", 1.0),\n                split_content_func=self.split_content_func,\n                rss_items=rss_items if display_regions.get(\"RSS\", True) else None,\n                rss_new_items=rss_new_items if (display_regions.get(\"RSS\", True) and display_regions.get(\"NEW_ITEMS\", True)) else None,\n                ai_analysis=ai_analysis if display_regions.get(\"AI_ANALYSIS\", True) else None,\n                display_regions=display_regions,\n                standalone_data=standalone_data if display_regions.get(\"STANDALONE\", False) else None,\n            ),\n        )\n\n    def _send_generic_webhook(\n        self,\n        report_data: Dict,\n        report_type: str,\n        update_info: Optional[Dict],\n        proxy_url: Optional[str],\n        mode: str,\n        rss_items: Optional[List[Dict]] = None,\n        rss_new_items: Optional[List[Dict]] = None,\n        ai_analysis: Optional[AIAnalysisResult] = None,\n        display_regions: Optional[Dict] = None,\n        standalone_data: Optional[Dict] = None,\n    ) -> bool:\n        \"\"\"发送到通用 Webhook（多账号，支持热榜+RSS合并+AI分析+独立展示区）\"\"\"\n        display_regions = display_regions or {}\n        if not display_regions.get(\"HOTLIST\", True):\n            report_data = {\"stats\": [], \"failed_ids\": [], \"new_titles\": [], \"id_to_name\": {}}\n\n        urls = parse_multi_account_config(self.config.get(\"GENERIC_WEBHOOK_URL\", \"\"))\n        templates = parse_multi_account_config(self.config.get(\"GENERIC_WEBHOOK_TEMPLATE\", \"\"))\n\n        if not urls:\n            return False\n\n        urls = limit_accounts(urls, self.max_accounts, \"通用Webhook\")\n        results = []\n\n        for i, url in enumerate(urls):\n            if not url:\n                continue\n\n            template = \"\"\n            if templates:\n                if i < len(templates):\n                    template = templates[i]\n                elif len(templates) == 1:\n                    template = templates[0]\n\n            account_label = f\"账号{i+1}\" if len(urls) > 1 else \"\"\n\n            result = send_to_generic_webhook(\n                webhook_url=url,\n                payload_template=template,\n                report_data=report_data,\n                report_type=report_type,\n                update_info=update_info,\n                proxy_url=proxy_url,\n                mode=mode,\n                account_label=account_label,\n                batch_size=self.config.get(\"MESSAGE_BATCH_SIZE\", 4000),\n                batch_interval=self.config.get(\"BATCH_SEND_INTERVAL\", 1.0),\n                split_content_func=self.split_content_func,\n                rss_items=rss_items if display_regions.get(\"RSS\", True) else None,\n                rss_new_items=rss_new_items if (display_regions.get(\"RSS\", True) and display_regions.get(\"NEW_ITEMS\", True)) else None,\n                ai_analysis=ai_analysis if display_regions.get(\"AI_ANALYSIS\", True) else None,\n                display_regions=display_regions,\n                standalone_data=standalone_data if display_regions.get(\"STANDALONE\", False) else None,\n            )\n            results.append(result)\n\n        return any(results) if results else False\n\n    def _send_email(\n        self,\n        report_type: str,\n        html_file_path: Optional[str],\n    ) -> bool:\n        \"\"\"发送邮件（保持原有逻辑，已支持多收件人）\n\n        Note:\n            AI 分析内容已在 HTML 生成时嵌入，无需在此传递\n        \"\"\"\n        return send_to_email(\n            from_email=self.config[\"EMAIL_FROM\"],\n            password=self.config[\"EMAIL_PASSWORD\"],\n            to_email=self.config[\"EMAIL_TO\"],\n            report_type=report_type,\n            html_file_path=html_file_path,\n            custom_smtp_server=self.config.get(\"EMAIL_SMTP_SERVER\", \"\"),\n            custom_smtp_port=self.config.get(\"EMAIL_SMTP_PORT\", \"\"),\n            get_time_func=self.get_time_func,\n        )\n\n    # === RSS 通知方法 ===\n\n    def dispatch_rss(\n        self,\n        rss_items: List[Dict],\n        feeds_info: Optional[Dict[str, str]] = None,\n        proxy_url: Optional[str] = None,\n        html_file_path: Optional[str] = None,\n    ) -> Dict[str, bool]:\n        \"\"\"\n        分发 RSS 通知到所有已配置的渠道\n\n        Args:\n            rss_items: RSS 条目列表，每个条目包含:\n                - title: 标题\n                - feed_id: RSS 源 ID\n                - feed_name: RSS 源名称\n                - url: 链接\n                - published_at: 发布时间\n                - summary: 摘要（可选）\n                - author: 作者（可选）\n            feeds_info: RSS 源 ID 到名称的映射\n            proxy_url: 代理 URL（可选）\n            html_file_path: HTML 报告文件路径（邮件使用）\n\n        Returns:\n            Dict[str, bool]: 每个渠道的发送结果\n        \"\"\"\n        if not rss_items:\n            print(\"[RSS通知] 没有 RSS 内容，跳过通知\")\n            return {}\n\n        results = {}\n        report_type = \"RSS 订阅更新\"\n\n        # 飞书\n        if self.config.get(\"FEISHU_WEBHOOK_URL\"):\n            results[\"feishu\"] = self._send_rss_feishu(\n                rss_items, feeds_info, proxy_url\n            )\n\n        # 钉钉\n        if self.config.get(\"DINGTALK_WEBHOOK_URL\"):\n            results[\"dingtalk\"] = self._send_rss_dingtalk(\n                rss_items, feeds_info, proxy_url\n            )\n\n        # 企业微信\n        if self.config.get(\"WEWORK_WEBHOOK_URL\"):\n            results[\"wework\"] = self._send_rss_markdown(\n                rss_items, feeds_info, proxy_url, \"wework\"\n            )\n\n        # Telegram\n        if self.config.get(\"TELEGRAM_BOT_TOKEN\") and self.config.get(\"TELEGRAM_CHAT_ID\"):\n            results[\"telegram\"] = self._send_rss_markdown(\n                rss_items, feeds_info, proxy_url, \"telegram\"\n            )\n\n        # ntfy\n        if self.config.get(\"NTFY_SERVER_URL\") and self.config.get(\"NTFY_TOPIC\"):\n            results[\"ntfy\"] = self._send_rss_markdown(\n                rss_items, feeds_info, proxy_url, \"ntfy\"\n            )\n\n        # Bark\n        if self.config.get(\"BARK_URL\"):\n            results[\"bark\"] = self._send_rss_markdown(\n                rss_items, feeds_info, proxy_url, \"bark\"\n            )\n\n        # Slack\n        if self.config.get(\"SLACK_WEBHOOK_URL\"):\n            results[\"slack\"] = self._send_rss_markdown(\n                rss_items, feeds_info, proxy_url, \"slack\"\n            )\n\n        # 邮件\n        if (\n            self.config.get(\"EMAIL_FROM\")\n            and self.config.get(\"EMAIL_PASSWORD\")\n            and self.config.get(\"EMAIL_TO\")\n        ):\n            results[\"email\"] = self._send_email(report_type, html_file_path)\n\n        return results\n\n    def _send_rss_feishu(\n        self,\n        rss_items: List[Dict],\n        feeds_info: Optional[Dict[str, str]],\n        proxy_url: Optional[str],\n    ) -> bool:\n        \"\"\"发送 RSS 到飞书\"\"\"\n        import requests\n\n        content = render_rss_feishu_content(\n            rss_items=rss_items,\n            feeds_info=feeds_info,\n            get_time_func=self.get_time_func,\n        )\n\n        webhooks = parse_multi_account_config(self.config[\"FEISHU_WEBHOOK_URL\"])\n        webhooks = limit_accounts(webhooks, self.max_accounts, \"飞书\")\n\n        results = []\n        for i, webhook_url in enumerate(webhooks):\n            if not webhook_url:\n                continue\n\n            account_label = f\"账号{i+1}\" if len(webhooks) > 1 else \"\"\n            try:\n                # 分批发送\n                batches = self.split_content_func(\n                    content, self.config.get(\"FEISHU_BATCH_SIZE\", 29000)\n                )\n\n                for batch_idx, batch_content in enumerate(batches):\n                    payload = {\n                        \"msg_type\": \"interactive\",\n                        \"card\": {\n                            \"header\": {\n                                \"title\": {\n                                    \"tag\": \"plain_text\",\n                                    \"content\": f\"📰 RSS 订阅更新 {f'({batch_idx + 1}/{len(batches)})' if len(batches) > 1 else ''}\",\n                                },\n                                \"template\": \"green\",\n                            },\n                            \"elements\": [\n                                {\"tag\": \"markdown\", \"content\": batch_content}\n                            ],\n                        },\n                    }\n\n                    proxies = {\"http\": proxy_url, \"https\": proxy_url} if proxy_url else None\n                    resp = requests.post(webhook_url, json=payload, proxies=proxies, timeout=30)\n                    resp.raise_for_status()\n\n                print(f\"✅ 飞书{account_label} RSS 通知发送成功\")\n                results.append(True)\n            except Exception as e:\n                print(f\"❌ 飞书{account_label} RSS 通知发送失败: {e}\")\n                results.append(False)\n\n        return any(results) if results else False\n\n    def _send_rss_dingtalk(\n        self,\n        rss_items: List[Dict],\n        feeds_info: Optional[Dict[str, str]],\n        proxy_url: Optional[str],\n    ) -> bool:\n        \"\"\"发送 RSS 到钉钉\"\"\"\n        import requests\n\n        content = render_rss_dingtalk_content(\n            rss_items=rss_items,\n            feeds_info=feeds_info,\n            get_time_func=self.get_time_func,\n        )\n\n        webhooks = parse_multi_account_config(self.config[\"DINGTALK_WEBHOOK_URL\"])\n        webhooks = limit_accounts(webhooks, self.max_accounts, \"钉钉\")\n\n        results = []\n        for i, webhook_url in enumerate(webhooks):\n            if not webhook_url:\n                continue\n\n            account_label = f\"账号{i+1}\" if len(webhooks) > 1 else \"\"\n            try:\n                batches = self.split_content_func(\n                    content, self.config.get(\"DINGTALK_BATCH_SIZE\", 20000)\n                )\n\n                for batch_idx, batch_content in enumerate(batches):\n                    title = f\"📰 RSS 订阅更新 {f'({batch_idx + 1}/{len(batches)})' if len(batches) > 1 else ''}\"\n                    payload = {\n                        \"msgtype\": \"markdown\",\n                        \"markdown\": {\n                            \"title\": title,\n                            \"text\": batch_content,\n                        },\n                    }\n\n                    proxies = {\"http\": proxy_url, \"https\": proxy_url} if proxy_url else None\n                    resp = requests.post(webhook_url, json=payload, proxies=proxies, timeout=30)\n                    resp.raise_for_status()\n\n                print(f\"✅ 钉钉{account_label} RSS 通知发送成功\")\n                results.append(True)\n            except Exception as e:\n                print(f\"❌ 钉钉{account_label} RSS 通知发送失败: {e}\")\n                results.append(False)\n\n        return any(results) if results else False\n\n    def _send_rss_markdown(\n        self,\n        rss_items: List[Dict],\n        feeds_info: Optional[Dict[str, str]],\n        proxy_url: Optional[str],\n        channel: str,\n    ) -> bool:\n        \"\"\"发送 RSS 到 Markdown 兼容渠道（企业微信、Telegram、ntfy、Bark、Slack）\"\"\"\n\n        content = render_rss_markdown_content(\n            rss_items=rss_items,\n            feeds_info=feeds_info,\n            get_time_func=self.get_time_func,\n        )\n\n        try:\n            if channel == \"wework\":\n                return self._send_rss_wework(content, proxy_url)\n            elif channel == \"telegram\":\n                return self._send_rss_telegram(content, proxy_url)\n            elif channel == \"ntfy\":\n                return self._send_rss_ntfy(content, proxy_url)\n            elif channel == \"bark\":\n                return self._send_rss_bark(content, proxy_url)\n            elif channel == \"slack\":\n                return self._send_rss_slack(content, proxy_url)\n        except Exception as e:\n            print(f\"❌ {channel} RSS 通知发送失败: {e}\")\n            return False\n\n        return False\n\n    def _send_rss_wework(self, content: str, proxy_url: Optional[str]) -> bool:\n        \"\"\"发送 RSS 到企业微信\"\"\"\n        import requests\n\n        webhooks = parse_multi_account_config(self.config[\"WEWORK_WEBHOOK_URL\"])\n        webhooks = limit_accounts(webhooks, self.max_accounts, \"企业微信\")\n\n        results = []\n        for i, webhook_url in enumerate(webhooks):\n            if not webhook_url:\n                continue\n\n            account_label = f\"账号{i+1}\" if len(webhooks) > 1 else \"\"\n            try:\n                batches = self.split_content_func(\n                    content, self.config.get(\"MESSAGE_BATCH_SIZE\", 4000)\n                )\n\n                for batch_content in batches:\n                    payload = {\n                        \"msgtype\": \"markdown\",\n                        \"markdown\": {\"content\": batch_content},\n                    }\n\n                    proxies = {\"http\": proxy_url, \"https\": proxy_url} if proxy_url else None\n                    resp = requests.post(webhook_url, json=payload, proxies=proxies, timeout=30)\n                    resp.raise_for_status()\n\n                print(f\"✅ 企业微信{account_label} RSS 通知发送成功\")\n                results.append(True)\n            except Exception as e:\n                print(f\"❌ 企业微信{account_label} RSS 通知发送失败: {e}\")\n                results.append(False)\n\n        return any(results) if results else False\n\n    def _send_rss_telegram(self, content: str, proxy_url: Optional[str]) -> bool:\n        \"\"\"发送 RSS 到 Telegram\"\"\"\n        import requests\n\n        tokens = parse_multi_account_config(self.config[\"TELEGRAM_BOT_TOKEN\"])\n        chat_ids = parse_multi_account_config(self.config[\"TELEGRAM_CHAT_ID\"])\n\n        if not tokens or not chat_ids:\n            return False\n\n        results = []\n        for i in range(min(len(tokens), len(chat_ids), self.max_accounts)):\n            token = tokens[i]\n            chat_id = chat_ids[i]\n\n            if not token or not chat_id:\n                continue\n\n            account_label = f\"账号{i+1}\" if len(tokens) > 1 else \"\"\n            try:\n                batches = self.split_content_func(\n                    content, self.config.get(\"MESSAGE_BATCH_SIZE\", 4000)\n                )\n\n                for batch_content in batches:\n                    url = f\"https://api.telegram.org/bot{token}/sendMessage\"\n                    payload = {\n                        \"chat_id\": chat_id,\n                        \"text\": batch_content,\n                        \"parse_mode\": \"Markdown\",\n                    }\n\n                    proxies = {\"http\": proxy_url, \"https\": proxy_url} if proxy_url else None\n                    resp = requests.post(url, json=payload, proxies=proxies, timeout=30)\n                    resp.raise_for_status()\n\n                print(f\"✅ Telegram{account_label} RSS 通知发送成功\")\n                results.append(True)\n            except Exception as e:\n                print(f\"❌ Telegram{account_label} RSS 通知发送失败: {e}\")\n                results.append(False)\n\n        return any(results) if results else False\n\n    def _send_rss_ntfy(self, content: str, proxy_url: Optional[str]) -> bool:\n        \"\"\"发送 RSS 到 ntfy\"\"\"\n        import requests\n\n        server_url = self.config[\"NTFY_SERVER_URL\"]\n        topics = parse_multi_account_config(self.config[\"NTFY_TOPIC\"])\n        tokens = parse_multi_account_config(self.config.get(\"NTFY_TOKEN\", \"\"))\n\n        if not server_url or not topics:\n            return False\n\n        topics = limit_accounts(topics, self.max_accounts, \"ntfy\")\n\n        results = []\n        for i, topic in enumerate(topics):\n            if not topic:\n                continue\n\n            token = tokens[i] if tokens and i < len(tokens) else \"\"\n            account_label = f\"账号{i+1}\" if len(topics) > 1 else \"\"\n\n            try:\n                batches = self.split_content_func(content, 3800)\n\n                for batch_content in batches:\n                    url = f\"{server_url.rstrip('/')}/{topic}\"\n                    headers = {\"Title\": \"RSS 订阅更新\", \"Markdown\": \"yes\"}\n                    if token:\n                        headers[\"Authorization\"] = f\"Bearer {token}\"\n\n                    proxies = {\"http\": proxy_url, \"https\": proxy_url} if proxy_url else None\n                    resp = requests.post(\n                        url, data=batch_content.encode(\"utf-8\"),\n                        headers=headers, proxies=proxies, timeout=30\n                    )\n                    resp.raise_for_status()\n\n                print(f\"✅ ntfy{account_label} RSS 通知发送成功\")\n                results.append(True)\n            except Exception as e:\n                print(f\"❌ ntfy{account_label} RSS 通知发送失败: {e}\")\n                results.append(False)\n\n        return any(results) if results else False\n\n    def _send_rss_bark(self, content: str, proxy_url: Optional[str]) -> bool:\n        \"\"\"发送 RSS 到 Bark\"\"\"\n        import requests\n        import urllib.parse\n\n        urls = parse_multi_account_config(self.config[\"BARK_URL\"])\n        urls = limit_accounts(urls, self.max_accounts, \"Bark\")\n\n        results = []\n        for i, bark_url in enumerate(urls):\n            if not bark_url:\n                continue\n\n            account_label = f\"账号{i+1}\" if len(urls) > 1 else \"\"\n            try:\n                batches = self.split_content_func(\n                    content, self.config.get(\"BARK_BATCH_SIZE\", 3600)\n                )\n\n                for batch_content in batches:\n                    title = urllib.parse.quote(\"📰 RSS 订阅更新\")\n                    body = urllib.parse.quote(batch_content)\n                    url = f\"{bark_url.rstrip('/')}/{title}/{body}\"\n\n                    proxies = {\"http\": proxy_url, \"https\": proxy_url} if proxy_url else None\n                    resp = requests.get(url, proxies=proxies, timeout=30)\n                    resp.raise_for_status()\n\n                print(f\"✅ Bark{account_label} RSS 通知发送成功\")\n                results.append(True)\n            except Exception as e:\n                print(f\"❌ Bark{account_label} RSS 通知发送失败: {e}\")\n                results.append(False)\n\n        return any(results) if results else False\n\n    def _send_rss_slack(self, content: str, proxy_url: Optional[str]) -> bool:\n        \"\"\"发送 RSS 到 Slack\"\"\"\n        import requests\n\n        webhooks = parse_multi_account_config(self.config[\"SLACK_WEBHOOK_URL\"])\n        webhooks = limit_accounts(webhooks, self.max_accounts, \"Slack\")\n\n        results = []\n        for i, webhook_url in enumerate(webhooks):\n            if not webhook_url:\n                continue\n\n            account_label = f\"账号{i+1}\" if len(webhooks) > 1 else \"\"\n            try:\n                batches = self.split_content_func(\n                    content, self.config.get(\"SLACK_BATCH_SIZE\", 4000)\n                )\n\n                for batch_content in batches:\n                    payload = {\n                        \"blocks\": [\n                            {\n                                \"type\": \"section\",\n                                \"text\": {\n                                    \"type\": \"mrkdwn\",\n                                    \"text\": batch_content,\n                                },\n                            }\n                        ]\n                    }\n\n                    proxies = {\"http\": proxy_url, \"https\": proxy_url} if proxy_url else None\n                    resp = requests.post(webhook_url, json=payload, proxies=proxies, timeout=30)\n                    resp.raise_for_status()\n\n                print(f\"✅ Slack{account_label} RSS 通知发送成功\")\n                results.append(True)\n            except Exception as e:\n                print(f\"❌ Slack{account_label} RSS 通知发送失败: {e}\")\n                results.append(False)\n\n        return any(results) if results else False\n"
  },
  {
    "path": "trendradar/notification/formatters.py",
    "content": "# coding=utf-8\n\"\"\"\n通知内容格式转换模块\n\n提供不同推送平台间的格式转换功能\n\"\"\"\n\nimport re\n\n\ndef strip_markdown(text: str) -> str:\n    \"\"\"去除文本中的 markdown 语法格式，用于个人微信推送\n\n    Args:\n        text: 包含 markdown 格式的文本\n\n    Returns:\n        纯文本内容\n    \"\"\"\n    # 转换链接 [text](url) -> text url（保留 URL）\n    text = re.sub(r'\\[([^\\]]+)\\]\\(([^)]+)\\)', r'\\1 \\2', text)\n\n    # 先保护 URL，避免后续 markdown 清洗误伤链接中的下划线等字符\n    protected_urls: list[str] = []\n\n    def _protect_url(match: re.Match) -> str:\n        protected_urls.append(match.group(0))\n        return f\"@@URLTOKEN{len(protected_urls) - 1}@@\"\n\n    text = re.sub(r'https?://[^\\s<>\\]]+', _protect_url, text)\n\n    # 去除粗体 **text** 或 __text__\n    text = re.sub(r'\\*\\*(.+?)\\*\\*', r'\\1', text)\n    text = re.sub(r'(?<!\\w)__(?!\\s)(.+?)(?<!\\s)__(?!\\w)', r'\\1', text)\n\n    # 去除斜体 *text* 或 _text_\n    text = re.sub(r'\\*(.+?)\\*', r'\\1', text)\n    text = re.sub(r'(?<!\\w)_(?!\\s)(.+?)(?<!\\s)_(?!\\w)', r'\\1', text)\n\n    # 去除删除线 ~~text~~\n    text = re.sub(r'~~(.+?)~~', r'\\1', text)\n\n    # 去除图片 ![alt](url) -> alt\n    text = re.sub(r'!\\[(.+?)\\]\\(.+?\\)', r'\\1', text)\n\n    # 去除行内代码 `code`\n    text = re.sub(r'`(.+?)`', r'\\1', text)\n\n    # 去除引用符号 >\n    text = re.sub(r'^>\\s*', '', text, flags=re.MULTILINE)\n\n    # 去除标题符号 # ## ### 等\n    text = re.sub(r'^#+\\s*', '', text, flags=re.MULTILINE)\n\n    # 去除水平分割线 --- 或 ***\n    text = re.sub(r'^[\\-\\*]{3,}\\s*$', '', text, flags=re.MULTILINE)\n\n    # 去除 HTML 标签 <font color='xxx'>text</font> -> text\n    text = re.sub(r'<font[^>]*>(.+?)</font>', r'\\1', text)\n    text = re.sub(r'<[^>]+>', '', text)\n\n    # 清理多余的空行（保留最多两个连续空行）\n    text = re.sub(r'\\n{3,}', '\\n\\n', text)\n\n    # 还原之前保护的 URL\n    for idx, url in enumerate(protected_urls):\n        text = text.replace(f\"@@URLTOKEN{idx}@@\", url)\n\n    return text.strip()\n\n\ndef convert_markdown_to_mrkdwn(content: str) -> str:\n    \"\"\"\n    将标准 Markdown 转换为 Slack 的 mrkdwn 格式\n\n    转换规则：\n    - **粗体** → *粗体*\n    - [文本](url) → <url|文本>\n    - 保留其他格式（代码块、列表等）\n\n    Args:\n        content: Markdown 格式的内容\n\n    Returns:\n        Slack mrkdwn 格式的内容\n    \"\"\"\n    # 1. 转换链接格式: [文本](url) → <url|文本>\n    content = re.sub(r'\\[([^\\]]+)\\]\\(([^)]+)\\)', r'<\\2|\\1>', content)\n\n    # 2. 转换粗体: **文本** → *文本*\n    content = re.sub(r'\\*\\*([^*]+)\\*\\*', r'*\\1*', content)\n\n    return content\n"
  },
  {
    "path": "trendradar/notification/renderer.py",
    "content": "# coding=utf-8\n\"\"\"\n通知内容渲染模块\n\n提供多平台通知内容渲染功能，生成格式化的推送消息\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Dict, List, Optional, Callable\n\nfrom trendradar.report.formatter import format_title_for_platform\n\n\n# 默认区域顺序\nDEFAULT_REGION_ORDER = [\"hotlist\", \"rss\", \"new_items\", \"standalone\", \"ai_analysis\"]\n\n\ndef render_feishu_content(\n    report_data: Dict,\n    update_info: Optional[Dict] = None,\n    mode: str = \"daily\",\n    separator: str = \"---\",\n    region_order: Optional[List[str]] = None,\n    get_time_func: Optional[Callable[[], datetime]] = None,\n    rss_items: Optional[list] = None,\n    show_new_section: bool = True,\n) -> str:\n    \"\"\"渲染飞书通知内容（支持热榜+RSS合并）\n\n    Args:\n        report_data: 报告数据字典，包含 stats, new_titles, failed_ids, total_new_count\n        update_info: 版本更新信息（可选）\n        mode: 报告模式 (\"daily\", \"incremental\", \"current\")\n        separator: 内容分隔符\n        region_order: 区域显示顺序列表\n        get_time_func: 获取当前时间的函数（可选，默认使用 datetime.now()）\n        rss_items: RSS 条目列表（可选，用于合并推送）\n        show_new_section: 是否显示新增热点区域\n\n    Returns:\n        格式化的飞书消息内容\n    \"\"\"\n    if region_order is None:\n        region_order = DEFAULT_REGION_ORDER\n\n    # 生成热点词汇统计部分\n    stats_content = \"\"\n    if report_data[\"stats\"]:\n        stats_content += \"📊 **热点词汇统计**\\n\\n\"\n\n        total_count = len(report_data[\"stats\"])\n\n        for i, stat in enumerate(report_data[\"stats\"]):\n            word = stat[\"word\"]\n            count = stat[\"count\"]\n\n            sequence_display = f\"<font color='grey'>[{i + 1}/{total_count}]</font>\"\n\n            if count >= 10:\n                stats_content += f\"🔥 {sequence_display} **{word}** : <font color='red'>{count}</font> 条\\n\\n\"\n            elif count >= 5:\n                stats_content += f\"📈 {sequence_display} **{word}** : <font color='orange'>{count}</font> 条\\n\\n\"\n            else:\n                stats_content += f\"📌 {sequence_display} **{word}** : {count} 条\\n\\n\"\n\n            for j, title_data in enumerate(stat[\"titles\"], 1):\n                formatted_title = format_title_for_platform(\n                    \"feishu\", title_data, show_source=True\n                )\n                stats_content += f\"  {j}. {formatted_title}\\n\"\n\n                if j < len(stat[\"titles\"]):\n                    stats_content += \"\\n\"\n\n            if i < len(report_data[\"stats\"]) - 1:\n                stats_content += f\"\\n{separator}\\n\\n\"\n\n    # 生成新增新闻部分\n    new_titles_content = \"\"\n    if show_new_section and report_data[\"new_titles\"]:\n        new_titles_content += (\n            f\"🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\\n\\n\"\n        )\n\n        for source_data in report_data[\"new_titles\"]:\n            new_titles_content += (\n                f\"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\\n\"\n            )\n\n            for j, title_data in enumerate(source_data[\"titles\"], 1):\n                title_data_copy = title_data.copy()\n                title_data_copy[\"is_new\"] = False\n                formatted_title = format_title_for_platform(\n                    \"feishu\", title_data_copy, show_source=False\n                )\n                new_titles_content += f\"  {j}. {formatted_title}\\n\"\n\n            new_titles_content += \"\\n\"\n\n    # RSS 内容\n    rss_content = \"\"\n    if rss_items:\n        rss_content = _render_rss_section_feishu(rss_items, separator)\n\n    # 准备各区域内容映射\n    region_contents = {\n        \"hotlist\": stats_content,\n        \"new_items\": new_titles_content,\n        \"rss\": rss_content,\n    }\n\n    # 按 region_order 顺序组装内容\n    text_content = \"\"\n    for region in region_order:\n        content = region_contents.get(region, \"\")\n        if content:\n            if text_content:\n                text_content += f\"\\n{separator}\\n\\n\"\n            text_content += content\n\n    if not text_content:\n        if mode == \"incremental\":\n            mode_text = \"增量模式下暂无新增匹配的热点词汇\"\n        elif mode == \"current\":\n            mode_text = \"当前榜单模式下暂无匹配的热点词汇\"\n        else:\n            mode_text = \"暂无匹配的热点词汇\"\n        text_content = f\"📭 {mode_text}\\n\\n\"\n\n    if report_data[\"failed_ids\"]:\n        if text_content and \"暂无匹配\" not in text_content:\n            text_content += f\"\\n{separator}\\n\\n\"\n\n        text_content += \"⚠️ **数据获取失败的平台：**\\n\\n\"\n        for i, id_value in enumerate(report_data[\"failed_ids\"], 1):\n            text_content += f\"  • <font color='red'>{id_value}</font>\\n\"\n\n    # 获取当前时间\n    now = get_time_func() if get_time_func else datetime.now()\n    text_content += (\n        f\"\\n\\n<font color='grey'>更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}</font>\"\n    )\n\n    if update_info:\n        text_content += f\"\\n<font color='grey'>TrendRadar 发现新版本 {update_info['remote_version']}，当前 {update_info['current_version']}</font>\"\n\n    return text_content\n\n\ndef render_dingtalk_content(\n    report_data: Dict,\n    update_info: Optional[Dict] = None,\n    mode: str = \"daily\",\n    region_order: Optional[List[str]] = None,\n    get_time_func: Optional[Callable[[], datetime]] = None,\n    rss_items: Optional[list] = None,\n    show_new_section: bool = True,\n) -> str:\n    \"\"\"渲染钉钉通知内容（支持热榜+RSS合并）\n\n    Args:\n        report_data: 报告数据字典，包含 stats, new_titles, failed_ids, total_new_count\n        update_info: 版本更新信息（可选）\n        mode: 报告模式 (\"daily\", \"incremental\", \"current\")\n        region_order: 区域显示顺序列表\n        get_time_func: 获取当前时间的函数（可选，默认使用 datetime.now()）\n        rss_items: RSS 条目列表（可选，用于合并推送）\n        show_new_section: 是否显示新增热点区域\n\n    Returns:\n        格式化的钉钉消息内容\n    \"\"\"\n    if region_order is None:\n        region_order = DEFAULT_REGION_ORDER\n\n    total_titles = sum(\n        len(stat[\"titles\"]) for stat in report_data[\"stats\"] if stat[\"count\"] > 0\n    )\n    now = get_time_func() if get_time_func else datetime.now()\n\n    # 头部信息\n    header_content = f\"**总新闻数：** {total_titles}\\n\\n\"\n    header_content += f\"**时间：** {now.strftime('%Y-%m-%d %H:%M:%S')}\\n\\n\"\n    header_content += \"**类型：** 热点分析报告\\n\\n\"\n    header_content += \"---\\n\\n\"\n\n    # 生成热点词汇统计部分\n    stats_content = \"\"\n    if report_data[\"stats\"]:\n        stats_content += \"📊 **热点词汇统计**\\n\\n\"\n\n        total_count = len(report_data[\"stats\"])\n\n        for i, stat in enumerate(report_data[\"stats\"]):\n            word = stat[\"word\"]\n            count = stat[\"count\"]\n\n            sequence_display = f\"[{i + 1}/{total_count}]\"\n\n            if count >= 10:\n                stats_content += f\"🔥 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n            elif count >= 5:\n                stats_content += f\"📈 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n            else:\n                stats_content += f\"📌 {sequence_display} **{word}** : {count} 条\\n\\n\"\n\n            for j, title_data in enumerate(stat[\"titles\"], 1):\n                formatted_title = format_title_for_platform(\n                    \"dingtalk\", title_data, show_source=True\n                )\n                stats_content += f\"  {j}. {formatted_title}\\n\"\n\n                if j < len(stat[\"titles\"]):\n                    stats_content += \"\\n\"\n\n            if i < len(report_data[\"stats\"]) - 1:\n                stats_content += \"\\n---\\n\\n\"\n\n    # 生成新增新闻部分\n    new_titles_content = \"\"\n    if show_new_section and report_data[\"new_titles\"]:\n        new_titles_content += (\n            f\"🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\\n\\n\"\n        )\n\n        for source_data in report_data[\"new_titles\"]:\n            new_titles_content += f\"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\\n\\n\"\n\n            for j, title_data in enumerate(source_data[\"titles\"], 1):\n                title_data_copy = title_data.copy()\n                title_data_copy[\"is_new\"] = False\n                formatted_title = format_title_for_platform(\n                    \"dingtalk\", title_data_copy, show_source=False\n                )\n                new_titles_content += f\"  {j}. {formatted_title}\\n\"\n\n            new_titles_content += \"\\n\"\n\n    # RSS 内容\n    rss_content = \"\"\n    if rss_items:\n        rss_content = _render_rss_section_markdown(rss_items)\n\n    # 准备各区域内容映射\n    region_contents = {\n        \"hotlist\": stats_content,\n        \"new_items\": new_titles_content,\n        \"rss\": rss_content,\n    }\n\n    # 按 region_order 顺序组装内容\n    text_content = header_content\n    has_content = False\n    for region in region_order:\n        content = region_contents.get(region, \"\")\n        if content:\n            if has_content:\n                text_content += \"\\n---\\n\\n\"\n            text_content += content\n            has_content = True\n\n    if not has_content:\n        if mode == \"incremental\":\n            mode_text = \"增量模式下暂无新增匹配的热点词汇\"\n        elif mode == \"current\":\n            mode_text = \"当前榜单模式下暂无匹配的热点词汇\"\n        else:\n            mode_text = \"暂无匹配的热点词汇\"\n        text_content += f\"📭 {mode_text}\\n\\n\"\n\n    if report_data[\"failed_ids\"]:\n        if \"暂无匹配\" not in text_content:\n            text_content += \"\\n---\\n\\n\"\n\n        text_content += \"⚠️ **数据获取失败的平台：**\\n\\n\"\n        for i, id_value in enumerate(report_data[\"failed_ids\"], 1):\n            text_content += f\"  • **{id_value}**\\n\"\n\n    text_content += f\"\\n\\n> 更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}\"\n\n    if update_info:\n        text_content += f\"\\n> TrendRadar 发现新版本 **{update_info['remote_version']}**，当前 **{update_info['current_version']}**\"\n\n    return text_content\n\n\ndef render_rss_feishu_content(\n    rss_items: list,\n    feeds_info: Optional[Dict] = None,\n    separator: str = \"---\",\n    get_time_func: Optional[Callable[[], datetime]] = None,\n) -> str:\n    \"\"\"渲染 RSS 飞书通知内容\n\n    Args:\n        rss_items: RSS 条目列表，每个条目包含:\n            - title: 标题\n            - feed_id: RSS 源 ID\n            - feed_name: RSS 源名称\n            - url: 链接\n            - published_at: 发布时间\n            - summary: 摘要（可选）\n            - author: 作者（可选）\n        feeds_info: RSS 源 ID 到名称的映射\n        separator: 内容分隔符\n        get_time_func: 获取当前时间的函数（可选）\n\n    Returns:\n        格式化的飞书消息内容\n    \"\"\"\n    if not rss_items:\n        now = get_time_func() if get_time_func else datetime.now()\n        return f\"📭 暂无新的 RSS 订阅内容\\n\\n<font color='grey'>更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}</font>\"\n\n    # 按 feed_id 分组\n    feeds_map: Dict[str, list] = {}\n    for item in rss_items:\n        feed_id = item.get(\"feed_id\", \"unknown\")\n        if feed_id not in feeds_map:\n            feeds_map[feed_id] = []\n        feeds_map[feed_id].append(item)\n\n    text_content = f\"📰 **RSS 订阅更新** (共 {len(rss_items)} 条)\\n\\n\"\n\n    text_content += f\"{separator}\\n\\n\"\n\n    for feed_id, items in feeds_map.items():\n        feed_name = items[0].get(\"feed_name\", feed_id) if items else feed_id\n        if feeds_info and feed_id in feeds_info:\n            feed_name = feeds_info[feed_id]\n\n        text_content += f\"**{feed_name}** ({len(items)} 条)\\n\\n\"\n\n        for i, item in enumerate(items, 1):\n            title = item.get(\"title\", \"\")\n            url = item.get(\"url\", \"\")\n            published_at = item.get(\"published_at\", \"\")\n\n            if url:\n                text_content += f\"  {i}. [{title}]({url})\"\n            else:\n                text_content += f\"  {i}. {title}\"\n\n            if published_at:\n                text_content += f\" <font color='grey'>- {published_at}</font>\"\n\n            text_content += \"\\n\"\n\n            if i < len(items):\n                text_content += \"\\n\"\n\n        text_content += f\"\\n{separator}\\n\\n\"\n\n    now = get_time_func() if get_time_func else datetime.now()\n    text_content += f\"<font color='grey'>更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}</font>\"\n\n    return text_content\n\n\ndef render_rss_dingtalk_content(\n    rss_items: list,\n    feeds_info: Optional[Dict] = None,\n    get_time_func: Optional[Callable[[], datetime]] = None,\n) -> str:\n    \"\"\"渲染 RSS 钉钉通知内容\n\n    Args:\n        rss_items: RSS 条目列表\n        feeds_info: RSS 源 ID 到名称的映射\n        get_time_func: 获取当前时间的函数（可选）\n\n    Returns:\n        格式化的钉钉消息内容\n    \"\"\"\n    now = get_time_func() if get_time_func else datetime.now()\n\n    if not rss_items:\n        return f\"📭 暂无新的 RSS 订阅内容\\n\\n> 更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}\"\n\n    # 按 feed_id 分组\n    feeds_map: Dict[str, list] = {}\n    for item in rss_items:\n        feed_id = item.get(\"feed_id\", \"unknown\")\n        if feed_id not in feeds_map:\n            feeds_map[feed_id] = []\n        feeds_map[feed_id].append(item)\n\n    # 头部信息\n    text_content = f\"**总条目数：** {len(rss_items)}\\n\\n\"\n    text_content += f\"**时间：** {now.strftime('%Y-%m-%d %H:%M:%S')}\\n\\n\"\n    text_content += \"**类型：** RSS 订阅更新\\n\\n\"\n\n    text_content += \"---\\n\\n\"\n\n    for feed_id, items in feeds_map.items():\n        feed_name = items[0].get(\"feed_name\", feed_id) if items else feed_id\n        if feeds_info and feed_id in feeds_info:\n            feed_name = feeds_info[feed_id]\n\n        text_content += f\"📰 **{feed_name}** ({len(items)} 条)\\n\\n\"\n\n        for i, item in enumerate(items, 1):\n            title = item.get(\"title\", \"\")\n            url = item.get(\"url\", \"\")\n            published_at = item.get(\"published_at\", \"\")\n\n            if url:\n                text_content += f\"  {i}. [{title}]({url})\"\n            else:\n                text_content += f\"  {i}. {title}\"\n\n            if published_at:\n                text_content += f\" - {published_at}\"\n\n            text_content += \"\\n\"\n\n            if i < len(items):\n                text_content += \"\\n\"\n\n        text_content += \"\\n---\\n\\n\"\n\n    text_content += f\"> 更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}\"\n\n    return text_content\n\n\ndef render_rss_markdown_content(\n    rss_items: list,\n    feeds_info: Optional[Dict] = None,\n    get_time_func: Optional[Callable[[], datetime]] = None,\n) -> str:\n    \"\"\"渲染 RSS 通用 Markdown 格式内容（企业微信、Bark、ntfy、Slack）\n\n    Args:\n        rss_items: RSS 条目列表\n        feeds_info: RSS 源 ID 到名称的映射\n        get_time_func: 获取当前时间的函数（可选）\n\n    Returns:\n        格式化的 Markdown 消息内容\n    \"\"\"\n    now = get_time_func() if get_time_func else datetime.now()\n\n    if not rss_items:\n        return f\"📭 暂无新的 RSS 订阅内容\\n\\n更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}\"\n\n    # 按 feed_id 分组\n    feeds_map: Dict[str, list] = {}\n    for item in rss_items:\n        feed_id = item.get(\"feed_id\", \"unknown\")\n        if feed_id not in feeds_map:\n            feeds_map[feed_id] = []\n        feeds_map[feed_id].append(item)\n\n    text_content = f\"📰 **RSS 订阅更新** (共 {len(rss_items)} 条)\\n\\n\"\n\n    for feed_id, items in feeds_map.items():\n        feed_name = items[0].get(\"feed_name\", feed_id) if items else feed_id\n        if feeds_info and feed_id in feeds_info:\n            feed_name = feeds_info[feed_id]\n\n        text_content += f\"**{feed_name}** ({len(items)} 条)\\n\"\n\n        for i, item in enumerate(items, 1):\n            title = item.get(\"title\", \"\")\n            url = item.get(\"url\", \"\")\n            published_at = item.get(\"published_at\", \"\")\n\n            if url:\n                text_content += f\"  {i}. [{title}]({url})\"\n            else:\n                text_content += f\"  {i}. {title}\"\n\n            if published_at:\n                text_content += f\" `{published_at}`\"\n\n            text_content += \"\\n\"\n\n        text_content += \"\\n\"\n\n    text_content += f\"更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}\"\n\n    return text_content\n\n\n# === RSS 内容渲染辅助函数（用于合并推送） ===\n\ndef _render_rss_section_feishu(rss_items: list, separator: str = \"---\") -> str:\n    \"\"\"渲染 RSS 内容区块（飞书格式，用于合并推送）\"\"\"\n    if not rss_items:\n        return \"\"\n\n    # 按 feed_id 分组\n    feeds_map: Dict[str, list] = {}\n    for item in rss_items:\n        feed_id = item.get(\"feed_id\", \"unknown\")\n        if feed_id not in feeds_map:\n            feeds_map[feed_id] = []\n        feeds_map[feed_id].append(item)\n\n    text_content = f\"📰 **RSS 订阅更新** (共 {len(rss_items)} 条)\\n\\n\"\n\n    for feed_id, items in feeds_map.items():\n        feed_name = items[0].get(\"feed_name\", feed_id) if items else feed_id\n\n        text_content += f\"**{feed_name}** ({len(items)} 条)\\n\\n\"\n\n        for i, item in enumerate(items, 1):\n            title = item.get(\"title\", \"\")\n            url = item.get(\"url\", \"\")\n            published_at = item.get(\"published_at\", \"\")\n\n            if url:\n                text_content += f\"  {i}. [{title}]({url})\"\n            else:\n                text_content += f\"  {i}. {title}\"\n\n            if published_at:\n                text_content += f\" <font color='grey'>- {published_at}</font>\"\n\n            text_content += \"\\n\"\n\n            if i < len(items):\n                text_content += \"\\n\"\n\n        text_content += \"\\n\"\n\n    return text_content.rstrip(\"\\n\")\n\n\ndef _render_rss_section_markdown(rss_items: list) -> str:\n    \"\"\"渲染 RSS 内容区块（通用 Markdown 格式，用于合并推送）\"\"\"\n    if not rss_items:\n        return \"\"\n\n    # 按 feed_id 分组\n    feeds_map: Dict[str, list] = {}\n    for item in rss_items:\n        feed_id = item.get(\"feed_id\", \"unknown\")\n        if feed_id not in feeds_map:\n            feeds_map[feed_id] = []\n        feeds_map[feed_id].append(item)\n\n    text_content = f\"📰 **RSS 订阅更新** (共 {len(rss_items)} 条)\\n\\n\"\n\n    for feed_id, items in feeds_map.items():\n        feed_name = items[0].get(\"feed_name\", feed_id) if items else feed_id\n\n        text_content += f\"**{feed_name}** ({len(items)} 条)\\n\"\n\n        for i, item in enumerate(items, 1):\n            title = item.get(\"title\", \"\")\n            url = item.get(\"url\", \"\")\n            published_at = item.get(\"published_at\", \"\")\n\n            if url:\n                text_content += f\"  {i}. [{title}]({url})\"\n            else:\n                text_content += f\"  {i}. {title}\"\n\n            if published_at:\n                text_content += f\" `{published_at}`\"\n\n            text_content += \"\\n\"\n\n        text_content += \"\\n\"\n\n    return text_content.rstrip(\"\\n\")\n"
  },
  {
    "path": "trendradar/notification/senders.py",
    "content": "# coding=utf-8\n\"\"\"\n消息发送器模块\n\n将报告数据发送到各种通知渠道：\n- 飞书 (Feishu/Lark)\n- 钉钉 (DingTalk)\n- 企业微信 (WeCom/WeWork)\n- Telegram\n- 邮件 (Email)\n- ntfy\n- Bark\n- Slack\n\n每个发送函数都支持分批发送，并通过参数化配置实现与 CONFIG 的解耦。\n\"\"\"\n\nimport smtplib\nimport time\nimport json\nfrom datetime import datetime\nfrom email.header import Header\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.text import MIMEText\nfrom email.utils import formataddr, formatdate, make_msgid\nfrom pathlib import Path\nfrom typing import Any, Callable, Dict, Optional\nfrom urllib.parse import urlparse\n\nimport requests\n\nfrom .batch import add_batch_headers, get_max_batch_header_size\nfrom .formatters import convert_markdown_to_mrkdwn, strip_markdown\n\n\ndef _render_ai_analysis(ai_analysis: Any, channel: str) -> str:\n    \"\"\"渲染 AI 分析内容为指定渠道格式\"\"\"\n    if not ai_analysis:\n        return \"\"\n\n    try:\n        from trendradar.ai.formatter import get_ai_analysis_renderer\n        renderer = get_ai_analysis_renderer(channel)\n        return renderer(ai_analysis)\n    except ImportError:\n        return \"\"\n\n\n# === SMTP 邮件配置 ===\nSMTP_CONFIGS = {\n    # Gmail（使用 STARTTLS）\n    \"gmail.com\": {\"server\": \"smtp.gmail.com\", \"port\": 587, \"encryption\": \"TLS\"},\n    # QQ邮箱（使用 SSL，更稳定）\n    \"qq.com\": {\"server\": \"smtp.qq.com\", \"port\": 465, \"encryption\": \"SSL\"},\n    # Outlook（使用 STARTTLS）\n    \"outlook.com\": {\"server\": \"smtp-mail.outlook.com\", \"port\": 587, \"encryption\": \"TLS\"},\n    \"hotmail.com\": {\"server\": \"smtp-mail.outlook.com\", \"port\": 587, \"encryption\": \"TLS\"},\n    \"live.com\": {\"server\": \"smtp-mail.outlook.com\", \"port\": 587, \"encryption\": \"TLS\"},\n    # 网易邮箱（使用 SSL，更稳定）\n    \"163.com\": {\"server\": \"smtp.163.com\", \"port\": 465, \"encryption\": \"SSL\"},\n    \"126.com\": {\"server\": \"smtp.126.com\", \"port\": 465, \"encryption\": \"SSL\"},\n    # 新浪邮箱（使用 SSL）\n    \"sina.com\": {\"server\": \"smtp.sina.com\", \"port\": 465, \"encryption\": \"SSL\"},\n    # 搜狐邮箱（使用 SSL）\n    \"sohu.com\": {\"server\": \"smtp.sohu.com\", \"port\": 465, \"encryption\": \"SSL\"},\n    # 天翼邮箱（使用 SSL）\n    \"189.cn\": {\"server\": \"smtp.189.cn\", \"port\": 465, \"encryption\": \"SSL\"},\n    # 阿里云邮箱（使用 TLS）\n    \"aliyun.com\": {\"server\": \"smtp.aliyun.com\", \"port\": 465, \"encryption\": \"TLS\"},\n    # Yandex邮箱（使用 TLS）\n    \"yandex.com\": {\"server\": \"smtp.yandex.com\", \"port\": 465, \"encryption\": \"TLS\"},\n    # iCloud邮箱（使用 SSL）\n    \"icloud.com\": {\"server\": \"smtp.mail.me.com\", \"port\": 587, \"encryption\": \"SSL\"},\n}\n\n\ndef send_to_feishu(\n    webhook_url: str,\n    report_data: Dict,\n    report_type: str,\n    update_info: Optional[Dict] = None,\n    proxy_url: Optional[str] = None,\n    mode: str = \"daily\",\n    account_label: str = \"\",\n    *,\n    batch_size: int = 29000,\n    batch_interval: float = 1.0,\n    split_content_func: Callable = None,\n    get_time_func: Callable = None,\n    rss_items: Optional[list] = None,\n    rss_new_items: Optional[list] = None,\n    ai_analysis: Any = None,\n    display_regions: Optional[Dict] = None,\n    standalone_data: Optional[Dict] = None,\n) -> bool:\n    \"\"\"\n    发送到飞书（支持分批发送，支持热榜+RSS合并+独立展示区）\n\n    Args:\n        webhook_url: 飞书 Webhook URL\n        report_data: 报告数据\n        report_type: 报告类型\n        update_info: 更新信息（可选）\n        proxy_url: 代理 URL（可选）\n        mode: 报告模式 (daily/current)\n        account_label: 账号标签（多账号时显示）\n        batch_size: 批次大小（字节）\n        batch_interval: 批次发送间隔（秒）\n        split_content_func: 内容分批函数\n        get_time_func: 获取当前时间的函数\n        rss_items: RSS 统计条目列表（可选，用于合并推送）\n        rss_new_items: RSS 新增条目列表（可选，用于新增区块）\n\n    Returns:\n        bool: 发送是否成功\n    \"\"\"\n    headers = {\"Content-Type\": \"application/json\"}\n    proxies = None\n    if proxy_url:\n        proxies = {\"http\": proxy_url, \"https\": proxy_url}\n\n    # 日志前缀\n    log_prefix = f\"飞书{account_label}\" if account_label else \"飞书\"\n\n    # 渲染 AI 分析内容（如果有）\n    ai_content = None\n    ai_stats = None\n    if ai_analysis:\n        ai_content = _render_ai_analysis(ai_analysis, \"feishu\")\n        # 提取 AI 分析统计数据（只要 AI 分析成功就显示）\n        if getattr(ai_analysis, \"success\", False):\n            ai_stats = {\n                \"total_news\": getattr(ai_analysis, \"total_news\", 0),\n                \"analyzed_news\": getattr(ai_analysis, \"analyzed_news\", 0),\n                \"max_news_limit\": getattr(ai_analysis, \"max_news_limit\", 0),\n                \"hotlist_count\": getattr(ai_analysis, \"hotlist_count\", 0),\n                \"rss_count\": getattr(ai_analysis, \"rss_count\", 0),\n                \"ai_mode\": getattr(ai_analysis, \"ai_mode\", \"\"),\n            }\n\n    # 预留批次头部空间，避免添加头部后超限\n    header_reserve = get_max_batch_header_size(\"feishu\")\n    batches = split_content_func(\n        report_data,\n        \"feishu\",\n        update_info,\n        max_bytes=batch_size - header_reserve,\n        mode=mode,\n        rss_items=rss_items,\n        rss_new_items=rss_new_items,\n        ai_content=ai_content,\n        standalone_data=standalone_data,\n        ai_stats=ai_stats,\n        report_type=report_type,\n    )\n\n    # 统一添加批次头部（已预留空间，不会超限）\n    batches = add_batch_headers(batches, \"feishu\", batch_size)\n\n    print(f\"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]\")\n\n    # 逐批发送\n    for i, batch_content in enumerate(batches, 1):\n        content_size = len(batch_content.encode(\"utf-8\"))\n        print(\n            f\"发送{log_prefix}第 {i}/{len(batches)} 批次，大小：{content_size} 字节 [{report_type}]\"\n        )\n\n        # 飞书 webhook 只显示 content.text，所有信息都整合到 text 中\n        payload = {\n            \"msg_type\": \"interactive\",\n            \"content\": {\n                \"text\": batch_content,\n            },\n        }\n\n        try:\n            response = requests.post(\n                webhook_url, headers=headers, json=payload, proxies=proxies, timeout=30\n            )\n            if response.status_code == 200:\n                result = response.json()\n                # 检查飞书的响应状态\n                if result.get(\"StatusCode\") == 0 or result.get(\"code\") == 0:\n                    print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]\")\n                    # 批次间间隔\n                    if i < len(batches):\n                        time.sleep(batch_interval)\n                else:\n                    error_msg = result.get(\"msg\") or result.get(\"StatusMessage\", \"未知错误\")\n                    print(\n                        f\"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}]，错误：{error_msg}\"\n                    )\n                    return False\n            else:\n                print(\n                    f\"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}]，状态码：{response.status_code}\"\n                )\n                return False\n        except Exception as e:\n            print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]：{e}\")\n            return False\n\n    print(f\"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]\")\n\n    return True\n\n\ndef send_to_dingtalk(\n    webhook_url: str,\n    report_data: Dict,\n    report_type: str,\n    update_info: Optional[Dict] = None,\n    proxy_url: Optional[str] = None,\n    mode: str = \"daily\",\n    account_label: str = \"\",\n    *,\n    batch_size: int = 20000,\n    batch_interval: float = 1.0,\n    split_content_func: Callable = None,\n    rss_items: Optional[list] = None,\n    rss_new_items: Optional[list] = None,\n    ai_analysis: Any = None,\n    display_regions: Optional[Dict] = None,\n    standalone_data: Optional[Dict] = None,\n) -> bool:\n    \"\"\"\n    发送到钉钉（支持分批发送，支持热榜+RSS合并+独立展示区）\n\n    Args:\n        webhook_url: 钉钉 Webhook URL\n        report_data: 报告数据\n        report_type: 报告类型\n        update_info: 更新信息（可选）\n        proxy_url: 代理 URL（可选）\n        mode: 报告模式 (daily/current)\n        account_label: 账号标签（多账号时显示）\n        batch_size: 批次大小（字节）\n        batch_interval: 批次发送间隔（秒）\n        split_content_func: 内容分批函数\n        rss_items: RSS 统计条目列表（可选，用于合并推送）\n        rss_new_items: RSS 新增条目列表（可选，用于新增区块）\n\n    Returns:\n        bool: 发送是否成功\n    \"\"\"\n    headers = {\"Content-Type\": \"application/json\"}\n    proxies = None\n    if proxy_url:\n        proxies = {\"http\": proxy_url, \"https\": proxy_url}\n\n    # 日志前缀\n    log_prefix = f\"钉钉{account_label}\" if account_label else \"钉钉\"\n\n    # 渲染 AI 分析内容（如果有）\n    ai_content = None\n    ai_stats = None\n    if ai_analysis:\n        ai_content = _render_ai_analysis(ai_analysis, \"dingtalk\")\n        # 提取 AI 分析统计数据（只要 AI 分析成功就显示）\n        if getattr(ai_analysis, \"success\", False):\n            ai_stats = {\n                \"total_news\": getattr(ai_analysis, \"total_news\", 0),\n                \"analyzed_news\": getattr(ai_analysis, \"analyzed_news\", 0),\n                \"max_news_limit\": getattr(ai_analysis, \"max_news_limit\", 0),\n                \"hotlist_count\": getattr(ai_analysis, \"hotlist_count\", 0),\n                \"rss_count\": getattr(ai_analysis, \"rss_count\", 0),\n                \"ai_mode\": getattr(ai_analysis, \"ai_mode\", \"\"),\n            }\n\n    # 预留批次头部空间，避免添加头部后超限\n    header_reserve = get_max_batch_header_size(\"dingtalk\")\n    batches = split_content_func(\n        report_data,\n        \"dingtalk\",\n        update_info,\n        max_bytes=batch_size - header_reserve,\n        mode=mode,\n        rss_items=rss_items,\n        rss_new_items=rss_new_items,\n        ai_content=ai_content,\n        standalone_data=standalone_data,\n        ai_stats=ai_stats,\n        report_type=report_type,\n    )\n\n    # 统一添加批次头部（已预留空间，不会超限）\n    batches = add_batch_headers(batches, \"dingtalk\", batch_size)\n\n    print(f\"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]\")\n\n    # 逐批发送\n    for i, batch_content in enumerate(batches, 1):\n        content_size = len(batch_content.encode(\"utf-8\"))\n        print(\n            f\"发送{log_prefix}第 {i}/{len(batches)} 批次，大小：{content_size} 字节 [{report_type}]\"\n        )\n\n        payload = {\n            \"msgtype\": \"markdown\",\n            \"markdown\": {\n                \"title\": f\"TrendRadar 热点分析报告 - {report_type}\",\n                \"text\": batch_content,\n            },\n        }\n\n        try:\n            response = requests.post(\n                webhook_url, headers=headers, json=payload, proxies=proxies, timeout=30\n            )\n            if response.status_code == 200:\n                result = response.json()\n                if result.get(\"errcode\") == 0:\n                    print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]\")\n                    # 批次间间隔\n                    if i < len(batches):\n                        time.sleep(batch_interval)\n                else:\n                    print(\n                        f\"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}]，错误：{result.get('errmsg')}\"\n                    )\n                    return False\n            else:\n                print(\n                    f\"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}]，状态码：{response.status_code}\"\n                )\n                return False\n        except Exception as e:\n            print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]：{e}\")\n            return False\n\n    print(f\"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]\")\n\n    return True\n\n\ndef send_to_wework(\n    webhook_url: str,\n    report_data: Dict,\n    report_type: str,\n    update_info: Optional[Dict] = None,\n    proxy_url: Optional[str] = None,\n    mode: str = \"daily\",\n    account_label: str = \"\",\n    *,\n    batch_size: int = 4000,\n    batch_interval: float = 1.0,\n    msg_type: str = \"markdown\",\n    split_content_func: Callable = None,\n    rss_items: Optional[list] = None,\n    rss_new_items: Optional[list] = None,\n    ai_analysis: Any = None,\n    display_regions: Optional[Dict] = None,\n    standalone_data: Optional[Dict] = None,\n) -> bool:\n    \"\"\"\n    发送到企业微信（支持分批发送，支持 markdown 和 text 两种格式，支持热榜+RSS合并+独立展示区）\n\n    Args:\n        webhook_url: 企业微信 Webhook URL\n        report_data: 报告数据\n        report_type: 报告类型\n        update_info: 更新信息（可选）\n        proxy_url: 代理 URL（可选）\n        mode: 报告模式 (daily/current)\n        account_label: 账号标签（多账号时显示）\n        batch_size: 批次大小（字节）\n        batch_interval: 批次发送间隔（秒）\n        msg_type: 消息类型 (markdown/text)\n        split_content_func: 内容分批函数\n        rss_items: RSS 统计条目列表（可选，用于合并推送）\n        rss_new_items: RSS 新增条目列表（可选，用于新增区块）\n\n    Returns:\n        bool: 发送是否成功\n    \"\"\"\n    headers = {\"Content-Type\": \"application/json\"}\n    proxies = None\n    if proxy_url:\n        proxies = {\"http\": proxy_url, \"https\": proxy_url}\n\n    # 日志前缀\n    log_prefix = f\"企业微信{account_label}\" if account_label else \"企业微信\"\n\n    # 获取消息类型配置（markdown 或 text）\n    is_text_mode = msg_type.lower() == \"text\"\n\n    if is_text_mode:\n        print(f\"{log_prefix}使用 text 格式（个人微信模式）[{report_type}]\")\n    else:\n        print(f\"{log_prefix}使用 markdown 格式（群机器人模式）[{report_type}]\")\n\n    # text 模式使用 wework_text，markdown 模式使用 wework\n    header_format_type = \"wework_text\" if is_text_mode else \"wework\"\n\n    # 渲染 AI 分析内容（如果有）\n    ai_content = None\n    ai_stats = None\n    if ai_analysis:\n        ai_content = _render_ai_analysis(ai_analysis, \"wework\")\n        # 提取 AI 分析统计数据（只要 AI 分析成功就显示）\n        if getattr(ai_analysis, \"success\", False):\n            ai_stats = {\n                \"total_news\": getattr(ai_analysis, \"total_news\", 0),\n                \"analyzed_news\": getattr(ai_analysis, \"analyzed_news\", 0),\n                \"max_news_limit\": getattr(ai_analysis, \"max_news_limit\", 0),\n                \"hotlist_count\": getattr(ai_analysis, \"hotlist_count\", 0),\n                \"rss_count\": getattr(ai_analysis, \"rss_count\", 0),\n                \"ai_mode\": getattr(ai_analysis, \"ai_mode\", \"\"),\n            }\n\n    # 获取分批内容，预留批次头部空间\n    header_reserve = get_max_batch_header_size(header_format_type)\n    batches = split_content_func(\n        report_data, \"wework\", update_info, max_bytes=batch_size - header_reserve, mode=mode,\n        rss_items=rss_items,\n        rss_new_items=rss_new_items,\n        ai_content=ai_content,\n        standalone_data=standalone_data,\n        ai_stats=ai_stats,\n        report_type=report_type,\n    )\n\n    # 统一添加批次头部（已预留空间，不会超限）\n    batches = add_batch_headers(batches, header_format_type, batch_size)\n\n    print(f\"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]\")\n\n    # 逐批发送\n    for i, batch_content in enumerate(batches, 1):\n        # 根据消息类型构建 payload\n        if is_text_mode:\n            # text 格式：去除 markdown 语法\n            plain_content = strip_markdown(batch_content)\n            payload = {\"msgtype\": \"text\", \"text\": {\"content\": plain_content}}\n            content_size = len(plain_content.encode(\"utf-8\"))\n        else:\n            # markdown 格式：保持原样\n            payload = {\"msgtype\": \"markdown\", \"markdown\": {\"content\": batch_content}}\n            content_size = len(batch_content.encode(\"utf-8\"))\n\n        print(\n            f\"发送{log_prefix}第 {i}/{len(batches)} 批次，大小：{content_size} 字节 [{report_type}]\"\n        )\n\n        try:\n            response = requests.post(\n                webhook_url, headers=headers, json=payload, proxies=proxies, timeout=30\n            )\n            if response.status_code == 200:\n                result = response.json()\n                if result.get(\"errcode\") == 0:\n                    print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]\")\n                    # 批次间间隔\n                    if i < len(batches):\n                        time.sleep(batch_interval)\n                else:\n                    print(\n                        f\"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}]，错误：{result.get('errmsg')}\"\n                    )\n                    return False\n            else:\n                print(\n                    f\"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}]，状态码：{response.status_code}\"\n                )\n                return False\n        except Exception as e:\n            print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]：{e}\")\n            return False\n\n    print(f\"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]\")\n\n    return True\n\n\ndef send_to_telegram(\n    bot_token: str,\n    chat_id: str,\n    report_data: Dict,\n    report_type: str,\n    update_info: Optional[Dict] = None,\n    proxy_url: Optional[str] = None,\n    mode: str = \"daily\",\n    account_label: str = \"\",\n    *,\n    batch_size: int = 4000,\n    batch_interval: float = 1.0,\n    split_content_func: Callable = None,\n    rss_items: Optional[list] = None,\n    rss_new_items: Optional[list] = None,\n    ai_analysis: Any = None,\n    display_regions: Optional[Dict] = None,\n    standalone_data: Optional[Dict] = None,\n) -> bool:\n    \"\"\"\n    发送到 Telegram（支持分批发送，支持热榜+RSS合并+独立展示区）\n\n    Args:\n        bot_token: Telegram Bot Token\n        chat_id: Telegram Chat ID\n        report_data: 报告数据\n        report_type: 报告类型\n        update_info: 更新信息（可选）\n        proxy_url: 代理 URL（可选）\n        mode: 报告模式 (daily/current)\n        account_label: 账号标签（多账号时显示）\n        batch_size: 批次大小（字节）\n        batch_interval: 批次发送间隔（秒）\n        split_content_func: 内容分批函数\n        rss_items: RSS 统计条目列表（可选，用于合并推送）\n        rss_new_items: RSS 新增条目列表（可选，用于新增区块）\n\n    Returns:\n        bool: 发送是否成功\n    \"\"\"\n    headers = {\"Content-Type\": \"application/json\"}\n    url = f\"https://api.telegram.org/bot{bot_token}/sendMessage\"\n\n    proxies = None\n    if proxy_url:\n        proxies = {\"http\": proxy_url, \"https\": proxy_url}\n\n    # 日志前缀\n    log_prefix = f\"Telegram{account_label}\" if account_label else \"Telegram\"\n\n    # 渲染 AI 分析内容（如果有）\n    ai_content = None\n    ai_stats = None\n    if ai_analysis:\n        ai_content = _render_ai_analysis(ai_analysis, \"telegram\")\n        # 提取 AI 分析统计数据（只要 AI 分析成功就显示）\n        if getattr(ai_analysis, \"success\", False):\n            ai_stats = {\n                \"total_news\": getattr(ai_analysis, \"total_news\", 0),\n                \"analyzed_news\": getattr(ai_analysis, \"analyzed_news\", 0),\n                \"max_news_limit\": getattr(ai_analysis, \"max_news_limit\", 0),\n                \"hotlist_count\": getattr(ai_analysis, \"hotlist_count\", 0),\n                \"rss_count\": getattr(ai_analysis, \"rss_count\", 0),\n                \"ai_mode\": getattr(ai_analysis, \"ai_mode\", \"\"),\n            }\n\n    # 获取分批内容，预留批次头部空间\n    header_reserve = get_max_batch_header_size(\"telegram\")\n    batches = split_content_func(\n        report_data, \"telegram\", update_info, max_bytes=batch_size - header_reserve, mode=mode,\n        rss_items=rss_items,\n        rss_new_items=rss_new_items,\n        ai_content=ai_content,\n        standalone_data=standalone_data,\n        ai_stats=ai_stats,\n        report_type=report_type,\n    )\n\n    # 统一添加批次头部（已预留空间，不会超限）\n    batches = add_batch_headers(batches, \"telegram\", batch_size)\n\n    print(f\"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]\")\n\n    # 逐批发送\n    for i, batch_content in enumerate(batches, 1):\n        content_size = len(batch_content.encode(\"utf-8\"))\n        print(\n            f\"发送{log_prefix}第 {i}/{len(batches)} 批次，大小：{content_size} 字节 [{report_type}]\"\n        )\n\n        payload = {\n            \"chat_id\": chat_id,\n            \"text\": batch_content,\n            \"parse_mode\": \"HTML\",\n            \"disable_web_page_preview\": True,\n        }\n\n        try:\n            response = requests.post(\n                url, headers=headers, json=payload, proxies=proxies, timeout=30\n            )\n            if response.status_code == 200:\n                result = response.json()\n                if result.get(\"ok\"):\n                    print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]\")\n                    # 批次间间隔\n                    if i < len(batches):\n                        time.sleep(batch_interval)\n                else:\n                    print(\n                        f\"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}]，错误：{result.get('description')}\"\n                    )\n                    return False\n            else:\n                print(\n                    f\"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}]，状态码：{response.status_code}\"\n                )\n                return False\n        except Exception as e:\n            print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]：{e}\")\n            return False\n\n    print(f\"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]\")\n\n    return True\n\n\ndef send_to_email(\n    from_email: str,\n    password: str,\n    to_email: str,\n    report_type: str,\n    html_file_path: str,\n    custom_smtp_server: Optional[str] = None,\n    custom_smtp_port: Optional[int] = None,\n    *,\n    get_time_func: Callable = None,\n) -> bool:\n    \"\"\"\n    发送邮件通知\n\n    Args:\n        from_email: 发件人邮箱\n        password: 邮箱密码/授权码\n        to_email: 收件人邮箱（多个用逗号分隔）\n        report_type: 报告类型\n        html_file_path: HTML 报告文件路径\n        custom_smtp_server: 自定义 SMTP 服务器（可选）\n        custom_smtp_port: 自定义 SMTP 端口（可选）\n        get_time_func: 获取当前时间的函数\n\n    Returns:\n        bool: 发送是否成功\n\n    Note:\n        AI 分析内容已在 HTML 生成时嵌入，无需再追加\n    \"\"\"\n    try:\n        if not html_file_path or not Path(html_file_path).exists():\n            print(f\"错误：HTML文件不存在或未提供: {html_file_path}\")\n            return False\n\n        print(f\"使用HTML文件: {html_file_path}\")\n        with open(html_file_path, \"r\", encoding=\"utf-8\") as f:\n            html_content = f.read()\n\n        domain = from_email.split(\"@\")[-1].lower()\n\n        if custom_smtp_server and custom_smtp_port:\n            # 使用自定义 SMTP 配置\n            smtp_server = custom_smtp_server\n            smtp_port = int(custom_smtp_port)\n            # 根据端口判断加密方式：465=SSL, 587=TLS\n            if smtp_port == 465:\n                use_tls = False  # SSL 模式（SMTP_SSL）\n            elif smtp_port == 587:\n                use_tls = True  # TLS 模式（STARTTLS）\n            else:\n                # 其他端口优先尝试 TLS（更安全，更广泛支持）\n                use_tls = True\n        elif domain in SMTP_CONFIGS:\n            # 使用预设配置\n            config = SMTP_CONFIGS[domain]\n            smtp_server = config[\"server\"]\n            smtp_port = config[\"port\"]\n            use_tls = config[\"encryption\"] == \"TLS\"\n        else:\n            print(f\"未识别的邮箱服务商: {domain}，使用通用 SMTP 配置\")\n            smtp_server = f\"smtp.{domain}\"\n            smtp_port = 587\n            use_tls = True\n\n        msg = MIMEMultipart(\"alternative\")\n\n        # 严格按照 RFC 标准设置 From header\n        sender_name = \"TrendRadar\"\n        msg[\"From\"] = formataddr((sender_name, from_email))\n\n        # 设置收件人\n        recipients = [addr.strip() for addr in to_email.split(\",\")]\n        if len(recipients) == 1:\n            msg[\"To\"] = recipients[0]\n        else:\n            msg[\"To\"] = \", \".join(recipients)\n\n        # 设置邮件主题\n        now = get_time_func() if get_time_func else datetime.now()\n        subject = f\"TrendRadar 热点分析报告 - {report_type} - {now.strftime('%m月%d日 %H:%M')}\"\n        msg[\"Subject\"] = Header(subject, \"utf-8\")\n\n        # 设置其他标准 header\n        msg[\"MIME-Version\"] = \"1.0\"\n        msg[\"Date\"] = formatdate(localtime=True)\n        msg[\"Message-ID\"] = make_msgid()\n\n        # 添加纯文本部分（作为备选）\n        text_content = f\"\"\"\nTrendRadar 热点分析报告\n========================\n报告类型：{report_type}\n生成时间：{now.strftime('%Y-%m-%d %H:%M:%S')}\n\n请使用支持HTML的邮件客户端查看完整报告内容。\n        \"\"\"\n        text_part = MIMEText(text_content, \"plain\", \"utf-8\")\n        msg.attach(text_part)\n\n        html_part = MIMEText(html_content, \"html\", \"utf-8\")\n        msg.attach(html_part)\n\n        print(f\"正在发送邮件到 {to_email}...\")\n        print(f\"SMTP 服务器: {smtp_server}:{smtp_port}\")\n        print(f\"发件人: {from_email}\")\n\n        try:\n            if use_tls:\n                # TLS 模式\n                server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)\n                server.set_debuglevel(0)  # 设为1可以查看详细调试信息\n                server.ehlo()\n                server.starttls()\n                server.ehlo()\n            else:\n                # SSL 模式\n                server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30)\n                server.set_debuglevel(0)\n                server.ehlo()\n\n            # 登录\n            server.login(from_email, password)\n\n            # 发送邮件\n            server.send_message(msg)\n            server.quit()\n\n            print(f\"邮件发送成功 [{report_type}] -> {to_email}\")\n            return True\n\n        except smtplib.SMTPServerDisconnected:\n            print(\"邮件发送失败：服务器意外断开连接，请检查网络或稍后重试\")\n            return False\n\n    except smtplib.SMTPAuthenticationError as e:\n        print(\"邮件发送失败：认证错误，请检查邮箱和密码/授权码\")\n        print(f\"详细错误: {str(e)}\")\n        return False\n    except smtplib.SMTPRecipientsRefused as e:\n        print(f\"邮件发送失败：收件人地址被拒绝 {e}\")\n        return False\n    except smtplib.SMTPSenderRefused as e:\n        print(f\"邮件发送失败：发件人地址被拒绝 {e}\")\n        return False\n    except smtplib.SMTPDataError as e:\n        print(f\"邮件发送失败：邮件数据错误 {e}\")\n        return False\n    except smtplib.SMTPConnectError as e:\n        print(f\"邮件发送失败：无法连接到 SMTP 服务器 {smtp_server}:{smtp_port}\")\n        print(f\"详细错误: {str(e)}\")\n        return False\n    except Exception as e:\n        print(f\"邮件发送失败 [{report_type}]：{e}\")\n        import traceback\n        traceback.print_exc()\n        return False\n\n\ndef send_to_ntfy(\n    server_url: str,\n    topic: str,\n    token: Optional[str],\n    report_data: Dict,\n    report_type: str,\n    update_info: Optional[Dict] = None,\n    proxy_url: Optional[str] = None,\n    mode: str = \"daily\",\n    account_label: str = \"\",\n    *,\n    batch_size: int = 3800,\n    split_content_func: Callable = None,\n    rss_items: Optional[list] = None,\n    rss_new_items: Optional[list] = None,\n    ai_analysis: Any = None,\n    display_regions: Optional[Dict] = None,\n    standalone_data: Optional[Dict] = None,\n) -> bool:\n    \"\"\"\n    发送到 ntfy（支持分批发送，严格遵守4KB限制，支持热榜+RSS合并+独立展示区）\n\n    Args:\n        server_url: ntfy 服务器 URL\n        topic: ntfy 主题\n        token: ntfy 访问令牌（可选）\n        report_data: 报告数据\n        report_type: 报告类型\n        update_info: 更新信息（可选）\n        proxy_url: 代理 URL（可选）\n        mode: 报告模式 (daily/current)\n        account_label: 账号标签（多账号时显示）\n        batch_size: 批次大小（字节）\n        split_content_func: 内容分批函数\n        rss_items: RSS 统计条目列表（可选，用于合并推送）\n        rss_new_items: RSS 新增条目列表（可选，用于新增区块）\n\n    Returns:\n        bool: 发送是否成功\n    \"\"\"\n    # 日志前缀\n    log_prefix = f\"ntfy{account_label}\" if account_label else \"ntfy\"\n\n    # 避免 HTTP header 编码问题\n    report_type_en_map = {\n        \"全天汇总\": \"Daily Summary\",\n        \"当前榜单\": \"Current Ranking\",\n        \"增量分析\": \"Incremental Update\",\n        \"通知连通性测试\": \"Notification Test\",\n    }\n    report_type_en = report_type_en_map.get(report_type, \"News Report\")\n\n    headers = {\n        \"Content-Type\": \"text/plain; charset=utf-8\",\n        \"Markdown\": \"yes\",\n        \"Title\": report_type_en,\n        \"Priority\": \"default\",\n        \"Tags\": \"news\",\n    }\n\n    if token:\n        headers[\"Authorization\"] = f\"Bearer {token}\"\n\n    # 构建完整URL，确保格式正确\n    base_url = server_url.rstrip(\"/\")\n    if not base_url.startswith((\"http://\", \"https://\")):\n        base_url = f\"https://{base_url}\"\n    url = f\"{base_url}/{topic}\"\n\n    proxies = None\n    if proxy_url:\n        proxies = {\"http\": proxy_url, \"https\": proxy_url}\n\n    # 渲染 AI 分析内容（如果有），合并到主内容中\n    ai_content = None\n    ai_stats = None\n    if ai_analysis:\n        ai_content = _render_ai_analysis(ai_analysis, \"ntfy\")\n        # 提取 AI 分析统计数据（只要 AI 分析成功就显示）\n        if getattr(ai_analysis, \"success\", False):\n            ai_stats = {\n                \"total_news\": getattr(ai_analysis, \"total_news\", 0),\n                \"analyzed_news\": getattr(ai_analysis, \"analyzed_news\", 0),\n                \"max_news_limit\": getattr(ai_analysis, \"max_news_limit\", 0),\n                \"hotlist_count\": getattr(ai_analysis, \"hotlist_count\", 0),\n                \"rss_count\": getattr(ai_analysis, \"rss_count\", 0),\n                \"ai_mode\": getattr(ai_analysis, \"ai_mode\", \"\"),\n            }\n\n    # 获取分批内容，预留批次头部空间\n    header_reserve = get_max_batch_header_size(\"ntfy\")\n    batches = split_content_func(\n        report_data, \"ntfy\", update_info, max_bytes=batch_size - header_reserve, mode=mode,\n        rss_items=rss_items,\n        rss_new_items=rss_new_items,\n        ai_content=ai_content,\n        standalone_data=standalone_data,\n        ai_stats=ai_stats,\n        report_type=report_type,\n    )\n\n    # 统一添加批次头部（已预留空间，不会超限）\n    batches = add_batch_headers(batches, \"ntfy\", batch_size)\n\n    total_batches = len(batches)\n    print(f\"{log_prefix}消息分为 {total_batches} 批次发送 [{report_type}]\")\n\n    # 反转批次顺序，使得在ntfy客户端显示时顺序正确\n    # ntfy显示最新消息在上面，所以我们从最后一批开始推送\n    reversed_batches = list(reversed(batches))\n\n    print(f\"{log_prefix}将按反向顺序推送（最后批次先推送），确保客户端显示顺序正确\")\n\n    # 逐批发送（反向顺序）\n    success_count = 0\n    for idx, batch_content in enumerate(reversed_batches, 1):\n        # 计算正确的批次编号（用户视角的编号）\n        actual_batch_num = total_batches - idx + 1\n\n        content_size = len(batch_content.encode(\"utf-8\"))\n        print(\n            f\"发送{log_prefix}第 {actual_batch_num}/{total_batches} 批次（推送顺序: {idx}/{total_batches}），大小：{content_size} 字节 [{report_type}]\"\n        )\n\n        # 检查消息大小，确保不超过4KB\n        if content_size > 4096:\n            print(f\"警告：{log_prefix}第 {actual_batch_num} 批次消息过大（{content_size} 字节），可能被拒绝\")\n\n        # 更新 headers 的批次标识\n        current_headers = headers.copy()\n        if total_batches > 1:\n            current_headers[\"Title\"] = f\"{report_type_en} ({actual_batch_num}/{total_batches})\"\n\n        try:\n            response = requests.post(\n                url,\n                headers=current_headers,\n                data=batch_content.encode(\"utf-8\"),\n                proxies=proxies,\n                timeout=30,\n            )\n\n            if response.status_code == 200:\n                print(f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送成功 [{report_type}]\")\n                success_count += 1\n                if idx < total_batches:\n                    # 公共服务器建议 2-3 秒，自托管可以更短\n                    interval = 2 if \"ntfy.sh\" in server_url else 1\n                    time.sleep(interval)\n            elif response.status_code == 429:\n                print(\n                    f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次速率限制 [{report_type}]，等待后重试\"\n                )\n                time.sleep(10)  # 等待10秒后重试\n                # 重试一次\n                retry_response = requests.post(\n                    url,\n                    headers=current_headers,\n                    data=batch_content.encode(\"utf-8\"),\n                    proxies=proxies,\n                    timeout=30,\n                )\n                if retry_response.status_code == 200:\n                    print(f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次重试成功 [{report_type}]\")\n                    success_count += 1\n                else:\n                    print(\n                        f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次重试失败，状态码：{retry_response.status_code}\"\n                    )\n            elif response.status_code == 413:\n                print(\n                    f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次消息过大被拒绝 [{report_type}]，消息大小：{content_size} 字节\"\n                )\n            else:\n                print(\n                    f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送失败 [{report_type}]，状态码：{response.status_code}\"\n                )\n                try:\n                    print(f\"错误详情：{response.text}\")\n                except:\n                    pass\n\n        except requests.exceptions.ConnectTimeout:\n            print(f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次连接超时 [{report_type}]\")\n        except requests.exceptions.ReadTimeout:\n            print(f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次读取超时 [{report_type}]\")\n        except requests.exceptions.ConnectionError as e:\n            print(f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次连接错误 [{report_type}]：{e}\")\n        except Exception as e:\n            print(f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送异常 [{report_type}]：{e}\")\n\n    # 判断整体发送是否成功\n    if success_count == total_batches:\n        print(f\"{log_prefix}所有 {total_batches} 批次发送完成 [{report_type}]\")\n    elif success_count > 0:\n        print(f\"{log_prefix}部分发送成功：{success_count}/{total_batches} 批次 [{report_type}]\")\n    else:\n        print(f\"{log_prefix}发送完全失败 [{report_type}]\")\n        return False\n\n    return True\n\n\ndef send_to_bark(\n    bark_url: str,\n    report_data: Dict,\n    report_type: str,\n    update_info: Optional[Dict] = None,\n    proxy_url: Optional[str] = None,\n    mode: str = \"daily\",\n    account_label: str = \"\",\n    *,\n    batch_size: int = 3600,\n    batch_interval: float = 1.0,\n    split_content_func: Callable = None,\n    rss_items: Optional[list] = None,\n    rss_new_items: Optional[list] = None,\n    ai_analysis: Any = None,\n    display_regions: Optional[Dict] = None,\n    standalone_data: Optional[Dict] = None,\n) -> bool:\n    \"\"\"\n    发送到 Bark（支持分批发送，使用 markdown 格式，支持热榜+RSS合并+独立展示区）\n\n    Args:\n        bark_url: Bark URL（包含 device_key）\n        report_data: 报告数据\n        report_type: 报告类型\n        update_info: 更新信息（可选）\n        proxy_url: 代理 URL（可选）\n        mode: 报告模式 (daily/current)\n        account_label: 账号标签（多账号时显示）\n        batch_size: 批次大小（字节）\n        batch_interval: 批次发送间隔（秒）\n        split_content_func: 内容分批函数\n        rss_items: RSS 统计条目列表（可选，用于合并推送）\n        rss_new_items: RSS 新增条目列表（可选，用于新增区块）\n\n    Returns:\n        bool: 发送是否成功\n    \"\"\"\n    # 日志前缀\n    log_prefix = f\"Bark{account_label}\" if account_label else \"Bark\"\n\n    proxies = None\n    if proxy_url:\n        proxies = {\"http\": proxy_url, \"https\": proxy_url}\n\n    # 解析 Bark URL，提取 device_key 和 API 端点\n    # Bark URL 格式: https://api.day.app/device_key 或 https://bark.day.app/device_key\n    parsed_url = urlparse(bark_url)\n    device_key = parsed_url.path.strip('/').split('/')[0] if parsed_url.path else None\n\n    if not device_key:\n        print(f\"{log_prefix} URL 格式错误，无法提取 device_key: {bark_url}\")\n        return False\n\n    # 构建正确的 API 端点\n    api_endpoint = f\"{parsed_url.scheme}://{parsed_url.netloc}/push\"\n\n    # 渲染 AI 分析内容（如果有），合并到主内容中\n    ai_content = None\n    ai_stats = None\n    if ai_analysis:\n        ai_content = _render_ai_analysis(ai_analysis, \"bark\")\n        # 提取 AI 分析统计数据（只要 AI 分析成功就显示）\n        if getattr(ai_analysis, \"success\", False):\n            ai_stats = {\n                \"total_news\": getattr(ai_analysis, \"total_news\", 0),\n                \"analyzed_news\": getattr(ai_analysis, \"analyzed_news\", 0),\n                \"max_news_limit\": getattr(ai_analysis, \"max_news_limit\", 0),\n                \"hotlist_count\": getattr(ai_analysis, \"hotlist_count\", 0),\n                \"rss_count\": getattr(ai_analysis, \"rss_count\", 0),\n                \"ai_mode\": getattr(ai_analysis, \"ai_mode\", \"\"),\n            }\n\n    # 获取分批内容，预留批次头部空间\n    header_reserve = get_max_batch_header_size(\"bark\")\n    batches = split_content_func(\n        report_data, \"bark\", update_info, max_bytes=batch_size - header_reserve, mode=mode,\n        rss_items=rss_items,\n        rss_new_items=rss_new_items,\n        ai_content=ai_content,\n        standalone_data=standalone_data,\n        ai_stats=ai_stats,\n        report_type=report_type,\n    )\n\n    # 统一添加批次头部（已预留空间，不会超限）\n    batches = add_batch_headers(batches, \"bark\", batch_size)\n\n    total_batches = len(batches)\n    print(f\"{log_prefix}消息分为 {total_batches} 批次发送 [{report_type}]\")\n\n    # 反转批次顺序，使得在Bark客户端显示时顺序正确\n    # Bark显示最新消息在上面，所以我们从最后一批开始推送\n    reversed_batches = list(reversed(batches))\n\n    print(f\"{log_prefix}将按反向顺序推送（最后批次先推送），确保客户端显示顺序正确\")\n\n    # 逐批发送（反向顺序）\n    success_count = 0\n    for idx, batch_content in enumerate(reversed_batches, 1):\n        # 计算正确的批次编号（用户视角的编号）\n        actual_batch_num = total_batches - idx + 1\n\n        content_size = len(batch_content.encode(\"utf-8\"))\n        print(\n            f\"发送{log_prefix}第 {actual_batch_num}/{total_batches} 批次（推送顺序: {idx}/{total_batches}），大小：{content_size} 字节 [{report_type}]\"\n        )\n\n        # 检查消息大小（Bark使用APNs，限制4KB）\n        if content_size > 4096:\n            print(\n                f\"警告：{log_prefix}第 {actual_batch_num}/{total_batches} 批次消息过大（{content_size} 字节），可能被拒绝\"\n            )\n\n        # 构建JSON payload\n        payload = {\n            \"title\": report_type,\n            \"markdown\": batch_content,\n            \"device_key\": device_key,\n            \"sound\": \"default\",\n            \"group\": \"TrendRadar\",\n            \"action\": \"none\",  # 点击推送跳到 APP 不弹出弹框,方便阅读\n        }\n\n        try:\n            response = requests.post(\n                api_endpoint,\n                json=payload,\n                proxies=proxies,\n                timeout=30,\n            )\n\n            if response.status_code == 200:\n                result = response.json()\n                if result.get(\"code\") == 200:\n                    print(f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送成功 [{report_type}]\")\n                    success_count += 1\n                    # 批次间间隔\n                    if idx < total_batches:\n                        time.sleep(batch_interval)\n                else:\n                    print(\n                        f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送失败 [{report_type}]，错误：{result.get('message', '未知错误')}\"\n                    )\n            else:\n                print(\n                    f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送失败 [{report_type}]，状态码：{response.status_code}\"\n                )\n                try:\n                    print(f\"错误详情：{response.text}\")\n                except:\n                    pass\n\n        except requests.exceptions.ConnectTimeout:\n            print(f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次连接超时 [{report_type}]\")\n        except requests.exceptions.ReadTimeout:\n            print(f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次读取超时 [{report_type}]\")\n        except requests.exceptions.ConnectionError as e:\n            print(f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次连接错误 [{report_type}]：{e}\")\n        except Exception as e:\n            print(f\"{log_prefix}第 {actual_batch_num}/{total_batches} 批次发送异常 [{report_type}]：{e}\")\n\n    # 判断整体发送是否成功\n    if success_count == total_batches:\n        print(f\"{log_prefix}所有 {total_batches} 批次发送完成 [{report_type}]\")\n    elif success_count > 0:\n        print(f\"{log_prefix}部分发送成功：{success_count}/{total_batches} 批次 [{report_type}]\")\n    else:\n        print(f\"{log_prefix}发送完全失败 [{report_type}]\")\n        return False\n\n    return True\n\n\ndef send_to_slack(\n    webhook_url: str,\n    report_data: Dict,\n    report_type: str,\n    update_info: Optional[Dict] = None,\n    proxy_url: Optional[str] = None,\n    mode: str = \"daily\",\n    account_label: str = \"\",\n    *,\n    batch_size: int = 4000,\n    batch_interval: float = 1.0,\n    split_content_func: Callable = None,\n    rss_items: Optional[list] = None,\n    rss_new_items: Optional[list] = None,\n    ai_analysis: Any = None,\n    display_regions: Optional[Dict] = None,\n    standalone_data: Optional[Dict] = None,\n) -> bool:\n    \"\"\"\n    发送到 Slack（支持分批发送，使用 mrkdwn 格式，支持热榜+RSS合并+独立展示区）\n\n    Args:\n        webhook_url: Slack Webhook URL\n        report_data: 报告数据\n        report_type: 报告类型\n        update_info: 更新信息（可选）\n        proxy_url: 代理 URL（可选）\n        mode: 报告模式 (daily/current)\n        account_label: 账号标签（多账号时显示）\n        batch_size: 批次大小（字节）\n        batch_interval: 批次发送间隔（秒）\n        split_content_func: 内容分批函数\n        rss_items: RSS 统计条目列表（可选，用于合并推送）\n        rss_new_items: RSS 新增条目列表（可选，用于新增区块）\n\n    Returns:\n        bool: 发送是否成功\n    \"\"\"\n    headers = {\"Content-Type\": \"application/json\"}\n    proxies = None\n    if proxy_url:\n        proxies = {\"http\": proxy_url, \"https\": proxy_url}\n\n    # 日志前缀\n    log_prefix = f\"Slack{account_label}\" if account_label else \"Slack\"\n\n    # 渲染 AI 分析内容（如果有），合并到主内容中\n    ai_content = None\n    ai_stats = None\n    if ai_analysis:\n        ai_content = _render_ai_analysis(ai_analysis, \"slack\")\n        # 提取 AI 分析统计数据（只要 AI 分析成功就显示）\n        if getattr(ai_analysis, \"success\", False):\n            ai_stats = {\n                \"total_news\": getattr(ai_analysis, \"total_news\", 0),\n                \"analyzed_news\": getattr(ai_analysis, \"analyzed_news\", 0),\n                \"max_news_limit\": getattr(ai_analysis, \"max_news_limit\", 0),\n                \"hotlist_count\": getattr(ai_analysis, \"hotlist_count\", 0),\n                \"rss_count\": getattr(ai_analysis, \"rss_count\", 0),\n                \"ai_mode\": getattr(ai_analysis, \"ai_mode\", \"\"),\n            }\n\n    # 获取分批内容，预留批次头部空间\n    header_reserve = get_max_batch_header_size(\"slack\")\n    batches = split_content_func(\n        report_data, \"slack\", update_info, max_bytes=batch_size - header_reserve, mode=mode,\n        rss_items=rss_items,\n        rss_new_items=rss_new_items,\n        ai_content=ai_content,\n        standalone_data=standalone_data,\n        ai_stats=ai_stats,\n        report_type=report_type,\n    )\n\n    # 统一添加批次头部（已预留空间，不会超限）\n    batches = add_batch_headers(batches, \"slack\", batch_size)\n\n    print(f\"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]\")\n\n    # 逐批发送\n    for i, batch_content in enumerate(batches, 1):\n        # 转换 Markdown 到 mrkdwn 格式\n        mrkdwn_content = convert_markdown_to_mrkdwn(batch_content)\n\n        content_size = len(mrkdwn_content.encode(\"utf-8\"))\n        print(\n            f\"发送{log_prefix}第 {i}/{len(batches)} 批次，大小：{content_size} 字节 [{report_type}]\"\n        )\n\n        # 构建 Slack payload（使用简单的 text 字段，支持 mrkdwn）\n        payload = {\"text\": mrkdwn_content}\n\n        try:\n            response = requests.post(\n                webhook_url, headers=headers, json=payload, proxies=proxies, timeout=30\n            )\n\n            # Slack Incoming Webhooks 成功时返回 \"ok\" 文本\n            if response.status_code == 200 and response.text == \"ok\":\n                print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]\")\n                # 批次间间隔\n                if i < len(batches):\n                    time.sleep(batch_interval)\n            else:\n                error_msg = response.text if response.text else f\"状态码：{response.status_code}\"\n                print(\n                    f\"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}]，错误：{error_msg}\"\n                )\n                return False\n        except Exception as e:\n            print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]：{e}\")\n            return False\n\n    print(f\"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]\")\n\n    return True\n\n\ndef send_to_generic_webhook(\n    webhook_url: str,\n    payload_template: Optional[str],\n    report_data: Dict,\n    report_type: str,\n    update_info: Optional[Dict] = None,\n    proxy_url: Optional[str] = None,\n    mode: str = \"daily\",\n    account_label: str = \"\",\n    *,\n    batch_size: int = 4000,\n    batch_interval: float = 1.0,\n    split_content_func: Optional[Callable] = None,\n    rss_items: Optional[list] = None,\n    rss_new_items: Optional[list] = None,\n    ai_analysis: Any = None,\n    display_regions: Optional[Dict] = None,\n    standalone_data: Optional[Dict] = None,\n) -> bool:\n    \"\"\"\n    发送到通用 Webhook（支持分批发送，支持自定义 JSON 模板，支持热榜+RSS合并+独立展示区）\n\n    Args:\n        webhook_url: Webhook URL\n        payload_template: JSON 模板字符串，支持 {title} 和 {content} 占位符\n        report_data: 报告数据\n        report_type: 报告类型\n        update_info: 更新信息（可选）\n        proxy_url: 代理 URL（可选）\n        mode: 报告模式 (daily/current)\n        account_label: 账号标签（多账号时显示）\n        batch_size: 批次大小（字节）\n        batch_interval: 批次发送间隔（秒）\n        split_content_func: 内容分批函数\n        rss_items: RSS 统计条目列表（可选，用于合并推送）\n        rss_new_items: RSS 新增条目列表（可选，用于新增区块）\n\n    Returns:\n        bool: 发送是否成功\n    \"\"\"\n    if split_content_func is None:\n        raise ValueError(\"split_content_func is required\")\n\n    headers = {\"Content-Type\": \"application/json\"}\n    proxies = None\n    if proxy_url:\n        proxies = {\"http\": proxy_url, \"https\": proxy_url}\n\n    # 日志前缀\n    log_prefix = f\"通用Webhook{account_label}\" if account_label else \"通用Webhook\"\n\n    # 渲染 AI 分析内容（如果有）\n    ai_content = None\n    ai_stats = None\n    if ai_analysis:\n        # 通用 Webhook 使用 markdown 格式渲染 AI 分析\n        ai_content = _render_ai_analysis(ai_analysis, \"wework\")\n        # 提取 AI 分析统计数据\n        if getattr(ai_analysis, \"success\", False):\n            ai_stats = {\n                \"total_news\": getattr(ai_analysis, \"total_news\", 0),\n                \"analyzed_news\": getattr(ai_analysis, \"analyzed_news\", 0),\n                \"max_news_limit\": getattr(ai_analysis, \"max_news_limit\", 0),\n                \"hotlist_count\": getattr(ai_analysis, \"hotlist_count\", 0),\n                \"rss_count\": getattr(ai_analysis, \"rss_count\", 0),\n            }\n\n    # 获取分批内容\n    # 使用 'wework' 作为 format_type 以获取 markdown 格式的通用输出\n    # 预留一定空间给模板外壳\n    template_overhead = 200\n    batches = split_content_func(\n        report_data, \"wework\", update_info, max_bytes=batch_size - template_overhead, mode=mode,\n        rss_items=rss_items,\n        rss_new_items=rss_new_items,\n        ai_content=ai_content,\n        standalone_data=standalone_data,\n        ai_stats=ai_stats,\n        report_type=report_type,\n    )\n\n    # 统一添加批次头部\n    batches = add_batch_headers(batches, \"wework\", batch_size)\n\n    print(f\"{log_prefix}消息分为 {len(batches)} 批次发送 [{report_type}]\")\n\n    # 逐批发送\n    for i, batch_content in enumerate(batches, 1):\n        content_size = len(batch_content.encode(\"utf-8\"))\n        print(\n            f\"发送{log_prefix}第 {i}/{len(batches)} 批次，大小：{content_size} 字节 [{report_type}]\"\n        )\n\n        try:\n            # 构建 payload\n            if payload_template:\n                # 简单的字符串替换\n                # 注意：content 可能包含 JSON 特殊字符，需要先转义\n                json_content = json.dumps(batch_content)[1:-1] # 去掉首尾引号\n                json_title = json.dumps(report_type)[1:-1]\n                \n                payload_str = payload_template.replace(\"{content}\", json_content).replace(\"{title}\", json_title)\n                \n                # 尝试解析为 JSON 对象以验证有效性\n                try:\n                    payload = json.loads(payload_str)\n                except json.JSONDecodeError as e:\n                    print(f\"{log_prefix} JSON 模板解析失败: {e}\")\n                    # 回退到默认格式\n                    payload = {\"title\": report_type, \"content\": batch_content}\n            else:\n                # 默认格式\n                payload = {\"title\": report_type, \"content\": batch_content}\n\n            response = requests.post(\n                webhook_url, headers=headers, json=payload, proxies=proxies, timeout=30\n            )\n            \n            if response.status_code >= 200 and response.status_code < 300:\n                print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送成功 [{report_type}]\")\n                if i < len(batches):\n                    time.sleep(batch_interval)\n            else:\n                print(\n                    f\"{log_prefix}第 {i}/{len(batches)} 批次发送失败 [{report_type}]，状态码：{response.status_code}, 响应: {response.text}\"\n                )\n                return False\n        except Exception as e:\n            print(f\"{log_prefix}第 {i}/{len(batches)} 批次发送出错 [{report_type}]：{e}\")\n            return False\n\n    print(f\"{log_prefix}所有 {len(batches)} 批次发送完成 [{report_type}]\")\n\n    return True\n"
  },
  {
    "path": "trendradar/notification/splitter.py",
    "content": "# coding=utf-8\n\"\"\"\n消息分批处理模块\n\n提供消息内容分批拆分功能，确保消息大小不超过各平台限制\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Dict, List, Optional, Callable\n\nfrom trendradar.report.formatter import format_title_for_platform\nfrom trendradar.report.helpers import format_rank_display\nfrom trendradar.utils.time import DEFAULT_TIMEZONE, format_iso_time_friendly, convert_time_for_display\n\n\n# 默认批次大小配置\nDEFAULT_BATCH_SIZES = {\n    \"dingtalk\": 20000,\n    \"feishu\": 29000,\n    \"ntfy\": 3800,\n    \"default\": 4000,\n}\n\n# 默认区域顺序\nDEFAULT_REGION_ORDER = [\"hotlist\", \"rss\", \"new_items\", \"standalone\", \"ai_analysis\"]\n\n\ndef split_content_into_batches(\n    report_data: Dict,\n    format_type: str,\n    update_info: Optional[Dict] = None,\n    max_bytes: Optional[int] = None,\n    mode: str = \"daily\",\n    batch_sizes: Optional[Dict[str, int]] = None,\n    feishu_separator: str = \"---\",\n    region_order: Optional[List[str]] = None,\n    get_time_func: Optional[Callable[[], datetime]] = None,\n    rss_items: Optional[list] = None,\n    rss_new_items: Optional[list] = None,\n    timezone: str = DEFAULT_TIMEZONE,\n    display_mode: str = \"keyword\",\n    ai_content: Optional[str] = None,\n    standalone_data: Optional[Dict] = None,\n    rank_threshold: int = 10,\n    ai_stats: Optional[Dict] = None,\n    report_type: str = \"热点分析报告\",\n    show_new_section: bool = True,\n) -> List[str]:\n    \"\"\"分批处理消息内容，确保词组标题+至少第一条新闻的完整性（支持热榜+RSS合并+AI分析+独立展示区）\n\n    热榜统计与RSS统计并列显示，热榜新增与RSS新增并列显示。\n    region_order 控制各区域的显示顺序。\n    AI分析内容根据 region_order 中的位置显示。\n    独立展示区根据 region_order 中的位置显示。\n\n    Args:\n        report_data: 报告数据字典，包含 stats, new_titles, failed_ids, total_new_count\n        format_type: 格式类型 (feishu, dingtalk, wework, telegram, ntfy, bark, slack)\n        update_info: 版本更新信息（可选）\n        max_bytes: 最大字节数（可选，如果不指定则使用默认配置）\n        mode: 报告模式 (daily, incremental, current)\n        batch_sizes: 批次大小配置字典（可选）\n        feishu_separator: 飞书消息分隔符\n        region_order: 区域显示顺序列表\n        get_time_func: 获取当前时间的函数（可选）\n        rss_items: RSS 统计条目列表（按源分组，用于合并推送）\n        rss_new_items: RSS 新增条目列表（可选，用于新增区块）\n        timezone: 时区名称（用于 RSS 时间格式化）\n        display_mode: 显示模式 (keyword=按关键词分组, platform=按平台分组)\n        ai_content: AI 分析内容（已渲染的字符串，可选）\n        standalone_data: 独立展示区数据（可选），包含 platforms 和 rss_feeds 列表\n        ai_stats: AI 分析统计数据（可选），包含 total_news, analyzed_news, max_news_limit 等\n\n    Returns:\n        分批后的消息内容列表\n    \"\"\"\n    if region_order is None:\n        region_order = DEFAULT_REGION_ORDER\n    # 合并批次大小配置\n    sizes = {**DEFAULT_BATCH_SIZES, **(batch_sizes or {})}\n\n    if max_bytes is None:\n        if format_type == \"dingtalk\":\n            max_bytes = sizes.get(\"dingtalk\", 20000)\n        elif format_type == \"feishu\":\n            max_bytes = sizes.get(\"feishu\", 29000)\n        elif format_type == \"ntfy\":\n            max_bytes = sizes.get(\"ntfy\", 3800)\n        else:\n            max_bytes = sizes.get(\"default\", 4000)\n\n    batches = []\n\n    total_hotlist_count = sum(\n        len(stat[\"titles\"]) for stat in report_data[\"stats\"] if stat[\"count\"] > 0\n    )\n    total_titles = total_hotlist_count\n    \n    # 累加 RSS 条目数\n    if rss_items:\n        total_titles += sum(stat.get(\"count\", 0) for stat in rss_items)\n\n    now = get_time_func() if get_time_func else datetime.now()\n\n    # 构建头部信息\n    base_header = \"\"\n    \n    # 准备 AI 分析统计行（如果存在）\n    ai_stats_line = \"\"\n    if ai_stats and ai_stats.get(\"analyzed_news\", 0) > 0:\n        analyzed_news = ai_stats.get(\"analyzed_news\", 0)\n        total_news = ai_stats.get(\"total_news\", 0)\n        ai_mode = ai_stats.get(\"ai_mode\", \"\")\n\n        # 构建分析数显示：如果被截断则显示 \"实际分析数/总可分析数\"\n        if total_news > analyzed_news:\n            news_display = f\"{analyzed_news}/{total_news}\"\n        else:\n            news_display = str(analyzed_news)\n\n        # 如果 AI 模式与推送模式不同，显示模式标识\n        mode_suffix = \"\"\n        if ai_mode and ai_mode != mode:\n            mode_map = {\n                \"daily\": \"全天汇总\",\n                \"current\": \"当前榜单\",\n                \"incremental\": \"增量分析\"\n            }\n            mode_label = mode_map.get(ai_mode, ai_mode)\n            mode_suffix = f\" ({mode_label})\"\n\n        if format_type in (\"wework\", \"bark\", \"ntfy\", \"feishu\", \"dingtalk\"):\n            ai_stats_line = f\"**AI 分析数：** {news_display}{mode_suffix}\\n\"\n        elif format_type == \"slack\":\n            ai_stats_line = f\"*AI 分析数：* {news_display}{mode_suffix}\\n\"\n        elif format_type == \"telegram\":\n            ai_stats_line = f\"AI 分析数： {news_display}{mode_suffix}\\n\"\n\n    # 构建统一的头部（总是显示总新闻数、时间和类型）\n    if format_type in (\"wework\", \"bark\"):\n        base_header = f\"**总新闻数：** {total_titles}\\n\"\n        base_header += ai_stats_line\n        base_header += f\"**时间：** {now.strftime('%Y-%m-%d %H:%M:%S')}\\n\"\n        base_header += f\"**类型：** {report_type}\\n\\n\"\n    elif format_type == \"telegram\":\n        base_header = f\"总新闻数： {total_titles}\\n\"\n        base_header += ai_stats_line\n        base_header += f\"时间： {now.strftime('%Y-%m-%d %H:%M:%S')}\\n\"\n        base_header += f\"类型： {report_type}\\n\\n\"\n    elif format_type == \"ntfy\":\n        base_header = f\"**总新闻数：** {total_titles}\\n\"\n        base_header += ai_stats_line\n        base_header += f\"**时间：** {now.strftime('%Y-%m-%d %H:%M:%S')}\\n\"\n        base_header += f\"**类型：** {report_type}\\n\\n\"\n    elif format_type == \"feishu\":\n        base_header = f\"**总新闻数：** {total_titles}\\n\"\n        base_header += ai_stats_line\n        base_header += f\"**时间：** {now.strftime('%Y-%m-%d %H:%M:%S')}\\n\"\n        base_header += f\"**类型：** {report_type}\\n\\n\"\n        base_header += \"---\\n\\n\"\n    elif format_type == \"dingtalk\":\n        base_header = f\"**总新闻数：** {total_titles}\\n\"\n        base_header += ai_stats_line\n        base_header += f\"**时间：** {now.strftime('%Y-%m-%d %H:%M:%S')}\\n\"\n        base_header += f\"**类型：** {report_type}\\n\\n\"\n        base_header += \"---\\n\\n\"\n    elif format_type == \"slack\":\n        base_header = f\"*总新闻数：* {total_titles}\\n\"\n        base_header += ai_stats_line\n        base_header += f\"*时间：* {now.strftime('%Y-%m-%d %H:%M:%S')}\\n\"\n        base_header += f\"*类型：* {report_type}\\n\\n\"\n\n    base_footer = \"\"\n    if format_type in (\"wework\", \"bark\"):\n        base_footer = f\"\\n\\n\\n> 更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}\"\n        if update_info:\n            base_footer += f\"\\n> TrendRadar 发现新版本 **{update_info['remote_version']}**，当前 **{update_info['current_version']}**\"\n    elif format_type == \"telegram\":\n        base_footer = f\"\\n\\n更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}\"\n        if update_info:\n            base_footer += f\"\\nTrendRadar 发现新版本 {update_info['remote_version']}，当前 {update_info['current_version']}\"\n    elif format_type == \"ntfy\":\n        base_footer = f\"\\n\\n> 更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}\"\n        if update_info:\n            base_footer += f\"\\n> TrendRadar 发现新版本 **{update_info['remote_version']}**，当前 **{update_info['current_version']}**\"\n    elif format_type == \"feishu\":\n        base_footer = f\"\\n\\n<font color='grey'>更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}</font>\"\n        if update_info:\n            base_footer += f\"\\n<font color='grey'>TrendRadar 发现新版本 {update_info['remote_version']}，当前 {update_info['current_version']}</font>\"\n    elif format_type == \"dingtalk\":\n        base_footer = f\"\\n\\n> 更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}\"\n        if update_info:\n            base_footer += f\"\\n> TrendRadar 发现新版本 **{update_info['remote_version']}**，当前 **{update_info['current_version']}**\"\n    elif format_type == \"slack\":\n        base_footer = f\"\\n\\n_更新时间：{now.strftime('%Y-%m-%d %H:%M:%S')}_\"\n        if update_info:\n            base_footer += f\"\\n_TrendRadar 发现新版本 *{update_info['remote_version']}*，当前 *{update_info['current_version']}_\"\n\n    # 根据 display_mode 选择统计标题\n    stats_title = \"热点词汇统计\" if display_mode == \"keyword\" else \"热点新闻统计\"\n    stats_header = \"\"\n    if report_data[\"stats\"]:\n        if format_type in (\"wework\", \"bark\"):\n            stats_header = f\"📊 **{stats_title}** (共 {total_hotlist_count} 条)\\n\\n\"\n        elif format_type == \"telegram\":\n            stats_header = f\"📊 {stats_title} (共 {total_hotlist_count} 条)\\n\\n\"\n        elif format_type == \"ntfy\":\n            stats_header = f\"📊 **{stats_title}** (共 {total_hotlist_count} 条)\\n\\n\"\n        elif format_type == \"feishu\":\n            stats_header = f\"📊 **{stats_title}** (共 {total_hotlist_count} 条)\\n\\n\"\n        elif format_type == \"dingtalk\":\n            stats_header = f\"📊 **{stats_title}** (共 {total_hotlist_count} 条)\\n\\n\"\n        elif format_type == \"slack\":\n            stats_header = f\"📊 *{stats_title}* (共 {total_hotlist_count} 条)\\n\\n\"\n\n    current_batch = base_header\n    current_batch_has_content = False\n\n    # 当没有热榜数据时的处理\n    # 注意：如果有 ai_content，不应该返回\"暂无匹配\"消息，而应该继续处理 AI 内容\n    if (\n        not report_data[\"stats\"]\n        and not report_data[\"new_titles\"]\n        and not report_data[\"failed_ids\"]\n        and not ai_content  # 有 AI 内容时不返回\"暂无匹配\"\n        and not rss_items  # 有 RSS 内容时也不返回\n        and not standalone_data  # 有独立展示区数据时也不返回\n    ):\n        if mode == \"incremental\":\n            mode_text = \"增量模式下暂无新增匹配的热点词汇\"\n        elif mode == \"current\":\n            mode_text = \"当前榜单模式下暂无匹配的热点词汇\"\n        else:\n            mode_text = \"暂无匹配的热点词汇\"\n        simple_content = f\"📭 {mode_text}\\n\\n\"\n        final_content = base_header + simple_content + base_footer\n        batches.append(final_content)\n        return batches\n\n    # 定义处理热点词汇统计的函数\n    def process_stats_section(current_batch, current_batch_has_content, batches, add_separator=True):\n        \"\"\"处理热点词汇统计\"\"\"\n        if not report_data[\"stats\"]:\n            return current_batch, current_batch_has_content, batches\n\n        total_count = len(report_data[\"stats\"])\n\n        # 根据 add_separator 决定是否添加前置分割线\n        actual_stats_header = \"\"\n        if add_separator and current_batch_has_content:\n            # 需要添加分割线\n            if format_type == \"feishu\":\n                actual_stats_header = f\"\\n{feishu_separator}\\n\\n{stats_header}\"\n            elif format_type == \"dingtalk\":\n                actual_stats_header = f\"\\n---\\n\\n{stats_header}\"\n            elif format_type in (\"wework\", \"bark\"):\n                actual_stats_header = f\"\\n\\n\\n\\n{stats_header}\"\n            else:\n                actual_stats_header = f\"\\n\\n{stats_header}\"\n        else:\n            # 不需要分割线（第一个区域）\n            actual_stats_header = stats_header\n\n        # 添加统计标题\n        test_content = current_batch + actual_stats_header\n        if (\n            len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\"))\n            < max_bytes\n        ):\n            current_batch = test_content\n            current_batch_has_content = True\n        else:\n            if current_batch_has_content:\n                batches.append(current_batch + base_footer)\n            # 新批次开头不需要分割线，使用原始 stats_header\n            current_batch = base_header + stats_header\n            current_batch_has_content = True\n\n        # 逐个处理词组（确保词组标题+第一条新闻的原子性）\n        for i, stat in enumerate(report_data[\"stats\"]):\n            word = stat[\"word\"]\n            count = stat[\"count\"]\n            sequence_display = f\"[{i + 1}/{total_count}]\"\n\n            # 构建词组标题\n            word_header = \"\"\n            if format_type in (\"wework\", \"bark\"):\n                if count >= 10:\n                    word_header = (\n                        f\"🔥 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n                    )\n                elif count >= 5:\n                    word_header = (\n                        f\"📈 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n                    )\n                else:\n                    word_header = f\"📌 {sequence_display} **{word}** : {count} 条\\n\\n\"\n            elif format_type == \"telegram\":\n                if count >= 10:\n                    word_header = f\"🔥 {sequence_display} {word} : {count} 条\\n\\n\"\n                elif count >= 5:\n                    word_header = f\"📈 {sequence_display} {word} : {count} 条\\n\\n\"\n                else:\n                    word_header = f\"📌 {sequence_display} {word} : {count} 条\\n\\n\"\n            elif format_type == \"ntfy\":\n                if count >= 10:\n                    word_header = (\n                        f\"🔥 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n                    )\n                elif count >= 5:\n                    word_header = (\n                        f\"📈 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n                    )\n                else:\n                    word_header = f\"📌 {sequence_display} **{word}** : {count} 条\\n\\n\"\n            elif format_type == \"feishu\":\n                if count >= 10:\n                    word_header = f\"🔥 <font color='grey'>{sequence_display}</font> **{word}** : <font color='red'>{count}</font> 条\\n\\n\"\n                elif count >= 5:\n                    word_header = f\"📈 <font color='grey'>{sequence_display}</font> **{word}** : <font color='orange'>{count}</font> 条\\n\\n\"\n                else:\n                    word_header = f\"📌 <font color='grey'>{sequence_display}</font> **{word}** : {count} 条\\n\\n\"\n            elif format_type == \"dingtalk\":\n                if count >= 10:\n                    word_header = (\n                        f\"🔥 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n                    )\n                elif count >= 5:\n                    word_header = (\n                        f\"📈 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n                    )\n                else:\n                    word_header = f\"📌 {sequence_display} **{word}** : {count} 条\\n\\n\"\n            elif format_type == \"slack\":\n                if count >= 10:\n                    word_header = (\n                        f\"🔥 {sequence_display} *{word}* : *{count}* 条\\n\\n\"\n                    )\n                elif count >= 5:\n                    word_header = (\n                        f\"📈 {sequence_display} *{word}* : *{count}* 条\\n\\n\"\n                    )\n                else:\n                    word_header = f\"📌 {sequence_display} *{word}* : {count} 条\\n\\n\"\n\n            # 构建第一条新闻\n            # display_mode: keyword=显示来源, platform=显示关键词\n            show_source = display_mode == \"keyword\"\n            show_keyword = display_mode == \"platform\"\n            first_news_line = \"\"\n            if stat[\"titles\"]:\n                first_title_data = stat[\"titles\"][0]\n                if format_type in (\"wework\", \"bark\"):\n                    formatted_title = format_title_for_platform(\n                        \"wework\", first_title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                elif format_type == \"telegram\":\n                    formatted_title = format_title_for_platform(\n                        \"telegram\", first_title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                elif format_type == \"ntfy\":\n                    formatted_title = format_title_for_platform(\n                        \"ntfy\", first_title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                elif format_type == \"feishu\":\n                    formatted_title = format_title_for_platform(\n                        \"feishu\", first_title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                elif format_type == \"dingtalk\":\n                    formatted_title = format_title_for_platform(\n                        \"dingtalk\", first_title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                elif format_type == \"slack\":\n                    formatted_title = format_title_for_platform(\n                        \"slack\", first_title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                else:\n                    formatted_title = f\"{first_title_data['title']}\"\n\n                first_news_line = f\"  1. {formatted_title}\\n\"\n                if len(stat[\"titles\"]) > 1:\n                    first_news_line += \"\\n\"\n\n            # 原子性检查：词组标题+第一条新闻必须一起处理\n            word_with_first_news = word_header + first_news_line\n            test_content = current_batch + word_with_first_news\n\n            if (\n                len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\"))\n                >= max_bytes\n            ):\n                # 当前批次容纳不下，开启新批次\n                if current_batch_has_content:\n                    batches.append(current_batch + base_footer)\n                current_batch = base_header + stats_header + word_with_first_news\n                current_batch_has_content = True\n                start_index = 1\n            else:\n                current_batch = test_content\n                current_batch_has_content = True\n                start_index = 1\n\n            # 处理剩余新闻条目\n            for j in range(start_index, len(stat[\"titles\"])):\n                title_data = stat[\"titles\"][j]\n                if format_type in (\"wework\", \"bark\"):\n                    formatted_title = format_title_for_platform(\n                        \"wework\", title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                elif format_type == \"telegram\":\n                    formatted_title = format_title_for_platform(\n                        \"telegram\", title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                elif format_type == \"ntfy\":\n                    formatted_title = format_title_for_platform(\n                        \"ntfy\", title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                elif format_type == \"feishu\":\n                    formatted_title = format_title_for_platform(\n                        \"feishu\", title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                elif format_type == \"dingtalk\":\n                    formatted_title = format_title_for_platform(\n                        \"dingtalk\", title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                elif format_type == \"slack\":\n                    formatted_title = format_title_for_platform(\n                        \"slack\", title_data, show_source=show_source, show_keyword=show_keyword\n                    )\n                else:\n                    formatted_title = f\"{title_data['title']}\"\n\n                news_line = f\"  {j + 1}. {formatted_title}\\n\"\n                if j < len(stat[\"titles\"]) - 1:\n                    news_line += \"\\n\"\n\n                test_content = current_batch + news_line\n                if (\n                    len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\"))\n                    >= max_bytes\n                ):\n                    if current_batch_has_content:\n                        batches.append(current_batch + base_footer)\n                    current_batch = base_header + stats_header + word_header + news_line\n                    current_batch_has_content = True\n                else:\n                    current_batch = test_content\n                    current_batch_has_content = True\n\n            # 词组间分隔符\n            if i < len(report_data[\"stats\"]) - 1:\n                separator = \"\"\n                if format_type in (\"wework\", \"bark\"):\n                    separator = f\"\\n\\n\\n\\n\"\n                elif format_type == \"telegram\":\n                    separator = f\"\\n\\n\"\n                elif format_type == \"ntfy\":\n                    separator = f\"\\n\\n\"\n                elif format_type == \"feishu\":\n                    separator = f\"\\n{feishu_separator}\\n\\n\"\n                elif format_type == \"dingtalk\":\n                    separator = f\"\\n---\\n\\n\"\n                elif format_type == \"slack\":\n                    separator = f\"\\n\\n\"\n\n                test_content = current_batch + separator\n                if (\n                    len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\"))\n                    < max_bytes\n                ):\n                    current_batch = test_content\n\n        return current_batch, current_batch_has_content, batches\n\n    # 定义处理新增新闻的函数\n    def process_new_titles_section(current_batch, current_batch_has_content, batches, add_separator=True):\n        \"\"\"处理新增新闻\"\"\"\n        if not show_new_section or not report_data[\"new_titles\"]:\n            return current_batch, current_batch_has_content, batches\n\n        # 根据 add_separator 决定是否添加前置分割线\n        new_header = \"\"\n        if add_separator and current_batch_has_content:\n            # 需要添加分割线\n            if format_type in (\"wework\", \"bark\"):\n                new_header = f\"\\n\\n\\n\\n🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\\n\\n\"\n            elif format_type == \"telegram\":\n                new_header = (\n                    f\"\\n\\n🆕 本次新增热点新闻 (共 {report_data['total_new_count']} 条)\\n\\n\"\n                )\n            elif format_type == \"ntfy\":\n                new_header = f\"\\n\\n🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\\n\\n\"\n            elif format_type == \"feishu\":\n                new_header = f\"\\n{feishu_separator}\\n\\n🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\\n\\n\"\n            elif format_type == \"dingtalk\":\n                new_header = f\"\\n---\\n\\n🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\\n\\n\"\n            elif format_type == \"slack\":\n                new_header = f\"\\n\\n🆕 *本次新增热点新闻* (共 {report_data['total_new_count']} 条)\\n\\n\"\n        else:\n            # 不需要分割线（第一个区域）\n            if format_type in (\"wework\", \"bark\"):\n                new_header = f\"🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\\n\\n\"\n            elif format_type == \"telegram\":\n                new_header = f\"🆕 本次新增热点新闻 (共 {report_data['total_new_count']} 条)\\n\\n\"\n            elif format_type == \"ntfy\":\n                new_header = f\"🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\\n\\n\"\n            elif format_type == \"feishu\":\n                new_header = f\"🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\\n\\n\"\n            elif format_type == \"dingtalk\":\n                new_header = f\"🆕 **本次新增热点新闻** (共 {report_data['total_new_count']} 条)\\n\\n\"\n            elif format_type == \"slack\":\n                new_header = f\"🆕 *本次新增热点新闻* (共 {report_data['total_new_count']} 条)\\n\\n\"\n\n        test_content = current_batch + new_header\n        if (\n            len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\"))\n            >= max_bytes\n        ):\n            if current_batch_has_content:\n                batches.append(current_batch + base_footer)\n            current_batch = base_header + new_header\n            current_batch_has_content = True\n        else:\n            current_batch = test_content\n            current_batch_has_content = True\n\n        # 逐个处理新增新闻来源\n        for source_data in report_data[\"new_titles\"]:\n            source_header = \"\"\n            if format_type in (\"wework\", \"bark\"):\n                source_header = f\"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\\n\\n\"\n            elif format_type == \"telegram\":\n                source_header = f\"{source_data['source_name']} ({len(source_data['titles'])} 条):\\n\\n\"\n            elif format_type == \"ntfy\":\n                source_header = f\"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\\n\\n\"\n            elif format_type == \"feishu\":\n                source_header = f\"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\\n\\n\"\n            elif format_type == \"dingtalk\":\n                source_header = f\"**{source_data['source_name']}** ({len(source_data['titles'])} 条):\\n\\n\"\n            elif format_type == \"slack\":\n                source_header = f\"*{source_data['source_name']}* ({len(source_data['titles'])} 条):\\n\\n\"\n\n            # 构建第一条新增新闻\n            first_news_line = \"\"\n            if source_data[\"titles\"]:\n                first_title_data = source_data[\"titles\"][0]\n                title_data_copy = first_title_data.copy()\n                title_data_copy[\"is_new\"] = False\n\n                if format_type in (\"wework\", \"bark\"):\n                    formatted_title = format_title_for_platform(\n                        \"wework\", title_data_copy, show_source=False\n                    )\n                elif format_type == \"telegram\":\n                    formatted_title = format_title_for_platform(\n                        \"telegram\", title_data_copy, show_source=False\n                    )\n                elif format_type == \"feishu\":\n                    formatted_title = format_title_for_platform(\n                        \"feishu\", title_data_copy, show_source=False\n                    )\n                elif format_type == \"dingtalk\":\n                    formatted_title = format_title_for_platform(\n                        \"dingtalk\", title_data_copy, show_source=False\n                    )\n                elif format_type == \"slack\":\n                    formatted_title = format_title_for_platform(\n                        \"slack\", title_data_copy, show_source=False\n                    )\n                else:\n                    formatted_title = f\"{title_data_copy['title']}\"\n\n                first_news_line = f\"  1. {formatted_title}\\n\"\n\n            # 原子性检查：来源标题+第一条新闻\n            source_with_first_news = source_header + first_news_line\n            test_content = current_batch + source_with_first_news\n\n            if (\n                len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\"))\n                >= max_bytes\n            ):\n                if current_batch_has_content:\n                    batches.append(current_batch + base_footer)\n                current_batch = base_header + new_header + source_with_first_news\n                current_batch_has_content = True\n                start_index = 1\n            else:\n                current_batch = test_content\n                current_batch_has_content = True\n                start_index = 1\n\n            # 处理剩余新增新闻\n            for j in range(start_index, len(source_data[\"titles\"])):\n                title_data = source_data[\"titles\"][j]\n                title_data_copy = title_data.copy()\n                title_data_copy[\"is_new\"] = False\n\n                if format_type == \"wework\":\n                    formatted_title = format_title_for_platform(\n                        \"wework\", title_data_copy, show_source=False\n                    )\n                elif format_type == \"telegram\":\n                    formatted_title = format_title_for_platform(\n                        \"telegram\", title_data_copy, show_source=False\n                    )\n                elif format_type == \"feishu\":\n                    formatted_title = format_title_for_platform(\n                        \"feishu\", title_data_copy, show_source=False\n                    )\n                elif format_type == \"dingtalk\":\n                    formatted_title = format_title_for_platform(\n                        \"dingtalk\", title_data_copy, show_source=False\n                    )\n                elif format_type == \"slack\":\n                    formatted_title = format_title_for_platform(\n                        \"slack\", title_data_copy, show_source=False\n                    )\n                else:\n                    formatted_title = f\"{title_data_copy['title']}\"\n\n                news_line = f\"  {j + 1}. {formatted_title}\\n\"\n\n                test_content = current_batch + news_line\n                if (\n                    len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\"))\n                    >= max_bytes\n                ):\n                    if current_batch_has_content:\n                        batches.append(current_batch + base_footer)\n                    current_batch = base_header + new_header + source_header + news_line\n                    current_batch_has_content = True\n                else:\n                    current_batch = test_content\n                    current_batch_has_content = True\n\n            current_batch += \"\\n\"\n\n        return current_batch, current_batch_has_content, batches\n\n    # 定义处理 AI 分析的函数\n    def process_ai_section(current_batch, current_batch_has_content, batches, add_separator=True):\n        \"\"\"处理 AI 分析内容\"\"\"\n        nonlocal ai_content\n        if not ai_content:\n            return current_batch, current_batch_has_content, batches\n\n        # 根据 add_separator 决定是否添加前置分割线\n        ai_separator = \"\"\n        if add_separator and current_batch_has_content:\n            # 需要添加分割线\n            if format_type == \"feishu\":\n                ai_separator = f\"\\n{feishu_separator}\\n\\n\"\n            elif format_type == \"dingtalk\":\n                ai_separator = \"\\n---\\n\\n\"\n            elif format_type in (\"wework\", \"bark\"):\n                ai_separator = \"\\n\\n\\n\\n\"\n            elif format_type in (\"telegram\", \"ntfy\", \"slack\"):\n                ai_separator = \"\\n\\n\"\n        # 如果不需要分割线，ai_separator 保持为空字符串\n\n        # 尝试将 AI 内容添加到当前批次\n        test_content = current_batch + ai_separator + ai_content\n        if (\n            len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\"))\n            < max_bytes\n        ):\n            current_batch = test_content\n            current_batch_has_content = True\n        else:\n            # 当前批次容纳不下，开启新批次\n            if current_batch_has_content:\n                batches.append(current_batch + base_footer)\n            # AI 内容可能很长，需要考虑是否需要进一步分割\n            ai_with_header = base_header + ai_content\n            current_batch = ai_with_header\n            current_batch_has_content = True\n\n        return current_batch, current_batch_has_content, batches\n\n    # 定义处理独立展示区的函数\n    def process_standalone_section_wrapper(current_batch, current_batch_has_content, batches, add_separator=True):\n        \"\"\"处理独立展示区\"\"\"\n        if not standalone_data:\n            return current_batch, current_batch_has_content, batches\n        return _process_standalone_section(\n            standalone_data, format_type, feishu_separator, base_header, base_footer,\n            max_bytes, current_batch, current_batch_has_content, batches, timezone,\n            rank_threshold, add_separator\n        )\n\n    # 定义处理 RSS 统计的函数\n    def process_rss_stats_wrapper(current_batch, current_batch_has_content, batches, add_separator=True):\n        \"\"\"处理 RSS 统计\"\"\"\n        if not rss_items:\n            return current_batch, current_batch_has_content, batches\n        return _process_rss_stats_section(\n            rss_items, format_type, feishu_separator, base_header, base_footer,\n            max_bytes, current_batch, current_batch_has_content, batches, timezone,\n            add_separator\n        )\n\n    # 定义处理 RSS 新增的函数\n    def process_rss_new_wrapper(current_batch, current_batch_has_content, batches, add_separator=True):\n        \"\"\"处理 RSS 新增\"\"\"\n        if not rss_new_items:\n            return current_batch, current_batch_has_content, batches\n        return _process_rss_new_titles_section(\n            rss_new_items, format_type, feishu_separator, base_header, base_footer,\n            max_bytes, current_batch, current_batch_has_content, batches, timezone,\n            add_separator\n        )\n\n    # 按 region_order 顺序处理各区域\n    # 记录是否已有区域内容（用于决定是否添加分割线）\n    has_region_content = False\n\n    for region in region_order:\n        # 记录处理前的状态，用于判断该区域是否产生了内容\n        batch_before = current_batch\n        has_content_before = current_batch_has_content\n        batches_len_before = len(batches)\n\n        # 决定是否需要添加分割线（第一个有内容的区域不需要）\n        add_separator = has_region_content\n\n        if region == \"hotlist\":\n            # 处理热榜统计\n            current_batch, current_batch_has_content, batches = process_stats_section(\n                current_batch, current_batch_has_content, batches, add_separator\n            )\n        elif region == \"rss\":\n            # 处理 RSS 统计\n            current_batch, current_batch_has_content, batches = process_rss_stats_wrapper(\n                current_batch, current_batch_has_content, batches, add_separator\n            )\n        elif region == \"new_items\":\n            # 处理热榜新增\n            current_batch, current_batch_has_content, batches = process_new_titles_section(\n                current_batch, current_batch_has_content, batches, add_separator\n            )\n            # 处理 RSS 新增（跟随 new_items，继承 add_separator 逻辑）\n            # 如果热榜新增产生了内容，RSS 新增需要分割线\n            new_batch_changed = (\n                current_batch != batch_before or\n                current_batch_has_content != has_content_before or\n                len(batches) != batches_len_before\n            )\n            rss_new_separator = new_batch_changed or has_region_content\n            current_batch, current_batch_has_content, batches = process_rss_new_wrapper(\n                current_batch, current_batch_has_content, batches, rss_new_separator\n            )\n        elif region == \"standalone\":\n            # 处理独立展示区\n            current_batch, current_batch_has_content, batches = process_standalone_section_wrapper(\n                current_batch, current_batch_has_content, batches, add_separator\n            )\n        elif region == \"ai_analysis\":\n            # 处理 AI 分析\n            current_batch, current_batch_has_content, batches = process_ai_section(\n                current_batch, current_batch_has_content, batches, add_separator\n            )\n\n        # 检查该区域是否产生了内容\n        region_produced_content = (\n            current_batch != batch_before or\n            current_batch_has_content != has_content_before or\n            len(batches) != batches_len_before\n        )\n        if region_produced_content:\n            has_region_content = True\n\n    if report_data[\"failed_ids\"]:\n        failed_header = \"\"\n        if format_type == \"wework\":\n            failed_header = f\"\\n\\n\\n\\n⚠️ **数据获取失败的平台：**\\n\\n\"\n        elif format_type == \"telegram\":\n            failed_header = f\"\\n\\n⚠️ 数据获取失败的平台：\\n\\n\"\n        elif format_type == \"ntfy\":\n            failed_header = f\"\\n\\n⚠️ **数据获取失败的平台：**\\n\\n\"\n        elif format_type == \"feishu\":\n            failed_header = f\"\\n{feishu_separator}\\n\\n⚠️ **数据获取失败的平台：**\\n\\n\"\n        elif format_type == \"dingtalk\":\n            failed_header = f\"\\n---\\n\\n⚠️ **数据获取失败的平台：**\\n\\n\"\n\n        test_content = current_batch + failed_header\n        if (\n            len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\"))\n            >= max_bytes\n        ):\n            if current_batch_has_content:\n                batches.append(current_batch + base_footer)\n            current_batch = base_header + failed_header\n            current_batch_has_content = True\n        else:\n            current_batch = test_content\n            current_batch_has_content = True\n\n        for i, id_value in enumerate(report_data[\"failed_ids\"], 1):\n            if format_type == \"feishu\":\n                failed_line = f\"  • <font color='red'>{id_value}</font>\\n\"\n            elif format_type == \"dingtalk\":\n                failed_line = f\"  • **{id_value}**\\n\"\n            else:\n                failed_line = f\"  • {id_value}\\n\"\n\n            test_content = current_batch + failed_line\n            if (\n                len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\"))\n                >= max_bytes\n            ):\n                if current_batch_has_content:\n                    batches.append(current_batch + base_footer)\n                current_batch = base_header + failed_header + failed_line\n                current_batch_has_content = True\n            else:\n                current_batch = test_content\n                current_batch_has_content = True\n\n    # 完成最后批次\n    if current_batch_has_content:\n        batches.append(current_batch + base_footer)\n\n    return batches\n\n\ndef _process_rss_stats_section(\n    rss_stats: list,\n    format_type: str,\n    feishu_separator: str,\n    base_header: str,\n    base_footer: str,\n    max_bytes: int,\n    current_batch: str,\n    current_batch_has_content: bool,\n    batches: List[str],\n    timezone: str = DEFAULT_TIMEZONE,\n    add_separator: bool = True,\n) -> tuple:\n    \"\"\"处理 RSS 统计区块（按关键词分组，与热榜统计格式一致）\n\n    Args:\n        rss_stats: RSS 关键词统计列表，格式与热榜 stats 一致：\n            [{\"word\": \"AI\", \"count\": 5, \"titles\": [...]}]\n        format_type: 格式类型\n        feishu_separator: 飞书分隔符\n        base_header: 基础头部\n        base_footer: 基础尾部\n        max_bytes: 最大字节数\n        current_batch: 当前批次内容\n        current_batch_has_content: 当前批次是否有内容\n        batches: 已完成的批次列表\n        timezone: 时区名称\n        add_separator: 是否在区块前添加分割线（第一个区域时为 False）\n\n    Returns:\n        (current_batch, current_batch_has_content, batches) 元组\n    \"\"\"\n    if not rss_stats:\n        return current_batch, current_batch_has_content, batches\n\n    # 计算总条目数\n    total_items = sum(stat[\"count\"] for stat in rss_stats)\n    total_keywords = len(rss_stats)\n\n    # RSS 统计区块标题（根据 add_separator 决定是否添加前置分割线）\n    rss_header = \"\"\n    if add_separator and current_batch_has_content:\n        # 需要添加分割线\n        if format_type == \"feishu\":\n            rss_header = f\"\\n{feishu_separator}\\n\\n📰 **RSS 订阅统计** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"dingtalk\":\n            rss_header = f\"\\n---\\n\\n📰 **RSS 订阅统计** (共 {total_items} 条)\\n\\n\"\n        elif format_type in (\"wework\", \"bark\"):\n            rss_header = f\"\\n\\n\\n\\n📰 **RSS 订阅统计** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"telegram\":\n            rss_header = f\"\\n\\n📰 RSS 订阅统计 (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"slack\":\n            rss_header = f\"\\n\\n📰 *RSS 订阅统计* (共 {total_items} 条)\\n\\n\"\n        else:\n            rss_header = f\"\\n\\n📰 **RSS 订阅统计** (共 {total_items} 条)\\n\\n\"\n    else:\n        # 不需要分割线（第一个区域）\n        if format_type == \"feishu\":\n            rss_header = f\"📰 **RSS 订阅统计** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"dingtalk\":\n            rss_header = f\"📰 **RSS 订阅统计** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"telegram\":\n            rss_header = f\"📰 RSS 订阅统计 (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"slack\":\n            rss_header = f\"📰 *RSS 订阅统计* (共 {total_items} 条)\\n\\n\"\n        else:\n            rss_header = f\"📰 **RSS 订阅统计** (共 {total_items} 条)\\n\\n\"\n\n    # 添加 RSS 标题\n    test_content = current_batch + rss_header\n    if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) < max_bytes:\n        current_batch = test_content\n        current_batch_has_content = True\n    else:\n        if current_batch_has_content:\n            batches.append(current_batch + base_footer)\n        current_batch = base_header + rss_header\n        current_batch_has_content = True\n\n    # 逐个处理关键词组（与热榜一致）\n    for i, stat in enumerate(rss_stats):\n        word = stat[\"word\"]\n        count = stat[\"count\"]\n        sequence_display = f\"[{i + 1}/{total_keywords}]\"\n\n        # 构建关键词标题（与热榜格式一致）\n        word_header = \"\"\n        if format_type in (\"wework\", \"bark\"):\n            if count >= 10:\n                word_header = f\"🔥 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n            elif count >= 5:\n                word_header = f\"📈 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n            else:\n                word_header = f\"📌 {sequence_display} **{word}** : {count} 条\\n\\n\"\n        elif format_type == \"telegram\":\n            if count >= 10:\n                word_header = f\"🔥 {sequence_display} {word} : {count} 条\\n\\n\"\n            elif count >= 5:\n                word_header = f\"📈 {sequence_display} {word} : {count} 条\\n\\n\"\n            else:\n                word_header = f\"📌 {sequence_display} {word} : {count} 条\\n\\n\"\n        elif format_type == \"ntfy\":\n            if count >= 10:\n                word_header = f\"🔥 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n            elif count >= 5:\n                word_header = f\"📈 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n            else:\n                word_header = f\"📌 {sequence_display} **{word}** : {count} 条\\n\\n\"\n        elif format_type == \"feishu\":\n            if count >= 10:\n                word_header = f\"🔥 <font color='grey'>{sequence_display}</font> **{word}** : <font color='red'>{count}</font> 条\\n\\n\"\n            elif count >= 5:\n                word_header = f\"📈 <font color='grey'>{sequence_display}</font> **{word}** : <font color='orange'>{count}</font> 条\\n\\n\"\n            else:\n                word_header = f\"📌 <font color='grey'>{sequence_display}</font> **{word}** : {count} 条\\n\\n\"\n        elif format_type == \"dingtalk\":\n            if count >= 10:\n                word_header = f\"🔥 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n            elif count >= 5:\n                word_header = f\"📈 {sequence_display} **{word}** : **{count}** 条\\n\\n\"\n            else:\n                word_header = f\"📌 {sequence_display} **{word}** : {count} 条\\n\\n\"\n        elif format_type == \"slack\":\n            if count >= 10:\n                word_header = f\"🔥 {sequence_display} *{word}* : *{count}* 条\\n\\n\"\n            elif count >= 5:\n                word_header = f\"📈 {sequence_display} *{word}* : *{count}* 条\\n\\n\"\n            else:\n                word_header = f\"📌 {sequence_display} *{word}* : {count} 条\\n\\n\"\n\n        # 构建第一条新闻（使用 format_title_for_platform）\n        first_news_line = \"\"\n        if stat[\"titles\"]:\n            first_title_data = stat[\"titles\"][0]\n            if format_type in (\"wework\", \"bark\"):\n                formatted_title = format_title_for_platform(\"wework\", first_title_data, show_source=True)\n            elif format_type == \"telegram\":\n                formatted_title = format_title_for_platform(\"telegram\", first_title_data, show_source=True)\n            elif format_type == \"ntfy\":\n                formatted_title = format_title_for_platform(\"ntfy\", first_title_data, show_source=True)\n            elif format_type == \"feishu\":\n                formatted_title = format_title_for_platform(\"feishu\", first_title_data, show_source=True)\n            elif format_type == \"dingtalk\":\n                formatted_title = format_title_for_platform(\"dingtalk\", first_title_data, show_source=True)\n            elif format_type == \"slack\":\n                formatted_title = format_title_for_platform(\"slack\", first_title_data, show_source=True)\n            else:\n                formatted_title = f\"{first_title_data['title']}\"\n\n            first_news_line = f\"  1. {formatted_title}\\n\"\n            if len(stat[\"titles\"]) > 1:\n                first_news_line += \"\\n\"\n\n        # 原子性检查：关键词标题 + 第一条新闻必须一起处理\n        word_with_first_news = word_header + first_news_line\n        test_content = current_batch + word_with_first_news\n\n        if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) >= max_bytes:\n            if current_batch_has_content:\n                batches.append(current_batch + base_footer)\n            current_batch = base_header + rss_header + word_with_first_news\n            current_batch_has_content = True\n            start_index = 1\n        else:\n            current_batch = test_content\n            current_batch_has_content = True\n            start_index = 1\n\n        # 处理剩余新闻条目\n        for j in range(start_index, len(stat[\"titles\"])):\n            title_data = stat[\"titles\"][j]\n            if format_type in (\"wework\", \"bark\"):\n                formatted_title = format_title_for_platform(\"wework\", title_data, show_source=True)\n            elif format_type == \"telegram\":\n                formatted_title = format_title_for_platform(\"telegram\", title_data, show_source=True)\n            elif format_type == \"ntfy\":\n                formatted_title = format_title_for_platform(\"ntfy\", title_data, show_source=True)\n            elif format_type == \"feishu\":\n                formatted_title = format_title_for_platform(\"feishu\", title_data, show_source=True)\n            elif format_type == \"dingtalk\":\n                formatted_title = format_title_for_platform(\"dingtalk\", title_data, show_source=True)\n            elif format_type == \"slack\":\n                formatted_title = format_title_for_platform(\"slack\", title_data, show_source=True)\n            else:\n                formatted_title = f\"{title_data['title']}\"\n\n            news_line = f\"  {j + 1}. {formatted_title}\\n\"\n            if j < len(stat[\"titles\"]) - 1:\n                news_line += \"\\n\"\n\n            test_content = current_batch + news_line\n            if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) >= max_bytes:\n                if current_batch_has_content:\n                    batches.append(current_batch + base_footer)\n                current_batch = base_header + rss_header + word_header + news_line\n                current_batch_has_content = True\n            else:\n                current_batch = test_content\n                current_batch_has_content = True\n\n        # 关键词间分隔符\n        if i < len(rss_stats) - 1:\n            separator = \"\"\n            if format_type in (\"wework\", \"bark\"):\n                separator = \"\\n\\n\\n\\n\"\n            elif format_type == \"telegram\":\n                separator = \"\\n\\n\"\n            elif format_type == \"ntfy\":\n                separator = \"\\n\\n\"\n            elif format_type == \"feishu\":\n                separator = f\"\\n{feishu_separator}\\n\\n\"\n            elif format_type == \"dingtalk\":\n                separator = \"\\n---\\n\\n\"\n            elif format_type == \"slack\":\n                separator = \"\\n\\n\"\n\n            test_content = current_batch + separator\n            if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) < max_bytes:\n                current_batch = test_content\n\n    return current_batch, current_batch_has_content, batches\n\n\ndef _process_rss_new_titles_section(\n    rss_new_stats: list,\n    format_type: str,\n    feishu_separator: str,\n    base_header: str,\n    base_footer: str,\n    max_bytes: int,\n    current_batch: str,\n    current_batch_has_content: bool,\n    batches: List[str],\n    timezone: str = DEFAULT_TIMEZONE,\n    add_separator: bool = True,\n) -> tuple:\n    \"\"\"处理 RSS 新增区块（按来源分组，与热榜新增格式一致）\n\n    Args:\n        rss_new_stats: RSS 新增关键词统计列表，格式与热榜 stats 一致：\n            [{\"word\": \"AI\", \"count\": 5, \"titles\": [...]}]\n        format_type: 格式类型\n        feishu_separator: 飞书分隔符\n        base_header: 基础头部\n        base_footer: 基础尾部\n        max_bytes: 最大字节数\n        current_batch: 当前批次内容\n        current_batch_has_content: 当前批次是否有内容\n        batches: 已完成的批次列表\n        timezone: 时区名称\n        add_separator: 是否在区块前添加分割线（第一个区域时为 False）\n\n    Returns:\n        (current_batch, current_batch_has_content, batches) 元组\n    \"\"\"\n    if not rss_new_stats:\n        return current_batch, current_batch_has_content, batches\n\n    # 从关键词分组中提取所有条目，重新按来源分组\n    source_map = {}\n    for stat in rss_new_stats:\n        for title_data in stat.get(\"titles\", []):\n            source_name = title_data.get(\"source_name\", \"未知来源\")\n            if source_name not in source_map:\n                source_map[source_name] = []\n            source_map[source_name].append(title_data)\n\n    if not source_map:\n        return current_batch, current_batch_has_content, batches\n\n    # 计算总条目数\n    total_items = sum(len(titles) for titles in source_map.values())\n\n    # RSS 新增区块标题（根据 add_separator 决定是否添加前置分割线）\n    new_header = \"\"\n    if add_separator and current_batch_has_content:\n        # 需要添加分割线\n        if format_type in (\"wework\", \"bark\"):\n            new_header = f\"\\n\\n\\n\\n🆕 **RSS 本次新增** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"telegram\":\n            new_header = f\"\\n\\n🆕 RSS 本次新增 (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"ntfy\":\n            new_header = f\"\\n\\n🆕 **RSS 本次新增** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"feishu\":\n            new_header = f\"\\n{feishu_separator}\\n\\n🆕 **RSS 本次新增** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"dingtalk\":\n            new_header = f\"\\n---\\n\\n🆕 **RSS 本次新增** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"slack\":\n            new_header = f\"\\n\\n🆕 *RSS 本次新增* (共 {total_items} 条)\\n\\n\"\n    else:\n        # 不需要分割线（第一个区域）\n        if format_type in (\"wework\", \"bark\"):\n            new_header = f\"🆕 **RSS 本次新增** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"telegram\":\n            new_header = f\"🆕 RSS 本次新增 (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"ntfy\":\n            new_header = f\"🆕 **RSS 本次新增** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"feishu\":\n            new_header = f\"🆕 **RSS 本次新增** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"dingtalk\":\n            new_header = f\"🆕 **RSS 本次新增** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"slack\":\n            new_header = f\"🆕 *RSS 本次新增* (共 {total_items} 条)\\n\\n\"\n\n    # 添加 RSS 新增标题\n    test_content = current_batch + new_header\n    if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) >= max_bytes:\n        if current_batch_has_content:\n            batches.append(current_batch + base_footer)\n        current_batch = base_header + new_header\n        current_batch_has_content = True\n    else:\n        current_batch = test_content\n        current_batch_has_content = True\n\n    # 按来源分组显示（与热榜新增格式一致）\n    source_list = list(source_map.items())\n    for i, (source_name, titles) in enumerate(source_list):\n        count = len(titles)\n\n        # 构建来源标题（与热榜新增格式一致）\n        source_header = \"\"\n        if format_type in (\"wework\", \"bark\"):\n            source_header = f\"**{source_name}** ({count} 条):\\n\\n\"\n        elif format_type == \"telegram\":\n            source_header = f\"{source_name} ({count} 条):\\n\\n\"\n        elif format_type == \"ntfy\":\n            source_header = f\"**{source_name}** ({count} 条):\\n\\n\"\n        elif format_type == \"feishu\":\n            source_header = f\"**{source_name}** ({count} 条):\\n\\n\"\n        elif format_type == \"dingtalk\":\n            source_header = f\"**{source_name}** ({count} 条):\\n\\n\"\n        elif format_type == \"slack\":\n            source_header = f\"*{source_name}* ({count} 条):\\n\\n\"\n\n        # 构建第一条新闻（不显示来源，禁用 new emoji）\n        first_news_line = \"\"\n        if titles:\n            first_title_data = titles[0].copy()\n            first_title_data[\"is_new\"] = False\n            if format_type in (\"wework\", \"bark\"):\n                formatted_title = format_title_for_platform(\"wework\", first_title_data, show_source=False)\n            elif format_type == \"telegram\":\n                formatted_title = format_title_for_platform(\"telegram\", first_title_data, show_source=False)\n            elif format_type == \"ntfy\":\n                formatted_title = format_title_for_platform(\"ntfy\", first_title_data, show_source=False)\n            elif format_type == \"feishu\":\n                formatted_title = format_title_for_platform(\"feishu\", first_title_data, show_source=False)\n            elif format_type == \"dingtalk\":\n                formatted_title = format_title_for_platform(\"dingtalk\", first_title_data, show_source=False)\n            elif format_type == \"slack\":\n                formatted_title = format_title_for_platform(\"slack\", first_title_data, show_source=False)\n            else:\n                formatted_title = f\"{first_title_data['title']}\"\n\n            first_news_line = f\"  1. {formatted_title}\\n\"\n\n        # 原子性检查：来源标题 + 第一条新闻必须一起处理\n        source_with_first_news = source_header + first_news_line\n        test_content = current_batch + source_with_first_news\n\n        if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) >= max_bytes:\n            if current_batch_has_content:\n                batches.append(current_batch + base_footer)\n            current_batch = base_header + new_header + source_with_first_news\n            current_batch_has_content = True\n            start_index = 1\n        else:\n            current_batch = test_content\n            current_batch_has_content = True\n            start_index = 1\n\n        # 处理剩余新闻条目（禁用 new emoji）\n        for j in range(start_index, len(titles)):\n            title_data = titles[j].copy()\n            title_data[\"is_new\"] = False\n            if format_type in (\"wework\", \"bark\"):\n                formatted_title = format_title_for_platform(\"wework\", title_data, show_source=False)\n            elif format_type == \"telegram\":\n                formatted_title = format_title_for_platform(\"telegram\", title_data, show_source=False)\n            elif format_type == \"ntfy\":\n                formatted_title = format_title_for_platform(\"ntfy\", title_data, show_source=False)\n            elif format_type == \"feishu\":\n                formatted_title = format_title_for_platform(\"feishu\", title_data, show_source=False)\n            elif format_type == \"dingtalk\":\n                formatted_title = format_title_for_platform(\"dingtalk\", title_data, show_source=False)\n            elif format_type == \"slack\":\n                formatted_title = format_title_for_platform(\"slack\", title_data, show_source=False)\n            else:\n                formatted_title = f\"{title_data['title']}\"\n\n            news_line = f\"  {j + 1}. {formatted_title}\\n\"\n\n            test_content = current_batch + news_line\n            if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) >= max_bytes:\n                if current_batch_has_content:\n                    batches.append(current_batch + base_footer)\n                current_batch = base_header + new_header + source_header + news_line\n                current_batch_has_content = True\n            else:\n                current_batch = test_content\n                current_batch_has_content = True\n\n        # 来源间添加空行（与热榜新增格式一致）\n        current_batch += \"\\n\"\n\n    return current_batch, current_batch_has_content, batches\n\n\ndef _format_rss_item_line(\n    item: Dict,\n    index: int,\n    format_type: str,\n    timezone: str = DEFAULT_TIMEZONE,\n) -> str:\n    \"\"\"格式化单条 RSS 条目\n\n    Args:\n        item: RSS 条目字典\n        index: 序号\n        format_type: 格式类型\n        timezone: 时区名称\n\n    Returns:\n        格式化后的条目行字符串\n    \"\"\"\n    title = item.get(\"title\", \"\")\n    url = item.get(\"url\", \"\")\n    published_at = item.get(\"published_at\", \"\")\n\n    # 使用友好时间格式\n    if published_at:\n        friendly_time = format_iso_time_friendly(published_at, timezone, include_date=True)\n    else:\n        friendly_time = \"\"\n\n    # 构建条目行\n    if format_type == \"feishu\":\n        if url:\n            item_line = f\"  {index}. [{title}]({url})\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if friendly_time:\n            item_line += f\" <font color='grey'>- {friendly_time}</font>\"\n    elif format_type == \"telegram\":\n        if url:\n            item_line = f\"  {index}. {title} ({url})\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if friendly_time:\n            item_line += f\" - {friendly_time}\"\n    else:\n        if url:\n            item_line = f\"  {index}. [{title}]({url})\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if friendly_time:\n            item_line += f\" `{friendly_time}`\"\n\n    item_line += \"\\n\"\n    return item_line\n\n\ndef _process_standalone_section(\n    standalone_data: Dict,\n    format_type: str,\n    feishu_separator: str,\n    base_header: str,\n    base_footer: str,\n    max_bytes: int,\n    current_batch: str,\n    current_batch_has_content: bool,\n    batches: List[str],\n    timezone: str = DEFAULT_TIMEZONE,\n    rank_threshold: int = 10,\n    add_separator: bool = True,\n) -> tuple:\n    \"\"\"处理独立展示区区块\n\n    独立展示区显示指定平台的完整热榜或 RSS 源内容，不受关键词过滤影响。\n    热榜按原始排名排序，RSS 按发布时间排序。\n\n    Args:\n        standalone_data: 独立展示数据，格式：\n            {\n                \"platforms\": [{\"id\": \"zhihu\", \"name\": \"知乎热榜\", \"items\": [...]}],\n                \"rss_feeds\": [{\"id\": \"hacker-news\", \"name\": \"Hacker News\", \"items\": [...]}]\n            }\n        format_type: 格式类型\n        feishu_separator: 飞书分隔符\n        base_header: 基础头部\n        base_footer: 基础尾部\n        max_bytes: 最大字节数\n        current_batch: 当前批次内容\n        current_batch_has_content: 当前批次是否有内容\n        batches: 已完成的批次列表\n        timezone: 时区名称\n        rank_threshold: 排名高亮阈值\n        add_separator: 是否在区块前添加分割线（第一个区域时为 False）\n\n    Returns:\n        (current_batch, current_batch_has_content, batches) 元组\n    \"\"\"\n    if not standalone_data:\n        return current_batch, current_batch_has_content, batches\n\n    platforms = standalone_data.get(\"platforms\", [])\n    rss_feeds = standalone_data.get(\"rss_feeds\", [])\n\n    if not platforms and not rss_feeds:\n        return current_batch, current_batch_has_content, batches\n\n    # 计算总条目数\n    total_platform_items = sum(len(p.get(\"items\", [])) for p in platforms)\n    total_rss_items = sum(len(f.get(\"items\", [])) for f in rss_feeds)\n    total_items = total_platform_items + total_rss_items\n\n    # 独立展示区标题（根据 add_separator 决定是否添加前置分割线）\n    section_header = \"\"\n    if add_separator and current_batch_has_content:\n        # 需要添加分割线\n        if format_type == \"feishu\":\n            section_header = f\"\\n{feishu_separator}\\n\\n📋 **独立展示区** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"dingtalk\":\n            section_header = f\"\\n---\\n\\n📋 **独立展示区** (共 {total_items} 条)\\n\\n\"\n        elif format_type in (\"wework\", \"bark\"):\n            section_header = f\"\\n\\n\\n\\n📋 **独立展示区** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"telegram\":\n            section_header = f\"\\n\\n📋 独立展示区 (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"slack\":\n            section_header = f\"\\n\\n📋 *独立展示区* (共 {total_items} 条)\\n\\n\"\n        else:\n            section_header = f\"\\n\\n📋 **独立展示区** (共 {total_items} 条)\\n\\n\"\n    else:\n        # 不需要分割线（第一个区域）\n        if format_type == \"feishu\":\n            section_header = f\"📋 **独立展示区** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"dingtalk\":\n            section_header = f\"📋 **独立展示区** (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"telegram\":\n            section_header = f\"📋 独立展示区 (共 {total_items} 条)\\n\\n\"\n        elif format_type == \"slack\":\n            section_header = f\"📋 *独立展示区* (共 {total_items} 条)\\n\\n\"\n        else:\n            section_header = f\"📋 **独立展示区** (共 {total_items} 条)\\n\\n\"\n\n    # 添加区块标题\n    test_content = current_batch + section_header\n    if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) < max_bytes:\n        current_batch = test_content\n        current_batch_has_content = True\n    else:\n        if current_batch_has_content:\n            batches.append(current_batch + base_footer)\n        current_batch = base_header + section_header\n        current_batch_has_content = True\n\n    # 处理热榜平台\n    for platform in platforms:\n        platform_name = platform.get(\"name\", platform.get(\"id\", \"\"))\n        items = platform.get(\"items\", [])\n        if not items:\n            continue\n\n        # 平台标题\n        platform_header = \"\"\n        if format_type in (\"wework\", \"bark\"):\n            platform_header = f\"**{platform_name}** ({len(items)} 条):\\n\\n\"\n        elif format_type == \"telegram\":\n            platform_header = f\"{platform_name} ({len(items)} 条):\\n\\n\"\n        elif format_type == \"ntfy\":\n            platform_header = f\"**{platform_name}** ({len(items)} 条):\\n\\n\"\n        elif format_type == \"feishu\":\n            platform_header = f\"**{platform_name}** ({len(items)} 条):\\n\\n\"\n        elif format_type == \"dingtalk\":\n            platform_header = f\"**{platform_name}** ({len(items)} 条):\\n\\n\"\n        elif format_type == \"slack\":\n            platform_header = f\"*{platform_name}* ({len(items)} 条):\\n\\n\"\n\n        # 构建第一条新闻\n        first_item_line = \"\"\n        if items:\n            first_item_line = _format_standalone_platform_item(items[0], 1, format_type, rank_threshold)\n\n        # 原子性检查\n        platform_with_first = platform_header + first_item_line\n        test_content = current_batch + platform_with_first\n\n        if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) >= max_bytes:\n            if current_batch_has_content:\n                batches.append(current_batch + base_footer)\n            current_batch = base_header + section_header + platform_with_first\n            current_batch_has_content = True\n            start_index = 1\n        else:\n            current_batch = test_content\n            current_batch_has_content = True\n            start_index = 1\n\n        # 处理剩余条目\n        for j in range(start_index, len(items)):\n            item_line = _format_standalone_platform_item(items[j], j + 1, format_type, rank_threshold)\n\n            test_content = current_batch + item_line\n            if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) >= max_bytes:\n                if current_batch_has_content:\n                    batches.append(current_batch + base_footer)\n                current_batch = base_header + section_header + platform_header + item_line\n                current_batch_has_content = True\n            else:\n                current_batch = test_content\n                current_batch_has_content = True\n\n        current_batch += \"\\n\"\n\n    # 处理 RSS 源\n    for feed in rss_feeds:\n        feed_name = feed.get(\"name\", feed.get(\"id\", \"\"))\n        items = feed.get(\"items\", [])\n        if not items:\n            continue\n\n        # RSS 源标题\n        feed_header = \"\"\n        if format_type in (\"wework\", \"bark\"):\n            feed_header = f\"**{feed_name}** ({len(items)} 条):\\n\\n\"\n        elif format_type == \"telegram\":\n            feed_header = f\"{feed_name} ({len(items)} 条):\\n\\n\"\n        elif format_type == \"ntfy\":\n            feed_header = f\"**{feed_name}** ({len(items)} 条):\\n\\n\"\n        elif format_type == \"feishu\":\n            feed_header = f\"**{feed_name}** ({len(items)} 条):\\n\\n\"\n        elif format_type == \"dingtalk\":\n            feed_header = f\"**{feed_name}** ({len(items)} 条):\\n\\n\"\n        elif format_type == \"slack\":\n            feed_header = f\"*{feed_name}* ({len(items)} 条):\\n\\n\"\n\n        # 构建第一条 RSS\n        first_item_line = \"\"\n        if items:\n            first_item_line = _format_standalone_rss_item(items[0], 1, format_type, timezone)\n\n        # 原子性检查\n        feed_with_first = feed_header + first_item_line\n        test_content = current_batch + feed_with_first\n\n        if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) >= max_bytes:\n            if current_batch_has_content:\n                batches.append(current_batch + base_footer)\n            current_batch = base_header + section_header + feed_with_first\n            current_batch_has_content = True\n            start_index = 1\n        else:\n            current_batch = test_content\n            current_batch_has_content = True\n            start_index = 1\n\n        # 处理剩余条目\n        for j in range(start_index, len(items)):\n            item_line = _format_standalone_rss_item(items[j], j + 1, format_type, timezone)\n\n            test_content = current_batch + item_line\n            if len(test_content.encode(\"utf-8\")) + len(base_footer.encode(\"utf-8\")) >= max_bytes:\n                if current_batch_has_content:\n                    batches.append(current_batch + base_footer)\n                current_batch = base_header + section_header + feed_header + item_line\n                current_batch_has_content = True\n            else:\n                current_batch = test_content\n                current_batch_has_content = True\n\n        current_batch += \"\\n\"\n\n    return current_batch, current_batch_has_content, batches\n\n\ndef _format_standalone_platform_item(item: Dict, index: int, format_type: str, rank_threshold: int = 10) -> str:\n    \"\"\"格式化独立展示区的热榜条目（复用热点词汇统计区样式）\n\n    Args:\n        item: 热榜条目，包含 title, url, rank, ranks, first_time, last_time, count\n        index: 序号\n        format_type: 格式类型\n        rank_threshold: 排名高亮阈值\n\n    Returns:\n        格式化后的条目行字符串\n    \"\"\"\n    title = item.get(\"title\", \"\")\n    url = item.get(\"url\", \"\") or item.get(\"mobileUrl\", \"\")\n    ranks = item.get(\"ranks\", [])\n    rank = item.get(\"rank\", 0)\n    first_time = item.get(\"first_time\", \"\")\n    last_time = item.get(\"last_time\", \"\")\n    count = item.get(\"count\", 1)\n\n    # 使用 format_rank_display 格式化排名（复用热点词汇统计区逻辑）\n    # 如果没有 ranks 列表，用单个 rank 构造\n    if not ranks and rank > 0:\n        ranks = [rank]\n    rank_display = format_rank_display(ranks, rank_threshold, format_type) if ranks else \"\"\n\n    # 构建时间显示（用 ~ 连接范围，与热点词汇统计区一致）\n    # 将 HH-MM 格式转换为 HH:MM 格式\n    time_display = \"\"\n    if first_time and last_time and first_time != last_time:\n        first_time_display = convert_time_for_display(first_time)\n        last_time_display = convert_time_for_display(last_time)\n        time_display = f\"{first_time_display}~{last_time_display}\"\n    elif first_time:\n        time_display = convert_time_for_display(first_time)\n\n    # 构建次数显示（格式为 (N次)，与热点词汇统计区一致）\n    count_display = f\"({count}次)\" if count > 1 else \"\"\n\n    # 根据格式类型构建条目行（复用热点词汇统计区样式）\n    if format_type == \"feishu\":\n        if url:\n            item_line = f\"  {index}. [{title}]({url})\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if rank_display:\n            item_line += f\" {rank_display}\"\n        if time_display:\n            item_line += f\" <font color='grey'>- {time_display}</font>\"\n        if count_display:\n            item_line += f\" <font color='green'>{count_display}</font>\"\n\n    elif format_type == \"dingtalk\":\n        if url:\n            item_line = f\"  {index}. [{title}]({url})\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if rank_display:\n            item_line += f\" {rank_display}\"\n        if time_display:\n            item_line += f\" - {time_display}\"\n        if count_display:\n            item_line += f\" {count_display}\"\n\n    elif format_type == \"telegram\":\n        if url:\n            item_line = f\"  {index}. {title} ({url})\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if rank_display:\n            item_line += f\" {rank_display}\"\n        if time_display:\n            item_line += f\" - {time_display}\"\n        if count_display:\n            item_line += f\" {count_display}\"\n\n    elif format_type == \"slack\":\n        if url:\n            item_line = f\"  {index}. <{url}|{title}>\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if rank_display:\n            item_line += f\" {rank_display}\"\n        if time_display:\n            item_line += f\" _{time_display}_\"\n        if count_display:\n            item_line += f\" {count_display}\"\n\n    else:\n        # wework, bark, ntfy\n        if url:\n            item_line = f\"  {index}. [{title}]({url})\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if rank_display:\n            item_line += f\" {rank_display}\"\n        if time_display:\n            item_line += f\" - {time_display}\"\n        if count_display:\n            item_line += f\" {count_display}\"\n\n    item_line += \"\\n\"\n    return item_line\n\n\ndef _format_standalone_rss_item(\n    item: Dict, index: int, format_type: str, timezone: str = \"Asia/Shanghai\"\n) -> str:\n    \"\"\"格式化独立展示区的 RSS 条目\n\n    Args:\n        item: RSS 条目，包含 title, url, published_at, author\n        index: 序号\n        format_type: 格式类型\n        timezone: 时区名称\n\n    Returns:\n        格式化后的条目行字符串\n    \"\"\"\n    title = item.get(\"title\", \"\")\n    url = item.get(\"url\", \"\")\n    published_at = item.get(\"published_at\", \"\")\n    author = item.get(\"author\", \"\")\n\n    # 使用友好时间格式\n    friendly_time = \"\"\n    if published_at:\n        friendly_time = format_iso_time_friendly(published_at, timezone, include_date=True)\n\n    # 构建元信息\n    meta_parts = []\n    if friendly_time:\n        meta_parts.append(friendly_time)\n    if author:\n        meta_parts.append(author)\n    meta_str = \", \".join(meta_parts)\n\n    # 根据格式类型构建条目行\n    if format_type == \"feishu\":\n        if url:\n            item_line = f\"  {index}. [{title}]({url})\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if meta_str:\n            item_line += f\" <font color='grey'>- {meta_str}</font>\"\n    elif format_type == \"telegram\":\n        if url:\n            item_line = f\"  {index}. {title} ({url})\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if meta_str:\n            item_line += f\" - {meta_str}\"\n    elif format_type == \"slack\":\n        if url:\n            item_line = f\"  {index}. <{url}|{title}>\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if meta_str:\n            item_line += f\" _{meta_str}_\"\n    else:\n        # wework, bark, ntfy, dingtalk\n        if url:\n            item_line = f\"  {index}. [{title}]({url})\"\n        else:\n            item_line = f\"  {index}. {title}\"\n        if meta_str:\n            item_line += f\" `{meta_str}`\"\n\n    item_line += \"\\n\"\n    return item_line\n"
  },
  {
    "path": "trendradar/report/__init__.py",
    "content": "# coding=utf-8\n\"\"\"\n报告生成模块\n\n提供报告生成和格式化功能，包括：\n- HTML 报告生成\n- 标题格式化工具\n\n模块结构：\n- helpers: 报告辅助函数（清理、转义、格式化）\n- formatter: 平台标题格式化\n- html: HTML 报告渲染\n- generator: 报告生成器\n\"\"\"\n\nfrom trendradar.report.helpers import (\n    clean_title,\n    html_escape,\n    format_rank_display,\n)\nfrom trendradar.report.formatter import format_title_for_platform\nfrom trendradar.report.html import render_html_content\nfrom trendradar.report.generator import (\n    prepare_report_data,\n    generate_html_report,\n)\n\n__all__ = [\n    # 辅助函数\n    \"clean_title\",\n    \"html_escape\",\n    \"format_rank_display\",\n    # 格式化函数\n    \"format_title_for_platform\",\n    # HTML 渲染\n    \"render_html_content\",\n    # 报告生成器\n    \"prepare_report_data\",\n    \"generate_html_report\",\n]\n"
  },
  {
    "path": "trendradar/report/formatter.py",
    "content": "# coding=utf-8\n\"\"\"\n平台标题格式化模块\n\n提供多平台标题格式化功能\n\"\"\"\n\nfrom typing import Dict\n\nfrom trendradar.report.helpers import clean_title, html_escape, format_rank_display\n\n\ndef format_title_for_platform(\n    platform: str, title_data: Dict, show_source: bool = True, show_keyword: bool = False\n) -> str:\n    \"\"\"统一的标题格式化方法\n\n    为不同平台生成对应格式的标题字符串。\n\n    Args:\n        platform: 目标平台，支持:\n            - \"feishu\": 飞书\n            - \"dingtalk\": 钉钉\n            - \"wework\": 企业微信\n            - \"bark\": Bark\n            - \"telegram\": Telegram\n            - \"ntfy\": ntfy\n            - \"slack\": Slack\n            - \"html\": HTML 报告\n        title_data: 标题数据字典，包含以下字段:\n            - title: 标题文本\n            - source_name: 来源名称\n            - time_display: 时间显示\n            - count: 出现次数\n            - ranks: 排名列表\n            - rank_threshold: 高亮阈值\n            - url: PC端链接\n            - mobile_url: 移动端链接（优先使用）\n            - is_new: 是否为新增标题（可选）\n            - matched_keyword: 匹配的关键词（可选，platform 模式使用）\n        show_source: 是否显示来源名称（keyword 模式使用）\n        show_keyword: 是否显示关键词标签（platform 模式使用）\n\n    Returns:\n        格式化后的标题字符串\n    \"\"\"\n    rank_display = format_rank_display(\n        title_data[\"ranks\"], title_data[\"rank_threshold\"], platform\n    )\n\n    link_url = title_data[\"mobile_url\"] or title_data[\"url\"]\n    cleaned_title = clean_title(title_data[\"title\"])\n\n    # 获取关键词标签（platform 模式使用）\n    keyword = title_data.get(\"matched_keyword\", \"\") if show_keyword else \"\"\n\n    if platform == \"feishu\":\n        if link_url:\n            formatted_title = f\"[{cleaned_title}]({link_url})\"\n        else:\n            formatted_title = cleaned_title\n\n        title_prefix = \"🆕 \" if title_data.get(\"is_new\") else \"\"\n\n        if show_source:\n            result = f\"<font color='grey'>[{title_data['source_name']}]</font> {title_prefix}{formatted_title}\"\n        elif show_keyword and keyword:\n            result = f\"<font color='blue'>[{keyword}]</font> {title_prefix}{formatted_title}\"\n        else:\n            result = f\"{title_prefix}{formatted_title}\"\n\n        if rank_display:\n            result += f\" {rank_display}\"\n        if title_data[\"time_display\"]:\n            result += f\" <font color='grey'>- {title_data['time_display']}</font>\"\n        if title_data[\"count\"] > 1:\n            result += f\" <font color='green'>({title_data['count']}次)</font>\"\n\n        return result\n\n    elif platform == \"dingtalk\":\n        if link_url:\n            formatted_title = f\"[{cleaned_title}]({link_url})\"\n        else:\n            formatted_title = cleaned_title\n\n        title_prefix = \"🆕 \" if title_data.get(\"is_new\") else \"\"\n\n        if show_source:\n            result = f\"[{title_data['source_name']}] {title_prefix}{formatted_title}\"\n        elif show_keyword and keyword:\n            result = f\"[{keyword}] {title_prefix}{formatted_title}\"\n        else:\n            result = f\"{title_prefix}{formatted_title}\"\n\n        if rank_display:\n            result += f\" {rank_display}\"\n        if title_data[\"time_display\"]:\n            result += f\" - {title_data['time_display']}\"\n        if title_data[\"count\"] > 1:\n            result += f\" ({title_data['count']}次)\"\n\n        return result\n\n    elif platform in (\"wework\", \"bark\"):\n        # WeWork 和 Bark 使用 markdown 格式\n        if link_url:\n            formatted_title = f\"[{cleaned_title}]({link_url})\"\n        else:\n            formatted_title = cleaned_title\n\n        title_prefix = \"🆕 \" if title_data.get(\"is_new\") else \"\"\n\n        if show_source:\n            result = f\"[{title_data['source_name']}] {title_prefix}{formatted_title}\"\n        elif show_keyword and keyword:\n            result = f\"[{keyword}] {title_prefix}{formatted_title}\"\n        else:\n            result = f\"{title_prefix}{formatted_title}\"\n\n        if rank_display:\n            result += f\" {rank_display}\"\n        if title_data[\"time_display\"]:\n            result += f\" - {title_data['time_display']}\"\n        if title_data[\"count\"] > 1:\n            result += f\" ({title_data['count']}次)\"\n\n        return result\n\n    elif platform == \"telegram\":\n        if link_url:\n            formatted_title = f'<a href=\"{link_url}\">{html_escape(cleaned_title)}</a>'\n        else:\n            formatted_title = cleaned_title\n\n        title_prefix = \"🆕 \" if title_data.get(\"is_new\") else \"\"\n\n        if show_source:\n            result = f\"[{title_data['source_name']}] {title_prefix}{formatted_title}\"\n        elif show_keyword and keyword:\n            result = f\"<b>[{html_escape(keyword)}]</b> {title_prefix}{formatted_title}\"\n        else:\n            result = f\"{title_prefix}{formatted_title}\"\n\n        if rank_display:\n            result += f\" {rank_display}\"\n        if title_data[\"time_display\"]:\n            result += f\" <code>- {title_data['time_display']}</code>\"\n        if title_data[\"count\"] > 1:\n            result += f\" <code>({title_data['count']}次)</code>\"\n\n        return result\n\n    elif platform == \"ntfy\":\n        if link_url:\n            formatted_title = f\"[{cleaned_title}]({link_url})\"\n        else:\n            formatted_title = cleaned_title\n\n        title_prefix = \"🆕 \" if title_data.get(\"is_new\") else \"\"\n\n        if show_source:\n            result = f\"[{title_data['source_name']}] {title_prefix}{formatted_title}\"\n        elif show_keyword and keyword:\n            result = f\"[{keyword}] {title_prefix}{formatted_title}\"\n        else:\n            result = f\"{title_prefix}{formatted_title}\"\n\n        if rank_display:\n            result += f\" {rank_display}\"\n        if title_data[\"time_display\"]:\n            result += f\" `- {title_data['time_display']}`\"\n        if title_data[\"count\"] > 1:\n            result += f\" `({title_data['count']}次)`\"\n\n        return result\n\n    elif platform == \"slack\":\n        # Slack 使用 mrkdwn 格式\n        if link_url:\n            # Slack 链接格式: <url|text>\n            formatted_title = f\"<{link_url}|{cleaned_title}>\"\n        else:\n            formatted_title = cleaned_title\n\n        title_prefix = \"🆕 \" if title_data.get(\"is_new\") else \"\"\n\n        if show_source:\n            result = f\"[{title_data['source_name']}] {title_prefix}{formatted_title}\"\n        elif show_keyword and keyword:\n            result = f\"*[{keyword}]* {title_prefix}{formatted_title}\"\n        else:\n            result = f\"{title_prefix}{formatted_title}\"\n\n        # 排名（使用 * 加粗）\n        rank_display = format_rank_display(\n            title_data[\"ranks\"], title_data[\"rank_threshold\"], \"slack\"\n        )\n        if rank_display:\n            result += f\" {rank_display}\"\n        if title_data[\"time_display\"]:\n            result += f\" `- {title_data['time_display']}`\"\n        if title_data[\"count\"] > 1:\n            result += f\" `({title_data['count']}次)`\"\n\n        return result\n\n    elif platform == \"html\":\n        rank_display = format_rank_display(\n            title_data[\"ranks\"], title_data[\"rank_threshold\"], \"html\"\n        )\n\n        link_url = title_data[\"mobile_url\"] or title_data[\"url\"]\n\n        escaped_title = html_escape(cleaned_title)\n        escaped_source_name = html_escape(title_data[\"source_name\"])\n\n        # 构建前缀（来源或关键词）\n        if show_source:\n            prefix = f'<span class=\"source-tag\">[{escaped_source_name}]</span> '\n        elif show_keyword and keyword:\n            escaped_keyword = html_escape(keyword)\n            prefix = f'<span class=\"keyword-tag\">[{escaped_keyword}]</span> '\n        else:\n            prefix = \"\"\n\n        if link_url:\n            escaped_url = html_escape(link_url)\n            formatted_title = f'{prefix}<a href=\"{escaped_url}\" target=\"_blank\" class=\"news-link\">{escaped_title}</a>'\n        else:\n            formatted_title = f'{prefix}<span class=\"no-link\">{escaped_title}</span>'\n\n        if rank_display:\n            formatted_title += f\" {rank_display}\"\n        if title_data[\"time_display\"]:\n            escaped_time = html_escape(title_data[\"time_display\"])\n            formatted_title += f\" <font color='grey'>- {escaped_time}</font>\"\n        if title_data[\"count\"] > 1:\n            formatted_title += f\" <font color='green'>({title_data['count']}次)</font>\"\n\n        if title_data.get(\"is_new\"):\n            formatted_title = f\"<div class='new-title'>🆕 {formatted_title}</div>\"\n\n        return formatted_title\n\n    else:\n        return cleaned_title\n"
  },
  {
    "path": "trendradar/report/generator.py",
    "content": "# coding=utf-8\n\"\"\"\n报告生成模块\n\n提供报告数据准备和 HTML 生成功能：\n- prepare_report_data: 准备报告数据\n- generate_html_report: 生成 HTML 报告\n\"\"\"\n\nfrom pathlib import Path\nfrom typing import Dict, List, Optional, Callable\n\n\ndef prepare_report_data(\n    stats: List[Dict],\n    failed_ids: Optional[List] = None,\n    new_titles: Optional[Dict] = None,\n    id_to_name: Optional[Dict] = None,\n    mode: str = \"daily\",\n    rank_threshold: int = 3,\n    matches_word_groups_func: Optional[Callable] = None,\n    load_frequency_words_func: Optional[Callable] = None,\n    show_new_section: bool = True,\n) -> Dict:\n    \"\"\"\n    准备报告数据\n\n    Args:\n        stats: 统计结果列表\n        failed_ids: 失败的 ID 列表\n        new_titles: 新增标题\n        id_to_name: ID 到名称的映射\n        mode: 报告模式 (daily/incremental/current)\n        rank_threshold: 排名阈值\n        matches_word_groups_func: 词组匹配函数\n        load_frequency_words_func: 加载频率词函数\n        show_new_section: 是否显示新增热点区域\n\n    Returns:\n        Dict: 准备好的报告数据\n    \"\"\"\n    processed_new_titles = []\n\n    # 在增量模式下或配置关闭时隐藏新增新闻区域\n    hide_new_section = mode == \"incremental\" or not show_new_section\n\n    # 只有在非隐藏模式下才处理新增新闻部分\n    if not hide_new_section:\n        filtered_new_titles = {}\n        if new_titles and id_to_name:\n            # 如果提供了匹配函数，使用它过滤\n            if matches_word_groups_func and load_frequency_words_func:\n                word_groups, filter_words, global_filters = load_frequency_words_func()\n                for source_id, titles_data in new_titles.items():\n                    filtered_titles = {}\n                    for title, title_data in titles_data.items():\n                        if matches_word_groups_func(title, word_groups, filter_words, global_filters):\n                            filtered_titles[title] = title_data\n                    if filtered_titles:\n                        filtered_new_titles[source_id] = filtered_titles\n            else:\n                # 没有匹配函数时，使用全部\n                filtered_new_titles = new_titles\n\n            # 打印过滤后的新增热点数（与推送显示一致）\n            original_new_count = sum(len(titles) for titles in new_titles.values()) if new_titles else 0\n            filtered_new_count = sum(len(titles) for titles in filtered_new_titles.values()) if filtered_new_titles else 0\n            if original_new_count > 0:\n                print(f\"频率词过滤后：{filtered_new_count} 条新增热点匹配（原始 {original_new_count} 条）\")\n\n        if filtered_new_titles and id_to_name:\n            for source_id, titles_data in filtered_new_titles.items():\n                source_name = id_to_name.get(source_id, source_id)\n                source_titles = []\n\n                for title, title_data in titles_data.items():\n                    url = title_data.get(\"url\", \"\")\n                    mobile_url = title_data.get(\"mobileUrl\", \"\")\n                    ranks = title_data.get(\"ranks\", [])\n\n                    processed_title = {\n                        \"title\": title,\n                        \"source_name\": source_name,\n                        \"time_display\": \"\",\n                        \"count\": 1,\n                        \"ranks\": ranks,\n                        \"rank_threshold\": rank_threshold,\n                        \"url\": url,\n                        \"mobile_url\": mobile_url,\n                        \"is_new\": True,\n                    }\n                    source_titles.append(processed_title)\n\n                if source_titles:\n                    processed_new_titles.append(\n                        {\n                            \"source_id\": source_id,\n                            \"source_name\": source_name,\n                            \"titles\": source_titles,\n                        }\n                    )\n\n    processed_stats = []\n    for stat in stats:\n        if stat[\"count\"] <= 0:\n            continue\n\n        processed_titles = []\n        for title_data in stat[\"titles\"]:\n            processed_title = {\n                \"title\": title_data[\"title\"],\n                \"source_name\": title_data[\"source_name\"],\n                \"time_display\": title_data[\"time_display\"],\n                \"count\": title_data[\"count\"],\n                \"ranks\": title_data[\"ranks\"],\n                \"rank_threshold\": title_data[\"rank_threshold\"],\n                \"url\": title_data.get(\"url\", \"\"),\n                \"mobile_url\": title_data.get(\"mobileUrl\", \"\"),\n                \"is_new\": title_data.get(\"is_new\", False),\n            }\n            processed_titles.append(processed_title)\n\n        processed_stats.append(\n            {\n                \"word\": stat[\"word\"],\n                \"count\": stat[\"count\"],\n                \"percentage\": stat.get(\"percentage\", 0),\n                \"titles\": processed_titles,\n            }\n        )\n\n    return {\n        \"stats\": processed_stats,\n        \"new_titles\": processed_new_titles,\n        \"failed_ids\": failed_ids or [],\n        \"total_new_count\": sum(\n            len(source[\"titles\"]) for source in processed_new_titles\n        ),\n    }\n\n\ndef generate_html_report(\n    stats: List[Dict],\n    total_titles: int,\n    failed_ids: Optional[List] = None,\n    new_titles: Optional[Dict] = None,\n    id_to_name: Optional[Dict] = None,\n    mode: str = \"daily\",\n    update_info: Optional[Dict] = None,\n    rank_threshold: int = 3,\n    output_dir: str = \"output\",\n    date_folder: str = \"\",\n    time_filename: str = \"\",\n    render_html_func: Optional[Callable] = None,\n    matches_word_groups_func: Optional[Callable] = None,\n    load_frequency_words_func: Optional[Callable] = None,\n) -> str:\n    \"\"\"\n    生成 HTML 报告\n\n    每次生成 HTML 后会：\n    1. 保存时间戳快照到 output/html/日期/时间.html（历史记录）\n    2. 复制到 output/html/latest/{mode}.html（最新报告）\n    3. 复制到 output/index.html 和根目录 index.html（入口）\n\n    Args:\n        stats: 统计结果列表\n        total_titles: 总标题数\n        failed_ids: 失败的 ID 列表\n        new_titles: 新增标题\n        id_to_name: ID 到名称的映射\n        mode: 报告模式 (daily/incremental/current)\n        update_info: 更新信息\n        rank_threshold: 排名阈值\n        output_dir: 输出目录\n        date_folder: 日期文件夹名称\n        time_filename: 时间文件名\n        render_html_func: HTML 渲染函数\n        matches_word_groups_func: 词组匹配函数\n        load_frequency_words_func: 加载频率词函数\n\n    Returns:\n        str: 生成的 HTML 文件路径（时间戳快照路径）\n    \"\"\"\n    # 时间戳快照文件名\n    snapshot_filename = f\"{time_filename}.html\"\n\n    # 构建输出路径（扁平化结构：output/html/日期/）\n    snapshot_path = Path(output_dir) / \"html\" / date_folder\n    snapshot_path.mkdir(parents=True, exist_ok=True)\n    snapshot_file = str(snapshot_path / snapshot_filename)\n\n    # 准备报告数据\n    report_data = prepare_report_data(\n        stats,\n        failed_ids,\n        new_titles,\n        id_to_name,\n        mode,\n        rank_threshold,\n        matches_word_groups_func,\n        load_frequency_words_func,\n    )\n\n    # 渲染 HTML 内容\n    if render_html_func:\n        html_content = render_html_func(\n            report_data, total_titles, mode, update_info\n        )\n    else:\n        # 默认简单 HTML\n        html_content = f\"<html><body><h1>Report</h1><pre>{report_data}</pre></body></html>\"\n\n    # 1. 保存时间戳快照（历史记录）\n    with open(snapshot_file, \"w\", encoding=\"utf-8\") as f:\n        f.write(html_content)\n\n    # 2. 复制到 html/latest/{mode}.html（最新报告）\n    latest_dir = Path(output_dir) / \"html\" / \"latest\"\n    latest_dir.mkdir(parents=True, exist_ok=True)\n    latest_file = latest_dir / f\"{mode}.html\"\n    with open(latest_file, \"w\", encoding=\"utf-8\") as f:\n        f.write(html_content)\n\n    # 3. 复制到 index.html（入口）\n    # output/index.html（供 Docker Volume 挂载访问）\n    output_index = Path(output_dir) / \"index.html\"\n    with open(output_index, \"w\", encoding=\"utf-8\") as f:\n        f.write(html_content)\n\n    # 根目录 index.html（供 GitHub Pages 访问）\n    root_index = Path(\"index.html\")\n    with open(root_index, \"w\", encoding=\"utf-8\") as f:\n        f.write(html_content)\n\n    return snapshot_file\n"
  },
  {
    "path": "trendradar/report/helpers.py",
    "content": "# coding=utf-8\n\"\"\"\n报告辅助函数模块\n\n提供报告生成相关的通用辅助函数\n\"\"\"\n\nimport re\nfrom typing import List\n\n\ndef clean_title(title: str) -> str:\n    \"\"\"清理标题中的特殊字符\n\n    清理规则：\n    - 将换行符(\\n, \\r)替换为空格\n    - 将多个连续空白字符合并为单个空格\n    - 去除首尾空白\n\n    Args:\n        title: 原始标题字符串\n\n    Returns:\n        清理后的标题字符串\n    \"\"\"\n    if not isinstance(title, str):\n        title = str(title)\n    cleaned_title = title.replace(\"\\n\", \" \").replace(\"\\r\", \" \")\n    cleaned_title = re.sub(r\"\\s+\", \" \", cleaned_title)\n    cleaned_title = cleaned_title.strip()\n    return cleaned_title\n\n\ndef html_escape(text: str) -> str:\n    \"\"\"HTML特殊字符转义\n\n    转义规则（按顺序）：\n    - & → &amp;\n    - < → &lt;\n    - > → &gt;\n    - \" → &quot;\n    - ' → &#x27;\n\n    Args:\n        text: 原始文本\n\n    Returns:\n        转义后的文本\n    \"\"\"\n    if not isinstance(text, str):\n        text = str(text)\n\n    return (\n        text.replace(\"&\", \"&amp;\")\n        .replace(\"<\", \"&lt;\")\n        .replace(\">\", \"&gt;\")\n        .replace('\"', \"&quot;\")\n        .replace(\"'\", \"&#x27;\")\n    )\n\n\ndef format_rank_display(ranks: List[int], rank_threshold: int, format_type: str) -> str:\n    \"\"\"格式化排名显示\n\n    根据不同平台类型生成对应格式的排名字符串。\n    当最小排名小于等于阈值时，使用高亮格式。\n\n    Args:\n        ranks: 排名列表（可能包含重复值）\n        rank_threshold: 高亮阈值，小于等于此值的排名会高亮显示\n        format_type: 平台类型，支持:\n            - \"html\": HTML格式\n            - \"feishu\": 飞书格式\n            - \"dingtalk\": 钉钉格式\n            - \"wework\": 企业微信格式\n            - \"telegram\": Telegram格式\n            - \"slack\": Slack格式\n            - 其他: 默认markdown格式\n\n    Returns:\n        格式化后的排名字符串，如 \"[1]\" 或 \"[1 - 5]\"\n        如果排名列表为空，返回空字符串\n    \"\"\"\n    if not ranks:\n        return \"\"\n\n    unique_ranks = sorted(set(ranks))\n    min_rank = unique_ranks[0]\n    max_rank = unique_ranks[-1]\n\n    # 根据平台类型选择高亮格式\n    if format_type == \"html\":\n        highlight_start = \"<font color='red'><strong>\"\n        highlight_end = \"</strong></font>\"\n    elif format_type == \"feishu\":\n        highlight_start = \"<font color='red'>**\"\n        highlight_end = \"**</font>\"\n    elif format_type == \"dingtalk\":\n        highlight_start = \"**\"\n        highlight_end = \"**\"\n    elif format_type == \"wework\":\n        highlight_start = \"**\"\n        highlight_end = \"**\"\n    elif format_type == \"telegram\":\n        highlight_start = \"<b>\"\n        highlight_end = \"</b>\"\n    elif format_type == \"slack\":\n        highlight_start = \"*\"\n        highlight_end = \"*\"\n    else:\n        # 默认 markdown 格式\n        highlight_start = \"**\"\n        highlight_end = \"**\"\n\n    # 生成排名显示\n    rank_str = \"\"\n    if min_rank <= rank_threshold:\n        if min_rank == max_rank:\n            rank_str = f\"{highlight_start}[{min_rank}]{highlight_end}\"\n        else:\n            rank_str = f\"{highlight_start}[{min_rank} - {max_rank}]{highlight_end}\"\n    else:\n        if min_rank == max_rank:\n            rank_str = f\"[{min_rank}]\"\n        else:\n            rank_str = f\"[{min_rank} - {max_rank}]\"\n\n    # 计算热度趋势\n    trend_arrow = \"\"\n    if len(ranks) >= 2:\n        prev_rank = ranks[-2]\n        curr_rank = ranks[-1]\n        if curr_rank < prev_rank:\n            trend_arrow = \"🔺\"  # 排名上升（数值变小）\n        elif curr_rank > prev_rank:\n            trend_arrow = \"🔻\"  # 排名下降（数值变大）\n        else:\n            trend_arrow = \"➖\"  # 排名持平\n    # len(ranks) == 1 时不显示趋势箭头（新上榜由 is_new 字段在 formatter.py 中处理）\n\n    return f\"{rank_str} {trend_arrow}\" if trend_arrow else rank_str\n"
  },
  {
    "path": "trendradar/report/html.py",
    "content": "# coding=utf-8\n\"\"\"\nHTML 报告渲染模块\n\n提供 HTML 格式的热点新闻报告生成功能\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Any, Dict, List, Optional, Callable\n\nfrom trendradar.report.helpers import html_escape\nfrom trendradar.utils.time import convert_time_for_display\nfrom trendradar.ai.formatter import render_ai_analysis_html_rich\n\n\ndef render_html_content(\n    report_data: Dict,\n    total_titles: int,\n    mode: str = \"daily\",\n    update_info: Optional[Dict] = None,\n    *,\n    region_order: Optional[List[str]] = None,\n    get_time_func: Optional[Callable[[], datetime]] = None,\n    rss_items: Optional[List[Dict]] = None,\n    rss_new_items: Optional[List[Dict]] = None,\n    display_mode: str = \"keyword\",\n    standalone_data: Optional[Dict] = None,\n    ai_analysis: Optional[Any] = None,\n    show_new_section: bool = True,\n) -> str:\n    \"\"\"渲染HTML内容\n\n    Args:\n        report_data: 报告数据字典，包含 stats, new_titles, failed_ids, total_new_count\n        total_titles: 新闻总数\n        mode: 报告模式 (\"daily\", \"current\", \"incremental\")\n        update_info: 更新信息（可选）\n        region_order: 区域显示顺序列表\n        get_time_func: 获取当前时间的函数（可选，默认使用 datetime.now）\n        rss_items: RSS 统计条目列表（可选）\n        rss_new_items: RSS 新增条目列表（可选）\n        display_mode: 显示模式 (\"keyword\"=按关键词分组, \"platform\"=按平台分组)\n        standalone_data: 独立展示区数据（可选），包含 platforms 和 rss_feeds\n        ai_analysis: AI 分析结果对象（可选），AIAnalysisResult 实例\n        show_new_section: 是否显示新增热点区域\n\n    Returns:\n        渲染后的 HTML 字符串\n    \"\"\"\n    # 默认区域顺序\n    default_region_order = [\"hotlist\", \"rss\", \"new_items\", \"standalone\", \"ai_analysis\"]\n    if region_order is None:\n        region_order = default_region_order\n\n    html = \"\"\"\n    <!DOCTYPE html>\n    <html>\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>热点新闻分析</title>\n        <script src=\"https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js\" integrity=\"sha512-BNaRQnYJYiPSqHHDb58B0yaPfCu+Wgds8Gp/gU33kqBtgNS4tSPHuGibyoeqMV/TJlSKda6FXzoEyYGjTe+vXA==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n        <style>\n            * { box-sizing: border-box; }\n            body {\n                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;\n                margin: 0;\n                padding: 16px;\n                background: #fafafa;\n                color: #333;\n                line-height: 1.5;\n            }\n\n            .container {\n                max-width: 600px;\n                margin: 0 auto;\n                background: white;\n                border-radius: 12px;\n                overflow: hidden;\n                box-shadow: 0 2px 16px rgba(0,0,0,0.06);\n            }\n\n            .header {\n                background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);\n                color: white;\n                padding: 32px 24px;\n                text-align: center;\n                position: relative;\n            }\n\n            .save-buttons {\n                position: absolute;\n                top: 16px;\n                right: 16px;\n                display: flex;\n                gap: 8px;\n            }\n\n            .save-btn {\n                background: rgba(255, 255, 255, 0.2);\n                border: 1px solid rgba(255, 255, 255, 0.3);\n                color: white;\n                padding: 8px 16px;\n                border-radius: 6px;\n                cursor: pointer;\n                font-size: 13px;\n                font-weight: 500;\n                transition: all 0.2s ease;\n                backdrop-filter: blur(10px);\n                white-space: nowrap;\n            }\n\n            .save-btn:hover {\n                background: rgba(255, 255, 255, 0.3);\n                border-color: rgba(255, 255, 255, 0.5);\n                transform: translateY(-1px);\n            }\n\n            .save-btn:active {\n                transform: translateY(0);\n            }\n\n            .save-btn:disabled {\n                opacity: 0.6;\n                cursor: not-allowed;\n            }\n\n            .header-title {\n                font-size: 22px;\n                font-weight: 700;\n                margin: 0 0 20px 0;\n            }\n\n            .header-info {\n                display: grid;\n                grid-template-columns: 1fr 1fr;\n                gap: 16px;\n                font-size: 14px;\n                opacity: 0.95;\n            }\n\n            .info-item {\n                text-align: center;\n            }\n\n            .info-label {\n                display: block;\n                font-size: 12px;\n                opacity: 0.8;\n                margin-bottom: 4px;\n            }\n\n            .info-value {\n                font-weight: 600;\n                font-size: 16px;\n            }\n\n            .content {\n                padding: 24px;\n            }\n\n            .word-group {\n                margin-bottom: 40px;\n            }\n\n            .word-group:first-child {\n                margin-top: 0;\n            }\n\n            .word-header {\n                display: flex;\n                align-items: center;\n                justify-content: space-between;\n                margin-bottom: 20px;\n                padding-bottom: 8px;\n                border-bottom: 1px solid #f0f0f0;\n            }\n\n            .word-info {\n                display: flex;\n                align-items: center;\n                gap: 12px;\n            }\n\n            .word-name {\n                font-size: 17px;\n                font-weight: 600;\n                color: #1a1a1a;\n            }\n\n            .word-count {\n                color: #666;\n                font-size: 13px;\n                font-weight: 500;\n            }\n\n            .word-count.hot { color: #dc2626; font-weight: 600; }\n            .word-count.warm { color: #ea580c; font-weight: 600; }\n\n            .word-index {\n                color: #999;\n                font-size: 12px;\n            }\n\n            .news-item {\n                margin-bottom: 20px;\n                padding: 16px 0;\n                border-bottom: 1px solid #f5f5f5;\n                position: relative;\n                display: flex;\n                gap: 12px;\n                align-items: center;\n            }\n\n            .news-item:last-child {\n                border-bottom: none;\n            }\n\n            .news-item.new::after {\n                content: \"NEW\";\n                position: absolute;\n                top: 12px;\n                right: 0;\n                background: #fbbf24;\n                color: #92400e;\n                font-size: 9px;\n                font-weight: 700;\n                padding: 3px 6px;\n                border-radius: 4px;\n                letter-spacing: 0.5px;\n            }\n\n            .news-number {\n                color: #999;\n                font-size: 13px;\n                font-weight: 600;\n                min-width: 20px;\n                text-align: center;\n                flex-shrink: 0;\n                background: #f8f9fa;\n                border-radius: 50%;\n                width: 24px;\n                height: 24px;\n                display: flex;\n                align-items: center;\n                justify-content: center;\n                align-self: flex-start;\n                margin-top: 8px;\n            }\n\n            .news-content {\n                flex: 1;\n                min-width: 0;\n                padding-right: 40px;\n            }\n\n            .news-item.new .news-content {\n                padding-right: 50px;\n            }\n\n            .news-header {\n                display: flex;\n                align-items: center;\n                gap: 8px;\n                margin-bottom: 8px;\n                flex-wrap: wrap;\n            }\n\n            .source-name {\n                color: #666;\n                font-size: 12px;\n                font-weight: 500;\n            }\n\n            .keyword-tag {\n                color: #2563eb;\n                font-size: 12px;\n                font-weight: 500;\n                background: #eff6ff;\n                padding: 2px 6px;\n                border-radius: 4px;\n            }\n\n            .rank-num {\n                color: #fff;\n                background: #6b7280;\n                font-size: 10px;\n                font-weight: 700;\n                padding: 2px 6px;\n                border-radius: 10px;\n                min-width: 18px;\n                text-align: center;\n            }\n\n            .rank-num.top { background: #dc2626; }\n            .rank-num.high { background: #ea580c; }\n\n            .time-info {\n                color: #999;\n                font-size: 11px;\n            }\n\n            .count-info {\n                color: #059669;\n                font-size: 11px;\n                font-weight: 500;\n            }\n\n            .news-title {\n                font-size: 15px;\n                line-height: 1.4;\n                color: #1a1a1a;\n                margin: 0;\n            }\n\n            .news-link {\n                color: #2563eb;\n                text-decoration: none;\n            }\n\n            .news-link:hover {\n                text-decoration: underline;\n            }\n\n            .news-link:visited {\n                color: #7c3aed;\n            }\n\n            /* 通用区域分割线样式 */\n            .section-divider {\n                margin-top: 32px;\n                padding-top: 24px;\n                border-top: 2px solid #e5e7eb;\n            }\n\n            /* 热榜统计区样式 */\n            .hotlist-section {\n                /* 默认无边框，由 section-divider 动态添加 */\n            }\n\n            .new-section {\n                margin-top: 40px;\n                padding-top: 24px;\n            }\n\n            .new-section-title {\n                color: #1a1a1a;\n                font-size: 16px;\n                font-weight: 600;\n                margin: 0 0 20px 0;\n            }\n\n            .new-source-group {\n                margin-bottom: 24px;\n            }\n\n            .new-source-title {\n                color: #666;\n                font-size: 13px;\n                font-weight: 500;\n                margin: 0 0 12px 0;\n                padding-bottom: 6px;\n                border-bottom: 1px solid #f5f5f5;\n            }\n\n            .new-item {\n                display: flex;\n                align-items: center;\n                gap: 12px;\n                padding: 8px 0;\n                border-bottom: 1px solid #f9f9f9;\n            }\n\n            .new-item:last-child {\n                border-bottom: none;\n            }\n\n            .new-item-number {\n                color: #999;\n                font-size: 12px;\n                font-weight: 600;\n                min-width: 18px;\n                text-align: center;\n                flex-shrink: 0;\n                background: #f8f9fa;\n                border-radius: 50%;\n                width: 20px;\n                height: 20px;\n                display: flex;\n                align-items: center;\n                justify-content: center;\n            }\n\n            .new-item-rank {\n                color: #fff;\n                background: #6b7280;\n                font-size: 10px;\n                font-weight: 700;\n                padding: 3px 6px;\n                border-radius: 8px;\n                min-width: 20px;\n                text-align: center;\n                flex-shrink: 0;\n            }\n\n            .new-item-rank.top { background: #dc2626; }\n            .new-item-rank.high { background: #ea580c; }\n\n            .new-item-content {\n                flex: 1;\n                min-width: 0;\n            }\n\n            .new-item-title {\n                font-size: 14px;\n                line-height: 1.4;\n                color: #1a1a1a;\n                margin: 0;\n            }\n\n            .error-section {\n                background: #fef2f2;\n                border: 1px solid #fecaca;\n                border-radius: 8px;\n                padding: 16px;\n                margin-bottom: 24px;\n            }\n\n            .error-title {\n                color: #dc2626;\n                font-size: 14px;\n                font-weight: 600;\n                margin: 0 0 8px 0;\n            }\n\n            .error-list {\n                list-style: none;\n                padding: 0;\n                margin: 0;\n            }\n\n            .error-item {\n                color: #991b1b;\n                font-size: 13px;\n                padding: 2px 0;\n                font-family: 'SF Mono', Consolas, monospace;\n            }\n\n            .footer {\n                margin-top: 32px;\n                padding: 20px 24px;\n                background: #f8f9fa;\n                border-top: 1px solid #e5e7eb;\n                text-align: center;\n            }\n\n            .footer-content {\n                font-size: 13px;\n                color: #6b7280;\n                line-height: 1.6;\n            }\n\n            .footer-link {\n                color: #4f46e5;\n                text-decoration: none;\n                font-weight: 500;\n                transition: color 0.2s ease;\n            }\n\n            .footer-link:hover {\n                color: #7c3aed;\n                text-decoration: underline;\n            }\n\n            .project-name {\n                font-weight: 600;\n                color: #374151;\n            }\n\n            @media (max-width: 480px) {\n                body { padding: 12px; }\n                .header { padding: 24px 20px; }\n                .content { padding: 20px; }\n                .footer { padding: 16px 20px; }\n                .header-info { grid-template-columns: 1fr; gap: 12px; }\n                .news-header { gap: 6px; }\n                .news-content { padding-right: 45px; }\n                .news-item { gap: 8px; }\n                .new-item { gap: 8px; }\n                .news-number { width: 20px; height: 20px; font-size: 12px; }\n                .save-buttons {\n                    position: static;\n                    margin-bottom: 16px;\n                    display: flex;\n                    gap: 8px;\n                    justify-content: center;\n                    flex-direction: column;\n                    width: 100%;\n                }\n                .save-btn {\n                    width: 100%;\n                }\n            }\n\n            /* RSS 订阅内容样式 */\n            .rss-section {\n                margin-top: 32px;\n                padding-top: 24px;\n            }\n\n            .rss-section-header {\n                display: flex;\n                align-items: center;\n                justify-content: space-between;\n                margin-bottom: 20px;\n            }\n\n            .rss-section-title {\n                font-size: 18px;\n                font-weight: 600;\n                color: #059669;\n            }\n\n            .rss-section-count {\n                color: #6b7280;\n                font-size: 14px;\n            }\n\n            .feed-group {\n                margin-bottom: 24px;\n            }\n\n            .feed-group:last-child {\n                margin-bottom: 0;\n            }\n\n            .feed-header {\n                display: flex;\n                align-items: center;\n                justify-content: space-between;\n                margin-bottom: 12px;\n                padding-bottom: 8px;\n                border-bottom: 2px solid #10b981;\n            }\n\n            .feed-name {\n                font-size: 15px;\n                font-weight: 600;\n                color: #059669;\n            }\n\n            .feed-count {\n                color: #666;\n                font-size: 13px;\n                font-weight: 500;\n            }\n\n            .rss-item {\n                margin-bottom: 12px;\n                padding: 14px;\n                background: #f0fdf4;\n                border-radius: 8px;\n                border-left: 3px solid #10b981;\n            }\n\n            .rss-item:last-child {\n                margin-bottom: 0;\n            }\n\n            .rss-meta {\n                display: flex;\n                align-items: center;\n                gap: 12px;\n                margin-bottom: 6px;\n                flex-wrap: wrap;\n            }\n\n            .rss-time {\n                color: #6b7280;\n                font-size: 12px;\n            }\n\n            .rss-author {\n                color: #059669;\n                font-size: 12px;\n                font-weight: 500;\n            }\n\n            .rss-title {\n                font-size: 14px;\n                line-height: 1.5;\n                margin-bottom: 6px;\n            }\n\n            .rss-link {\n                color: #1f2937;\n                text-decoration: none;\n                font-weight: 500;\n            }\n\n            .rss-link:hover {\n                color: #059669;\n                text-decoration: underline;\n            }\n\n            .rss-summary {\n                font-size: 13px;\n                color: #6b7280;\n                line-height: 1.5;\n                margin: 0;\n                display: -webkit-box;\n                -webkit-line-clamp: 2;\n                -webkit-box-orient: vertical;\n                overflow: hidden;\n            }\n\n            /* 独立展示区样式 - 复用热点词汇统计区样式 */\n            .standalone-section {\n                margin-top: 32px;\n                padding-top: 24px;\n            }\n\n            .standalone-section-header {\n                display: flex;\n                align-items: center;\n                justify-content: space-between;\n                margin-bottom: 20px;\n            }\n\n            .standalone-section-title {\n                font-size: 18px;\n                font-weight: 600;\n                color: #059669;\n            }\n\n            .standalone-section-count {\n                color: #6b7280;\n                font-size: 14px;\n            }\n\n            .standalone-group {\n                margin-bottom: 40px;\n            }\n\n            .standalone-group:last-child {\n                margin-bottom: 0;\n            }\n\n            .standalone-header {\n                display: flex;\n                align-items: center;\n                justify-content: space-between;\n                margin-bottom: 20px;\n                padding-bottom: 8px;\n                border-bottom: 1px solid #f0f0f0;\n            }\n\n            .standalone-name {\n                font-size: 17px;\n                font-weight: 600;\n                color: #1a1a1a;\n            }\n\n            .standalone-count {\n                color: #666;\n                font-size: 13px;\n                font-weight: 500;\n            }\n\n            /* AI 分析区块样式 */\n            .ai-section {\n                margin-top: 32px;\n                padding: 24px;\n                background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);\n                border-radius: 12px;\n                border: 1px solid #bae6fd;\n            }\n\n            .ai-section-header {\n                display: flex;\n                align-items: center;\n                gap: 10px;\n                margin-bottom: 20px;\n            }\n\n            .ai-section-title {\n                font-size: 18px;\n                font-weight: 600;\n                color: #0369a1;\n            }\n\n            .ai-section-badge {\n                background: #0ea5e9;\n                color: white;\n                font-size: 11px;\n                font-weight: 600;\n                padding: 3px 8px;\n                border-radius: 4px;\n            }\n\n            .ai-block {\n                margin-bottom: 16px;\n                padding: 16px;\n                background: white;\n                border-radius: 8px;\n                box-shadow: 0 1px 3px rgba(0,0,0,0.05);\n            }\n\n            .ai-block:last-child {\n                margin-bottom: 0;\n            }\n\n            .ai-block-title {\n                font-size: 14px;\n                font-weight: 600;\n                color: #0369a1;\n                margin-bottom: 8px;\n            }\n\n            .ai-block-content {\n                font-size: 14px;\n                line-height: 1.6;\n                color: #334155;\n                white-space: pre-wrap;\n            }\n\n            .ai-error {\n                padding: 16px;\n                background: #fef2f2;\n                border: 1px solid #fecaca;\n                border-radius: 8px;\n                color: #991b1b;\n                font-size: 14px;\n            }\n        </style>\n    </head>\n    <body>\n        <div class=\"container\">\n            <div class=\"header\">\n                <div class=\"save-buttons\">\n                    <button class=\"save-btn\" onclick=\"saveAsImage()\">保存为图片</button>\n                    <button class=\"save-btn\" onclick=\"saveAsMultipleImages()\">分段保存</button>\n                </div>\n                <div class=\"header-title\">热点新闻分析</div>\n                <div class=\"header-info\">\n                    <div class=\"info-item\">\n                        <span class=\"info-label\">报告类型</span>\n                        <span class=\"info-value\">\"\"\"\n\n    # 处理报告类型显示（根据 mode 直接显示）\n    if mode == \"current\":\n        html += \"当前榜单\"\n    elif mode == \"incremental\":\n        html += \"增量分析\"\n    else:\n        html += \"全天汇总\"\n\n    html += \"\"\"</span>\n                    </div>\n                    <div class=\"info-item\">\n                        <span class=\"info-label\">新闻总数</span>\n                        <span class=\"info-value\">\"\"\"\n\n    html += f\"{total_titles} 条\"\n\n    # 计算筛选后的热点新闻数量\n    hot_news_count = sum(len(stat[\"titles\"]) for stat in report_data[\"stats\"])\n\n    html += \"\"\"</span>\n                    </div>\n                    <div class=\"info-item\">\n                        <span class=\"info-label\">热点新闻</span>\n                        <span class=\"info-value\">\"\"\"\n\n    html += f\"{hot_news_count} 条\"\n\n    html += \"\"\"</span>\n                    </div>\n                    <div class=\"info-item\">\n                        <span class=\"info-label\">生成时间</span>\n                        <span class=\"info-value\">\"\"\"\n\n    # 使用提供的时间函数或默认 datetime.now\n    if get_time_func:\n        now = get_time_func()\n    else:\n        now = datetime.now()\n    html += now.strftime(\"%m-%d %H:%M\")\n\n    html += \"\"\"</span>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"content\">\"\"\"\n\n    # 处理失败ID错误信息\n    if report_data[\"failed_ids\"]:\n        html += \"\"\"\n                <div class=\"error-section\">\n                    <div class=\"error-title\">⚠️ 请求失败的平台</div>\n                    <ul class=\"error-list\">\"\"\"\n        for id_value in report_data[\"failed_ids\"]:\n            html += f'<li class=\"error-item\">{html_escape(id_value)}</li>'\n        html += \"\"\"\n                    </ul>\n                </div>\"\"\"\n\n    # 生成热点词汇统计部分的HTML\n    stats_html = \"\"\n    if report_data[\"stats\"]:\n        total_count = len(report_data[\"stats\"])\n\n        for i, stat in enumerate(report_data[\"stats\"], 1):\n            count = stat[\"count\"]\n\n            # 确定热度等级\n            if count >= 10:\n                count_class = \"hot\"\n            elif count >= 5:\n                count_class = \"warm\"\n            else:\n                count_class = \"\"\n\n            escaped_word = html_escape(stat[\"word\"])\n\n            stats_html += f\"\"\"\n                <div class=\"word-group\">\n                    <div class=\"word-header\">\n                        <div class=\"word-info\">\n                            <div class=\"word-name\">{escaped_word}</div>\n                            <div class=\"word-count {count_class}\">{count} 条</div>\n                        </div>\n                        <div class=\"word-index\">{i}/{total_count}</div>\n                    </div>\"\"\"\n\n            # 处理每个词组下的新闻标题，给每条新闻标上序号\n            for j, title_data in enumerate(stat[\"titles\"], 1):\n                is_new = title_data.get(\"is_new\", False)\n                new_class = \"new\" if is_new else \"\"\n\n                stats_html += f\"\"\"\n                    <div class=\"news-item {new_class}\">\n                        <div class=\"news-number\">{j}</div>\n                        <div class=\"news-content\">\n                            <div class=\"news-header\">\"\"\"\n\n                # 根据 display_mode 决定显示来源还是关键词\n                if display_mode == \"keyword\":\n                    # keyword 模式：显示来源\n                    stats_html += f'<span class=\"source-name\">{html_escape(title_data[\"source_name\"])}</span>'\n                else:\n                    # platform 模式：显示关键词\n                    matched_keyword = title_data.get(\"matched_keyword\", \"\")\n                    if matched_keyword:\n                        stats_html += f'<span class=\"keyword-tag\">[{html_escape(matched_keyword)}]</span>'\n\n                # 处理排名显示\n                ranks = title_data.get(\"ranks\", [])\n                if ranks:\n                    min_rank = min(ranks)\n                    max_rank = max(ranks)\n                    rank_threshold = title_data.get(\"rank_threshold\", 10)\n\n                    # 确定排名等级\n                    if min_rank <= 3:\n                        rank_class = \"top\"\n                    elif min_rank <= rank_threshold:\n                        rank_class = \"high\"\n                    else:\n                        rank_class = \"\"\n\n                    if min_rank == max_rank:\n                        rank_text = str(min_rank)\n                    else:\n                        rank_text = f\"{min_rank}-{max_rank}\"\n\n                    stats_html += f'<span class=\"rank-num {rank_class}\">{rank_text}</span>'\n\n                # 处理时间显示\n                time_display = title_data.get(\"time_display\", \"\")\n                if time_display:\n                    # 简化时间显示格式，将波浪线替换为~\n                    simplified_time = (\n                        time_display.replace(\" ~ \", \"~\")\n                        .replace(\"[\", \"\")\n                        .replace(\"]\", \"\")\n                    )\n                    stats_html += (\n                        f'<span class=\"time-info\">{html_escape(simplified_time)}</span>'\n                    )\n\n                # 处理出现次数\n                count_info = title_data.get(\"count\", 1)\n                if count_info > 1:\n                    stats_html += f'<span class=\"count-info\">{count_info}次</span>'\n\n                stats_html += \"\"\"\n                            </div>\n                            <div class=\"news-title\">\"\"\"\n\n                # 处理标题和链接\n                escaped_title = html_escape(title_data[\"title\"])\n                link_url = title_data.get(\"mobile_url\") or title_data.get(\"url\", \"\")\n\n                if link_url:\n                    escaped_url = html_escape(link_url)\n                    stats_html += f'<a href=\"{escaped_url}\" target=\"_blank\" class=\"news-link\">{escaped_title}</a>'\n                else:\n                    stats_html += escaped_title\n\n                stats_html += \"\"\"\n                            </div>\n                        </div>\n                    </div>\"\"\"\n\n            stats_html += \"\"\"\n                </div>\"\"\"\n\n    # 给热榜统计添加外层包装\n    if stats_html:\n        stats_html = f\"\"\"\n                <div class=\"hotlist-section\">{stats_html}\n                </div>\"\"\"\n\n    # 生成新增新闻区域的HTML\n    new_titles_html = \"\"\n    if show_new_section and report_data[\"new_titles\"]:\n        new_titles_html += f\"\"\"\n                <div class=\"new-section\">\n                    <div class=\"new-section-title\">本次新增热点 (共 {report_data['total_new_count']} 条)</div>\"\"\"\n\n        for source_data in report_data[\"new_titles\"]:\n            escaped_source = html_escape(source_data[\"source_name\"])\n            titles_count = len(source_data[\"titles\"])\n\n            new_titles_html += f\"\"\"\n                    <div class=\"new-source-group\">\n                        <div class=\"new-source-title\">{escaped_source} · {titles_count}条</div>\"\"\"\n\n            # 为新增新闻也添加序号\n            for idx, title_data in enumerate(source_data[\"titles\"], 1):\n                ranks = title_data.get(\"ranks\", [])\n\n                # 处理新增新闻的排名显示\n                rank_class = \"\"\n                if ranks:\n                    min_rank = min(ranks)\n                    if min_rank <= 3:\n                        rank_class = \"top\"\n                    elif min_rank <= title_data.get(\"rank_threshold\", 10):\n                        rank_class = \"high\"\n\n                    if len(ranks) == 1:\n                        rank_text = str(ranks[0])\n                    else:\n                        rank_text = f\"{min(ranks)}-{max(ranks)}\"\n                else:\n                    rank_text = \"?\"\n\n                new_titles_html += f\"\"\"\n                        <div class=\"new-item\">\n                            <div class=\"new-item-number\">{idx}</div>\n                            <div class=\"new-item-rank {rank_class}\">{rank_text}</div>\n                            <div class=\"new-item-content\">\n                                <div class=\"new-item-title\">\"\"\"\n\n                # 处理新增新闻的链接\n                escaped_title = html_escape(title_data[\"title\"])\n                link_url = title_data.get(\"mobile_url\") or title_data.get(\"url\", \"\")\n\n                if link_url:\n                    escaped_url = html_escape(link_url)\n                    new_titles_html += f'<a href=\"{escaped_url}\" target=\"_blank\" class=\"news-link\">{escaped_title}</a>'\n                else:\n                    new_titles_html += escaped_title\n\n                new_titles_html += \"\"\"\n                                </div>\n                            </div>\n                        </div>\"\"\"\n\n            new_titles_html += \"\"\"\n                    </div>\"\"\"\n\n        new_titles_html += \"\"\"\n                </div>\"\"\"\n\n    # 生成 RSS 统计内容\n    def render_rss_stats_html(stats: List[Dict], title: str = \"RSS 订阅更新\") -> str:\n        \"\"\"渲染 RSS 统计区块 HTML\n\n        Args:\n            stats: RSS 分组统计列表，格式与热榜一致：\n                [\n                    {\n                        \"word\": \"关键词\",\n                        \"count\": 5,\n                        \"titles\": [\n                            {\n                                \"title\": \"标题\",\n                                \"source_name\": \"Feed 名称\",\n                                \"time_display\": \"12-29 08:20\",\n                                \"url\": \"...\",\n                                \"is_new\": True/False\n                            }\n                        ]\n                    }\n                ]\n            title: 区块标题\n\n        Returns:\n            渲染后的 HTML 字符串\n        \"\"\"\n        if not stats:\n            return \"\"\n\n        # 计算总条目数\n        total_count = sum(stat.get(\"count\", 0) for stat in stats)\n        if total_count == 0:\n            return \"\"\n\n        rss_html = f\"\"\"\n                <div class=\"rss-section\">\n                    <div class=\"rss-section-header\">\n                        <div class=\"rss-section-title\">{title}</div>\n                        <div class=\"rss-section-count\">{total_count} 条</div>\n                    </div>\"\"\"\n\n        # 按关键词分组渲染（与热榜格式一致）\n        for stat in stats:\n            keyword = stat.get(\"word\", \"\")\n            titles = stat.get(\"titles\", [])\n            if not titles:\n                continue\n\n            keyword_count = len(titles)\n\n            rss_html += f\"\"\"\n                    <div class=\"feed-group\">\n                        <div class=\"feed-header\">\n                            <div class=\"feed-name\">{html_escape(keyword)}</div>\n                            <div class=\"feed-count\">{keyword_count} 条</div>\n                        </div>\"\"\"\n\n            for title_data in titles:\n                item_title = title_data.get(\"title\", \"\")\n                url = title_data.get(\"url\", \"\")\n                time_display = title_data.get(\"time_display\", \"\")\n                source_name = title_data.get(\"source_name\", \"\")\n                is_new = title_data.get(\"is_new\", False)\n\n                rss_html += \"\"\"\n                        <div class=\"rss-item\">\n                            <div class=\"rss-meta\">\"\"\"\n\n                if time_display:\n                    rss_html += f'<span class=\"rss-time\">{html_escape(time_display)}</span>'\n\n                if source_name:\n                    rss_html += f'<span class=\"rss-author\">{html_escape(source_name)}</span>'\n\n                if is_new:\n                    rss_html += '<span class=\"rss-author\" style=\"color: #dc2626;\">NEW</span>'\n\n                rss_html += \"\"\"\n                            </div>\n                            <div class=\"rss-title\">\"\"\"\n\n                escaped_title = html_escape(item_title)\n                if url:\n                    escaped_url = html_escape(url)\n                    rss_html += f'<a href=\"{escaped_url}\" target=\"_blank\" class=\"rss-link\">{escaped_title}</a>'\n                else:\n                    rss_html += escaped_title\n\n                rss_html += \"\"\"\n                            </div>\n                        </div>\"\"\"\n\n            rss_html += \"\"\"\n                    </div>\"\"\"\n\n        rss_html += \"\"\"\n                </div>\"\"\"\n        return rss_html\n\n    # 生成独立展示区内容\n    def render_standalone_html(data: Optional[Dict]) -> str:\n        \"\"\"渲染独立展示区 HTML（复用热点词汇统计区样式）\n\n        Args:\n            data: 独立展示数据，格式：\n                {\n                    \"platforms\": [\n                        {\n                            \"id\": \"zhihu\",\n                            \"name\": \"知乎热榜\",\n                            \"items\": [\n                                {\n                                    \"title\": \"标题\",\n                                    \"url\": \"链接\",\n                                    \"rank\": 1,\n                                    \"ranks\": [1, 2, 1],\n                                    \"first_time\": \"08:00\",\n                                    \"last_time\": \"12:30\",\n                                    \"count\": 3,\n                                }\n                            ]\n                        }\n                    ],\n                    \"rss_feeds\": [\n                        {\n                            \"id\": \"hacker-news\",\n                            \"name\": \"Hacker News\",\n                            \"items\": [\n                                {\n                                    \"title\": \"标题\",\n                                    \"url\": \"链接\",\n                                    \"published_at\": \"2025-01-07T08:00:00\",\n                                    \"author\": \"作者\",\n                                }\n                            ]\n                        }\n                    ]\n                }\n\n        Returns:\n            渲染后的 HTML 字符串\n        \"\"\"\n        if not data:\n            return \"\"\n\n        platforms = data.get(\"platforms\", [])\n        rss_feeds = data.get(\"rss_feeds\", [])\n\n        if not platforms and not rss_feeds:\n            return \"\"\n\n        # 计算总条目数\n        total_platform_items = sum(len(p.get(\"items\", [])) for p in platforms)\n        total_rss_items = sum(len(f.get(\"items\", [])) for f in rss_feeds)\n        total_count = total_platform_items + total_rss_items\n\n        if total_count == 0:\n            return \"\"\n\n        standalone_html = f\"\"\"\n                <div class=\"standalone-section\">\n                    <div class=\"standalone-section-header\">\n                        <div class=\"standalone-section-title\">独立展示区</div>\n                        <div class=\"standalone-section-count\">{total_count} 条</div>\n                    </div>\"\"\"\n\n        # 渲染热榜平台（复用 word-group 结构）\n        for platform in platforms:\n            platform_name = platform.get(\"name\", platform.get(\"id\", \"\"))\n            items = platform.get(\"items\", [])\n            if not items:\n                continue\n\n            standalone_html += f\"\"\"\n                    <div class=\"standalone-group\">\n                        <div class=\"standalone-header\">\n                            <div class=\"standalone-name\">{html_escape(platform_name)}</div>\n                            <div class=\"standalone-count\">{len(items)} 条</div>\n                        </div>\"\"\"\n\n            # 渲染每个条目（复用 news-item 结构）\n            for j, item in enumerate(items, 1):\n                title = item.get(\"title\", \"\")\n                url = item.get(\"url\", \"\") or item.get(\"mobileUrl\", \"\")\n                rank = item.get(\"rank\", 0)\n                ranks = item.get(\"ranks\", [])\n                first_time = item.get(\"first_time\", \"\")\n                last_time = item.get(\"last_time\", \"\")\n                count = item.get(\"count\", 1)\n\n                standalone_html += f\"\"\"\n                        <div class=\"news-item\">\n                            <div class=\"news-number\">{j}</div>\n                            <div class=\"news-content\">\n                                <div class=\"news-header\">\"\"\"\n\n                # 排名显示（复用 rank-num 样式，无 # 前缀）\n                if ranks:\n                    min_rank = min(ranks)\n                    max_rank = max(ranks)\n\n                    # 确定排名等级\n                    if min_rank <= 3:\n                        rank_class = \"top\"\n                    elif min_rank <= 10:\n                        rank_class = \"high\"\n                    else:\n                        rank_class = \"\"\n\n                    if min_rank == max_rank:\n                        rank_text = str(min_rank)\n                    else:\n                        rank_text = f\"{min_rank}-{max_rank}\"\n\n                    standalone_html += f'<span class=\"rank-num {rank_class}\">{rank_text}</span>'\n                elif rank > 0:\n                    if rank <= 3:\n                        rank_class = \"top\"\n                    elif rank <= 10:\n                        rank_class = \"high\"\n                    else:\n                        rank_class = \"\"\n                    standalone_html += f'<span class=\"rank-num {rank_class}\">{rank}</span>'\n\n                # 时间显示（复用 time-info 样式，将 HH-MM 转换为 HH:MM）\n                if first_time and last_time and first_time != last_time:\n                    first_time_display = convert_time_for_display(first_time)\n                    last_time_display = convert_time_for_display(last_time)\n                    standalone_html += f'<span class=\"time-info\">{html_escape(first_time_display)}~{html_escape(last_time_display)}</span>'\n                elif first_time:\n                    first_time_display = convert_time_for_display(first_time)\n                    standalone_html += f'<span class=\"time-info\">{html_escape(first_time_display)}</span>'\n\n                # 出现次数（复用 count-info 样式）\n                if count > 1:\n                    standalone_html += f'<span class=\"count-info\">{count}次</span>'\n\n                standalone_html += \"\"\"\n                                </div>\n                                <div class=\"news-title\">\"\"\"\n\n                # 标题和链接（复用 news-link 样式）\n                escaped_title = html_escape(title)\n                if url:\n                    escaped_url = html_escape(url)\n                    standalone_html += f'<a href=\"{escaped_url}\" target=\"_blank\" class=\"news-link\">{escaped_title}</a>'\n                else:\n                    standalone_html += escaped_title\n\n                standalone_html += \"\"\"\n                                </div>\n                            </div>\n                        </div>\"\"\"\n\n            standalone_html += \"\"\"\n                    </div>\"\"\"\n\n        # 渲染 RSS 源（复用相同结构）\n        for feed in rss_feeds:\n            feed_name = feed.get(\"name\", feed.get(\"id\", \"\"))\n            items = feed.get(\"items\", [])\n            if not items:\n                continue\n\n            standalone_html += f\"\"\"\n                    <div class=\"standalone-group\">\n                        <div class=\"standalone-header\">\n                            <div class=\"standalone-name\">{html_escape(feed_name)}</div>\n                            <div class=\"standalone-count\">{len(items)} 条</div>\n                        </div>\"\"\"\n\n            for j, item in enumerate(items, 1):\n                title = item.get(\"title\", \"\")\n                url = item.get(\"url\", \"\")\n                published_at = item.get(\"published_at\", \"\")\n                author = item.get(\"author\", \"\")\n\n                standalone_html += f\"\"\"\n                        <div class=\"news-item\">\n                            <div class=\"news-number\">{j}</div>\n                            <div class=\"news-content\">\n                                <div class=\"news-header\">\"\"\"\n\n                # 时间显示（格式化 ISO 时间）\n                if published_at:\n                    try:\n                        from datetime import datetime as dt\n                        if \"T\" in published_at:\n                            dt_obj = dt.fromisoformat(published_at.replace(\"Z\", \"+00:00\"))\n                            time_display = dt_obj.strftime(\"%m-%d %H:%M\")\n                        else:\n                            time_display = published_at\n                    except:\n                        time_display = published_at\n\n                    standalone_html += f'<span class=\"time-info\">{html_escape(time_display)}</span>'\n\n                # 作者显示\n                if author:\n                    standalone_html += f'<span class=\"source-name\">{html_escape(author)}</span>'\n\n                standalone_html += \"\"\"\n                                </div>\n                                <div class=\"news-title\">\"\"\"\n\n                escaped_title = html_escape(title)\n                if url:\n                    escaped_url = html_escape(url)\n                    standalone_html += f'<a href=\"{escaped_url}\" target=\"_blank\" class=\"news-link\">{escaped_title}</a>'\n                else:\n                    standalone_html += escaped_title\n\n                standalone_html += \"\"\"\n                                </div>\n                            </div>\n                        </div>\"\"\"\n\n            standalone_html += \"\"\"\n                    </div>\"\"\"\n\n        standalone_html += \"\"\"\n                </div>\"\"\"\n        return standalone_html\n\n    # 生成 RSS 统计和新增 HTML\n    rss_stats_html = render_rss_stats_html(rss_items, \"RSS 订阅更新\") if rss_items else \"\"\n    rss_new_html = render_rss_stats_html(rss_new_items, \"RSS 新增更新\") if rss_new_items else \"\"\n\n    # 生成独立展示区 HTML\n    standalone_html = render_standalone_html(standalone_data)\n\n    # 生成 AI 分析 HTML\n    ai_html = render_ai_analysis_html_rich(ai_analysis) if ai_analysis else \"\"\n\n    # 准备各区域内容映射\n    region_contents = {\n        \"hotlist\": stats_html,\n        \"rss\": rss_stats_html,\n        \"new_items\": (new_titles_html, rss_new_html),  # 元组，分别处理\n        \"standalone\": standalone_html,\n        \"ai_analysis\": ai_html,\n    }\n\n    def add_section_divider(content: str) -> str:\n        \"\"\"为内容的外层 div 添加 section-divider 类\"\"\"\n        if not content or 'class=\"' not in content:\n            return content\n        first_class_pos = content.find('class=\"')\n        if first_class_pos != -1:\n            insert_pos = first_class_pos + len('class=\"')\n            return content[:insert_pos] + \"section-divider \" + content[insert_pos:]\n        return content\n\n    # 按 region_order 顺序组装内容，动态添加分割线\n    has_previous_content = False\n    for region in region_order:\n        content = region_contents.get(region, \"\")\n        if region == \"new_items\":\n            # 特殊处理 new_items 区域（包含热榜新增和 RSS 新增两部分）\n            new_html, rss_new = content\n            if new_html:\n                if has_previous_content:\n                    new_html = add_section_divider(new_html)\n                html += new_html\n                has_previous_content = True\n            if rss_new:\n                if has_previous_content:\n                    rss_new = add_section_divider(rss_new)\n                html += rss_new\n                has_previous_content = True\n        elif content:\n            if has_previous_content:\n                content = add_section_divider(content)\n            html += content\n            has_previous_content = True\n\n    html += \"\"\"\n            </div>\n\n            <div class=\"footer\">\n                <div class=\"footer-content\">\n                    由 <span class=\"project-name\">TrendRadar</span> 生成 ·\n                    <a href=\"https://github.com/sansan0/TrendRadar\" target=\"_blank\" class=\"footer-link\">\n                        GitHub 开源项目\n                    </a>\"\"\"\n\n    if update_info:\n        html += f\"\"\"\n                    <br>\n                    <span style=\"color: #ea580c; font-weight: 500;\">\n                        发现新版本 {update_info['remote_version']}，当前版本 {update_info['current_version']}\n                    </span>\"\"\"\n\n    html += \"\"\"\n                </div>\n            </div>\n        </div>\n\n        <script>\n            async function saveAsImage() {\n                const button = event.target;\n                const originalText = button.textContent;\n\n                try {\n                    button.textContent = '生成中...';\n                    button.disabled = true;\n                    window.scrollTo(0, 0);\n\n                    // 等待页面稳定\n                    await new Promise(resolve => setTimeout(resolve, 200));\n\n                    // 截图前隐藏按钮\n                    const buttons = document.querySelector('.save-buttons');\n                    buttons.style.visibility = 'hidden';\n\n                    // 再次等待确保按钮完全隐藏\n                    await new Promise(resolve => setTimeout(resolve, 100));\n\n                    const container = document.querySelector('.container');\n\n                    const canvas = await html2canvas(container, {\n                        backgroundColor: '#ffffff',\n                        scale: 1.5,\n                        useCORS: true,\n                        allowTaint: false,\n                        imageTimeout: 10000,\n                        removeContainer: false,\n                        foreignObjectRendering: false,\n                        logging: false,\n                        width: container.offsetWidth,\n                        height: container.offsetHeight,\n                        x: 0,\n                        y: 0,\n                        scrollX: 0,\n                        scrollY: 0,\n                        windowWidth: window.innerWidth,\n                        windowHeight: window.innerHeight\n                    });\n\n                    buttons.style.visibility = 'visible';\n\n                    const link = document.createElement('a');\n                    const now = new Date();\n                    const filename = `TrendRadar_热点新闻分析_${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}.png`;\n\n                    link.download = filename;\n                    link.href = canvas.toDataURL('image/png', 1.0);\n\n                    // 触发下载\n                    document.body.appendChild(link);\n                    link.click();\n                    document.body.removeChild(link);\n\n                    button.textContent = '保存成功!';\n                    setTimeout(() => {\n                        button.textContent = originalText;\n                        button.disabled = false;\n                    }, 2000);\n\n                } catch (error) {\n                    const buttons = document.querySelector('.save-buttons');\n                    buttons.style.visibility = 'visible';\n                    button.textContent = '保存失败';\n                    setTimeout(() => {\n                        button.textContent = originalText;\n                        button.disabled = false;\n                    }, 2000);\n                }\n            }\n\n            async function saveAsMultipleImages() {\n                const button = event.target;\n                const originalText = button.textContent;\n                const container = document.querySelector('.container');\n                const scale = 1.5;\n                const maxHeight = 5000 / scale;\n\n                try {\n                    button.textContent = '分析中...';\n                    button.disabled = true;\n\n                    // 获取所有可能的分割元素\n                    const newsItems = Array.from(container.querySelectorAll('.news-item'));\n                    const wordGroups = Array.from(container.querySelectorAll('.word-group'));\n                    const newSection = container.querySelector('.new-section');\n                    const errorSection = container.querySelector('.error-section');\n                    const header = container.querySelector('.header');\n                    const footer = container.querySelector('.footer');\n\n                    // 计算元素位置和高度\n                    const containerRect = container.getBoundingClientRect();\n                    const elements = [];\n\n                    // 添加header作为必须包含的元素\n                    elements.push({\n                        type: 'header',\n                        element: header,\n                        top: 0,\n                        bottom: header.offsetHeight,\n                        height: header.offsetHeight\n                    });\n\n                    // 添加错误信息（如果存在）\n                    if (errorSection) {\n                        const rect = errorSection.getBoundingClientRect();\n                        elements.push({\n                            type: 'error',\n                            element: errorSection,\n                            top: rect.top - containerRect.top,\n                            bottom: rect.bottom - containerRect.top,\n                            height: rect.height\n                        });\n                    }\n\n                    // 按word-group分组处理news-item\n                    wordGroups.forEach(group => {\n                        const groupRect = group.getBoundingClientRect();\n                        const groupNewsItems = group.querySelectorAll('.news-item');\n\n                        // 添加word-group的header部分\n                        const wordHeader = group.querySelector('.word-header');\n                        if (wordHeader) {\n                            const headerRect = wordHeader.getBoundingClientRect();\n                            elements.push({\n                                type: 'word-header',\n                                element: wordHeader,\n                                parent: group,\n                                top: groupRect.top - containerRect.top,\n                                bottom: headerRect.bottom - containerRect.top,\n                                height: headerRect.height\n                            });\n                        }\n\n                        // 添加每个news-item\n                        groupNewsItems.forEach(item => {\n                            const rect = item.getBoundingClientRect();\n                            elements.push({\n                                type: 'news-item',\n                                element: item,\n                                parent: group,\n                                top: rect.top - containerRect.top,\n                                bottom: rect.bottom - containerRect.top,\n                                height: rect.height\n                            });\n                        });\n                    });\n\n                    // 添加新增新闻部分\n                    if (newSection) {\n                        const rect = newSection.getBoundingClientRect();\n                        elements.push({\n                            type: 'new-section',\n                            element: newSection,\n                            top: rect.top - containerRect.top,\n                            bottom: rect.bottom - containerRect.top,\n                            height: rect.height\n                        });\n                    }\n\n                    // 添加footer\n                    const footerRect = footer.getBoundingClientRect();\n                    elements.push({\n                        type: 'footer',\n                        element: footer,\n                        top: footerRect.top - containerRect.top,\n                        bottom: footerRect.bottom - containerRect.top,\n                        height: footer.offsetHeight\n                    });\n\n                    // 计算分割点\n                    const segments = [];\n                    let currentSegment = { start: 0, end: 0, height: 0, includeHeader: true };\n                    let headerHeight = header.offsetHeight;\n                    currentSegment.height = headerHeight;\n\n                    for (let i = 1; i < elements.length; i++) {\n                        const element = elements[i];\n                        const potentialHeight = element.bottom - currentSegment.start;\n\n                        // 检查是否需要创建新分段\n                        if (potentialHeight > maxHeight && currentSegment.height > headerHeight) {\n                            // 在前一个元素结束处分割\n                            currentSegment.end = elements[i - 1].bottom;\n                            segments.push(currentSegment);\n\n                            // 开始新分段\n                            currentSegment = {\n                                start: currentSegment.end,\n                                end: 0,\n                                height: element.bottom - currentSegment.end,\n                                includeHeader: false\n                            };\n                        } else {\n                            currentSegment.height = potentialHeight;\n                            currentSegment.end = element.bottom;\n                        }\n                    }\n\n                    // 添加最后一个分段\n                    if (currentSegment.height > 0) {\n                        currentSegment.end = container.offsetHeight;\n                        segments.push(currentSegment);\n                    }\n\n                    button.textContent = `生成中 (0/${segments.length})...`;\n\n                    // 隐藏保存按钮\n                    const buttons = document.querySelector('.save-buttons');\n                    buttons.style.visibility = 'hidden';\n\n                    // 为每个分段生成图片\n                    const images = [];\n                    for (let i = 0; i < segments.length; i++) {\n                        const segment = segments[i];\n                        button.textContent = `生成中 (${i + 1}/${segments.length})...`;\n\n                        // 创建临时容器用于截图\n                        const tempContainer = document.createElement('div');\n                        tempContainer.style.cssText = `\n                            position: absolute;\n                            left: -9999px;\n                            top: 0;\n                            width: ${container.offsetWidth}px;\n                            background: white;\n                        `;\n                        tempContainer.className = 'container';\n\n                        // 克隆容器内容\n                        const clonedContainer = container.cloneNode(true);\n\n                        // 移除克隆内容中的保存按钮\n                        const clonedButtons = clonedContainer.querySelector('.save-buttons');\n                        if (clonedButtons) {\n                            clonedButtons.style.display = 'none';\n                        }\n\n                        tempContainer.appendChild(clonedContainer);\n                        document.body.appendChild(tempContainer);\n\n                        // 等待DOM更新\n                        await new Promise(resolve => setTimeout(resolve, 100));\n\n                        // 使用html2canvas截取特定区域\n                        const canvas = await html2canvas(clonedContainer, {\n                            backgroundColor: '#ffffff',\n                            scale: scale,\n                            useCORS: true,\n                            allowTaint: false,\n                            imageTimeout: 10000,\n                            logging: false,\n                            width: container.offsetWidth,\n                            height: segment.end - segment.start,\n                            x: 0,\n                            y: segment.start,\n                            windowWidth: window.innerWidth,\n                            windowHeight: window.innerHeight\n                        });\n\n                        images.push(canvas.toDataURL('image/png', 1.0));\n\n                        // 清理临时容器\n                        document.body.removeChild(tempContainer);\n                    }\n\n                    // 恢复按钮显示\n                    buttons.style.visibility = 'visible';\n\n                    // 下载所有图片\n                    const now = new Date();\n                    const baseFilename = `TrendRadar_热点新闻分析_${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;\n\n                    for (let i = 0; i < images.length; i++) {\n                        const link = document.createElement('a');\n                        link.download = `${baseFilename}_part${i + 1}.png`;\n                        link.href = images[i];\n                        document.body.appendChild(link);\n                        link.click();\n                        document.body.removeChild(link);\n\n                        // 延迟一下避免浏览器阻止多个下载\n                        await new Promise(resolve => setTimeout(resolve, 100));\n                    }\n\n                    button.textContent = `已保存 ${segments.length} 张图片!`;\n                    setTimeout(() => {\n                        button.textContent = originalText;\n                        button.disabled = false;\n                    }, 2000);\n\n                } catch (error) {\n                    console.error('分段保存失败:', error);\n                    const buttons = document.querySelector('.save-buttons');\n                    buttons.style.visibility = 'visible';\n                    button.textContent = '保存失败';\n                    setTimeout(() => {\n                        button.textContent = originalText;\n                        button.disabled = false;\n                    }, 2000);\n                }\n            }\n\n            document.addEventListener('DOMContentLoaded', function() {\n                window.scrollTo(0, 0);\n            });\n        </script>\n    </body>\n    </html>\n    \"\"\"\n\n    return html\n"
  },
  {
    "path": "trendradar/report/rss_html.py",
    "content": "# coding=utf-8\n\"\"\"\nRSS HTML 报告渲染模块\n\n提供 RSS 订阅内容的 HTML 格式报告生成功能\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Dict, List, Optional, Callable\n\nfrom trendradar.report.helpers import html_escape\n\n\ndef render_rss_html_content(\n    rss_items: List[Dict],\n    total_count: int,\n    feeds_info: Optional[Dict[str, str]] = None,\n    *,\n    get_time_func: Optional[Callable[[], datetime]] = None,\n) -> str:\n    \"\"\"渲染 RSS HTML 内容\n\n    Args:\n        rss_items: RSS 条目列表，每个条目包含:\n            - title: 标题\n            - feed_id: RSS 源 ID\n            - feed_name: RSS 源名称\n            - url: 链接\n            - published_at: 发布时间\n            - summary: 摘要（可选）\n            - author: 作者（可选）\n        total_count: 条目总数\n        feeds_info: RSS 源 ID 到名称的映射\n        get_time_func: 获取当前时间的函数（可选，默认使用 datetime.now）\n\n    Returns:\n        渲染后的 HTML 字符串\n    \"\"\"\n    html = \"\"\"\n    <!DOCTYPE html>\n    <html>\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <title>RSS 订阅内容</title>\n        <script src=\"https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js\" integrity=\"sha512-BNaRQnYJYiPSqHHDb58B0yaPfCu+Wgds8Gp/gU33kqBtgNS4tSPHuGibyoeqMV/TJlSKda6FXzoEyYGjTe+vXA==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n        <style>\n            * { box-sizing: border-box; }\n            body {\n                font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;\n                margin: 0;\n                padding: 16px;\n                background: #fafafa;\n                color: #333;\n                line-height: 1.5;\n            }\n\n            .container {\n                max-width: 700px;\n                margin: 0 auto;\n                background: white;\n                border-radius: 12px;\n                overflow: hidden;\n                box-shadow: 0 2px 16px rgba(0,0,0,0.06);\n            }\n\n            .header {\n                background: linear-gradient(135deg, #059669 0%, #10b981 100%);\n                color: white;\n                padding: 32px 24px;\n                text-align: center;\n                position: relative;\n            }\n\n            .save-buttons {\n                position: absolute;\n                top: 16px;\n                right: 16px;\n                display: flex;\n                gap: 8px;\n            }\n\n            .save-btn {\n                background: rgba(255, 255, 255, 0.2);\n                border: 1px solid rgba(255, 255, 255, 0.3);\n                color: white;\n                padding: 8px 16px;\n                border-radius: 6px;\n                cursor: pointer;\n                font-size: 13px;\n                font-weight: 500;\n                transition: all 0.2s ease;\n                backdrop-filter: blur(10px);\n                white-space: nowrap;\n            }\n\n            .save-btn:hover {\n                background: rgba(255, 255, 255, 0.3);\n                border-color: rgba(255, 255, 255, 0.5);\n                transform: translateY(-1px);\n            }\n\n            .save-btn:active {\n                transform: translateY(0);\n            }\n\n            .save-btn:disabled {\n                opacity: 0.6;\n                cursor: not-allowed;\n            }\n\n            .header-title {\n                font-size: 22px;\n                font-weight: 700;\n                margin: 0 0 20px 0;\n            }\n\n            .header-info {\n                display: grid;\n                grid-template-columns: 1fr 1fr;\n                gap: 16px;\n                font-size: 14px;\n                opacity: 0.95;\n            }\n\n            .info-item {\n                text-align: center;\n            }\n\n            .info-label {\n                display: block;\n                font-size: 12px;\n                opacity: 0.8;\n                margin-bottom: 4px;\n            }\n\n            .info-value {\n                font-weight: 600;\n                font-size: 16px;\n            }\n\n            .content {\n                padding: 24px;\n            }\n\n            .feed-group {\n                margin-bottom: 32px;\n            }\n\n            .feed-group:last-child {\n                margin-bottom: 0;\n            }\n\n            .feed-header {\n                display: flex;\n                align-items: center;\n                justify-content: space-between;\n                margin-bottom: 16px;\n                padding-bottom: 8px;\n                border-bottom: 2px solid #10b981;\n            }\n\n            .feed-name {\n                font-size: 16px;\n                font-weight: 600;\n                color: #059669;\n            }\n\n            .feed-count {\n                color: #666;\n                font-size: 13px;\n                font-weight: 500;\n            }\n\n            .rss-item {\n                margin-bottom: 16px;\n                padding: 16px;\n                background: #f9fafb;\n                border-radius: 8px;\n                border-left: 3px solid #10b981;\n            }\n\n            .rss-item:last-child {\n                margin-bottom: 0;\n            }\n\n            .rss-meta {\n                display: flex;\n                align-items: center;\n                gap: 12px;\n                margin-bottom: 8px;\n                flex-wrap: wrap;\n            }\n\n            .rss-time {\n                color: #6b7280;\n                font-size: 12px;\n            }\n\n            .rss-author {\n                color: #059669;\n                font-size: 12px;\n                font-weight: 500;\n            }\n\n            .rss-title {\n                font-size: 15px;\n                line-height: 1.5;\n                color: #1a1a1a;\n                margin: 0 0 8px 0;\n                font-weight: 500;\n            }\n\n            .rss-link {\n                color: #2563eb;\n                text-decoration: none;\n            }\n\n            .rss-link:hover {\n                text-decoration: underline;\n            }\n\n            .rss-link:visited {\n                color: #7c3aed;\n            }\n\n            .rss-summary {\n                font-size: 13px;\n                color: #6b7280;\n                line-height: 1.6;\n                margin: 0;\n                display: -webkit-box;\n                -webkit-line-clamp: 3;\n                -webkit-box-orient: vertical;\n                overflow: hidden;\n            }\n\n            .footer {\n                margin-top: 32px;\n                padding: 20px 24px;\n                background: #f8f9fa;\n                border-top: 1px solid #e5e7eb;\n                text-align: center;\n            }\n\n            .footer-content {\n                font-size: 13px;\n                color: #6b7280;\n                line-height: 1.6;\n            }\n\n            .footer-link {\n                color: #059669;\n                text-decoration: none;\n                font-weight: 500;\n                transition: color 0.2s ease;\n            }\n\n            .footer-link:hover {\n                color: #10b981;\n                text-decoration: underline;\n            }\n\n            .project-name {\n                font-weight: 600;\n                color: #374151;\n            }\n\n            @media (max-width: 480px) {\n                body { padding: 12px; }\n                .header { padding: 24px 20px; }\n                .content { padding: 20px; }\n                .footer { padding: 16px 20px; }\n                .header-info { grid-template-columns: 1fr; gap: 12px; }\n                .rss-meta { gap: 8px; }\n                .rss-item { padding: 12px; }\n                .save-buttons {\n                    position: static;\n                    margin-bottom: 16px;\n                    display: flex;\n                    gap: 8px;\n                    justify-content: center;\n                    flex-direction: column;\n                    width: 100%;\n                }\n                .save-btn {\n                    width: 100%;\n                }\n            }\n        </style>\n    </head>\n    <body>\n        <div class=\"container\">\n            <div class=\"header\">\n                <div class=\"save-buttons\">\n                    <button class=\"save-btn\" onclick=\"saveAsImage()\">保存为图片</button>\n                </div>\n                <div class=\"header-title\">RSS 订阅内容</div>\n                <div class=\"header-info\">\n                    <div class=\"info-item\">\n                        <span class=\"info-label\">订阅条目</span>\n                        <span class=\"info-value\">\"\"\"\n\n    html += f\"{total_count} 条\"\n\n    html += \"\"\"</span>\n                    </div>\n                    <div class=\"info-item\">\n                        <span class=\"info-label\">生成时间</span>\n                        <span class=\"info-value\">\"\"\"\n\n    # 使用提供的时间函数或默认 datetime.now\n    if get_time_func:\n        now = get_time_func()\n    else:\n        now = datetime.now()\n    html += now.strftime(\"%m-%d %H:%M\")\n\n    html += \"\"\"</span>\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"content\">\"\"\"\n\n    # 按 feed_id 分组\n    feeds_map: Dict[str, List[Dict]] = {}\n    for item in rss_items:\n        feed_id = item.get(\"feed_id\", \"unknown\")\n        if feed_id not in feeds_map:\n            feeds_map[feed_id] = []\n        feeds_map[feed_id].append(item)\n\n    # 渲染每个 RSS 源的内容\n    for feed_id, items in feeds_map.items():\n        feed_name = items[0].get(\"feed_name\", feed_id) if items else feed_id\n        if feeds_info and feed_id in feeds_info:\n            feed_name = feeds_info[feed_id]\n\n        escaped_feed_name = html_escape(feed_name)\n\n        html += f\"\"\"\n                <div class=\"feed-group\">\n                    <div class=\"feed-header\">\n                        <div class=\"feed-name\">{escaped_feed_name}</div>\n                        <div class=\"feed-count\">{len(items)} 条</div>\n                    </div>\"\"\"\n\n        for item in items:\n            escaped_title = html_escape(item.get(\"title\", \"\"))\n            url = item.get(\"url\", \"\")\n            published_at = item.get(\"published_at\", \"\")\n            author = item.get(\"author\", \"\")\n            summary = item.get(\"summary\", \"\")\n\n            html += \"\"\"\n                    <div class=\"rss-item\">\n                        <div class=\"rss-meta\">\"\"\"\n\n            if published_at:\n                html += f'<span class=\"rss-time\">{html_escape(published_at)}</span>'\n\n            if author:\n                html += f'<span class=\"rss-author\">by {html_escape(author)}</span>'\n\n            html += \"\"\"\n                        </div>\n                        <div class=\"rss-title\">\"\"\"\n\n            if url:\n                escaped_url = html_escape(url)\n                html += f'<a href=\"{escaped_url}\" target=\"_blank\" class=\"rss-link\">{escaped_title}</a>'\n            else:\n                html += escaped_title\n\n            html += \"\"\"\n                        </div>\"\"\"\n\n            if summary:\n                escaped_summary = html_escape(summary)\n                html += f\"\"\"\n                        <p class=\"rss-summary\">{escaped_summary}</p>\"\"\"\n\n            html += \"\"\"\n                    </div>\"\"\"\n\n        html += \"\"\"\n                </div>\"\"\"\n\n    html += \"\"\"\n            </div>\n\n            <div class=\"footer\">\n                <div class=\"footer-content\">\n                    由 <span class=\"project-name\">TrendRadar</span> 生成 ·\n                    <a href=\"https://github.com/sansan0/TrendRadar\" target=\"_blank\" class=\"footer-link\">\n                        GitHub 开源项目\n                    </a>\n                </div>\n            </div>\n        </div>\n\n        <script>\n            async function saveAsImage() {\n                const button = event.target;\n                const originalText = button.textContent;\n\n                try {\n                    button.textContent = '生成中...';\n                    button.disabled = true;\n                    window.scrollTo(0, 0);\n\n                    await new Promise(resolve => setTimeout(resolve, 200));\n\n                    const buttons = document.querySelector('.save-buttons');\n                    buttons.style.visibility = 'hidden';\n\n                    await new Promise(resolve => setTimeout(resolve, 100));\n\n                    const container = document.querySelector('.container');\n\n                    const canvas = await html2canvas(container, {\n                        backgroundColor: '#ffffff',\n                        scale: 1.5,\n                        useCORS: true,\n                        allowTaint: false,\n                        imageTimeout: 10000,\n                        removeContainer: false,\n                        foreignObjectRendering: false,\n                        logging: false,\n                        width: container.offsetWidth,\n                        height: container.offsetHeight,\n                        x: 0,\n                        y: 0,\n                        scrollX: 0,\n                        scrollY: 0,\n                        windowWidth: window.innerWidth,\n                        windowHeight: window.innerHeight\n                    });\n\n                    buttons.style.visibility = 'visible';\n\n                    const link = document.createElement('a');\n                    const now = new Date();\n                    const filename = `TrendRadar_RSS订阅_${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}.png`;\n\n                    link.download = filename;\n                    link.href = canvas.toDataURL('image/png', 1.0);\n\n                    document.body.appendChild(link);\n                    link.click();\n                    document.body.removeChild(link);\n\n                    button.textContent = '保存成功!';\n                    setTimeout(() => {\n                        button.textContent = originalText;\n                        button.disabled = false;\n                    }, 2000);\n\n                } catch (error) {\n                    const buttons = document.querySelector('.save-buttons');\n                    buttons.style.visibility = 'visible';\n                    button.textContent = '保存失败';\n                    setTimeout(() => {\n                        button.textContent = originalText;\n                        button.disabled = false;\n                    }, 2000);\n                }\n            }\n\n            document.addEventListener('DOMContentLoaded', function() {\n                window.scrollTo(0, 0);\n            });\n        </script>\n    </body>\n    </html>\n    \"\"\"\n\n    return html\n"
  },
  {
    "path": "trendradar/storage/__init__.py",
    "content": "# coding=utf-8\n\"\"\"\n存储模块 - 支持多种存储后端\n\n支持的存储后端:\n- local: 本地 SQLite + TXT/HTML 文件\n- remote: 远程云存储（S3 兼容协议：R2/OSS/COS/S3 等）\n- auto: 根据环境自动选择（GitHub Actions 用 remote，其他用 local）\n\"\"\"\n\nfrom trendradar.storage.base import (\n    StorageBackend,\n    NewsItem,\n    NewsData,\n    RSSItem,\n    RSSData,\n    convert_crawl_results_to_news_data,\n)\nfrom trendradar.storage.sqlite_mixin import SQLiteStorageMixin\nfrom trendradar.storage.local import LocalStorageBackend\nfrom trendradar.storage.manager import StorageManager, get_storage_manager\n\n# 远程后端可选导入（需要 boto3）\ntry:\n    from trendradar.storage.remote import RemoteStorageBackend\n    HAS_REMOTE = True\nexcept ImportError:\n    RemoteStorageBackend = None\n    HAS_REMOTE = False\n\n__all__ = [\n    # 基础类\n    \"StorageBackend\",\n    \"NewsItem\",\n    \"NewsData\",\n    \"RSSItem\",\n    \"RSSData\",\n    # Mixin\n    \"SQLiteStorageMixin\",\n    # 转换函数\n    \"convert_crawl_results_to_news_data\",\n    # 后端实现\n    \"LocalStorageBackend\",\n    \"RemoteStorageBackend\",\n    \"HAS_REMOTE\",\n    # 管理器\n    \"StorageManager\",\n    \"get_storage_manager\",\n]\n"
  },
  {
    "path": "trendradar/storage/ai_filter_schema.sql",
    "content": "-- AI 智能筛选相关表结构\n-- 在 news 库中创建，与 news_items 同库\n\n-- ============================================\n-- AI 筛选兴趣标签表\n-- 存储从用户兴趣描述中 AI 提取的结构化标签\n-- 按版本管理，提示词变更时旧版本标记 deprecated\n-- 支持多兴趣文件隔离（interests_file 区分不同文件的标签集）\n-- ============================================\nCREATE TABLE IF NOT EXISTS ai_filter_tags (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    tag TEXT NOT NULL,                    -- 标签名，如 \"AI/大模型\"\n    description TEXT DEFAULT '',          -- 标签描述，AI 分类时参考\n    priority INTEGER NOT NULL DEFAULT 9999, -- 标签优先级（值越小优先级越高）\n    status TEXT DEFAULT 'active',        -- active / deprecated\n    deprecated_at TEXT,                   -- 废弃时间\n    version INTEGER NOT NULL,            -- 版本号，提示词变更时 +1\n    prompt_hash TEXT NOT NULL,           -- 兴趣描述文件的 hash（格式: filename:md5）\n    interests_file TEXT NOT NULL DEFAULT 'ai_interests.txt',  -- 关联的兴趣文件名\n    created_at TEXT NOT NULL\n);\n\n-- ============================================\n-- AI 筛选分类结果表\n-- 每条新闻 × 每个标签 = 一行\n-- 引用 news_items.id 或 rss_items.id（通过 source_type 区分）\n-- ============================================\nCREATE TABLE IF NOT EXISTS ai_filter_results (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    news_item_id INTEGER NOT NULL,       -- 引用 news_items.id 或 rss_items.id\n    source_type TEXT NOT NULL DEFAULT 'hotlist',  -- hotlist / rss\n    tag_id INTEGER NOT NULL,             -- 引用 ai_filter_tags.id\n    relevance_score REAL DEFAULT 0,      -- 相关度 0.0 ~ 1.0\n    status TEXT DEFAULT 'active',        -- active / deprecated\n    deprecated_at TEXT,\n    created_at TEXT NOT NULL,\n    UNIQUE(news_item_id, source_type, tag_id)\n);\n\n-- ============================================\n-- AI 筛选已分析新闻记录表\n-- 记录所有已被 AI 分析过的新闻（无论匹配与否）\n-- 用于去重，避免重复发送给 AI 浪费 token\n-- ============================================\nCREATE TABLE IF NOT EXISTS ai_filter_analyzed_news (\n    news_item_id INTEGER NOT NULL,       -- 引用 news_items.id 或 rss_items.id\n    source_type TEXT NOT NULL DEFAULT 'hotlist',  -- hotlist / rss\n    interests_file TEXT NOT NULL DEFAULT 'ai_interests.txt',  -- 关联的兴趣文件\n    prompt_hash TEXT NOT NULL,           -- 分析时使用的标签集 hash\n    matched INTEGER NOT NULL DEFAULT 0,  -- 是否匹配: 0=不匹配, 1=匹配\n    created_at TEXT NOT NULL,\n    PRIMARY KEY (news_item_id, source_type, interests_file)\n);\n\n-- ============================================\n-- 索引\n-- ============================================\nCREATE INDEX IF NOT EXISTS idx_ai_filter_tags_status ON ai_filter_tags(status);\nCREATE INDEX IF NOT EXISTS idx_ai_filter_tags_version ON ai_filter_tags(version);\nCREATE INDEX IF NOT EXISTS idx_ai_filter_tags_file ON ai_filter_tags(interests_file, status);\nCREATE INDEX IF NOT EXISTS idx_ai_filter_tags_priority ON ai_filter_tags(interests_file, status, priority);\nCREATE INDEX IF NOT EXISTS idx_ai_filter_results_status ON ai_filter_results(status);\nCREATE INDEX IF NOT EXISTS idx_ai_filter_results_news ON ai_filter_results(news_item_id, source_type);\nCREATE INDEX IF NOT EXISTS idx_ai_filter_results_tag ON ai_filter_results(tag_id);\nCREATE INDEX IF NOT EXISTS idx_analyzed_news_lookup ON ai_filter_analyzed_news(source_type, interests_file);\nCREATE INDEX IF NOT EXISTS idx_analyzed_news_hash ON ai_filter_analyzed_news(interests_file, prompt_hash);\n"
  },
  {
    "path": "trendradar/storage/base.py",
    "content": "# coding=utf-8\n\"\"\"\n存储后端抽象基类和数据模型\n\n定义统一的存储接口，所有存储后端都需要实现这些方法\n\"\"\"\n\nfrom abc import ABC, abstractmethod\nfrom dataclasses import dataclass, field\nfrom typing import Dict, List, Optional, Any, Set\n\n\n@dataclass\nclass NewsItem:\n    \"\"\"新闻条目数据模型（热榜数据）\"\"\"\n\n    title: str                          # 新闻标题\n    source_id: str                      # 来源平台ID（如 toutiao, baidu）\n    source_name: str = \"\"               # 来源平台名称（运行时使用，数据库不存储）\n    rank: int = 0                       # 排名\n    url: str = \"\"                       # 链接 URL\n    mobile_url: str = \"\"                # 移动端 URL\n    crawl_time: str = \"\"                # 抓取时间（HH:MM 格式）\n\n    # 统计信息（用于分析）\n    ranks: List[int] = field(default_factory=list)  # 历史排名列表\n    first_time: str = \"\"                # 首次出现时间\n    last_time: str = \"\"                 # 最后出现时间\n    count: int = 1                      # 出现次数\n    rank_timeline: List[Dict[str, Any]] = field(default_factory=list)  # 完整排名时间线\n                                        # 格式: [{\"time\": \"09:30\", \"rank\": 1}, {\"time\": \"10:00\", \"rank\": 2}, ...]\n                                        # None 表示脱榜: [{\"time\": \"11:00\", \"rank\": None}]\n\n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典\"\"\"\n        return {\n            \"title\": self.title,\n            \"source_id\": self.source_id,\n            \"source_name\": self.source_name,\n            \"rank\": self.rank,\n            \"url\": self.url,\n            \"mobile_url\": self.mobile_url,\n            \"crawl_time\": self.crawl_time,\n            \"ranks\": self.ranks,\n            \"first_time\": self.first_time,\n            \"last_time\": self.last_time,\n            \"count\": self.count,\n            \"rank_timeline\": self.rank_timeline,\n        }\n\n    @classmethod\n    def from_dict(cls, data: Dict[str, Any]) -> \"NewsItem\":\n        \"\"\"从字典创建\"\"\"\n        return cls(\n            title=data.get(\"title\", \"\"),\n            source_id=data.get(\"source_id\", \"\"),\n            source_name=data.get(\"source_name\", \"\"),\n            rank=data.get(\"rank\", 0),\n            url=data.get(\"url\", \"\"),\n            mobile_url=data.get(\"mobile_url\", \"\"),\n            crawl_time=data.get(\"crawl_time\", \"\"),\n            ranks=data.get(\"ranks\", []),\n            first_time=data.get(\"first_time\", \"\"),\n            last_time=data.get(\"last_time\", \"\"),\n            count=data.get(\"count\", 1),\n            rank_timeline=data.get(\"rank_timeline\", []),\n        )\n\n\n@dataclass\nclass RSSItem:\n    \"\"\"RSS 条目数据模型\"\"\"\n\n    title: str                          # 标题\n    feed_id: str                        # RSS 源 ID（如 \"hacker-news\"）\n    feed_name: str = \"\"                 # RSS 源名称（运行时使用）\n    url: str = \"\"                       # 文章链接\n    published_at: str = \"\"              # RSS 发布时间（ISO 格式）\n    summary: str = \"\"                   # 摘要/描述\n    author: str = \"\"                    # 作者\n    crawl_time: str = \"\"                # 抓取时间（HH:MM 格式）\n\n    # 统计信息\n    first_time: str = \"\"                # 首次抓取时间\n    last_time: str = \"\"                 # 最后抓取时间\n    count: int = 1                      # 抓取次数\n\n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典\"\"\"\n        return {\n            \"title\": self.title,\n            \"feed_id\": self.feed_id,\n            \"feed_name\": self.feed_name,\n            \"url\": self.url,\n            \"published_at\": self.published_at,\n            \"summary\": self.summary,\n            \"author\": self.author,\n            \"crawl_time\": self.crawl_time,\n            \"first_time\": self.first_time,\n            \"last_time\": self.last_time,\n            \"count\": self.count,\n        }\n\n    @classmethod\n    def from_dict(cls, data: Dict[str, Any]) -> \"RSSItem\":\n        \"\"\"从字典创建\"\"\"\n        return cls(\n            title=data.get(\"title\", \"\"),\n            feed_id=data.get(\"feed_id\", \"\"),\n            feed_name=data.get(\"feed_name\", \"\"),\n            url=data.get(\"url\", \"\"),\n            published_at=data.get(\"published_at\", \"\"),\n            summary=data.get(\"summary\", \"\"),\n            author=data.get(\"author\", \"\"),\n            crawl_time=data.get(\"crawl_time\", \"\"),\n            first_time=data.get(\"first_time\", \"\"),\n            last_time=data.get(\"last_time\", \"\"),\n            count=data.get(\"count\", 1),\n        )\n\n\n@dataclass\nclass RSSData:\n    \"\"\"\n    RSS 数据集合\n\n    结构:\n    - date: 日期（YYYY-MM-DD）\n    - crawl_time: 抓取时间（HH:MM）\n    - items: 按 feed_id 分组的 RSS 条目\n    - id_to_name: feed_id 到名称的映射\n    - failed_ids: 失败的 feed_id 列表\n    \"\"\"\n\n    date: str                                   # 日期\n    crawl_time: str                             # 抓取时间\n    items: Dict[str, List[RSSItem]]             # 按 feed_id 分组的条目\n    id_to_name: Dict[str, str] = field(default_factory=dict)   # ID到名称映射\n    failed_ids: List[str] = field(default_factory=list)        # 失败的ID\n\n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典\"\"\"\n        items_dict = {}\n        for feed_id, rss_list in self.items.items():\n            items_dict[feed_id] = [item.to_dict() for item in rss_list]\n\n        return {\n            \"date\": self.date,\n            \"crawl_time\": self.crawl_time,\n            \"items\": items_dict,\n            \"id_to_name\": self.id_to_name,\n            \"failed_ids\": self.failed_ids,\n        }\n\n    @classmethod\n    def from_dict(cls, data: Dict[str, Any]) -> \"RSSData\":\n        \"\"\"从字典创建\"\"\"\n        items = {}\n        items_data = data.get(\"items\", {})\n        for feed_id, rss_list in items_data.items():\n            items[feed_id] = [RSSItem.from_dict(item) for item in rss_list]\n\n        return cls(\n            date=data.get(\"date\", \"\"),\n            crawl_time=data.get(\"crawl_time\", \"\"),\n            items=items,\n            id_to_name=data.get(\"id_to_name\", {}),\n            failed_ids=data.get(\"failed_ids\", []),\n        )\n\n    def get_total_count(self) -> int:\n        \"\"\"获取条目总数\"\"\"\n        return sum(len(rss_list) for rss_list in self.items.values())\n\n\n@dataclass\nclass NewsData:\n    \"\"\"\n    新闻数据集合\n\n    结构:\n    - date: 日期（YYYY-MM-DD）\n    - crawl_time: 抓取时间（HH时MM分）\n    - items: 按来源ID分组的新闻条目\n    - id_to_name: 来源ID到名称的映射\n    - failed_ids: 失败的来源ID列表\n    \"\"\"\n\n    date: str                                   # 日期\n    crawl_time: str                             # 抓取时间\n    items: Dict[str, List[NewsItem]]            # 按来源分组的新闻\n    id_to_name: Dict[str, str] = field(default_factory=dict)   # ID到名称映射\n    failed_ids: List[str] = field(default_factory=list)        # 失败的ID\n\n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典\"\"\"\n        items_dict = {}\n        for source_id, news_list in self.items.items():\n            items_dict[source_id] = [item.to_dict() for item in news_list]\n\n        return {\n            \"date\": self.date,\n            \"crawl_time\": self.crawl_time,\n            \"items\": items_dict,\n            \"id_to_name\": self.id_to_name,\n            \"failed_ids\": self.failed_ids,\n        }\n\n    @classmethod\n    def from_dict(cls, data: Dict[str, Any]) -> \"NewsData\":\n        \"\"\"从字典创建\"\"\"\n        items = {}\n        items_data = data.get(\"items\", {})\n        for source_id, news_list in items_data.items():\n            items[source_id] = [NewsItem.from_dict(item) for item in news_list]\n\n        return cls(\n            date=data.get(\"date\", \"\"),\n            crawl_time=data.get(\"crawl_time\", \"\"),\n            items=items,\n            id_to_name=data.get(\"id_to_name\", {}),\n            failed_ids=data.get(\"failed_ids\", []),\n        )\n\n    def get_total_count(self) -> int:\n        \"\"\"获取新闻总数\"\"\"\n        return sum(len(news_list) for news_list in self.items.values())\n\n    def merge_with(self, other: \"NewsData\") -> \"NewsData\":\n        \"\"\"\n        合并另一个 NewsData 到当前数据\n\n        合并规则:\n        - 相同 source_id + title 的新闻合并排名历史\n        - 更新 last_time 和 count\n        - 保留较早的 first_time\n        \"\"\"\n        merged_items = {}\n\n        # 复制当前数据\n        for source_id, news_list in self.items.items():\n            merged_items[source_id] = {item.title: item for item in news_list}\n\n        # 合并其他数据\n        for source_id, news_list in other.items.items():\n            if source_id not in merged_items:\n                merged_items[source_id] = {}\n\n            for item in news_list:\n                if item.title in merged_items[source_id]:\n                    # 合并已存在的新闻\n                    existing = merged_items[source_id][item.title]\n\n                    # 合并排名\n                    existing_ranks = set(existing.ranks) if existing.ranks else set()\n                    new_ranks = set(item.ranks) if item.ranks else set()\n                    merged_ranks = sorted(existing_ranks | new_ranks)\n                    existing.ranks = merged_ranks\n\n                    # 更新时间\n                    if item.first_time and (not existing.first_time or item.first_time < existing.first_time):\n                        existing.first_time = item.first_time\n                    if item.last_time and (not existing.last_time or item.last_time > existing.last_time):\n                        existing.last_time = item.last_time\n\n                    # 更新计数\n                    existing.count += 1\n\n                    # 保留URL（如果原来没有）\n                    if not existing.url and item.url:\n                        existing.url = item.url\n                    if not existing.mobile_url and item.mobile_url:\n                        existing.mobile_url = item.mobile_url\n                else:\n                    # 添加新新闻\n                    merged_items[source_id][item.title] = item\n\n        # 转换回列表格式\n        final_items = {}\n        for source_id, items_dict in merged_items.items():\n            final_items[source_id] = list(items_dict.values())\n\n        # 合并 id_to_name\n        merged_id_to_name = {**self.id_to_name, **other.id_to_name}\n\n        # 合并 failed_ids（去重）\n        merged_failed_ids = list(set(self.failed_ids + other.failed_ids))\n\n        return NewsData(\n            date=self.date or other.date,\n            crawl_time=other.crawl_time,  # 使用较新的抓取时间\n            items=final_items,\n            id_to_name=merged_id_to_name,\n            failed_ids=merged_failed_ids,\n        )\n\n\nclass StorageBackend(ABC):\n    \"\"\"\n    存储后端抽象基类\n\n    所有存储后端都需要实现这些方法，以支持:\n    - 保存新闻数据\n    - 读取当天所有数据\n    - 检测新增新闻\n    - 生成报告文件（TXT/HTML）\n    \"\"\"\n\n    @abstractmethod\n    def save_news_data(self, data: NewsData) -> bool:\n        \"\"\"\n        保存新闻数据\n\n        Args:\n            data: 新闻数据\n\n        Returns:\n            是否保存成功\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def get_today_all_data(self, date: Optional[str] = None) -> Optional[NewsData]:\n        \"\"\"\n        获取指定日期的所有新闻数据\n\n        Args:\n            date: 日期字符串（YYYY-MM-DD），默认为今天\n\n        Returns:\n            合并后的新闻数据，如果没有数据返回 None\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def get_latest_crawl_data(self, date: Optional[str] = None) -> Optional[NewsData]:\n        \"\"\"\n        获取最新一次抓取的数据\n\n        Args:\n            date: 日期字符串，默认为今天\n\n        Returns:\n            最新抓取的新闻数据\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def detect_new_titles(self, current_data: NewsData) -> Dict[str, Dict]:\n        \"\"\"\n        检测新增的标题\n\n        Args:\n            current_data: 当前抓取的数据\n\n        Returns:\n            新增的标题数据，格式: {source_id: {title: title_data}}\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def save_txt_snapshot(self, data: NewsData) -> Optional[str]:\n        \"\"\"\n        保存 TXT 快照（可选功能，本地环境可用）\n\n        Args:\n            data: 新闻数据\n\n        Returns:\n            保存的文件路径，如果不支持返回 None\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def save_html_report(self, html_content: str, filename: str) -> Optional[str]:\n        \"\"\"\n        保存 HTML 报告\n\n        Args:\n            html_content: HTML 内容\n            filename: 文件名\n\n        Returns:\n            保存的文件路径\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def is_first_crawl_today(self, date: Optional[str] = None) -> bool:\n        \"\"\"\n        检查是否是当天第一次抓取\n\n        Args:\n            date: 日期字符串，默认为今天\n\n        Returns:\n            是否是第一次抓取\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def cleanup(self) -> None:\n        \"\"\"\n        清理资源（如临时文件、数据库连接等）\n        \"\"\"\n        pass\n\n    @abstractmethod\n    def cleanup_old_data(self, retention_days: int) -> int:\n        \"\"\"\n        清理过期数据\n\n        Args:\n            retention_days: 保留天数（0 表示不清理）\n\n        Returns:\n            删除的日期目录数量\n        \"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def backend_name(self) -> str:\n        \"\"\"\n        存储后端名称\n        \"\"\"\n        pass\n\n    @property\n    @abstractmethod\n    def supports_txt(self) -> bool:\n        \"\"\"\n        是否支持生成 TXT 快照\n        \"\"\"\n        pass\n\n    # === 时间段执行记录（调度系统）===\n\n    def has_period_executed(self, date_str: str, period_key: str, action: str) -> bool:\n        \"\"\"\n        检查指定时间段的某个 action 是否已执行\n\n        Args:\n            date_str: 日期字符串 YYYY-MM-DD\n            period_key: 时间段 key\n            action: 动作类型 (analyze / push)\n\n        Returns:\n            是否已执行\n        \"\"\"\n        return False\n\n    def record_period_execution(self, date_str: str, period_key: str, action: str) -> bool:\n        \"\"\"\n        记录时间段的 action 执行\n\n        Args:\n            date_str: 日期字符串 YYYY-MM-DD\n            period_key: 时间段 key\n            action: 动作类型 (analyze / push)\n\n        Returns:\n            是否记录成功\n        \"\"\"\n        return False\n\n    # === AI 智能筛选（默认实现，子类通过 mixin 覆盖） ===\n\n    def begin_batch(self) -> None:\n        \"\"\"开启批量模式（远程后端延迟上传，本地后端无操作）\"\"\"\n        pass\n\n    def end_batch(self) -> None:\n        \"\"\"结束批量模式\"\"\"\n        pass\n\n    def get_active_ai_filter_tags(self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> List[Dict]:\n        return []\n\n    def get_latest_prompt_hash(self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> Optional[str]:\n        return None\n\n    def get_latest_ai_filter_tag_version(self, date: Optional[str] = None) -> int:\n        return 0\n\n    def deprecate_all_ai_filter_tags(self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> int:\n        return 0\n\n    def save_ai_filter_tags(self, tags: List[Dict], version: int, prompt_hash: str, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> int:\n        return 0\n\n    def save_ai_filter_results(self, results: List[Dict], date: Optional[str] = None) -> int:\n        return 0\n\n    def get_active_ai_filter_results(self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> List[Dict]:\n        return []\n\n    def deprecate_specific_ai_filter_tags(self, tag_ids: List[int], date: Optional[str] = None) -> int:\n        return 0\n\n    def update_ai_filter_tags_hash(self, interests_file: str, new_hash: str, date: Optional[str] = None) -> int:\n        return 0\n\n    def update_ai_filter_tag_descriptions(self, tag_updates: List[Dict], date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> int:\n        return 0\n\n    def update_ai_filter_tag_priorities(self, tag_priorities: List[Dict], date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> int:\n        return 0\n\n    def save_analyzed_news(self, news_ids: List[str], source_type: str, interests_file: str, prompt_hash: str, matched_ids: Set[str], date: Optional[str] = None) -> int:\n        return 0\n\n    def get_analyzed_news_ids(self, source_type: str = \"hotlist\", date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> Set[str]:\n        return set()\n\n    def clear_analyzed_news(self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> int:\n        return 0\n\n    def clear_unmatched_analyzed_news(self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> int:\n        return 0\n\n    def get_all_news_ids(self, date: Optional[str] = None) -> List[Dict]:\n        return []\n\n    def get_all_rss_ids(self, date: Optional[str] = None) -> List[Dict]:\n        return []\n\n\ndef convert_crawl_results_to_news_data(\n    results: Dict[str, Dict],\n    id_to_name: Dict[str, str],\n    failed_ids: List[str],\n    crawl_time: str,\n    crawl_date: str,\n) -> NewsData:\n    \"\"\"\n    将爬虫结果转换为 NewsData 格式\n\n    Args:\n        results: 爬虫返回的结果 {source_id: {title: {ranks: [], url: \"\", mobileUrl: \"\"}}}\n        id_to_name: 来源ID到名称的映射\n        failed_ids: 失败的来源ID\n        crawl_time: 抓取时间（HH:MM）\n        crawl_date: 抓取日期（YYYY-MM-DD）\n\n    Returns:\n        NewsData 对象\n    \"\"\"\n    items = {}\n\n    for source_id, titles_data in results.items():\n        source_name = id_to_name.get(source_id, source_id)\n        news_list = []\n\n        for title, data in titles_data.items():\n            ranks = data.get(\"ranks\", [])\n            url = data.get(\"url\", \"\")\n            mobile_url = data.get(\"mobileUrl\", \"\")\n\n            rank = ranks[0] if ranks else 99\n\n            news_item = NewsItem(\n                title=title,\n                source_id=source_id,\n                source_name=source_name,\n                rank=rank,\n                url=url,\n                mobile_url=mobile_url,\n                crawl_time=crawl_time,\n                ranks=ranks,\n                first_time=crawl_time,\n                last_time=crawl_time,\n                count=1,\n            )\n            news_list.append(news_item)\n\n        items[source_id] = news_list\n\n    return NewsData(\n        date=crawl_date,\n        crawl_time=crawl_time,\n        items=items,\n        id_to_name=id_to_name,\n        failed_ids=failed_ids,\n    )\n"
  },
  {
    "path": "trendradar/storage/local.py",
    "content": "# coding=utf-8\n\"\"\"\n本地存储后端 - SQLite + TXT/HTML\n\n使用 SQLite 作为主存储，支持可选的 TXT 快照和 HTML 报告\n\"\"\"\n\nimport sqlite3\nimport shutil\nimport pytz\nimport re\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\nfrom typing import Dict, List, Optional\n\nfrom trendradar.storage.base import StorageBackend, NewsData, RSSItem, RSSData\nfrom trendradar.storage.sqlite_mixin import SQLiteStorageMixin\nfrom trendradar.utils.time import (\n    DEFAULT_TIMEZONE,\n    get_configured_time,\n    format_date_folder,\n    format_time_filename,\n)\n\n\nclass LocalStorageBackend(SQLiteStorageMixin, StorageBackend):\n    \"\"\"\n    本地存储后端\n\n    使用 SQLite 数据库存储新闻数据，支持：\n    - 按日期组织的 SQLite 数据库文件\n    - 可选的 TXT 快照（用于调试）\n    - HTML 报告生成\n    \"\"\"\n\n    def __init__(\n        self,\n        data_dir: str = \"output\",\n        enable_txt: bool = True,\n        enable_html: bool = True,\n        timezone: str = DEFAULT_TIMEZONE,\n    ):\n        \"\"\"\n        初始化本地存储后端\n\n        Args:\n            data_dir: 数据目录路径\n            enable_txt: 是否启用 TXT 快照\n            enable_html: 是否启用 HTML 报告\n            timezone: 时区配置\n        \"\"\"\n        self.data_dir = Path(data_dir)\n        self.enable_txt = enable_txt\n        self.enable_html = enable_html\n        self.timezone = timezone\n        self._db_connections: Dict[str, sqlite3.Connection] = {}\n\n    @property\n    def backend_name(self) -> str:\n        return \"local\"\n\n    @property\n    def supports_txt(self) -> bool:\n        return self.enable_txt\n\n    # ========================================\n    # SQLiteStorageMixin 抽象方法实现\n    # ========================================\n\n    def _get_configured_time(self) -> datetime:\n        \"\"\"获取配置时区的当前时间\"\"\"\n        return get_configured_time(self.timezone)\n\n    def _format_date_folder(self, date: Optional[str] = None) -> str:\n        \"\"\"格式化日期文件夹名 (ISO 格式: YYYY-MM-DD)\"\"\"\n        return format_date_folder(date, self.timezone)\n\n    def _format_time_filename(self) -> str:\n        \"\"\"格式化时间文件名 (格式: HH-MM)\"\"\"\n        return format_time_filename(self.timezone)\n\n    def _get_db_path(self, date: Optional[str] = None, db_type: str = \"news\") -> Path:\n        \"\"\"\n        获取 SQLite 数据库路径\n\n        新结构（扁平）：output/{type}/{date}.db\n        - output/news/2025-12-28.db\n        - output/rss/2025-12-28.db\n\n        Args:\n            date: 日期字符串\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            数据库文件路径\n        \"\"\"\n        date_str = self._format_date_folder(date)\n        db_dir = self.data_dir / db_type\n        db_dir.mkdir(parents=True, exist_ok=True)\n        return db_dir / f\"{date_str}.db\"\n\n    def _get_connection(self, date: Optional[str] = None, db_type: str = \"news\") -> sqlite3.Connection:\n        \"\"\"\n        获取数据库连接（带缓存）\n\n        Args:\n            date: 日期字符串\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            数据库连接\n        \"\"\"\n        db_path = str(self._get_db_path(date, db_type))\n\n        if db_path not in self._db_connections:\n            conn = sqlite3.connect(db_path)\n            conn.row_factory = sqlite3.Row\n            self._init_tables(conn, db_type)\n            self._db_connections[db_path] = conn\n\n        return self._db_connections[db_path]\n\n    # ========================================\n    # StorageBackend 接口实现（委托给 mixin）\n    # ========================================\n\n    def save_news_data(self, data: NewsData) -> bool:\n        \"\"\"保存新闻数据到 SQLite\"\"\"\n        db_path = self._get_db_path(data.date)\n        if not db_path.exists():\n            # 确保目录存在\n            db_path.parent.mkdir(parents=True, exist_ok=True)\n\n        success, new_count, updated_count, title_changed_count, off_list_count = \\\n            self._save_news_data_impl(data, \"[本地存储]\")\n\n        if success:\n            # 输出详细的存储统计日志\n            log_parts = [f\"[本地存储] 处理完成：新增 {new_count} 条\"]\n            if updated_count > 0:\n                log_parts.append(f\"更新 {updated_count} 条\")\n            if title_changed_count > 0:\n                log_parts.append(f\"标题变更 {title_changed_count} 条\")\n            if off_list_count > 0:\n                log_parts.append(f\"脱榜 {off_list_count} 条\")\n            print(\"，\".join(log_parts))\n\n        return success\n\n    def get_today_all_data(self, date: Optional[str] = None) -> Optional[NewsData]:\n        \"\"\"获取指定日期的所有新闻数据（合并后）\"\"\"\n        db_path = self._get_db_path(date)\n        if not db_path.exists():\n            return None\n        return self._get_today_all_data_impl(date)\n\n    def get_latest_crawl_data(self, date: Optional[str] = None) -> Optional[NewsData]:\n        \"\"\"获取最新一次抓取的数据\"\"\"\n        db_path = self._get_db_path(date)\n        if not db_path.exists():\n            return None\n        return self._get_latest_crawl_data_impl(date)\n\n    def detect_new_titles(self, current_data: NewsData) -> Dict[str, Dict]:\n        \"\"\"检测新增的标题\"\"\"\n        return self._detect_new_titles_impl(current_data)\n\n    def is_first_crawl_today(self, date: Optional[str] = None) -> bool:\n        \"\"\"检查是否是当天第一次抓取\"\"\"\n        db_path = self._get_db_path(date)\n        if not db_path.exists():\n            return True\n        return self._is_first_crawl_today_impl(date)\n\n    def get_crawl_times(self, date: Optional[str] = None) -> List[str]:\n        \"\"\"获取指定日期的所有抓取时间列表\"\"\"\n        db_path = self._get_db_path(date)\n        if not db_path.exists():\n            return []\n        return self._get_crawl_times_impl(date)\n\n    # ========================================\n    # 时间段执行记录（调度系统）\n    # ========================================\n\n    def has_period_executed(self, date_str: str, period_key: str, action: str) -> bool:\n        \"\"\"检查指定时间段的某个 action 是否已执行\"\"\"\n        return self._has_period_executed_impl(date_str, period_key, action)\n\n    def record_period_execution(self, date_str: str, period_key: str, action: str) -> bool:\n        \"\"\"记录时间段的 action 执行\"\"\"\n        success = self._record_period_execution_impl(date_str, period_key, action)\n        if success:\n            now_str = self._get_configured_time().strftime(\"%Y-%m-%d %H:%M:%S\")\n            print(f\"[本地存储] 时间段执行记录已保存: {period_key}/{action} at {now_str}\")\n        return success\n\n    # ========================================\n    # RSS 数据存储方法\n    # ========================================\n\n    def save_rss_data(self, data: RSSData) -> bool:\n        \"\"\"保存 RSS 数据到 SQLite\"\"\"\n        success, new_count, updated_count = self._save_rss_data_impl(data, \"[本地存储]\")\n\n        if success:\n            # 输出统计日志\n            log_parts = [f\"[本地存储] RSS 处理完成：新增 {new_count} 条\"]\n            if updated_count > 0:\n                log_parts.append(f\"更新 {updated_count} 条\")\n            print(\"，\".join(log_parts))\n\n        return success\n\n    def get_rss_data(self, date: Optional[str] = None) -> Optional[RSSData]:\n        \"\"\"获取指定日期的所有 RSS 数据\"\"\"\n        return self._get_rss_data_impl(date)\n\n    def detect_new_rss_items(self, current_data: RSSData) -> Dict[str, List[RSSItem]]:\n        \"\"\"检测新增的 RSS 条目\"\"\"\n        return self._detect_new_rss_items_impl(current_data)\n\n    def get_latest_rss_data(self, date: Optional[str] = None) -> Optional[RSSData]:\n        \"\"\"获取最新一次抓取的 RSS 数据\"\"\"\n        db_path = self._get_db_path(date, db_type=\"rss\")\n        if not db_path.exists():\n            return None\n        return self._get_latest_rss_data_impl(date)\n\n    # ========================================\n    # AI 智能筛选\n    # ========================================\n\n    def get_active_ai_filter_tags(self, date=None, interests_file=\"ai_interests.txt\"):\n        return self._get_active_tags_impl(date, interests_file)\n\n    def get_latest_prompt_hash(self, date=None, interests_file=\"ai_interests.txt\"):\n        return self._get_latest_prompt_hash_impl(date, interests_file)\n\n    def get_latest_ai_filter_tag_version(self, date=None):\n        return self._get_latest_tag_version_impl(date)\n\n    def deprecate_all_ai_filter_tags(self, date=None, interests_file=\"ai_interests.txt\"):\n        return self._deprecate_all_tags_impl(date, interests_file)\n\n    def save_ai_filter_tags(self, tags, version, prompt_hash, date=None, interests_file=\"ai_interests.txt\"):\n        return self._save_tags_impl(date, tags, version, prompt_hash, interests_file)\n\n    def save_ai_filter_results(self, results, date=None):\n        return self._save_filter_results_impl(date, results)\n\n    def get_active_ai_filter_results(self, date=None, interests_file=\"ai_interests.txt\"):\n        return self._get_active_filter_results_impl(date, interests_file)\n\n    def deprecate_specific_ai_filter_tags(self, tag_ids, date=None):\n        return self._deprecate_specific_tags_impl(date, tag_ids)\n\n    def update_ai_filter_tags_hash(self, interests_file, new_hash, date=None):\n        return self._update_tags_hash_impl(date, interests_file, new_hash)\n\n    def update_ai_filter_tag_descriptions(self, tag_updates, date=None, interests_file=\"ai_interests.txt\"):\n        return self._update_tag_descriptions_impl(date, tag_updates, interests_file)\n\n    def update_ai_filter_tag_priorities(self, tag_priorities, date=None, interests_file=\"ai_interests.txt\"):\n        return self._update_tag_priorities_impl(date, tag_priorities, interests_file)\n\n    def save_analyzed_news(self, news_ids, source_type, interests_file, prompt_hash, matched_ids, date=None):\n        return self._save_analyzed_news_impl(date, news_ids, source_type, interests_file, prompt_hash, matched_ids)\n\n    def get_analyzed_news_ids(self, source_type=\"hotlist\", date=None, interests_file=\"ai_interests.txt\"):\n        return self._get_analyzed_news_ids_impl(date, source_type, interests_file)\n\n    def clear_analyzed_news(self, date=None, interests_file=\"ai_interests.txt\"):\n        return self._clear_analyzed_news_impl(date, interests_file)\n\n    def clear_unmatched_analyzed_news(self, date=None, interests_file=\"ai_interests.txt\"):\n        return self._clear_unmatched_analyzed_news_impl(date, interests_file)\n\n    def get_all_news_ids(self, date=None):\n        return self._get_all_news_ids_impl(date)\n\n    def get_all_rss_ids(self, date=None):\n        return self._get_all_rss_ids_impl(date)\n\n    # ========================================\n    # 本地特有功能：TXT/HTML 快照\n    # ========================================\n\n    def save_txt_snapshot(self, data: NewsData) -> Optional[str]:\n        \"\"\"\n        保存 TXT 快照\n\n        新结构：output/txt/{date}/{time}.txt\n\n        Args:\n            data: 新闻数据\n\n        Returns:\n            保存的文件路径\n        \"\"\"\n        if not self.enable_txt:\n            return None\n\n        try:\n            date_folder = self._format_date_folder(data.date)\n            txt_dir = self.data_dir / \"txt\" / date_folder\n            txt_dir.mkdir(parents=True, exist_ok=True)\n\n            file_path = txt_dir / f\"{data.crawl_time}.txt\"\n\n            with open(file_path, \"w\", encoding=\"utf-8\") as f:\n                for source_id, news_list in data.items.items():\n                    source_name = data.id_to_name.get(source_id, source_id)\n\n                    # 写入来源标题\n                    if source_name and source_name != source_id:\n                        f.write(f\"{source_id} | {source_name}\\n\")\n                    else:\n                        f.write(f\"{source_id}\\n\")\n\n                    # 按排名排序\n                    sorted_news = sorted(news_list, key=lambda x: x.rank)\n\n                    for item in sorted_news:\n                        line = f\"{item.rank}. {item.title}\"\n                        if item.url:\n                            line += f\" [URL:{item.url}]\"\n                        if item.mobile_url:\n                            line += f\" [MOBILE:{item.mobile_url}]\"\n                        f.write(line + \"\\n\")\n\n                    f.write(\"\\n\")\n\n                # 写入失败的来源\n                if data.failed_ids:\n                    f.write(\"==== 以下ID请求失败 ====\\n\")\n                    for failed_id in data.failed_ids:\n                        f.write(f\"{failed_id}\\n\")\n\n            print(f\"[本地存储] TXT 快照已保存: {file_path}\")\n            return str(file_path)\n\n        except Exception as e:\n            print(f\"[本地存储] 保存 TXT 快照失败: {e}\")\n            return None\n\n    def save_html_report(self, html_content: str, filename: str) -> Optional[str]:\n        \"\"\"\n        保存 HTML 报告\n\n        新结构：output/html/{date}/{filename}\n\n        Args:\n            html_content: HTML 内容\n            filename: 文件名\n\n        Returns:\n            保存的文件路径\n        \"\"\"\n        if not self.enable_html:\n            return None\n\n        try:\n            date_folder = self._format_date_folder()\n            html_dir = self.data_dir / \"html\" / date_folder\n            html_dir.mkdir(parents=True, exist_ok=True)\n\n            file_path = html_dir / filename\n\n            with open(file_path, \"w\", encoding=\"utf-8\") as f:\n                f.write(html_content)\n\n            print(f\"[本地存储] HTML 报告已保存: {file_path}\")\n            return str(file_path)\n\n        except Exception as e:\n            print(f\"[本地存储] 保存 HTML 报告失败: {e}\")\n            return None\n\n    # ========================================\n    # 本地特有功能：资源清理\n    # ========================================\n\n    def cleanup(self) -> None:\n        \"\"\"清理资源（关闭数据库连接）\"\"\"\n        for db_path, conn in self._db_connections.items():\n            try:\n                conn.close()\n                print(f\"[本地存储] 关闭数据库连接: {db_path}\")\n            except Exception as e:\n                print(f\"[本地存储] 关闭连接失败 {db_path}: {e}\")\n\n        self._db_connections.clear()\n\n    def cleanup_old_data(self, retention_days: int) -> int:\n        \"\"\"\n        清理过期数据\n\n        新结构清理逻辑：\n        - output/news/{date}.db  -> 删除过期的 .db 文件\n        - output/rss/{date}.db   -> 删除过期的 .db 文件\n        - output/txt/{date}/     -> 删除过期的日期目录\n        - output/html/{date}/    -> 删除过期的日期目录\n\n        Args:\n            retention_days: 保留天数（0 表示不清理）\n\n        Returns:\n            删除的文件/目录数量\n        \"\"\"\n        if retention_days <= 0:\n            return 0\n\n        deleted_count = 0\n        cutoff_date = self._get_configured_time() - timedelta(days=retention_days)\n\n        def parse_date_from_name(name: str) -> Optional[datetime]:\n            \"\"\"从文件名或目录名解析日期 (ISO 格式: YYYY-MM-DD)\"\"\"\n            # 移除 .db 后缀\n            name = name.replace('.db', '')\n            try:\n                date_match = re.match(r'(\\d{4})-(\\d{2})-(\\d{2})', name)\n                if date_match:\n                    return datetime(\n                        int(date_match.group(1)),\n                        int(date_match.group(2)),\n                        int(date_match.group(3)),\n                        tzinfo=pytz.timezone(self.timezone)\n                    )\n            except Exception:\n                pass\n            return None\n\n        try:\n            if not self.data_dir.exists():\n                return 0\n\n            # 清理数据库文件 (news/, rss/)\n            for db_type in [\"news\", \"rss\"]:\n                db_dir = self.data_dir / db_type\n                if not db_dir.exists():\n                    continue\n\n                for db_file in db_dir.glob(\"*.db\"):\n                    file_date = parse_date_from_name(db_file.name)\n                    if file_date and file_date < cutoff_date:\n                        # 先关闭数据库连接\n                        db_path = str(db_file)\n                        if db_path in self._db_connections:\n                            try:\n                                self._db_connections[db_path].close()\n                                del self._db_connections[db_path]\n                            except Exception:\n                                pass\n\n                        # 删除文件\n                        try:\n                            db_file.unlink()\n                            deleted_count += 1\n                            print(f\"[本地存储] 清理过期数据: {db_type}/{db_file.name}\")\n                        except Exception as e:\n                            print(f\"[本地存储] 删除文件失败 {db_file}: {e}\")\n\n            # 清理快照目录 (txt/, html/)\n            for snapshot_type in [\"txt\", \"html\"]:\n                snapshot_dir = self.data_dir / snapshot_type\n                if not snapshot_dir.exists():\n                    continue\n\n                for date_folder in snapshot_dir.iterdir():\n                    if not date_folder.is_dir() or date_folder.name.startswith('.'):\n                        continue\n\n                    folder_date = parse_date_from_name(date_folder.name)\n                    if folder_date and folder_date < cutoff_date:\n                        try:\n                            shutil.rmtree(date_folder)\n                            deleted_count += 1\n                            print(f\"[本地存储] 清理过期数据: {snapshot_type}/{date_folder.name}\")\n                        except Exception as e:\n                            print(f\"[本地存储] 删除目录失败 {date_folder}: {e}\")\n\n            if deleted_count > 0:\n                print(f\"[本地存储] 共清理 {deleted_count} 个过期文件/目录\")\n\n            return deleted_count\n\n        except Exception as e:\n            print(f\"[本地存储] 清理过期数据失败: {e}\")\n            return deleted_count\n\n    def __del__(self):\n        \"\"\"析构函数，确保关闭连接\"\"\"\n        self.cleanup()\n"
  },
  {
    "path": "trendradar/storage/manager.py",
    "content": "# coding=utf-8\n\"\"\"\n存储管理器 - 统一管理存储后端\n\n根据环境和配置自动选择合适的存储后端\n\"\"\"\n\nimport os\nfrom typing import Optional\n\nfrom trendradar.storage.base import StorageBackend, NewsData, RSSData\nfrom trendradar.utils.time import DEFAULT_TIMEZONE\n\n\n# 存储管理器单例\n_storage_manager: Optional[\"StorageManager\"] = None\n\n\nclass StorageManager:\n    \"\"\"\n    存储管理器\n\n    功能：\n    - 自动检测运行环境（GitHub Actions / Docker / 本地）\n    - 根据配置选择存储后端（local / remote / auto）\n    - 提供统一的存储接口\n    - 支持从远程拉取数据到本地\n    \"\"\"\n\n    def __init__(\n        self,\n        backend_type: str = \"auto\",\n        data_dir: str = \"output\",\n        enable_txt: bool = True,\n        enable_html: bool = True,\n        remote_config: Optional[dict] = None,\n        local_retention_days: int = 0,\n        remote_retention_days: int = 0,\n        pull_enabled: bool = False,\n        pull_days: int = 0,\n        timezone: str = DEFAULT_TIMEZONE,\n    ):\n        \"\"\"\n        初始化存储管理器\n\n        Args:\n            backend_type: 存储后端类型 (local / remote / auto)\n            data_dir: 本地数据目录\n            enable_txt: 是否启用 TXT 快照\n            enable_html: 是否启用 HTML 报告\n            remote_config: 远程存储配置（endpoint_url, bucket_name, access_key_id 等）\n            local_retention_days: 本地数据保留天数（0 = 无限制）\n            remote_retention_days: 远程数据保留天数（0 = 无限制）\n            pull_enabled: 是否启用启动时自动拉取\n            pull_days: 拉取最近 N 天的数据\n            timezone: 时区配置\n        \"\"\"\n        self.backend_type = backend_type\n        self.data_dir = data_dir\n        self.enable_txt = enable_txt\n        self.enable_html = enable_html\n        self.remote_config = remote_config or {}\n        self.local_retention_days = local_retention_days\n        self.remote_retention_days = remote_retention_days\n        self.pull_enabled = pull_enabled\n        self.pull_days = pull_days\n        self.timezone = timezone\n\n        self._backend: Optional[StorageBackend] = None\n        self._remote_backend: Optional[StorageBackend] = None\n\n    @staticmethod\n    def is_github_actions() -> bool:\n        \"\"\"检测是否在 GitHub Actions 环境中运行\"\"\"\n        return os.environ.get(\"GITHUB_ACTIONS\") == \"true\"\n\n    @staticmethod\n    def is_docker() -> bool:\n        \"\"\"检测是否在 Docker 容器中运行\"\"\"\n        # 方法1: 检查 /.dockerenv 文件\n        if os.path.exists(\"/.dockerenv\"):\n            return True\n\n        # 方法2: 检查 cgroup（Linux）\n        try:\n            with open(\"/proc/1/cgroup\", \"r\") as f:\n                return \"docker\" in f.read()\n        except (FileNotFoundError, PermissionError):\n            pass\n\n        # 方法3: 检查环境变量\n        return os.environ.get(\"DOCKER_CONTAINER\") == \"true\"\n\n    def _resolve_backend_type(self) -> str:\n        \"\"\"解析实际使用的后端类型\"\"\"\n        if self.backend_type == \"auto\":\n            if self.is_github_actions():\n                # GitHub Actions 环境，检查是否配置了远程存储\n                if self._has_remote_config():\n                    return \"remote\"\n                else:\n                    print(\"[存储管理器] GitHub Actions 环境但未配置远程存储，使用本地存储\")\n                    return \"local\"\n            else:\n                return \"local\"\n        return self.backend_type\n\n    def _has_remote_config(self) -> bool:\n        \"\"\"检查是否有有效的远程存储配置\"\"\"\n        # 检查配置或环境变量\n        bucket_name = self.remote_config.get(\"bucket_name\") or os.environ.get(\"S3_BUCKET_NAME\")\n        access_key = self.remote_config.get(\"access_key_id\") or os.environ.get(\"S3_ACCESS_KEY_ID\")\n        secret_key = self.remote_config.get(\"secret_access_key\") or os.environ.get(\"S3_SECRET_ACCESS_KEY\")\n        endpoint = self.remote_config.get(\"endpoint_url\") or os.environ.get(\"S3_ENDPOINT_URL\")\n\n        # 调试日志\n        has_config = bool(bucket_name and access_key and secret_key and endpoint)\n        if not has_config:\n            print(f\"[存储管理器] 远程存储配置检查失败:\")\n            print(f\"  - bucket_name: {'已配置' if bucket_name else '未配置'}\")\n            print(f\"  - access_key_id: {'已配置' if access_key else '未配置'}\")\n            print(f\"  - secret_access_key: {'已配置' if secret_key else '未配置'}\")\n            print(f\"  - endpoint_url: {'已配置' if endpoint else '未配置'}\")\n\n        return has_config\n\n    def _create_remote_backend(self) -> Optional[StorageBackend]:\n        \"\"\"创建远程存储后端\"\"\"\n        try:\n            from trendradar.storage.remote import RemoteStorageBackend\n\n            return RemoteStorageBackend(\n                bucket_name=self.remote_config.get(\"bucket_name\") or os.environ.get(\"S3_BUCKET_NAME\", \"\"),\n                access_key_id=self.remote_config.get(\"access_key_id\") or os.environ.get(\"S3_ACCESS_KEY_ID\", \"\"),\n                secret_access_key=self.remote_config.get(\"secret_access_key\") or os.environ.get(\"S3_SECRET_ACCESS_KEY\", \"\"),\n                endpoint_url=self.remote_config.get(\"endpoint_url\") or os.environ.get(\"S3_ENDPOINT_URL\", \"\"),\n                region=self.remote_config.get(\"region\") or os.environ.get(\"S3_REGION\", \"\"),\n                enable_txt=self.enable_txt,\n                enable_html=self.enable_html,\n                timezone=self.timezone,\n            )\n        except ImportError as e:\n            print(f\"[存储管理器] 远程后端导入失败: {e}\")\n            print(\"[存储管理器] 请确保已安装 boto3: pip install boto3\")\n            return None\n        except Exception as e:\n            print(f\"[存储管理器] 远程后端初始化失败: {e}\")\n            return None\n\n    def get_backend(self) -> StorageBackend:\n        \"\"\"获取存储后端实例\"\"\"\n        if self._backend is None:\n            resolved_type = self._resolve_backend_type()\n\n            if resolved_type == \"remote\":\n                self._backend = self._create_remote_backend()\n                if self._backend:\n                    print(f\"[存储管理器] 使用远程存储后端\")\n                else:\n                    print(\"[存储管理器] 回退到本地存储\")\n                    resolved_type = \"local\"\n\n            if resolved_type == \"local\" or self._backend is None:\n                from trendradar.storage.local import LocalStorageBackend\n\n                self._backend = LocalStorageBackend(\n                    data_dir=self.data_dir,\n                    enable_txt=self.enable_txt,\n                    enable_html=self.enable_html,\n                    timezone=self.timezone,\n                )\n                print(f\"[存储管理器] 使用本地存储后端 (数据目录: {self.data_dir})\")\n\n        return self._backend\n\n    def pull_from_remote(self) -> int:\n        \"\"\"\n        从远程拉取数据到本地\n\n        Returns:\n            成功拉取的文件数量\n        \"\"\"\n        if not self.pull_enabled or self.pull_days <= 0:\n            return 0\n\n        if not self._has_remote_config():\n            print(\"[存储管理器] 未配置远程存储，无法拉取\")\n            return 0\n\n        # 创建远程后端（如果还没有）\n        if self._remote_backend is None:\n            self._remote_backend = self._create_remote_backend()\n\n        if self._remote_backend is None:\n            print(\"[存储管理器] 无法创建远程后端，拉取失败\")\n            return 0\n\n        # 调用拉取方法\n        return self._remote_backend.pull_recent_days(self.pull_days, self.data_dir)\n\n    def save_news_data(self, data: NewsData) -> bool:\n        \"\"\"保存新闻数据\"\"\"\n        return self.get_backend().save_news_data(data)\n\n    def save_rss_data(self, data: RSSData) -> bool:\n        \"\"\"保存 RSS 数据\"\"\"\n        return self.get_backend().save_rss_data(data)\n\n    def get_rss_data(self, date: Optional[str] = None) -> Optional[RSSData]:\n        \"\"\"获取指定日期的所有 RSS 数据（当日汇总模式）\"\"\"\n        return self.get_backend().get_rss_data(date)\n\n    def get_latest_rss_data(self, date: Optional[str] = None) -> Optional[RSSData]:\n        \"\"\"获取最新一次抓取的 RSS 数据（当前榜单模式）\"\"\"\n        return self.get_backend().get_latest_rss_data(date)\n\n    def detect_new_rss_items(self, current_data: RSSData) -> dict:\n        \"\"\"检测新增的 RSS 条目（增量模式）\"\"\"\n        return self.get_backend().detect_new_rss_items(current_data)\n\n    def get_today_all_data(self, date: Optional[str] = None) -> Optional[NewsData]:\n        \"\"\"获取当天所有数据\"\"\"\n        return self.get_backend().get_today_all_data(date)\n\n    def get_latest_crawl_data(self, date: Optional[str] = None) -> Optional[NewsData]:\n        \"\"\"获取最新抓取数据\"\"\"\n        return self.get_backend().get_latest_crawl_data(date)\n\n    def detect_new_titles(self, current_data: NewsData) -> dict:\n        \"\"\"检测新增标题\"\"\"\n        return self.get_backend().detect_new_titles(current_data)\n\n    def save_txt_snapshot(self, data: NewsData) -> Optional[str]:\n        \"\"\"保存 TXT 快照\"\"\"\n        return self.get_backend().save_txt_snapshot(data)\n\n    def save_html_report(self, html_content: str, filename: str) -> Optional[str]:\n        \"\"\"保存 HTML 报告\"\"\"\n        return self.get_backend().save_html_report(html_content, filename)\n\n    def is_first_crawl_today(self, date: Optional[str] = None) -> bool:\n        \"\"\"检查是否是当天第一次抓取\"\"\"\n        return self.get_backend().is_first_crawl_today(date)\n\n    def cleanup(self) -> None:\n        \"\"\"清理资源\"\"\"\n        if self._backend:\n            self._backend.cleanup()\n        if self._remote_backend:\n            self._remote_backend.cleanup()\n\n    def cleanup_old_data(self) -> int:\n        \"\"\"\n        清理过期数据\n\n        Returns:\n            删除的日期目录数量\n        \"\"\"\n        total_deleted = 0\n\n        # 清理本地数据\n        if self.local_retention_days > 0:\n            total_deleted += self.get_backend().cleanup_old_data(self.local_retention_days)\n\n        # 清理远程数据（如果配置了）\n        if self.remote_retention_days > 0 and self._has_remote_config():\n            if self._remote_backend is None:\n                self._remote_backend = self._create_remote_backend()\n            if self._remote_backend:\n                total_deleted += self._remote_backend.cleanup_old_data(self.remote_retention_days)\n\n        return total_deleted\n\n    @property\n    def backend_name(self) -> str:\n        \"\"\"获取当前后端名称\"\"\"\n        return self.get_backend().backend_name\n\n    @property\n    def supports_txt(self) -> bool:\n        \"\"\"是否支持 TXT 快照\"\"\"\n        return self.get_backend().supports_txt\n\n    def has_period_executed(self, date_str: str, period_key: str, action: str) -> bool:\n        \"\"\"检查指定时间段的某个 action 是否已执行\"\"\"\n        return self.get_backend().has_period_executed(date_str, period_key, action)\n\n    def record_period_execution(self, date_str: str, period_key: str, action: str) -> bool:\n        \"\"\"记录时间段的 action 执行\"\"\"\n        return self.get_backend().record_period_execution(date_str, period_key, action)\n\n    # === AI 智能筛选存储操作 ===\n\n    def begin_batch(self):\n        \"\"\"开启批量模式（远程后端延迟上传）\"\"\"\n        self.get_backend().begin_batch()\n\n    def end_batch(self):\n        \"\"\"结束批量模式（统一上传脏数据库）\"\"\"\n        self.get_backend().end_batch()\n\n    def get_active_ai_filter_tags(self, date=None, interests_file=\"ai_interests.txt\"):\n        \"\"\"获取指定兴趣文件的 active 标签\"\"\"\n        return self.get_backend().get_active_ai_filter_tags(date, interests_file)\n\n    def get_latest_prompt_hash(self, date=None, interests_file=\"ai_interests.txt\"):\n        \"\"\"获取指定兴趣文件的最新 prompt_hash\"\"\"\n        return self.get_backend().get_latest_prompt_hash(date, interests_file)\n\n    def get_latest_ai_filter_tag_version(self, date=None):\n        \"\"\"获取最新标签版本号\"\"\"\n        return self.get_backend().get_latest_ai_filter_tag_version(date)\n\n    def deprecate_all_ai_filter_tags(self, date=None, interests_file=\"ai_interests.txt\"):\n        \"\"\"废弃指定兴趣文件的 active 标签和分类结果\"\"\"\n        return self.get_backend().deprecate_all_ai_filter_tags(date, interests_file)\n\n    def save_ai_filter_tags(self, tags, version, prompt_hash, date=None, interests_file=\"ai_interests.txt\"):\n        \"\"\"保存新提取的标签\"\"\"\n        return self.get_backend().save_ai_filter_tags(tags, version, prompt_hash, date, interests_file)\n\n    def save_ai_filter_results(self, results, date=None):\n        \"\"\"保存分类结果\"\"\"\n        return self.get_backend().save_ai_filter_results(results, date)\n\n    def get_active_ai_filter_results(self, date=None, interests_file=\"ai_interests.txt\"):\n        \"\"\"获取指定兴趣文件的 active 分类结果\"\"\"\n        return self.get_backend().get_active_ai_filter_results(date, interests_file)\n\n    def deprecate_specific_ai_filter_tags(self, tag_ids, date=None):\n        \"\"\"废弃指定 ID 的标签及其关联分类结果\"\"\"\n        return self.get_backend().deprecate_specific_ai_filter_tags(tag_ids, date)\n\n    def update_ai_filter_tags_hash(self, interests_file, new_hash, date=None):\n        \"\"\"更新指定兴趣文件所有 active 标签的 prompt_hash\"\"\"\n        return self.get_backend().update_ai_filter_tags_hash(interests_file, new_hash, date)\n\n    def update_ai_filter_tag_descriptions(self, tag_updates, date=None, interests_file=\"ai_interests.txt\"):\n        \"\"\"按 tag 名匹配，更新 active 标签的 description\"\"\"\n        return self.get_backend().update_ai_filter_tag_descriptions(tag_updates, date, interests_file)\n\n    def update_ai_filter_tag_priorities(self, tag_priorities, date=None, interests_file=\"ai_interests.txt\"):\n        \"\"\"按 tag 名匹配，更新 active 标签的 priority\"\"\"\n        return self.get_backend().update_ai_filter_tag_priorities(tag_priorities, date, interests_file)\n\n    def save_analyzed_news(self, news_ids, source_type, interests_file, prompt_hash, matched_ids, date=None):\n        \"\"\"批量记录已分析的新闻（匹配与不匹配都记录）\"\"\"\n        return self.get_backend().save_analyzed_news(news_ids, source_type, interests_file, prompt_hash, matched_ids, date)\n\n    def get_analyzed_news_ids(self, source_type=\"hotlist\", date=None, interests_file=\"ai_interests.txt\"):\n        \"\"\"获取已分析过的新闻 ID 集合\"\"\"\n        return self.get_backend().get_analyzed_news_ids(source_type, date, interests_file)\n\n    def clear_analyzed_news(self, date=None, interests_file=\"ai_interests.txt\"):\n        \"\"\"清除指定兴趣文件的所有已分析记录\"\"\"\n        return self.get_backend().clear_analyzed_news(date, interests_file)\n\n    def clear_unmatched_analyzed_news(self, date=None, interests_file=\"ai_interests.txt\"):\n        \"\"\"清除不匹配的已分析记录\"\"\"\n        return self.get_backend().clear_unmatched_analyzed_news(date, interests_file)\n\n    def get_all_news_ids(self, date=None):\n        \"\"\"获取所有新闻 ID 和标题\"\"\"\n        return self.get_backend().get_all_news_ids(date)\n\n    def get_all_rss_ids(self, date=None):\n        \"\"\"获取所有 RSS ID 和标题\"\"\"\n        return self.get_backend().get_all_rss_ids(date)\n\n\n\ndef get_storage_manager(\n    backend_type: str = \"auto\",\n    data_dir: str = \"output\",\n    enable_txt: bool = True,\n    enable_html: bool = True,\n    remote_config: Optional[dict] = None,\n    local_retention_days: int = 0,\n    remote_retention_days: int = 0,\n    pull_enabled: bool = False,\n    pull_days: int = 0,\n    timezone: str = DEFAULT_TIMEZONE,\n    force_new: bool = False,\n) -> StorageManager:\n    \"\"\"\n    获取存储管理器单例\n\n    Args:\n        backend_type: 存储后端类型\n        data_dir: 本地数据目录\n        enable_txt: 是否启用 TXT 快照\n        enable_html: 是否启用 HTML 报告\n        remote_config: 远程存储配置\n        local_retention_days: 本地数据保留天数（0 = 无限制）\n        remote_retention_days: 远程数据保留天数（0 = 无限制）\n        pull_enabled: 是否启用启动时自动拉取\n        pull_days: 拉取最近 N 天的数据\n        timezone: 时区配置\n        force_new: 是否强制创建新实例\n\n    Returns:\n        StorageManager 实例\n    \"\"\"\n    global _storage_manager\n\n    if _storage_manager is None or force_new:\n        _storage_manager = StorageManager(\n            backend_type=backend_type,\n            data_dir=data_dir,\n            enable_txt=enable_txt,\n            enable_html=enable_html,\n            remote_config=remote_config,\n            local_retention_days=local_retention_days,\n            remote_retention_days=remote_retention_days,\n            pull_enabled=pull_enabled,\n            pull_days=pull_days,\n            timezone=timezone,\n        )\n\n    return _storage_manager\n"
  },
  {
    "path": "trendradar/storage/remote.py",
    "content": "# coding=utf-8\n\"\"\"\n远程存储后端（S3 兼容协议）\n\n支持 Cloudflare R2、阿里云 OSS、腾讯云 COS、AWS S3、MinIO 等\n使用 S3 兼容 API (boto3) 访问对象存储\n数据流程：下载当天 SQLite → 合并新数据 → 上传回远程\n\"\"\"\n\nimport pytz\nimport re\nimport shutil\nimport sys\nimport tempfile\nimport sqlite3\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\nfrom typing import Dict, List, Optional\n\ntry:\n    import boto3\n    from botocore.config import Config as BotoConfig\n    from botocore.exceptions import ClientError\n    HAS_BOTO3 = True\nexcept ImportError:\n    HAS_BOTO3 = False\n    boto3 = None\n    BotoConfig = None\n    ClientError = Exception\n\nfrom trendradar.storage.base import StorageBackend, NewsData, RSSItem, RSSData\nfrom trendradar.storage.sqlite_mixin import SQLiteStorageMixin\nfrom trendradar.utils.time import (\n    DEFAULT_TIMEZONE,\n    get_configured_time,\n    format_date_folder,\n    format_time_filename,\n)\n\n\nclass RemoteStorageBackend(SQLiteStorageMixin, StorageBackend):\n    \"\"\"\n    远程云存储后端（S3 兼容协议）\n\n    特点：\n    - 使用 S3 兼容 API 访问远程存储\n    - 支持 Cloudflare R2、阿里云 OSS、腾讯云 COS、AWS S3、MinIO 等\n    - 下载 SQLite 到临时目录进行操作\n    - 支持数据合并和上传\n    - 支持从远程拉取历史数据到本地\n    - 运行结束后自动清理临时文件\n    \"\"\"\n\n    def __init__(\n        self,\n        bucket_name: str,\n        access_key_id: str,\n        secret_access_key: str,\n        endpoint_url: str,\n        region: str = \"\",\n        enable_txt: bool = False,  # 远程模式默认不生成 TXT\n        enable_html: bool = True,\n        temp_dir: Optional[str] = None,\n        timezone: str = DEFAULT_TIMEZONE,\n    ):\n        \"\"\"\n        初始化远程存储后端\n\n        Args:\n            bucket_name: 存储桶名称\n            access_key_id: 访问密钥 ID\n            secret_access_key: 访问密钥\n            endpoint_url: 服务端点 URL\n            region: 区域（可选，部分服务商需要）\n            enable_txt: 是否启用 TXT 快照（默认关闭）\n            enable_html: 是否启用 HTML 报告\n            temp_dir: 临时目录路径（默认使用系统临时目录）\n            timezone: 时区配置\n        \"\"\"\n        if not HAS_BOTO3:\n            raise ImportError(\"远程存储后端需要安装 boto3: pip install boto3\")\n\n        self.bucket_name = bucket_name\n        self.endpoint_url = endpoint_url\n        self.region = region\n        self.enable_txt = enable_txt\n        self.enable_html = enable_html\n        self.timezone = timezone\n\n        # 创建临时目录\n        self.temp_dir = Path(temp_dir) if temp_dir else Path(tempfile.mkdtemp(prefix=\"trendradar_\"))\n        self.temp_dir.mkdir(parents=True, exist_ok=True)\n\n        # 初始化 S3 客户端\n        # 使用 virtual-hosted style addressing（主流）\n        # 根据服务商选择签名版本：\n        # - 腾讯云 COS 和 阿里云 OSS 使用 SigV2 以避免 chunked encoding 问题\n        # - 其他服务商（AWS S3、Cloudflare R2、MinIO 等）默认使用 SigV4\n        use_sigv2 = \"myqcloud.com\" in endpoint_url.lower() or \"aliyuncs.com\" in endpoint_url.lower()\n        signature_version = 's3' if use_sigv2 else 's3v4'\n\n        s3_config = BotoConfig(\n            s3={\"addressing_style\": \"virtual\"},\n            signature_version=signature_version,\n        )\n\n        client_kwargs = {\n            \"endpoint_url\": endpoint_url,\n            \"aws_access_key_id\": access_key_id,\n            \"aws_secret_access_key\": secret_access_key,\n            \"config\": s3_config,\n        }\n        if region:\n            client_kwargs[\"region_name\"] = region\n\n        self.s3_client = boto3.client(\"s3\", **client_kwargs)\n\n        # 跟踪下载的文件（用于清理）\n        self._downloaded_files: List[Path] = []\n        self._db_connections: Dict[str, sqlite3.Connection] = {}\n\n        # 批量模式：延迟上传，避免频繁上传同一文件\n        self._batch_mode = False\n        self._batch_dirty: set = set()  # 待上传的 (date, db_type) 集合\n\n        print(f\"[远程存储] 初始化完成，存储桶: {bucket_name}，签名版本: {signature_version}\")\n\n    @property\n    def backend_name(self) -> str:\n        return \"remote\"\n\n    @property\n    def supports_txt(self) -> bool:\n        return self.enable_txt\n\n    # ========================================\n    # SQLiteStorageMixin 抽象方法实现\n    # ========================================\n\n    def _get_configured_time(self) -> datetime:\n        \"\"\"获取配置时区的当前时间\"\"\"\n        return get_configured_time(self.timezone)\n\n    def _format_date_folder(self, date: Optional[str] = None) -> str:\n        \"\"\"格式化日期文件夹名 (ISO 格式: YYYY-MM-DD)\"\"\"\n        return format_date_folder(date, self.timezone)\n\n    def _format_time_filename(self) -> str:\n        \"\"\"格式化时间文件名 (格式: HH-MM)\"\"\"\n        return format_time_filename(self.timezone)\n\n    def _get_remote_db_key(self, date: Optional[str] = None, db_type: str = \"news\") -> str:\n        \"\"\"\n        获取远程存储中 SQLite 文件的对象键\n\n        Args:\n            date: 日期字符串\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            远程对象键，如 \"news/2025-12-28.db\" 或 \"rss/2025-12-28.db\"\n        \"\"\"\n        date_folder = self._format_date_folder(date)\n        return f\"{db_type}/{date_folder}.db\"\n\n    def _get_local_db_path(self, date: Optional[str] = None, db_type: str = \"news\") -> Path:\n        \"\"\"\n        获取本地临时 SQLite 文件路径\n\n        Args:\n            date: 日期字符串\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            本地临时文件路径\n        \"\"\"\n        date_folder = self._format_date_folder(date)\n        db_dir = self.temp_dir / db_type\n        db_dir.mkdir(parents=True, exist_ok=True)\n        return db_dir / f\"{date_folder}.db\"\n\n    def _check_object_exists(self, r2_key: str) -> bool:\n        \"\"\"\n        检查远程存储中对象是否存在\n\n        Args:\n            r2_key: 远程对象键\n\n        Returns:\n            是否存在\n        \"\"\"\n        try:\n            self.s3_client.head_object(Bucket=self.bucket_name, Key=r2_key)\n            return True\n        except ClientError as e:\n            error_code = e.response.get(\"Error\", {}).get(\"Code\", \"\")\n            # S3 兼容存储可能返回 404, NoSuchKey, 或其他变体\n            if error_code in (\"404\", \"NoSuchKey\", \"Not Found\"):\n                return False\n            # 其他错误（如权限问题）也视为不存在，但打印警告\n            print(f\"[远程存储] 检查对象存在性失败 ({r2_key}): {e}\")\n            return False\n        except Exception as e:\n            print(f\"[远程存储] 检查对象存在性异常 ({r2_key}): {e}\")\n            return False\n\n    def _download_sqlite(self, date: Optional[str] = None, db_type: str = \"news\") -> Optional[Path]:\n        \"\"\"\n        从远程存储下载当天的 SQLite 文件到本地临时目录\n\n        使用 get_object + iter_chunks 替代 download_file，\n        以正确处理腾讯云 COS 的 chunked transfer encoding。\n\n        Args:\n            date: 日期字符串\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            本地文件路径，如果不存在返回 None\n        \"\"\"\n        r2_key = self._get_remote_db_key(date, db_type)\n        local_path = self._get_local_db_path(date, db_type)\n\n        # 确保目录存在\n        local_path.parent.mkdir(parents=True, exist_ok=True)\n\n        # 先检查文件是否存在\n        if not self._check_object_exists(r2_key):\n            print(f\"[远程存储] 文件不存在，将创建新数据库: {r2_key}\")\n            return None\n\n        try:\n            # 使用 get_object + iter_chunks 替代 download_file\n            # iter_chunks 会自动处理 chunked transfer encoding\n            response = self.s3_client.get_object(Bucket=self.bucket_name, Key=r2_key)\n            with open(local_path, 'wb') as f:\n                for chunk in response['Body'].iter_chunks(chunk_size=1024*1024):\n                    f.write(chunk)\n            self._downloaded_files.append(local_path)\n            print(f\"[远程存储] 已下载: {r2_key} -> {local_path}\")\n            return local_path\n        except ClientError as e:\n            error_code = e.response.get(\"Error\", {}).get(\"Code\", \"\")\n            # S3 兼容存储可能返回不同的错误码\n            if error_code in (\"404\", \"NoSuchKey\", \"Not Found\"):\n                print(f\"[远程存储] 文件不存在，将创建新数据库: {r2_key}\")\n                return None\n            else:\n                print(f\"[远程存储] 下载失败 (错误码: {error_code}): {e}\")\n                raise\n        except Exception as e:\n            print(f\"[远程存储] 下载异常: {e}\")\n            raise\n\n    def begin_batch(self):\n        \"\"\"开启批量模式：延迟上传，避免频繁上传同一文件\"\"\"\n        self._batch_mode = True\n        self._batch_dirty.clear()\n\n    def end_batch(self):\n        \"\"\"结束批量模式：统一上传所有脏数据库\"\"\"\n        self._batch_mode = False\n        for date, db_type in self._batch_dirty:\n            self._upload_sqlite(date, db_type)\n        self._batch_dirty.clear()\n\n    def _upload_sqlite(self, date: Optional[str] = None, db_type: str = \"news\") -> bool:\n        \"\"\"\n        上传本地 SQLite 文件到远程存储\n\n        批量模式下延迟上传，由 end_batch() 统一触发。\n\n        Args:\n            date: 日期字符串\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            是否上传成功\n        \"\"\"\n        if self._batch_mode:\n            self._batch_dirty.add((date, db_type))\n            return True\n        local_path = self._get_local_db_path(date, db_type)\n        r2_key = self._get_remote_db_key(date, db_type)\n\n        if not local_path.exists():\n            print(f\"[远程存储] 本地文件不存在，无法上传: {local_path}\")\n            return False\n\n        try:\n            # 获取本地文件大小\n            local_size = local_path.stat().st_size\n            print(f\"[远程存储] 准备上传: {local_path} ({local_size} bytes) -> {r2_key}\")\n\n            # 读取文件内容为 bytes 后上传\n            # 避免传入文件对象时 requests 库使用 chunked transfer encoding\n            # 腾讯云 COS 等 S3 兼容服务可能无法正确处理 chunked encoding\n            with open(local_path, 'rb') as f:\n                file_content = f.read()\n\n            # 使用 put_object 并明确设置 ContentLength，确保不使用 chunked encoding\n            self.s3_client.put_object(\n                Bucket=self.bucket_name,\n                Key=r2_key,\n                Body=file_content,\n                ContentLength=local_size,\n                ContentType='application/x-sqlite3',\n            )\n            print(f\"[远程存储] 已上传: {local_path} -> {r2_key}\")\n\n            # 验证上传成功\n            if self._check_object_exists(r2_key):\n                print(f\"[远程存储] 上传验证成功: {r2_key}\")\n                return True\n            else:\n                print(f\"[远程存储] 上传验证失败: 文件未在远程存储中找到\")\n                return False\n\n        except Exception as e:\n            print(f\"[远程存储] 上传失败: {e}\")\n            return False\n\n    def _get_connection(self, date: Optional[str] = None, db_type: str = \"news\") -> sqlite3.Connection:\n        \"\"\"\n        获取数据库连接\n\n        Args:\n            date: 日期字符串\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            数据库连接\n        \"\"\"\n        local_path = self._get_local_db_path(date, db_type)\n        db_path = str(local_path)\n\n        if db_path not in self._db_connections:\n            # 确保目录存在\n            local_path.parent.mkdir(parents=True, exist_ok=True)\n\n            # 如果本地不存在，尝试从远程存储下载\n            if not local_path.exists():\n                self._download_sqlite(date, db_type)\n\n            conn = sqlite3.connect(db_path)\n            conn.row_factory = sqlite3.Row\n            self._init_tables(conn, db_type)\n            self._db_connections[db_path] = conn\n\n        return self._db_connections[db_path]\n\n    # ========================================\n    # StorageBackend 接口实现（委托给 mixin + 上传）\n    # ========================================\n\n    def save_news_data(self, data: NewsData) -> bool:\n        \"\"\"\n        保存新闻数据到远程存储\n\n        流程：下载现有数据库 → 插入/更新数据 → 上传回远程存储\n        \"\"\"\n        # 查询已有记录数\n        conn = self._get_connection(data.date)\n        cursor = conn.cursor()\n        cursor.execute(\"SELECT COUNT(*) as count FROM news_items\")\n        row = cursor.fetchone()\n        existing_count = row[0] if row else 0\n        if existing_count > 0:\n            print(f\"[远程存储] 已有 {existing_count} 条历史记录，将合并新数据\")\n\n        # 使用 mixin 的实现保存数据\n        success, new_count, updated_count, title_changed_count, off_list_count = \\\n            self._save_news_data_impl(data, \"[远程存储]\")\n\n        if not success:\n            return False\n\n        # 查询合并后的总记录数\n        cursor.execute(\"SELECT COUNT(*) as count FROM news_items\")\n        row = cursor.fetchone()\n        final_count = row[0] if row else 0\n\n        # 输出详细的存储统计日志\n        log_parts = [f\"[远程存储] 处理完成：新增 {new_count} 条\"]\n        if updated_count > 0:\n            log_parts.append(f\"更新 {updated_count} 条\")\n        if title_changed_count > 0:\n            log_parts.append(f\"标题变更 {title_changed_count} 条\")\n        if off_list_count > 0:\n            log_parts.append(f\"脱榜 {off_list_count} 条\")\n        log_parts.append(f\"(去重后总计: {final_count} 条)\")\n        print(\"，\".join(log_parts))\n\n        # 上传到远程存储\n        if self._upload_sqlite(data.date):\n            print(f\"[远程存储] 数据已同步到远程存储\")\n            return True\n        else:\n            print(f\"[远程存储] 上传远程存储失败\")\n            return False\n\n    def get_today_all_data(self, date: Optional[str] = None) -> Optional[NewsData]:\n        \"\"\"获取指定日期的所有新闻数据（合并后）\"\"\"\n        return self._get_today_all_data_impl(date)\n\n    def get_latest_crawl_data(self, date: Optional[str] = None) -> Optional[NewsData]:\n        \"\"\"获取最新一次抓取的数据\"\"\"\n        return self._get_latest_crawl_data_impl(date)\n\n    def detect_new_titles(self, current_data: NewsData) -> Dict[str, Dict]:\n        \"\"\"检测新增的标题\"\"\"\n        return self._detect_new_titles_impl(current_data)\n\n    def is_first_crawl_today(self, date: Optional[str] = None) -> bool:\n        \"\"\"检查是否是当天第一次抓取\"\"\"\n        return self._is_first_crawl_today_impl(date)\n\n    # ========================================\n    # 时间段执行记录（调度系统）\n    # ========================================\n\n    def has_period_executed(self, date_str: str, period_key: str, action: str) -> bool:\n        \"\"\"检查指定时间段的某个 action 是否已执行\"\"\"\n        return self._has_period_executed_impl(date_str, period_key, action)\n\n    def record_period_execution(self, date_str: str, period_key: str, action: str) -> bool:\n        \"\"\"记录时间段的 action 执行\"\"\"\n        success = self._record_period_execution_impl(date_str, period_key, action)\n\n        if success:\n            now_str = self._get_configured_time().strftime(\"%Y-%m-%d %H:%M:%S\")\n            print(f\"[远程存储] 时间段执行记录已保存: {period_key}/{action} at {now_str}\")\n\n            # 上传到远程存储确保记录持久化\n            if self._upload_sqlite(date_str):\n                print(f\"[远程存储] 时间段执行记录已同步到远程存储\")\n                return True\n            else:\n                print(f\"[远程存储] 时间段执行记录同步到远程存储失败\")\n                return False\n\n        return False\n\n    # ========================================\n    # RSS 数据存储方法\n    # ========================================\n\n    def save_rss_data(self, data: RSSData) -> bool:\n        \"\"\"\n        保存 RSS 数据到远程存储\n\n        流程：下载现有数据库 → 插入/更新数据 → 上传回远程存储\n        \"\"\"\n        success, new_count, updated_count = self._save_rss_data_impl(data, \"[远程存储]\")\n\n        if not success:\n            return False\n\n        # 输出统计日志\n        log_parts = [f\"[远程存储] RSS 处理完成：新增 {new_count} 条\"]\n        if updated_count > 0:\n            log_parts.append(f\"更新 {updated_count} 条\")\n        print(\"，\".join(log_parts))\n\n        # 上传到远程存储\n        if self._upload_sqlite(data.date, db_type=\"rss\"):\n            print(f\"[远程存储] RSS 数据已同步到远程存储\")\n            return True\n        else:\n            print(f\"[远程存储] RSS 上传远程存储失败\")\n            return False\n\n    def get_rss_data(self, date: Optional[str] = None) -> Optional[RSSData]:\n        \"\"\"获取指定日期的所有 RSS 数据\"\"\"\n        return self._get_rss_data_impl(date)\n\n    def detect_new_rss_items(self, current_data: RSSData) -> Dict[str, List[RSSItem]]:\n        \"\"\"检测新增的 RSS 条目\"\"\"\n        return self._detect_new_rss_items_impl(current_data)\n\n    def get_latest_rss_data(self, date: Optional[str] = None) -> Optional[RSSData]:\n        \"\"\"获取最新一次抓取的 RSS 数据\"\"\"\n        return self._get_latest_rss_data_impl(date)\n\n    # ========================================\n    # AI 智能筛选存储方法\n    # ========================================\n\n    def get_active_ai_filter_tags(self, date=None, interests_file=\"ai_interests.txt\"):\n        return self._get_active_tags_impl(date, interests_file)\n\n    def get_latest_prompt_hash(self, date=None, interests_file=\"ai_interests.txt\"):\n        return self._get_latest_prompt_hash_impl(date, interests_file)\n\n    def get_latest_ai_filter_tag_version(self, date=None):\n        return self._get_latest_tag_version_impl(date)\n\n    def deprecate_all_ai_filter_tags(self, date=None, interests_file=\"ai_interests.txt\"):\n        count = self._deprecate_all_tags_impl(date, interests_file)\n        if count > 0:\n            self._upload_sqlite(date)\n        return count\n\n    def save_ai_filter_tags(self, tags, version, prompt_hash, date=None, interests_file=\"ai_interests.txt\"):\n        count = self._save_tags_impl(date, tags, version, prompt_hash, interests_file)\n        if count > 0:\n            self._upload_sqlite(date)\n        return count\n\n    def save_ai_filter_results(self, results, date=None):\n        count = self._save_filter_results_impl(date, results)\n        if count > 0:\n            self._upload_sqlite(date)\n        return count\n\n    def get_active_ai_filter_results(self, date=None, interests_file=\"ai_interests.txt\"):\n        return self._get_active_filter_results_impl(date, interests_file)\n\n    def deprecate_specific_ai_filter_tags(self, tag_ids, date=None):\n        count = self._deprecate_specific_tags_impl(date, tag_ids)\n        if count > 0:\n            self._upload_sqlite(date)\n        return count\n\n    def update_ai_filter_tags_hash(self, interests_file, new_hash, date=None):\n        count = self._update_tags_hash_impl(date, interests_file, new_hash)\n        if count > 0:\n            self._upload_sqlite(date)\n        return count\n\n    def update_ai_filter_tag_descriptions(self, tag_updates, date=None, interests_file=\"ai_interests.txt\"):\n        count = self._update_tag_descriptions_impl(date, tag_updates, interests_file)\n        if count > 0:\n            self._upload_sqlite(date)\n        return count\n\n    def update_ai_filter_tag_priorities(self, tag_priorities, date=None, interests_file=\"ai_interests.txt\"):\n        count = self._update_tag_priorities_impl(date, tag_priorities, interests_file)\n        if count > 0:\n            self._upload_sqlite(date)\n        return count\n\n    def save_analyzed_news(self, news_ids, source_type, interests_file, prompt_hash, matched_ids, date=None):\n        count = self._save_analyzed_news_impl(date, news_ids, source_type, interests_file, prompt_hash, matched_ids)\n        if count > 0:\n            self._upload_sqlite(date)\n        return count\n\n    def get_analyzed_news_ids(self, source_type=\"hotlist\", date=None, interests_file=\"ai_interests.txt\"):\n        return self._get_analyzed_news_ids_impl(date, source_type, interests_file)\n\n    def clear_analyzed_news(self, date=None, interests_file=\"ai_interests.txt\"):\n        count = self._clear_analyzed_news_impl(date, interests_file)\n        if count > 0:\n            self._upload_sqlite(date)\n        return count\n\n    def clear_unmatched_analyzed_news(self, date=None, interests_file=\"ai_interests.txt\"):\n        count = self._clear_unmatched_analyzed_news_impl(date, interests_file)\n        if count > 0:\n            self._upload_sqlite(date)\n        return count\n\n    def get_all_news_ids(self, date=None):\n        return self._get_all_news_ids_impl(date)\n\n    def get_all_rss_ids(self, date=None):\n        return self._get_all_rss_ids_impl(date)\n\n    # ========================================\n    # 远程特有功能：TXT/HTML 快照（临时目录）\n    # ========================================\n\n    def save_txt_snapshot(self, data: NewsData) -> Optional[str]:\n        \"\"\"保存 TXT 快照（远程存储模式下默认不支持）\"\"\"\n        if not self.enable_txt:\n            return None\n\n        # 如果启用，保存到本地临时目录\n        try:\n            date_folder = self._format_date_folder(data.date)\n            txt_dir = self.temp_dir / date_folder / \"txt\"\n            txt_dir.mkdir(parents=True, exist_ok=True)\n\n            file_path = txt_dir / f\"{data.crawl_time}.txt\"\n\n            with open(file_path, \"w\", encoding=\"utf-8\") as f:\n                for source_id, news_list in data.items.items():\n                    source_name = data.id_to_name.get(source_id, source_id)\n\n                    if source_name and source_name != source_id:\n                        f.write(f\"{source_id} | {source_name}\\n\")\n                    else:\n                        f.write(f\"{source_id}\\n\")\n\n                    sorted_news = sorted(news_list, key=lambda x: x.rank)\n\n                    for item in sorted_news:\n                        line = f\"{item.rank}. {item.title}\"\n                        if item.url:\n                            line += f\" [URL:{item.url}]\"\n                        if item.mobile_url:\n                            line += f\" [MOBILE:{item.mobile_url}]\"\n                        f.write(line + \"\\n\")\n\n                    f.write(\"\\n\")\n\n                if data.failed_ids:\n                    f.write(\"==== 以下ID请求失败 ====\\n\")\n                    for failed_id in data.failed_ids:\n                        f.write(f\"{failed_id}\\n\")\n\n            print(f\"[远程存储] TXT 快照已保存: {file_path}\")\n            return str(file_path)\n\n        except Exception as e:\n            print(f\"[远程存储] 保存 TXT 快照失败: {e}\")\n            return None\n\n    def save_html_report(self, html_content: str, filename: str) -> Optional[str]:\n        \"\"\"保存 HTML 报告到临时目录\"\"\"\n        if not self.enable_html:\n            return None\n\n        try:\n            date_folder = self._format_date_folder()\n            html_dir = self.temp_dir / date_folder / \"html\"\n            html_dir.mkdir(parents=True, exist_ok=True)\n\n            file_path = html_dir / filename\n\n            with open(file_path, \"w\", encoding=\"utf-8\") as f:\n                f.write(html_content)\n\n            print(f\"[远程存储] HTML 报告已保存: {file_path}\")\n            return str(file_path)\n\n        except Exception as e:\n            print(f\"[远程存储] 保存 HTML 报告失败: {e}\")\n            return None\n\n    # ========================================\n    # 远程特有功能：资源清理\n    # ========================================\n\n    def cleanup(self) -> None:\n        \"\"\"清理资源（关闭连接和删除临时文件）\"\"\"\n        # 检查 Python 是否正在关闭\n        if sys.meta_path is None:\n            return\n\n        # 关闭数据库连接\n        db_connections = getattr(self, \"_db_connections\", {})\n        for db_path, conn in list(db_connections.items()):\n            try:\n                conn.close()\n                print(f\"[远程存储] 关闭数据库连接: {db_path}\")\n            except Exception as e:\n                print(f\"[远程存储] 关闭连接失败 {db_path}: {e}\")\n\n        if db_connections:\n            db_connections.clear()\n\n        # 删除临时目录\n        temp_dir = getattr(self, \"temp_dir\", None)\n        if temp_dir:\n            try:\n                if temp_dir.exists():\n                    shutil.rmtree(temp_dir)\n                    print(f\"[远程存储] 临时目录已清理: {temp_dir}\")\n            except Exception as e:\n                # 忽略 Python 关闭时的错误\n                if sys.meta_path is not None:\n                    print(f\"[远程存储] 清理临时目录失败: {e}\")\n\n        downloaded_files = getattr(self, \"_downloaded_files\", None)\n        if downloaded_files:\n            downloaded_files.clear()\n\n    def cleanup_old_data(self, retention_days: int) -> int:\n        \"\"\"\n        清理远程存储上的过期数据\n\n        Args:\n            retention_days: 保留天数（0 表示不清理）\n\n        Returns:\n            删除的数据库文件数量\n        \"\"\"\n        if retention_days <= 0:\n            return 0\n\n        deleted_count = 0\n        cutoff_date = self._get_configured_time() - timedelta(days=retention_days)\n\n        try:\n            # 列出远程存储中 news/ 前缀下的所有对象\n            paginator = self.s3_client.get_paginator('list_objects_v2')\n            pages = paginator.paginate(Bucket=self.bucket_name, Prefix=\"news/\")\n\n            # 收集需要删除的对象键\n            objects_to_delete = []\n            deleted_dates = set()\n\n            for page in pages:\n                if 'Contents' not in page:\n                    continue\n\n                for obj in page['Contents']:\n                    key = obj['Key']\n\n                    # 解析日期（格式: news/YYYY-MM-DD.db）\n                    folder_date = None\n                    date_str = None\n                    try:\n                        date_match = re.match(r'news/(\\d{4})-(\\d{2})-(\\d{2})\\.db$', key)\n                        if date_match:\n                            folder_date = datetime(\n                                int(date_match.group(1)),\n                                int(date_match.group(2)),\n                                int(date_match.group(3)),\n                                tzinfo=pytz.timezone(self.timezone)\n                            )\n                            date_str = f\"{date_match.group(1)}-{date_match.group(2)}-{date_match.group(3)}\"\n                    except Exception:\n                        continue\n\n                    if folder_date and folder_date < cutoff_date:\n                        objects_to_delete.append({'Key': key})\n                        deleted_dates.add(date_str)\n\n            # 批量删除对象（每次最多 1000 个）\n            if objects_to_delete:\n                batch_size = 1000\n                for i in range(0, len(objects_to_delete), batch_size):\n                    batch = objects_to_delete[i:i + batch_size]\n                    try:\n                        self.s3_client.delete_objects(\n                            Bucket=self.bucket_name,\n                            Delete={'Objects': batch}\n                        )\n                        print(f\"[远程存储] 删除 {len(batch)} 个对象\")\n                    except Exception as e:\n                        print(f\"[远程存储] 批量删除失败: {e}\")\n\n                deleted_count = len(deleted_dates)\n                for date_str in sorted(deleted_dates):\n                    print(f\"[远程存储] 清理过期数据: news/{date_str}.db\")\n\n                print(f\"[远程存储] 共清理 {deleted_count} 个过期日期数据库文件\")\n\n            return deleted_count\n\n        except Exception as e:\n            print(f\"[远程存储] 清理过期数据失败: {e}\")\n            return deleted_count\n\n    def __del__(self):\n        \"\"\"析构函数\"\"\"\n        # 检查 Python 是否正在关闭\n        if sys.meta_path is None:\n            return\n        try:\n            self.cleanup()\n        except Exception:\n            # Python 关闭时可能会出错，忽略即可\n            pass\n\n    # ========================================\n    # 远程特有功能：数据拉取和列表\n    # ========================================\n\n    def pull_recent_days(self, days: int, local_data_dir: str = \"output\") -> int:\n        \"\"\"\n        从远程拉取最近 N 天的数据到本地\n\n        Args:\n            days: 拉取天数\n            local_data_dir: 本地数据目录\n\n        Returns:\n            成功拉取的数据库文件数量\n        \"\"\"\n        if days <= 0:\n            return 0\n\n        local_dir = Path(local_data_dir)\n        local_dir.mkdir(parents=True, exist_ok=True)\n\n        pulled_count = 0\n        now = self._get_configured_time()\n\n        print(f\"[远程存储] 开始拉取最近 {days} 天的数据...\")\n\n        for i in range(days):\n            date = now - timedelta(days=i)\n            date_str = date.strftime(\"%Y-%m-%d\")\n\n            # 本地目标路径\n            local_date_dir = local_dir / date_str\n            local_db_path = local_date_dir / \"news.db\"\n\n            # 如果本地已存在，跳过\n            if local_db_path.exists():\n                print(f\"[远程存储] 跳过（本地已存在）: {date_str}\")\n                continue\n\n            # 远程对象键\n            remote_key = f\"news/{date_str}.db\"\n\n            # 检查远程是否存在\n            if not self._check_object_exists(remote_key):\n                print(f\"[远程存储] 跳过（远程不存在）: {date_str}\")\n                continue\n\n            # 下载（使用 get_object + iter_chunks 处理 chunked encoding）\n            try:\n                local_date_dir.mkdir(parents=True, exist_ok=True)\n                response = self.s3_client.get_object(Bucket=self.bucket_name, Key=remote_key)\n                with open(local_db_path, 'wb') as f:\n                    for chunk in response['Body'].iter_chunks(chunk_size=1024*1024):\n                        f.write(chunk)\n                print(f\"[远程存储] 已拉取: {remote_key} -> {local_db_path}\")\n                pulled_count += 1\n            except Exception as e:\n                print(f\"[远程存储] 拉取失败 ({date_str}): {e}\")\n\n        print(f\"[远程存储] 拉取完成，共下载 {pulled_count} 个数据库文件\")\n        return pulled_count\n\n    def list_remote_dates(self) -> List[str]:\n        \"\"\"\n        列出远程存储中所有可用的日期\n\n        Returns:\n            日期字符串列表（YYYY-MM-DD 格式）\n        \"\"\"\n        dates = []\n\n        try:\n            paginator = self.s3_client.get_paginator('list_objects_v2')\n            pages = paginator.paginate(Bucket=self.bucket_name, Prefix=\"news/\")\n\n            for page in pages:\n                if 'Contents' not in page:\n                    continue\n\n                for obj in page['Contents']:\n                    key = obj['Key']\n                    # 解析日期\n                    date_match = re.match(r'news/(\\d{4}-\\d{2}-\\d{2})\\.db$', key)\n                    if date_match:\n                        dates.append(date_match.group(1))\n\n            return sorted(dates, reverse=True)\n\n        except Exception as e:\n            print(f\"[远程存储] 列出远程日期失败: {e}\")\n            return []\n"
  },
  {
    "path": "trendradar/storage/rss_schema.sql",
    "content": "-- TrendRadar RSS 数据库表结构\n-- 用于存储 RSS/Atom 订阅源数据\n\n-- ============================================\n-- RSS 源配置表\n-- 存储订阅源的基本信息\n-- ============================================\nCREATE TABLE IF NOT EXISTS rss_feeds (\n    id TEXT PRIMARY KEY,                      -- 源 ID（如 \"hacker-news\"）\n    name TEXT NOT NULL,                       -- 显示名称（如 \"Hacker News\"）\n    feed_url TEXT DEFAULT '',                 -- RSS/Atom URL（可选，配置文件中已有）\n    is_active INTEGER DEFAULT 1,              -- 是否启用\n    last_fetch_time TEXT,                     -- 最后抓取时间\n    last_fetch_status TEXT,                   -- 最后抓取状态（success/failed）\n    item_count INTEGER DEFAULT 0,             -- 当日条目数\n    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\n-- ============================================\n-- RSS 条目表\n-- 以 URL + feed_id 为唯一标识，支持去重存储\n-- ============================================\nCREATE TABLE IF NOT EXISTS rss_items (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    title TEXT NOT NULL,                      -- 标题\n    feed_id TEXT NOT NULL,                    -- 所属 RSS 源\n    url TEXT NOT NULL,                        -- 文章链接\n    published_at TEXT,                        -- RSS 发布时间（ISO 格式）\n    summary TEXT,                             -- 摘要/描述\n    author TEXT,                              -- 作者\n    first_crawl_time TEXT NOT NULL,           -- 首次抓取时间\n    last_crawl_time TEXT NOT NULL,            -- 最后抓取时间\n    crawl_count INTEGER DEFAULT 1,            -- 抓取次数\n    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY (feed_id) REFERENCES rss_feeds(id)\n);\n\n-- ============================================\n-- 抓取记录表\n-- 记录每次抓取的时间和数量\n-- ============================================\nCREATE TABLE IF NOT EXISTS rss_crawl_records (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    crawl_time TEXT NOT NULL UNIQUE,          -- 抓取时间（HH:MM）\n    total_items INTEGER DEFAULT 0,            -- 总条目数\n    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\n-- ============================================\n-- 抓取来源状态表\n-- 记录每次抓取各 RSS 源的成功/失败状态\n-- ============================================\nCREATE TABLE IF NOT EXISTS rss_crawl_status (\n    crawl_record_id INTEGER NOT NULL,\n    feed_id TEXT NOT NULL,\n    status TEXT NOT NULL CHECK(status IN ('success', 'failed')),\n    error_message TEXT,                       -- 失败时的错误信息\n    PRIMARY KEY (crawl_record_id, feed_id),\n    FOREIGN KEY (crawl_record_id) REFERENCES rss_crawl_records(id),\n    FOREIGN KEY (feed_id) REFERENCES rss_feeds(id)\n);\n\n-- ============================================\n-- 推送记录表\n-- 用于 push_window once_per_day 功能\n-- 以及 ai_analysis analysis_window once_per_day 功能\n-- ============================================\nCREATE TABLE IF NOT EXISTS rss_push_records (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    date TEXT NOT NULL UNIQUE,                -- 日期（YYYY-MM-DD）\n    pushed INTEGER DEFAULT 0,                 -- 是否已推送\n    push_time TEXT,                           -- 推送时间\n    ai_analyzed INTEGER DEFAULT 0,            -- 是否已进行 AI 分析\n    ai_analysis_time TEXT,                    -- AI 分析时间\n    ai_analysis_mode TEXT,                    -- AI 分析模式\n    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\n-- ============================================\n-- 索引定义\n-- ============================================\n\n-- RSS 源索引\nCREATE INDEX IF NOT EXISTS idx_rss_feed ON rss_items(feed_id);\n\n-- 发布时间索引（用于按时间排序）\nCREATE INDEX IF NOT EXISTS idx_rss_published ON rss_items(published_at DESC);\n\n-- 抓取时间索引（用于查询最新数据）\nCREATE INDEX IF NOT EXISTS idx_rss_crawl_time ON rss_items(last_crawl_time);\n\n-- 标题索引（用于标题搜索）\nCREATE INDEX IF NOT EXISTS idx_rss_title ON rss_items(title);\n\n-- URL + feed_id 唯一索引（实现去重）\nCREATE UNIQUE INDEX IF NOT EXISTS idx_rss_url_feed\n    ON rss_items(url, feed_id);\n\n-- 抓取状态索引\nCREATE INDEX IF NOT EXISTS idx_rss_crawl_status_record ON rss_crawl_status(crawl_record_id);\n"
  },
  {
    "path": "trendradar/storage/schema.sql",
    "content": "-- TrendRadar 数据库表结构\n\n-- ============================================\n-- 平台信息表\n-- 核心：id 不变，name 可变\n-- ============================================\nCREATE TABLE IF NOT EXISTS platforms (\n    id TEXT PRIMARY KEY,\n    name TEXT NOT NULL,\n    is_active INTEGER DEFAULT 1,\n    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\n-- ============================================\n-- 新闻条目表\n-- 以 URL + platform_id 为唯一标识，支持去重存储\n-- ============================================\nCREATE TABLE IF NOT EXISTS news_items (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    title TEXT NOT NULL,\n    platform_id TEXT NOT NULL,\n    rank INTEGER NOT NULL,\n    url TEXT DEFAULT '',\n    mobile_url TEXT DEFAULT '',\n    first_crawl_time TEXT NOT NULL,      -- 首次抓取时间\n    last_crawl_time TEXT NOT NULL,       -- 最后抓取时间\n    crawl_count INTEGER DEFAULT 1,       -- 抓取次数\n    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY (platform_id) REFERENCES platforms(id)\n);\n\n-- ============================================\n-- 标题变更历史表\n-- 记录同一 URL 下标题的变化\n-- ============================================\nCREATE TABLE IF NOT EXISTS title_changes (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    news_item_id INTEGER NOT NULL,\n    old_title TEXT NOT NULL,\n    new_title TEXT NOT NULL,\n    changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY (news_item_id) REFERENCES news_items(id)\n);\n\n-- ============================================\n-- 排名历史表\n-- 记录每次抓取时的排名变化\n-- ============================================\nCREATE TABLE IF NOT EXISTS rank_history (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    news_item_id INTEGER NOT NULL,\n    rank INTEGER NOT NULL,\n    crawl_time TEXT NOT NULL,\n    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY (news_item_id) REFERENCES news_items(id)\n);\n\n-- ============================================\n-- 抓取记录表\n-- 记录每次抓取的时间和数量\n-- ============================================\nCREATE TABLE IF NOT EXISTS crawl_records (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    crawl_time TEXT NOT NULL UNIQUE,\n    total_items INTEGER DEFAULT 0,\n    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP\n);\n\n-- ============================================\n-- 抓取来源状态表\n-- 记录每次抓取各平台的成功/失败状态\n-- ============================================\nCREATE TABLE IF NOT EXISTS crawl_source_status (\n    crawl_record_id INTEGER NOT NULL,\n    platform_id TEXT NOT NULL,\n    status TEXT NOT NULL CHECK(status IN ('success', 'failed')),\n    PRIMARY KEY (crawl_record_id, platform_id),\n    FOREIGN KEY (crawl_record_id) REFERENCES crawl_records(id),\n    FOREIGN KEY (platform_id) REFERENCES platforms(id)\n);\n\n-- ============================================\n-- 时间段执行记录表\n-- 记录每天每个时间段在各 action 维度的执行状态（用于 once 功能）\n-- 替代旧的 push_records 表\n-- ============================================\nCREATE TABLE IF NOT EXISTS period_executions (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    execution_date TEXT NOT NULL,          -- YYYY-MM-DD\n    period_key TEXT NOT NULL,              -- period 的稳定 key\n    action TEXT NOT NULL,                  -- analyze | push\n    executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE(execution_date, period_key, action)\n);\n\n-- ============================================\n-- 索引定义\n-- ============================================\n\n-- 平台索引\nCREATE INDEX IF NOT EXISTS idx_news_platform ON news_items(platform_id);\n\n-- 时间索引（用于查询最新数据）\nCREATE INDEX IF NOT EXISTS idx_news_crawl_time ON news_items(last_crawl_time);\n\n-- 标题索引（用于标题搜索）\nCREATE INDEX IF NOT EXISTS idx_news_title ON news_items(title);\n\n-- URL + platform_id 唯一索引（仅对非空 URL，实现去重）\nCREATE UNIQUE INDEX IF NOT EXISTS idx_news_url_platform\n    ON news_items(url, platform_id) WHERE url != '';\n\n-- 抓取状态索引\nCREATE INDEX IF NOT EXISTS idx_crawl_status_record ON crawl_source_status(crawl_record_id);\n\n-- 排名历史索引\nCREATE INDEX IF NOT EXISTS idx_rank_history_news ON rank_history(news_item_id);\n\n-- 时间段执行记录索引\nCREATE INDEX IF NOT EXISTS idx_period_exec_lookup\nON period_executions(execution_date, period_key, action);\n"
  },
  {
    "path": "trendradar/storage/sqlite_mixin.py",
    "content": "# coding=utf-8\n\"\"\"\nSQLite 存储 Mixin\n\n提供共用的 SQLite 数据库操作逻辑，供 LocalStorageBackend 和 RemoteStorageBackend 复用。\n\"\"\"\n\nimport sqlite3\nfrom abc import abstractmethod\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Any, Dict, List, Optional\n\nfrom trendradar.storage.base import NewsItem, NewsData, RSSItem, RSSData\nfrom trendradar.utils.url import normalize_url\n\n\nclass SQLiteStorageMixin:\n    \"\"\"\n    SQLite 存储操作 Mixin\n\n    子类需要实现以下抽象方法：\n    - _get_connection(date, db_type) -> sqlite3.Connection\n    - _get_configured_time() -> datetime\n    - _format_date_folder(date) -> str\n    - _format_time_filename() -> str\n    \"\"\"\n\n    # ========================================\n    # 抽象方法 - 子类必须实现\n    # ========================================\n\n    @abstractmethod\n    def _get_connection(self, date: Optional[str] = None, db_type: str = \"news\") -> sqlite3.Connection:\n        \"\"\"获取数据库连接\"\"\"\n        pass\n\n    @abstractmethod\n    def _get_configured_time(self) -> datetime:\n        \"\"\"获取配置时区的当前时间\"\"\"\n        pass\n\n    @abstractmethod\n    def _format_date_folder(self, date: Optional[str] = None) -> str:\n        \"\"\"格式化日期文件夹名 (ISO 格式: YYYY-MM-DD)\"\"\"\n        pass\n\n    @abstractmethod\n    def _format_time_filename(self) -> str:\n        \"\"\"格式化时间文件名 (格式: HH-MM)\"\"\"\n        pass\n\n    # ========================================\n    # Schema 管理\n    # ========================================\n\n    def _get_schema_path(self, db_type: str = \"news\") -> Path:\n        \"\"\"\n        获取 schema.sql 文件路径\n\n        Args:\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n\n        Returns:\n            schema 文件路径\n        \"\"\"\n        if db_type == \"rss\":\n            return Path(__file__).parent / \"rss_schema.sql\"\n        return Path(__file__).parent / \"schema.sql\"\n\n    def _get_ai_filter_schema_path(self) -> Path:\n        \"\"\"获取 AI 筛选 schema 文件路径\"\"\"\n        return Path(__file__).parent / \"ai_filter_schema.sql\"\n\n    def _init_tables(self, conn: sqlite3.Connection, db_type: str = \"news\") -> None:\n        \"\"\"\n        从 schema.sql 初始化数据库表结构\n\n        Args:\n            conn: 数据库连接\n            db_type: 数据库类型 (\"news\" 或 \"rss\")\n        \"\"\"\n        schema_path = self._get_schema_path(db_type)\n\n        if schema_path.exists():\n            with open(schema_path, \"r\", encoding=\"utf-8\") as f:\n                schema_sql = f.read()\n            conn.executescript(schema_sql)\n        else:\n            raise FileNotFoundError(f\"Schema file not found: {schema_path}\")\n\n        # news 库额外加载 AI 筛选表结构\n        if db_type == \"news\":\n            ai_filter_schema = self._get_ai_filter_schema_path()\n            if ai_filter_schema.exists():\n                with open(ai_filter_schema, \"r\", encoding=\"utf-8\") as f:\n                    conn.executescript(f.read())\n\n        conn.commit()\n\n    # ========================================\n    # 新闻数据存储\n    # ========================================\n\n    def _save_news_data_impl(self, data: NewsData, log_prefix: str = \"[存储]\") -> tuple[bool, int, int, int, int]:\n        \"\"\"\n        保存新闻数据到 SQLite（核心实现）\n\n        Args:\n            data: 新闻数据\n            log_prefix: 日志前缀\n\n        Returns:\n            (success, new_count, updated_count, title_changed_count, off_list_count)\n        \"\"\"\n        try:\n            conn = self._get_connection(data.date)\n            cursor = conn.cursor()\n\n            # 获取配置时区的当前时间\n            now_str = self._get_configured_time().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n            # 首先同步平台信息到 platforms 表\n            for source_id, source_name in data.id_to_name.items():\n                cursor.execute(\"\"\"\n                    INSERT INTO platforms (id, name, updated_at)\n                    VALUES (?, ?, ?)\n                    ON CONFLICT(id) DO UPDATE SET\n                        name = excluded.name,\n                        updated_at = excluded.updated_at\n                \"\"\", (source_id, source_name, now_str))\n\n            # 统计计数器\n            new_count = 0\n            updated_count = 0\n            title_changed_count = 0\n            success_sources = []\n\n            for source_id, news_list in data.items.items():\n                success_sources.append(source_id)\n\n                for item in news_list:\n                    try:\n                        # 标准化 URL（去除动态参数，如微博的 band_rank）\n                        normalized_url = normalize_url(item.url, source_id) if item.url else \"\"\n\n                        # 检查是否已存在（通过标准化 URL + platform_id）\n                        if normalized_url:\n                            cursor.execute(\"\"\"\n                                SELECT id, title FROM news_items\n                                WHERE url = ? AND platform_id = ?\n                            \"\"\", (normalized_url, source_id))\n                            existing = cursor.fetchone()\n\n                            if existing:\n                                # 已存在，更新记录\n                                existing_id, existing_title = existing\n\n                                # 检查标题是否变化\n                                if existing_title != item.title:\n                                    # 记录标题变更\n                                    cursor.execute(\"\"\"\n                                        INSERT INTO title_changes\n                                        (news_item_id, old_title, new_title, changed_at)\n                                        VALUES (?, ?, ?, ?)\n                                    \"\"\", (existing_id, existing_title, item.title, now_str))\n                                    title_changed_count += 1\n\n                                # 记录排名历史\n                                cursor.execute(\"\"\"\n                                    INSERT INTO rank_history\n                                    (news_item_id, rank, crawl_time, created_at)\n                                    VALUES (?, ?, ?, ?)\n                                \"\"\", (existing_id, item.rank, data.crawl_time, now_str))\n\n                                # 更新现有记录\n                                cursor.execute(\"\"\"\n                                    UPDATE news_items SET\n                                        title = ?,\n                                        rank = ?,\n                                        mobile_url = ?,\n                                        last_crawl_time = ?,\n                                        crawl_count = crawl_count + 1,\n                                        updated_at = ?\n                                    WHERE id = ?\n                                \"\"\", (item.title, item.rank, item.mobile_url,\n                                      data.crawl_time, now_str, existing_id))\n                                updated_count += 1\n                            else:\n                                # 不存在，插入新记录（存储标准化后的 URL）\n                                cursor.execute(\"\"\"\n                                    INSERT INTO news_items\n                                    (title, platform_id, rank, url, mobile_url,\n                                     first_crawl_time, last_crawl_time, crawl_count,\n                                     created_at, updated_at)\n                                    VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)\n                                \"\"\", (item.title, source_id, item.rank, normalized_url,\n                                      item.mobile_url, data.crawl_time, data.crawl_time,\n                                      now_str, now_str))\n                                new_id = cursor.lastrowid\n                                # 记录初始排名\n                                cursor.execute(\"\"\"\n                                    INSERT INTO rank_history\n                                    (news_item_id, rank, crawl_time, created_at)\n                                    VALUES (?, ?, ?, ?)\n                                \"\"\", (new_id, item.rank, data.crawl_time, now_str))\n                                new_count += 1\n                        else:\n                            # URL 为空的情况，直接插入（不做去重）\n                            cursor.execute(\"\"\"\n                                INSERT INTO news_items\n                                (title, platform_id, rank, url, mobile_url,\n                                 first_crawl_time, last_crawl_time, crawl_count,\n                                 created_at, updated_at)\n                                VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)\n                            \"\"\", (item.title, source_id, item.rank, \"\",\n                                  item.mobile_url, data.crawl_time, data.crawl_time,\n                                  now_str, now_str))\n                            new_id = cursor.lastrowid\n                            # 记录初始排名\n                            cursor.execute(\"\"\"\n                                INSERT INTO rank_history\n                                (news_item_id, rank, crawl_time, created_at)\n                                VALUES (?, ?, ?, ?)\n                            \"\"\", (new_id, item.rank, data.crawl_time, now_str))\n                            new_count += 1\n\n                    except sqlite3.Error as e:\n                        print(f\"{log_prefix} 保存新闻条目失败 [{item.title[:30]}...]: {e}\")\n\n            total_items = new_count + updated_count\n\n            # ========================================\n            # 脱榜检测：检测上次在榜但这次不在榜的新闻\n            # ========================================\n            off_list_count = 0\n\n            # 获取上一次抓取时间\n            cursor.execute(\"\"\"\n                SELECT crawl_time FROM crawl_records\n                WHERE crawl_time < ?\n                ORDER BY crawl_time DESC\n                LIMIT 1\n            \"\"\", (data.crawl_time,))\n            prev_record = cursor.fetchone()\n\n            if prev_record:\n                prev_crawl_time = prev_record[0]\n\n                # 对于每个成功抓取的平台，检测脱榜\n                for source_id in success_sources:\n                    # 获取当前抓取中该平台的所有标准化 URL\n                    current_urls = set()\n                    for item in data.items.get(source_id, []):\n                        normalized_url = normalize_url(item.url, source_id) if item.url else \"\"\n                        if normalized_url:\n                            current_urls.add(normalized_url)\n\n                    # 查询上次在榜（last_crawl_time = prev_crawl_time）但这次不在榜的新闻\n                    # 这些新闻是\"第一次脱榜\"，需要记录\n                    cursor.execute(\"\"\"\n                        SELECT id, url FROM news_items\n                        WHERE platform_id = ?\n                          AND last_crawl_time = ?\n                          AND url != ''\n                    \"\"\", (source_id, prev_crawl_time))\n\n                    for row in cursor.fetchall():\n                        news_id, url = row[0], row[1]\n                        if url not in current_urls:\n                            # 插入脱榜记录（rank=0 表示脱榜）\n                            cursor.execute(\"\"\"\n                                INSERT INTO rank_history\n                                (news_item_id, rank, crawl_time, created_at)\n                                VALUES (?, 0, ?, ?)\n                            \"\"\", (news_id, data.crawl_time, now_str))\n                            off_list_count += 1\n\n            # 记录抓取信息\n            cursor.execute(\"\"\"\n                INSERT OR REPLACE INTO crawl_records\n                (crawl_time, total_items, created_at)\n                VALUES (?, ?, ?)\n            \"\"\", (data.crawl_time, total_items, now_str))\n\n            # 获取刚插入的 crawl_record 的 ID\n            cursor.execute(\"\"\"\n                SELECT id FROM crawl_records WHERE crawl_time = ?\n            \"\"\", (data.crawl_time,))\n            record_row = cursor.fetchone()\n            if record_row:\n                crawl_record_id = record_row[0]\n\n                # 记录成功的来源\n                for source_id in success_sources:\n                    cursor.execute(\"\"\"\n                        INSERT OR REPLACE INTO crawl_source_status\n                        (crawl_record_id, platform_id, status)\n                        VALUES (?, ?, 'success')\n                    \"\"\", (crawl_record_id, source_id))\n\n                # 记录失败的来源\n                for failed_id in data.failed_ids:\n                    # 确保失败的平台也在 platforms 表中\n                    cursor.execute(\"\"\"\n                        INSERT OR IGNORE INTO platforms (id, name, updated_at)\n                        VALUES (?, ?, ?)\n                    \"\"\", (failed_id, failed_id, now_str))\n\n                    cursor.execute(\"\"\"\n                        INSERT OR REPLACE INTO crawl_source_status\n                        (crawl_record_id, platform_id, status)\n                        VALUES (?, ?, 'failed')\n                    \"\"\", (crawl_record_id, failed_id))\n\n            conn.commit()\n\n            return True, new_count, updated_count, title_changed_count, off_list_count\n\n        except Exception as e:\n            print(f\"{log_prefix} 保存失败: {e}\")\n            return False, 0, 0, 0, 0\n\n    def _get_today_all_data_impl(self, date: Optional[str] = None) -> Optional[NewsData]:\n        \"\"\"\n        获取指定日期的所有新闻数据（合并后）\n\n        Args:\n            date: 日期字符串，默认为今天\n\n        Returns:\n            合并后的新闻数据\n        \"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            # 获取所有新闻数据（包含 id 用于查询排名历史）\n            cursor.execute(\"\"\"\n                SELECT n.id, n.title, n.platform_id, p.name as platform_name,\n                       n.rank, n.url, n.mobile_url,\n                       n.first_crawl_time, n.last_crawl_time, n.crawl_count\n                FROM news_items n\n                LEFT JOIN platforms p ON n.platform_id = p.id\n                ORDER BY n.platform_id, n.last_crawl_time\n            \"\"\")\n\n            rows = cursor.fetchall()\n            if not rows:\n                return None\n\n            # 收集所有 news_item_id\n            news_ids = [row[0] for row in rows]\n\n            # 批量查询排名历史（同时获取时间和排名）\n            # 过滤逻辑：只保留 last_crawl_time 之前的脱榜记录（rank=0）\n            # 这样可以避免显示新闻永久脱榜后的无意义记录\n            rank_history_map: Dict[int, List[int]] = {}\n            rank_timeline_map: Dict[int, List[Dict[str, Any]]] = {}\n            if news_ids:\n                placeholders = \",\".join(\"?\" * len(news_ids))\n                cursor.execute(f\"\"\"\n                    SELECT rh.news_item_id, rh.rank, rh.crawl_time\n                    FROM rank_history rh\n                    JOIN news_items ni ON rh.news_item_id = ni.id\n                    WHERE rh.news_item_id IN ({placeholders})\n                      AND NOT (rh.rank = 0 AND rh.crawl_time > ni.last_crawl_time)\n                    ORDER BY rh.news_item_id, rh.crawl_time\n                \"\"\", news_ids)\n                for rh_row in cursor.fetchall():\n                    news_id, rank, crawl_time = rh_row[0], rh_row[1], rh_row[2]\n\n                    # 构建 ranks 列表（去重，排除脱榜记录 rank=0）\n                    if news_id not in rank_history_map:\n                        rank_history_map[news_id] = []\n                    if rank != 0 and rank not in rank_history_map[news_id]:\n                        rank_history_map[news_id].append(rank)\n\n                    # 构建 rank_timeline 列表（完整时间线，包含脱榜）\n                    if news_id not in rank_timeline_map:\n                        rank_timeline_map[news_id] = []\n                    # 提取时间部分（HH:MM）\n                    time_part = crawl_time.split()[1][:5] if ' ' in crawl_time else crawl_time[:5]\n                    rank_timeline_map[news_id].append({\n                        \"time\": time_part,\n                        \"rank\": rank if rank != 0 else None  # 0 转为 None 表示脱榜\n                    })\n\n            # 按 platform_id 分组\n            items: Dict[str, List[NewsItem]] = {}\n            id_to_name: Dict[str, str] = {}\n            crawl_date = self._format_date_folder(date)\n\n            for row in rows:\n                news_id = row[0]\n                platform_id = row[2]\n                title = row[1]\n                platform_name = row[3] or platform_id\n\n                id_to_name[platform_id] = platform_name\n\n                if platform_id not in items:\n                    items[platform_id] = []\n\n                # 获取排名历史，如果没有则使用当前排名\n                ranks = rank_history_map.get(news_id, [row[4]])\n                rank_timeline = rank_timeline_map.get(news_id, [])\n\n                items[platform_id].append(NewsItem(\n                    title=title,\n                    source_id=platform_id,\n                    source_name=platform_name,\n                    rank=row[4],\n                    url=row[5] or \"\",\n                    mobile_url=row[6] or \"\",\n                    crawl_time=row[8],  # last_crawl_time\n                    ranks=ranks,\n                    first_time=row[7],  # first_crawl_time\n                    last_time=row[8],   # last_crawl_time\n                    count=row[9],       # crawl_count\n                    rank_timeline=rank_timeline,\n                ))\n\n            final_items = items\n\n            # 获取失败的来源\n            cursor.execute(\"\"\"\n                SELECT DISTINCT css.platform_id\n                FROM crawl_source_status css\n                JOIN crawl_records cr ON css.crawl_record_id = cr.id\n                WHERE css.status = 'failed'\n            \"\"\")\n            failed_ids = [row[0] for row in cursor.fetchall()]\n\n            # 获取最新的抓取时间\n            cursor.execute(\"\"\"\n                SELECT crawl_time FROM crawl_records\n                ORDER BY crawl_time DESC\n                LIMIT 1\n            \"\"\")\n\n            time_row = cursor.fetchone()\n            crawl_time = time_row[0] if time_row else self._format_time_filename()\n\n            return NewsData(\n                date=crawl_date,\n                crawl_time=crawl_time,\n                items=final_items,\n                id_to_name=id_to_name,\n                failed_ids=failed_ids,\n            )\n\n        except Exception as e:\n            print(f\"[存储] 读取数据失败: {e}\")\n            return None\n\n    def _get_latest_crawl_data_impl(self, date: Optional[str] = None) -> Optional[NewsData]:\n        \"\"\"\n        获取最新一次抓取的数据\n\n        Args:\n            date: 日期字符串，默认为今天\n\n        Returns:\n            最新抓取的新闻数据\n        \"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            # 获取最新的抓取时间\n            cursor.execute(\"\"\"\n                SELECT crawl_time FROM crawl_records\n                ORDER BY crawl_time DESC\n                LIMIT 1\n            \"\"\")\n\n            time_row = cursor.fetchone()\n            if not time_row:\n                return None\n\n            latest_time = time_row[0]\n\n            # 获取该时间的新闻数据（包含 id 用于查询排名历史）\n            cursor.execute(\"\"\"\n                SELECT n.id, n.title, n.platform_id, p.name as platform_name,\n                       n.rank, n.url, n.mobile_url,\n                       n.first_crawl_time, n.last_crawl_time, n.crawl_count\n                FROM news_items n\n                LEFT JOIN platforms p ON n.platform_id = p.id\n                WHERE n.last_crawl_time = ?\n            \"\"\", (latest_time,))\n\n            rows = cursor.fetchall()\n            if not rows:\n                return None\n\n            # 收集所有 news_item_id\n            news_ids = [row[0] for row in rows]\n\n            # 批量查询排名历史（同时获取时间和排名）\n            # 过滤逻辑：只保留 last_crawl_time 之前的脱榜记录（rank=0）\n            # 这样可以避免显示新闻永久脱榜后的无意义记录\n            rank_history_map: Dict[int, List[int]] = {}\n            rank_timeline_map: Dict[int, List[Dict[str, Any]]] = {}\n            if news_ids:\n                placeholders = \",\".join(\"?\" * len(news_ids))\n                cursor.execute(f\"\"\"\n                    SELECT rh.news_item_id, rh.rank, rh.crawl_time\n                    FROM rank_history rh\n                    JOIN news_items ni ON rh.news_item_id = ni.id\n                    WHERE rh.news_item_id IN ({placeholders})\n                      AND NOT (rh.rank = 0 AND rh.crawl_time > ni.last_crawl_time)\n                    ORDER BY rh.news_item_id, rh.crawl_time\n                \"\"\", news_ids)\n                for rh_row in cursor.fetchall():\n                    news_id, rank, crawl_time = rh_row[0], rh_row[1], rh_row[2]\n\n                    # 构建 ranks 列表（去重，排除脱榜记录 rank=0）\n                    if news_id not in rank_history_map:\n                        rank_history_map[news_id] = []\n                    if rank != 0 and rank not in rank_history_map[news_id]:\n                        rank_history_map[news_id].append(rank)\n\n                    # 构建 rank_timeline 列表（完整时间线，包含脱榜）\n                    if news_id not in rank_timeline_map:\n                        rank_timeline_map[news_id] = []\n                    # 提取时间部分（HH:MM）\n                    time_part = crawl_time.split()[1][:5] if ' ' in crawl_time else crawl_time[:5]\n                    rank_timeline_map[news_id].append({\n                        \"time\": time_part,\n                        \"rank\": rank if rank != 0 else None  # 0 转为 None 表示脱榜\n                    })\n\n            items: Dict[str, List[NewsItem]] = {}\n            id_to_name: Dict[str, str] = {}\n            crawl_date = self._format_date_folder(date)\n\n            for row in rows:\n                news_id = row[0]\n                platform_id = row[2]\n                platform_name = row[3] or platform_id\n                id_to_name[platform_id] = platform_name\n\n                if platform_id not in items:\n                    items[platform_id] = []\n\n                # 获取排名历史，如果没有则使用当前排名\n                ranks = rank_history_map.get(news_id, [row[4]])\n                rank_timeline = rank_timeline_map.get(news_id, [])\n\n                items[platform_id].append(NewsItem(\n                    title=row[1],\n                    source_id=platform_id,\n                    source_name=platform_name,\n                    rank=row[4],\n                    url=row[5] or \"\",\n                    mobile_url=row[6] or \"\",\n                    crawl_time=row[8],  # last_crawl_time\n                    ranks=ranks,\n                    first_time=row[7],  # first_crawl_time\n                    last_time=row[8],   # last_crawl_time\n                    count=row[9],       # crawl_count\n                    rank_timeline=rank_timeline,\n                ))\n\n            # 获取失败的来源（针对最新一次抓取）\n            cursor.execute(\"\"\"\n                SELECT css.platform_id\n                FROM crawl_source_status css\n                JOIN crawl_records cr ON css.crawl_record_id = cr.id\n                WHERE cr.crawl_time = ? AND css.status = 'failed'\n            \"\"\", (latest_time,))\n\n            failed_ids = [row[0] for row in cursor.fetchall()]\n\n            return NewsData(\n                date=crawl_date,\n                crawl_time=latest_time,\n                items=items,\n                id_to_name=id_to_name,\n                failed_ids=failed_ids,\n            )\n\n        except Exception as e:\n            print(f\"[存储] 获取最新数据失败: {e}\")\n            return None\n\n    def _detect_new_titles_impl(self, current_data: NewsData) -> Dict[str, Dict]:\n        \"\"\"\n        检测新增的标题\n\n        该方法比较当前抓取数据与历史数据，找出新增的标题。\n        关键逻辑：只有在历史批次中从未出现过的标题才算新增。\n\n        Args:\n            current_data: 当前抓取的数据\n\n        Returns:\n            新增的标题数据 {source_id: {title: NewsItem}}\n        \"\"\"\n        try:\n            # 获取历史数据\n            historical_data = self._get_today_all_data_impl(current_data.date)\n\n            if not historical_data:\n                # 没有历史数据，所有都是新的\n                new_titles = {}\n                for source_id, news_list in current_data.items.items():\n                    new_titles[source_id] = {item.title: item for item in news_list}\n                return new_titles\n\n            # 获取当前批次时间\n            current_time = current_data.crawl_time\n\n            # 收集历史标题（first_time < current_time 的标题）\n            # 这样可以正确处理同一标题因 URL 变化而产生多条记录的情况\n            historical_titles: Dict[str, set] = {}\n            for source_id, news_list in historical_data.items.items():\n                historical_titles[source_id] = set()\n                for item in news_list:\n                    first_time = item.first_time or item.crawl_time\n                    if first_time < current_time:\n                        historical_titles[source_id].add(item.title)\n\n            # 检查是否有历史数据\n            has_historical_data = any(len(titles) > 0 for titles in historical_titles.values())\n            if not has_historical_data:\n                # 第一次抓取，没有\"新增\"概念\n                return {}\n\n            # 检测新增\n            new_titles = {}\n            for source_id, news_list in current_data.items.items():\n                hist_set = historical_titles.get(source_id, set())\n                for item in news_list:\n                    if item.title not in hist_set:\n                        if source_id not in new_titles:\n                            new_titles[source_id] = {}\n                        new_titles[source_id][item.title] = item\n\n            return new_titles\n\n        except Exception as e:\n            print(f\"[存储] 检测新标题失败: {e}\")\n            return {}\n\n    def _is_first_crawl_today_impl(self, date: Optional[str] = None) -> bool:\n        \"\"\"\n        检查是否是当天第一次抓取\n\n        Args:\n            date: 日期字符串，默认为今天\n\n        Returns:\n            是否是第一次抓取\n        \"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            cursor.execute(\"\"\"\n                SELECT COUNT(*) as count FROM crawl_records\n            \"\"\")\n\n            row = cursor.fetchone()\n            count = row[0] if row else 0\n\n            # 如果只有一条或没有记录，视为第一次抓取\n            return count <= 1\n\n        except Exception as e:\n            print(f\"[存储] 检查首次抓取失败: {e}\")\n            return True\n\n    def _get_crawl_times_impl(self, date: Optional[str] = None) -> List[str]:\n        \"\"\"\n        获取指定日期的所有抓取时间列表\n\n        Args:\n            date: 日期字符串，默认为今天\n\n        Returns:\n            抓取时间列表（按时间排序）\n        \"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            cursor.execute(\"\"\"\n                SELECT crawl_time FROM crawl_records\n                ORDER BY crawl_time\n            \"\"\")\n\n            rows = cursor.fetchall()\n            return [row[0] for row in rows]\n\n        except Exception as e:\n            print(f\"[存储] 获取抓取时间列表失败: {e}\")\n            return []\n\n    # ========================================\n    # 时间段执行记录（调度系统）\n    # ========================================\n\n    def _has_period_executed_impl(self, date_str: str, period_key: str, action: str) -> bool:\n        \"\"\"\n        检查指定时间段的某个 action 今天是否已执行\n\n        Args:\n            date_str: 日期字符串 YYYY-MM-DD\n            period_key: 时间段 key\n            action: 动作类型 (analyze / push)\n\n        Returns:\n            是否已执行\n        \"\"\"\n        try:\n            conn = self._get_connection(date_str)\n            cursor = conn.cursor()\n\n            # 先检查表是否存在\n            cursor.execute(\"\"\"\n                SELECT name FROM sqlite_master\n                WHERE type='table' AND name='period_executions'\n            \"\"\")\n            if not cursor.fetchone():\n                return False\n\n            cursor.execute(\"\"\"\n                SELECT 1 FROM period_executions\n                WHERE execution_date = ? AND period_key = ? AND action = ?\n            \"\"\", (date_str, period_key, action))\n\n            return cursor.fetchone() is not None\n\n        except Exception as e:\n            print(f\"[存储] 检查时间段执行记录失败: {e}\")\n            return False\n\n    def _record_period_execution_impl(self, date_str: str, period_key: str, action: str) -> bool:\n        \"\"\"\n        记录时间段的 action 执行\n\n        Args:\n            date_str: 日期字符串 YYYY-MM-DD\n            period_key: 时间段 key\n            action: 动作类型 (analyze / push)\n\n        Returns:\n            是否记录成功\n        \"\"\"\n        try:\n            conn = self._get_connection(date_str)\n            cursor = conn.cursor()\n\n            # 确保表存在\n            cursor.execute(\"\"\"\n                CREATE TABLE IF NOT EXISTS period_executions (\n                    id INTEGER PRIMARY KEY AUTOINCREMENT,\n                    execution_date TEXT NOT NULL,\n                    period_key TEXT NOT NULL,\n                    action TEXT NOT NULL,\n                    executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n                    UNIQUE(execution_date, period_key, action)\n                )\n            \"\"\")\n\n            now_str = self._get_configured_time().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n            cursor.execute(\"\"\"\n                INSERT OR IGNORE INTO period_executions (execution_date, period_key, action, executed_at)\n                VALUES (?, ?, ?, ?)\n            \"\"\", (date_str, period_key, action, now_str))\n\n            conn.commit()\n            return True\n\n        except Exception as e:\n            print(f\"[存储] 记录时间段执行失败: {e}\")\n            return False\n\n    # ========================================\n    # RSS 数据存储\n    # ========================================\n\n    def _save_rss_data_impl(self, data: RSSData, log_prefix: str = \"[存储]\") -> tuple[bool, int, int]:\n        \"\"\"\n        保存 RSS 数据到 SQLite（以 URL 为唯一标识）\n\n        Args:\n            data: RSS 数据\n            log_prefix: 日志前缀\n\n        Returns:\n            (success, new_count, updated_count)\n        \"\"\"\n        try:\n            conn = self._get_connection(data.date, db_type=\"rss\")\n            cursor = conn.cursor()\n\n            now_str = self._get_configured_time().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n            # 同步 RSS 源信息到 rss_feeds 表\n            for feed_id, feed_name in data.id_to_name.items():\n                cursor.execute(\"\"\"\n                    INSERT INTO rss_feeds (id, name, updated_at)\n                    VALUES (?, ?, ?)\n                    ON CONFLICT(id) DO UPDATE SET\n                        name = excluded.name,\n                        updated_at = excluded.updated_at\n                \"\"\", (feed_id, feed_name, now_str))\n\n            # 统计计数器\n            new_count = 0\n            updated_count = 0\n\n            for feed_id, rss_list in data.items.items():\n                for item in rss_list:\n                    try:\n                        # 检查是否已存在（通过 URL + feed_id）\n                        if item.url:\n                            cursor.execute(\"\"\"\n                                SELECT id, title FROM rss_items\n                                WHERE url = ? AND feed_id = ?\n                            \"\"\", (item.url, feed_id))\n                            existing = cursor.fetchone()\n\n                            if existing:\n                                # 已存在，更新记录\n                                existing_id = existing[0]\n                                cursor.execute(\"\"\"\n                                    UPDATE rss_items SET\n                                        title = ?,\n                                        published_at = ?,\n                                        summary = ?,\n                                        author = ?,\n                                        last_crawl_time = ?,\n                                        crawl_count = crawl_count + 1,\n                                        updated_at = ?\n                                    WHERE id = ?\n                                \"\"\", (item.title, item.published_at, item.summary,\n                                      item.author, data.crawl_time, now_str, existing_id))\n                                updated_count += 1\n                            else:\n                                # 不存在，插入新记录（使用 ON CONFLICT 兜底处理并发/竞争场景）\n                                cursor.execute(\"\"\"\n                                    INSERT INTO rss_items\n                                    (title, feed_id, url, published_at, summary, author,\n                                     first_crawl_time, last_crawl_time, crawl_count,\n                                     created_at, updated_at)\n                                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)\n                                    ON CONFLICT(url, feed_id) DO UPDATE SET\n                                        title = excluded.title,\n                                        published_at = excluded.published_at,\n                                        summary = excluded.summary,\n                                        author = excluded.author,\n                                        last_crawl_time = excluded.last_crawl_time,\n                                        crawl_count = crawl_count + 1,\n                                        updated_at = excluded.updated_at\n                                \"\"\", (item.title, feed_id, item.url, item.published_at,\n                                      item.summary, item.author, data.crawl_time,\n                                      data.crawl_time, now_str, now_str))\n                                new_count += 1\n                        else:\n                            # URL 为空，用 try-except 处理重复\n                            try:\n                                cursor.execute(\"\"\"\n                                    INSERT INTO rss_items\n                                    (title, feed_id, url, published_at, summary, author,\n                                     first_crawl_time, last_crawl_time, crawl_count,\n                                     created_at, updated_at)\n                                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)\n                                \"\"\", (item.title, feed_id, \"\", item.published_at,\n                                      item.summary, item.author, data.crawl_time,\n                                      data.crawl_time, now_str, now_str))\n                                new_count += 1\n                            except sqlite3.IntegrityError:\n                                # 重复的空 URL 条目，忽略\n                                pass\n\n                    except sqlite3.Error as e:\n                        print(f\"{log_prefix} 保存 RSS 条目失败 [{item.title[:30]}...]: {e}\")\n\n            total_items = new_count + updated_count\n\n            # 记录抓取信息\n            cursor.execute(\"\"\"\n                INSERT OR REPLACE INTO rss_crawl_records\n                (crawl_time, total_items, created_at)\n                VALUES (?, ?, ?)\n            \"\"\", (data.crawl_time, total_items, now_str))\n\n            # 记录抓取状态\n            cursor.execute(\"\"\"\n                SELECT id FROM rss_crawl_records WHERE crawl_time = ?\n            \"\"\", (data.crawl_time,))\n            record_row = cursor.fetchone()\n            if record_row:\n                crawl_record_id = record_row[0]\n\n                # 记录成功的源\n                for feed_id in data.items.keys():\n                    cursor.execute(\"\"\"\n                        INSERT OR REPLACE INTO rss_crawl_status\n                        (crawl_record_id, feed_id, status)\n                        VALUES (?, ?, 'success')\n                    \"\"\", (crawl_record_id, feed_id))\n\n                # 记录失败的源\n                for failed_id in data.failed_ids:\n                    cursor.execute(\"\"\"\n                        INSERT OR IGNORE INTO rss_feeds (id, name, updated_at)\n                        VALUES (?, ?, ?)\n                    \"\"\", (failed_id, failed_id, now_str))\n\n                    cursor.execute(\"\"\"\n                        INSERT OR REPLACE INTO rss_crawl_status\n                        (crawl_record_id, feed_id, status)\n                        VALUES (?, ?, 'failed')\n                    \"\"\", (crawl_record_id, failed_id))\n\n            conn.commit()\n\n            return True, new_count, updated_count\n\n        except Exception as e:\n            print(f\"{log_prefix} 保存 RSS 数据失败: {e}\")\n            return False, 0, 0\n\n    def _get_rss_data_impl(self, date: Optional[str] = None) -> Optional[RSSData]:\n        \"\"\"\n        获取指定日期的所有 RSS 数据\n\n        Args:\n            date: 日期字符串（YYYY-MM-DD），默认为今天\n\n        Returns:\n            RSSData 对象，如果没有数据返回 None\n        \"\"\"\n        try:\n            conn = self._get_connection(date, db_type=\"rss\")\n            cursor = conn.cursor()\n\n            # 获取所有 RSS 数据\n            cursor.execute(\"\"\"\n                SELECT i.id, i.title, i.feed_id, f.name as feed_name,\n                       i.url, i.published_at, i.summary, i.author,\n                       i.first_crawl_time, i.last_crawl_time, i.crawl_count\n                FROM rss_items i\n                LEFT JOIN rss_feeds f ON i.feed_id = f.id\n                ORDER BY i.published_at DESC\n            \"\"\")\n\n            rows = cursor.fetchall()\n            if not rows:\n                return None\n\n            items: Dict[str, List[RSSItem]] = {}\n            id_to_name: Dict[str, str] = {}\n            crawl_date = self._format_date_folder(date)\n\n            for row in rows:\n                feed_id = row[2]\n                feed_name = row[3] or feed_id\n\n                id_to_name[feed_id] = feed_name\n\n                if feed_id not in items:\n                    items[feed_id] = []\n\n                items[feed_id].append(RSSItem(\n                    title=row[1],\n                    feed_id=feed_id,\n                    feed_name=feed_name,\n                    url=row[4] or \"\",\n                    published_at=row[5] or \"\",\n                    summary=row[6] or \"\",\n                    author=row[7] or \"\",\n                    crawl_time=row[9],\n                    first_time=row[8],\n                    last_time=row[9],\n                    count=row[10],\n                ))\n\n            # 获取最新的抓取时间\n            cursor.execute(\"\"\"\n                SELECT crawl_time FROM rss_crawl_records\n                ORDER BY crawl_time DESC\n                LIMIT 1\n            \"\"\")\n            time_row = cursor.fetchone()\n            crawl_time = time_row[0] if time_row else self._format_time_filename()\n\n            # 获取失败的源\n            cursor.execute(\"\"\"\n                SELECT DISTINCT cs.feed_id\n                FROM rss_crawl_status cs\n                JOIN rss_crawl_records cr ON cs.crawl_record_id = cr.id\n                WHERE cs.status = 'failed'\n            \"\"\")\n            failed_ids = [row[0] for row in cursor.fetchall()]\n\n            return RSSData(\n                date=crawl_date,\n                crawl_time=crawl_time,\n                items=items,\n                id_to_name=id_to_name,\n                failed_ids=failed_ids,\n            )\n\n        except Exception as e:\n            print(f\"[存储] 读取 RSS 数据失败: {e}\")\n            return None\n\n    def _detect_new_rss_items_impl(self, current_data: RSSData) -> Dict[str, List[RSSItem]]:\n        \"\"\"\n        检测新增的 RSS 条目（增量模式）\n\n        该方法比较当前抓取数据与历史数据，找出新增的 RSS 条目。\n        关键逻辑：只有在历史批次中从未出现过的 URL 才算新增。\n\n        Args:\n            current_data: 当前抓取的 RSS 数据\n\n        Returns:\n            新增的 RSS 条目 {feed_id: [RSSItem, ...]}\n        \"\"\"\n        try:\n            # 获取历史数据\n            historical_data = self._get_rss_data_impl(current_data.date)\n\n            if not historical_data:\n                # 没有历史数据，所有都是新的\n                return current_data.items.copy()\n\n            # 获取当前批次时间\n            current_time = current_data.crawl_time\n\n            # 收集历史 URL（first_time < current_time 的条目）\n            historical_urls: Dict[str, set] = {}\n            for feed_id, rss_list in historical_data.items.items():\n                historical_urls[feed_id] = set()\n                for item in rss_list:\n                    first_time = item.first_time or item.crawl_time\n                    if first_time < current_time:\n                        if item.url:\n                            historical_urls[feed_id].add(item.url)\n\n            # 检查是否有历史数据\n            has_historical_data = any(len(urls) > 0 for urls in historical_urls.values())\n            if not has_historical_data:\n                # 第一次抓取，没有\"新增\"概念\n                return {}\n\n            # 检测新增\n            new_items: Dict[str, List[RSSItem]] = {}\n            for feed_id, rss_list in current_data.items.items():\n                hist_set = historical_urls.get(feed_id, set())\n                for item in rss_list:\n                    # 通过 URL 判断是否新增\n                    if item.url and item.url not in hist_set:\n                        if feed_id not in new_items:\n                            new_items[feed_id] = []\n                        new_items[feed_id].append(item)\n\n            return new_items\n\n        except Exception as e:\n            print(f\"[存储] 检测新 RSS 条目失败: {e}\")\n            return {}\n\n    def _get_latest_rss_data_impl(self, date: Optional[str] = None) -> Optional[RSSData]:\n        \"\"\"\n        获取最新一次抓取的 RSS 数据（当前榜单模式）\n\n        Args:\n            date: 日期字符串（YYYY-MM-DD），默认为今天\n\n        Returns:\n            最新抓取的 RSS 数据，如果没有数据返回 None\n        \"\"\"\n        try:\n            conn = self._get_connection(date, db_type=\"rss\")\n            cursor = conn.cursor()\n\n            # 获取最新的抓取时间\n            cursor.execute(\"\"\"\n                SELECT crawl_time FROM rss_crawl_records\n                ORDER BY crawl_time DESC\n                LIMIT 1\n            \"\"\")\n\n            time_row = cursor.fetchone()\n            if not time_row:\n                return None\n\n            latest_time = time_row[0]\n\n            # 获取该时间的 RSS 数据\n            cursor.execute(\"\"\"\n                SELECT i.id, i.title, i.feed_id, f.name as feed_name,\n                       i.url, i.published_at, i.summary, i.author,\n                       i.first_crawl_time, i.last_crawl_time, i.crawl_count\n                FROM rss_items i\n                LEFT JOIN rss_feeds f ON i.feed_id = f.id\n                WHERE i.last_crawl_time = ?\n                ORDER BY i.published_at DESC\n            \"\"\", (latest_time,))\n\n            rows = cursor.fetchall()\n            if not rows:\n                return None\n\n            items: Dict[str, List[RSSItem]] = {}\n            id_to_name: Dict[str, str] = {}\n            crawl_date = self._format_date_folder(date)\n\n            for row in rows:\n                feed_id = row[2]\n                feed_name = row[3] or feed_id\n\n                id_to_name[feed_id] = feed_name\n\n                if feed_id not in items:\n                    items[feed_id] = []\n\n                items[feed_id].append(RSSItem(\n                    title=row[1],\n                    feed_id=feed_id,\n                    feed_name=feed_name,\n                    url=row[4] or \"\",\n                    published_at=row[5] or \"\",\n                    summary=row[6] or \"\",\n                    author=row[7] or \"\",\n                    crawl_time=row[9],\n                    first_time=row[8],\n                    last_time=row[9],\n                    count=row[10],\n                ))\n\n            # 获取失败的源（针对最新一次抓取）\n            cursor.execute(\"\"\"\n                SELECT cs.feed_id\n                FROM rss_crawl_status cs\n                JOIN rss_crawl_records cr ON cs.crawl_record_id = cr.id\n                WHERE cr.crawl_time = ? AND cs.status = 'failed'\n            \"\"\", (latest_time,))\n\n            failed_ids = [row[0] for row in cursor.fetchall()]\n\n            return RSSData(\n                date=crawl_date,\n                crawl_time=latest_time,\n                items=items,\n                id_to_name=id_to_name,\n                failed_ids=failed_ids,\n            )\n\n        except Exception as e:\n            print(f\"[存储] 获取最新 RSS 数据失败: {e}\")\n            return None\n\n    # ========================================\n    # AI 智能筛选 - 标签管理\n    # ========================================\n\n    def _get_active_tags_impl(self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> List[Dict[str, Any]]:\n        \"\"\"获取指定兴趣文件的 active 标签列表\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            cursor.execute(\"\"\"\n                SELECT id, tag, description, version, prompt_hash, priority\n                FROM ai_filter_tags\n                WHERE status = 'active' AND interests_file = ?\n                ORDER BY priority ASC, id ASC\n            \"\"\", (interests_file,))\n\n            return [\n                {\n                    \"id\": row[0], \"tag\": row[1], \"description\": row[2],\n                    \"version\": row[3], \"prompt_hash\": row[4], \"priority\": row[5],\n                }\n                for row in cursor.fetchall()\n            ]\n        except Exception as e:\n            print(f\"[AI筛选] 获取标签失败: {e}\")\n            return []\n\n    def _get_latest_prompt_hash_impl(self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> Optional[str]:\n        \"\"\"获取指定兴趣文件最新版本标签的 prompt_hash\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            cursor.execute(\"\"\"\n                SELECT prompt_hash FROM ai_filter_tags\n                WHERE status = 'active' AND interests_file = ?\n                ORDER BY version DESC\n                LIMIT 1\n            \"\"\", (interests_file,))\n            row = cursor.fetchone()\n            return row[0] if row else None\n        except Exception as e:\n            print(f\"[AI筛选] 获取 prompt_hash 失败: {e}\")\n            return None\n\n    def _get_latest_tag_version_impl(self, date: Optional[str] = None) -> int:\n        \"\"\"获取最新版本号\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            cursor.execute(\"\"\"\n                SELECT MAX(version) FROM ai_filter_tags\n            \"\"\")\n            row = cursor.fetchone()\n            return row[0] if row and row[0] is not None else 0\n        except Exception as e:\n            print(f\"[AI筛选] 获取版本号失败: {e}\")\n            return 0\n\n    def _deprecate_all_tags_impl(self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> int:\n        \"\"\"将指定兴趣文件的 active 标签和关联的分类结果标记为 deprecated\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n            now_str = self._get_configured_time().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n            # 获取该兴趣文件的 active 标签 id\n            cursor.execute(\n                \"SELECT id FROM ai_filter_tags WHERE status = 'active' AND interests_file = ?\",\n                (interests_file,)\n            )\n            tag_ids = [row[0] for row in cursor.fetchall()]\n\n            if not tag_ids:\n                return 0\n\n            # 废弃标签\n            placeholders = \",\".join(\"?\" * len(tag_ids))\n            cursor.execute(f\"\"\"\n                UPDATE ai_filter_tags\n                SET status = 'deprecated', deprecated_at = ?\n                WHERE id IN ({placeholders})\n            \"\"\", [now_str] + tag_ids)\n            tag_count = cursor.rowcount\n\n            # 废弃关联的分类结果\n            placeholders = \",\".join(\"?\" * len(tag_ids))\n            cursor.execute(f\"\"\"\n                UPDATE ai_filter_results\n                SET status = 'deprecated', deprecated_at = ?\n                WHERE tag_id IN ({placeholders}) AND status = 'active'\n            \"\"\", [now_str] + tag_ids)\n\n            conn.commit()\n            print(f\"[AI筛选] 已废弃 {tag_count} 个标签及关联分类结果\")\n            return tag_count\n        except Exception as e:\n            print(f\"[AI筛选] 废弃标签失败: {e}\")\n            return 0\n\n    def _save_tags_impl(\n        self, date: Optional[str], tags: List[Dict], version: int, prompt_hash: str,\n        interests_file: str = \"ai_interests.txt\"\n    ) -> int:\n        \"\"\"保存新提取的标签\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n            now_str = self._get_configured_time().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n            count = 0\n            for idx, tag_data in enumerate(tags, start=1):\n                priority = tag_data.get(\"priority\", idx)\n                try:\n                    priority = int(priority)\n                except (TypeError, ValueError):\n                    priority = idx\n                cursor.execute(\"\"\"\n                    INSERT INTO ai_filter_tags\n                    (tag, description, priority, version, prompt_hash, interests_file, created_at)\n                    VALUES (?, ?, ?, ?, ?, ?, ?)\n                \"\"\", (\n                    tag_data[\"tag\"],\n                    tag_data.get(\"description\", \"\"),\n                    priority,\n                    version,\n                    prompt_hash,\n                    interests_file,\n                    now_str,\n                ))\n                count += 1\n\n            conn.commit()\n            return count\n        except Exception as e:\n            print(f\"[AI筛选] 保存标签失败: {e}\")\n            return 0\n\n    def _deprecate_specific_tags_impl(\n        self, date: Optional[str], tag_ids: List[int]\n    ) -> int:\n        \"\"\"废弃指定 ID 的标签及其关联分类结果（增量更新时使用）\"\"\"\n        if not tag_ids:\n            return 0\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n            now_str = self._get_configured_time().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n            placeholders = \",\".join(\"?\" * len(tag_ids))\n\n            cursor.execute(f\"\"\"\n                UPDATE ai_filter_tags\n                SET status = 'deprecated', deprecated_at = ?\n                WHERE id IN ({placeholders})\n            \"\"\", [now_str] + tag_ids)\n            tag_count = cursor.rowcount\n\n            cursor.execute(f\"\"\"\n                UPDATE ai_filter_results\n                SET status = 'deprecated', deprecated_at = ?\n                WHERE tag_id IN ({placeholders}) AND status = 'active'\n            \"\"\", [now_str] + tag_ids)\n\n            conn.commit()\n            return tag_count\n        except Exception as e:\n            print(f\"[AI筛选] 废弃指定标签失败: {e}\")\n            return 0\n\n    def _update_tags_hash_impl(\n        self, date: Optional[str], interests_file: str, new_hash: str\n    ) -> int:\n        \"\"\"更新指定兴趣文件所有 active 标签的 prompt_hash（增量更新时使用）\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            cursor.execute(\"\"\"\n                UPDATE ai_filter_tags\n                SET prompt_hash = ?\n                WHERE interests_file = ? AND status = 'active'\n            \"\"\", (new_hash, interests_file))\n            count = cursor.rowcount\n\n            conn.commit()\n            return count\n        except Exception as e:\n            print(f\"[AI筛选] 更新标签 hash 失败: {e}\")\n            return 0\n\n    # ========================================\n    # AI 智能筛选 - 分类结果管理\n    # ========================================\n\n    def _update_tag_descriptions_impl(\n        self, date: Optional[str], tag_updates: List[Dict],\n        interests_file: str = \"ai_interests.txt\"\n    ) -> int:\n        \"\"\"按 tag 名匹配，更新 active 标签的 description 字段\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            count = 0\n            for t in tag_updates:\n                tag_name = t.get(\"tag\", \"\")\n                description = t.get(\"description\", \"\")\n                if not tag_name:\n                    continue\n                cursor.execute(\"\"\"\n                    UPDATE ai_filter_tags\n                    SET description = ?\n                    WHERE tag = ? AND interests_file = ? AND status = 'active'\n                \"\"\", (description, tag_name, interests_file))\n                count += cursor.rowcount\n\n            conn.commit()\n            return count\n        except Exception as e:\n            print(f\"[AI筛选] 更新标签描述失败: {e}\")\n            return 0\n\n    def _update_tag_priorities_impl(\n        self, date: Optional[str], tag_priorities: List[Dict],\n        interests_file: str = \"ai_interests.txt\"\n    ) -> int:\n        \"\"\"按 tag 名匹配，更新 active 标签的 priority 字段\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            count = 0\n            for t in tag_priorities:\n                tag_name = t.get(\"tag\", \"\")\n                priority = t.get(\"priority\")\n                if not tag_name:\n                    continue\n                try:\n                    priority = int(priority)\n                except (TypeError, ValueError):\n                    continue\n                cursor.execute(\"\"\"\n                    UPDATE ai_filter_tags\n                    SET priority = ?\n                    WHERE tag = ? AND interests_file = ? AND status = 'active'\n                \"\"\", (priority, tag_name, interests_file))\n                count += cursor.rowcount\n\n            conn.commit()\n            return count\n        except Exception as e:\n            print(f\"[AI筛选] 更新标签优先级失败: {e}\")\n            return 0\n\n    # ========================================\n    # AI 智能筛选 - 已分析新闻追踪\n    # ========================================\n\n    def _save_analyzed_news_impl(\n        self, date: Optional[str], news_ids: List[int], source_type: str,\n        interests_file: str, prompt_hash: str, matched_ids: set\n    ) -> int:\n        \"\"\"批量记录已分析的新闻（匹配与不匹配都记录）\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n            now_str = self._get_configured_time().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n            count = 0\n            for nid in news_ids:\n                try:\n                    cursor.execute(\"\"\"\n                        INSERT OR REPLACE INTO ai_filter_analyzed_news\n                        (news_item_id, source_type, interests_file, prompt_hash, matched, created_at)\n                        VALUES (?, ?, ?, ?, ?, ?)\n                    \"\"\", (\n                        nid, source_type, interests_file, prompt_hash,\n                        1 if nid in matched_ids else 0,\n                        now_str,\n                    ))\n                    count += 1\n                except Exception:\n                    pass\n\n            conn.commit()\n            return count\n        except Exception as e:\n            print(f\"[AI筛选] 保存已分析记录失败: {e}\")\n            return 0\n\n    def _get_analyzed_news_ids_impl(\n        self, date: Optional[str] = None, source_type: str = \"hotlist\",\n        interests_file: str = \"ai_interests.txt\"\n    ) -> set:\n        \"\"\"获取已分析过的新闻 ID 集合（用于去重）\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            cursor.execute(\"\"\"\n                SELECT news_item_id FROM ai_filter_analyzed_news\n                WHERE source_type = ? AND interests_file = ?\n            \"\"\", (source_type, interests_file))\n\n            return {row[0] for row in cursor.fetchall()}\n        except Exception as e:\n            print(f\"[AI筛选] 获取已分析ID失败: {e}\")\n            return set()\n\n    def _clear_analyzed_news_impl(\n        self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\"\n    ) -> int:\n        \"\"\"清除指定兴趣文件的所有已分析记录（全量重分类时使用）\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            cursor.execute(\"\"\"\n                DELETE FROM ai_filter_analyzed_news\n                WHERE interests_file = ?\n            \"\"\", (interests_file,))\n\n            count = cursor.rowcount\n            conn.commit()\n            return count\n        except Exception as e:\n            print(f\"[AI筛选] 清除已分析记录失败: {e}\")\n            return 0\n\n    def _clear_unmatched_analyzed_news_impl(\n        self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\"\n    ) -> int:\n        \"\"\"清除不匹配的已分析记录，让这些新闻有机会被新标签重新分析\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            cursor.execute(\"\"\"\n                DELETE FROM ai_filter_analyzed_news\n                WHERE interests_file = ? AND matched = 0\n            \"\"\", (interests_file,))\n\n            count = cursor.rowcount\n            conn.commit()\n            return count\n        except Exception as e:\n            print(f\"[AI筛选] 清除不匹配记录失败: {e}\")\n            return 0\n\n    # ========================================\n    # AI 智能筛选 - 分类结果管理（原有）\n    # ========================================\n\n    def _save_filter_results_impl(\n        self, date: Optional[str], results: List[Dict]\n    ) -> int:\n        \"\"\"批量保存分类结果\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n            now_str = self._get_configured_time().strftime(\"%Y-%m-%d %H:%M:%S\")\n\n            count = 0\n            for r in results:\n                try:\n                    cursor.execute(\"\"\"\n                        INSERT INTO ai_filter_results\n                        (news_item_id, source_type, tag_id, relevance_score, created_at)\n                        VALUES (?, ?, ?, ?, ?)\n                    \"\"\", (\n                        r[\"news_item_id\"],\n                        r.get(\"source_type\", \"hotlist\"),\n                        r[\"tag_id\"],\n                        r.get(\"relevance_score\", 0.0),\n                        now_str,\n                    ))\n                    count += 1\n                except sqlite3.IntegrityError:\n                    pass  # 重复记录，跳过\n\n            conn.commit()\n            return count\n        except Exception as e:\n            print(f\"[AI筛选] 保存分类结果失败: {e}\")\n            return 0\n\n    def _get_active_filter_results_impl(self, date: Optional[str] = None, interests_file: str = \"ai_interests.txt\") -> List[Dict[str, Any]]:\n        \"\"\"获取指定兴趣文件的 active 分类结果，JOIN news_items 获取新闻详情\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            # 热榜结果\n            cursor.execute(\"\"\"\n                SELECT\n                    r.news_item_id, r.source_type, r.tag_id, r.relevance_score,\n                    t.tag, t.description as tag_description, t.priority,\n                    n.title, n.platform_id as source_id, p.name as source_name,\n                    n.url, n.mobile_url, n.rank,\n                    n.first_crawl_time, n.last_crawl_time, n.crawl_count\n                FROM ai_filter_results r\n                JOIN ai_filter_tags t ON r.tag_id = t.id\n                JOIN news_items n ON r.news_item_id = n.id\n                LEFT JOIN platforms p ON n.platform_id = p.id\n                WHERE r.status = 'active' AND r.source_type = 'hotlist'\n                    AND t.status = 'active' AND t.interests_file = ?\n                ORDER BY t.priority ASC, t.id ASC, r.relevance_score DESC\n            \"\"\", (interests_file,))\n\n            results = []\n            hotlist_news_ids = []\n            for row in cursor.fetchall():\n                results.append({\n                    \"news_item_id\": row[0], \"source_type\": row[1],\n                    \"tag_id\": row[2], \"relevance_score\": row[3],\n                    \"tag\": row[4], \"tag_description\": row[5], \"tag_priority\": row[6],\n                    \"title\": row[7], \"source_id\": row[8],\n                    \"source_name\": row[9] or row[8],\n                    \"url\": row[10] or \"\", \"mobile_url\": row[11] or \"\",\n                    \"rank\": row[12],\n                    \"first_time\": row[13], \"last_time\": row[14],\n                    \"count\": row[15],\n                })\n                hotlist_news_ids.append(row[0])\n\n            # 批量查排名历史（热榜）\n            ranks_map: Dict[int, List[int]] = {}\n            if hotlist_news_ids:\n                unique_ids = list(set(hotlist_news_ids))\n                placeholders = \",\".join(\"?\" * len(unique_ids))\n                cursor.execute(f\"\"\"\n                    SELECT news_item_id, rank FROM rank_history\n                    WHERE news_item_id IN ({placeholders}) AND rank != 0\n                \"\"\", unique_ids)\n                for rh_row in cursor.fetchall():\n                    nid, rank = rh_row[0], rh_row[1]\n                    if nid not in ranks_map:\n                        ranks_map[nid] = []\n                    if rank not in ranks_map[nid]:\n                        ranks_map[nid].append(rank)\n\n            for item in results:\n                item[\"ranks\"] = ranks_map.get(item[\"news_item_id\"], [item[\"rank\"]])\n\n            # RSS 结果（如果有 rss 库）\n            try:\n                rss_conn = self._get_connection(date, db_type=\"rss\")\n                rss_cursor = rss_conn.cursor()\n\n                # 从 news 库获取 rss 类型的分类结果 ID\n                cursor.execute(\"\"\"\n                    SELECT r.news_item_id, r.tag_id, r.relevance_score,\n                           t.tag, t.description, t.priority\n                    FROM ai_filter_results r\n                    JOIN ai_filter_tags t ON r.tag_id = t.id\n                    WHERE r.status = 'active' AND r.source_type = 'rss'\n                        AND t.status = 'active' AND t.interests_file = ?\n                    ORDER BY t.priority ASC, t.id ASC, r.relevance_score DESC\n                \"\"\", (interests_file,))\n\n                rss_filter_rows = cursor.fetchall()\n                if rss_filter_rows:\n                    rss_ids = [row[0] for row in rss_filter_rows]\n                    placeholders = \",\".join(\"?\" * len(rss_ids))\n                    rss_cursor.execute(f\"\"\"\n                        SELECT i.id, i.title, i.feed_id, f.name as feed_name,\n                               i.url, i.published_at\n                        FROM rss_items i\n                        LEFT JOIN rss_feeds f ON i.feed_id = f.id\n                        WHERE i.id IN ({placeholders})\n                    \"\"\", rss_ids)\n\n                    rss_info = {row[0]: row for row in rss_cursor.fetchall()}\n\n                    for fr_row in rss_filter_rows:\n                        rss_id = fr_row[0]\n                        info = rss_info.get(rss_id)\n                        if info:\n                            results.append({\n                                \"news_item_id\": rss_id,\n                                \"source_type\": \"rss\",\n                                \"tag_id\": fr_row[1],\n                                \"relevance_score\": fr_row[2],\n                                \"tag\": fr_row[3],\n                                \"tag_description\": fr_row[4],\n                                \"tag_priority\": fr_row[5],\n                                \"title\": info[1],\n                                \"source_id\": info[2],\n                                \"source_name\": info[3] or info[2],\n                                \"url\": info[4] or \"\",\n                                \"mobile_url\": \"\",\n                                \"rank\": 0,\n                                \"ranks\": [],\n                                \"first_time\": info[5] or \"\",\n                                \"last_time\": info[5] or \"\",\n                                \"count\": 1,\n                            })\n            except Exception:\n                pass  # RSS 库不存在时静默跳过\n\n            return results\n        except Exception as e:\n            print(f\"[AI筛选] 获取分类结果失败: {e}\")\n            return []\n\n    def _get_all_news_ids_impl(self, date: Optional[str] = None) -> List[Dict]:\n        \"\"\"获取当日所有新闻的 id 和标题（用于 AI 筛选分类）\"\"\"\n        try:\n            conn = self._get_connection(date)\n            cursor = conn.cursor()\n\n            cursor.execute(\"\"\"\n                SELECT n.id, n.title, n.platform_id, p.name as platform_name\n                FROM news_items n\n                LEFT JOIN platforms p ON n.platform_id = p.id\n                ORDER BY n.id\n            \"\"\")\n\n            return [\n                {\n                    \"id\": row[0], \"title\": row[1],\n                    \"source_id\": row[2], \"source_name\": row[3] or row[2],\n                }\n                for row in cursor.fetchall()\n            ]\n        except Exception as e:\n            print(f\"[AI筛选] 获取新闻列表失败: {e}\")\n            return []\n\n    def _get_all_rss_ids_impl(self, date: Optional[str] = None) -> List[Dict]:\n        \"\"\"获取当日所有 RSS 条目的 id 和标题（用于 AI 筛选分类）\"\"\"\n        try:\n            conn = self._get_connection(date, db_type=\"rss\")\n            cursor = conn.cursor()\n\n            cursor.execute(\"\"\"\n                SELECT i.id, i.title, i.feed_id, f.name as feed_name, i.published_at\n                FROM rss_items i\n                LEFT JOIN rss_feeds f ON i.feed_id = f.id\n                ORDER BY i.id\n            \"\"\")\n\n            return [\n                {\n                    \"id\": row[0], \"title\": row[1],\n                    \"source_id\": row[2], \"source_name\": row[3] or row[2],\n                    \"published_at\": row[4] or \"\",\n                }\n                for row in cursor.fetchall()\n            ]\n        except Exception as e:\n            print(f\"[AI筛选] 获取 RSS 列表失败: {e}\")\n            return []\n"
  },
  {
    "path": "trendradar/utils/__init__.py",
    "content": "# coding=utf-8\n\"\"\"\n工具模块 - 公共工具函数\n\"\"\"\n\nfrom trendradar.utils.time import (\n    get_configured_time,\n    format_date_folder,\n    format_time_filename,\n    get_current_time_display,\n    convert_time_for_display,\n)\nfrom trendradar.utils.url import normalize_url, get_url_signature\n\n__all__ = [\n    \"get_configured_time\",\n    \"format_date_folder\",\n    \"format_time_filename\",\n    \"get_current_time_display\",\n    \"convert_time_for_display\",\n    \"normalize_url\",\n    \"get_url_signature\",\n]\n"
  },
  {
    "path": "trendradar/utils/time.py",
    "content": "# coding=utf-8\n\"\"\"\n时间工具模块\n\n本模块提供统一的时间处理函数，所有时区相关操作都应使用 DEFAULT_TIMEZONE 常量。\n\"\"\"\n\nfrom datetime import datetime\nfrom typing import Optional, Tuple\n\nimport pytz\n\n# 默认时区常量 - 仅作为 fallback，正常运行时使用 config.yaml 中的 app.timezone\nDEFAULT_TIMEZONE = \"Asia/Shanghai\"\n\n\ndef get_configured_time(timezone: str = DEFAULT_TIMEZONE) -> datetime:\n    \"\"\"\n    获取配置时区的当前时间\n\n    Args:\n        timezone: 时区名称，如 'Asia/Shanghai', 'America/Los_Angeles'\n\n    Returns:\n        带时区信息的当前时间\n    \"\"\"\n    try:\n        tz = pytz.timezone(timezone)\n    except pytz.UnknownTimeZoneError:\n        print(f\"[警告] 未知时区 '{timezone}'，使用默认时区 {DEFAULT_TIMEZONE}\")\n        tz = pytz.timezone(DEFAULT_TIMEZONE)\n    return datetime.now(tz)\n\n\ndef format_date_folder(\n    date: Optional[str] = None, timezone: str = DEFAULT_TIMEZONE\n) -> str:\n    \"\"\"\n    格式化日期文件夹名 (ISO 格式: YYYY-MM-DD)\n\n    Args:\n        date: 指定日期字符串，为 None 则使用当前日期\n        timezone: 时区名称\n\n    Returns:\n        格式化后的日期字符串，如 '2025-12-09'\n    \"\"\"\n    if date:\n        return date\n    return get_configured_time(timezone).strftime(\"%Y-%m-%d\")\n\n\ndef format_time_filename(timezone: str = DEFAULT_TIMEZONE) -> str:\n    \"\"\"\n    格式化时间文件名 (格式: HH-MM，用于文件名)\n\n    Windows 系统不支持冒号作为文件名，因此使用连字符\n\n    Args:\n        timezone: 时区名称\n\n    Returns:\n        格式化后的时间字符串，如 '15-30'\n    \"\"\"\n    return get_configured_time(timezone).strftime(\"%H-%M\")\n\n\ndef get_current_time_display(timezone: str = DEFAULT_TIMEZONE) -> str:\n    \"\"\"\n    获取当前时间显示 (格式: HH:MM，用于显示)\n\n    Args:\n        timezone: 时区名称\n\n    Returns:\n        格式化后的时间字符串，如 '15:30'\n    \"\"\"\n    return get_configured_time(timezone).strftime(\"%H:%M\")\n\n\ndef convert_time_for_display(time_str: str) -> str:\n    \"\"\"\n    将 HH-MM 格式转换为 HH:MM 格式用于显示\n\n    Args:\n        time_str: 输入时间字符串，如 '15-30'\n\n    Returns:\n        转换后的时间字符串，如 '15:30'\n    \"\"\"\n    if time_str and \"-\" in time_str and len(time_str) == 5:\n        return time_str.replace(\"-\", \":\")\n    return time_str\n\n\ndef format_iso_time_friendly(\n    iso_time: str,\n    timezone: str = DEFAULT_TIMEZONE,\n    include_date: bool = True,\n) -> str:\n    \"\"\"\n    将 ISO 格式时间转换为用户时区的友好显示格式\n\n    Args:\n        iso_time: ISO 格式时间字符串，如 '2025-12-29T00:20:00' 或 '2025-12-29T00:20:00+00:00'\n        timezone: 目标时区名称\n        include_date: 是否包含日期部分\n\n    Returns:\n        友好格式的时间字符串，如 '12-29 08:20' 或 '08:20'\n    \"\"\"\n    if not iso_time:\n        return \"\"\n\n    try:\n        # 尝试解析各种 ISO 格式\n        dt = None\n\n        # 尝试解析带时区的格式\n        if \"+\" in iso_time or iso_time.endswith(\"Z\"):\n            iso_time = iso_time.replace(\"Z\", \"+00:00\")\n            try:\n                dt = datetime.fromisoformat(iso_time)\n            except ValueError:\n                pass\n\n        # 尝试解析不带时区的格式（假设为 UTC）\n        if dt is None:\n            try:\n                # 处理 T 分隔符\n                if \"T\" in iso_time:\n                    dt = datetime.fromisoformat(iso_time.replace(\"T\", \" \").split(\".\")[0])\n                else:\n                    dt = datetime.fromisoformat(iso_time.split(\".\")[0])\n                # 假设为 UTC 时间\n                dt = pytz.UTC.localize(dt)\n            except ValueError:\n                pass\n\n        if dt is None:\n            # 无法解析，返回原始字符串的简化版本\n            if \"T\" in iso_time:\n                parts = iso_time.split(\"T\")\n                if len(parts) == 2:\n                    date_part = parts[0][5:]  # MM-DD\n                    time_part = parts[1][:5]  # HH:MM\n                    return f\"{date_part} {time_part}\" if include_date else time_part\n            return iso_time\n\n        # 转换到目标时区\n        try:\n            target_tz = pytz.timezone(timezone)\n        except pytz.UnknownTimeZoneError:\n            target_tz = pytz.timezone(DEFAULT_TIMEZONE)\n\n        dt_local = dt.astimezone(target_tz)\n\n        # 格式化输出\n        if include_date:\n            return dt_local.strftime(\"%m-%d %H:%M\")\n        else:\n            return dt_local.strftime(\"%H:%M\")\n\n    except Exception:\n        # 出错时返回原始字符串的简化版本\n        if \"T\" in iso_time:\n            parts = iso_time.split(\"T\")\n            if len(parts) == 2:\n                date_part = parts[0][5:]  # MM-DD\n                time_part = parts[1][:5]  # HH:MM\n                return f\"{date_part} {time_part}\" if include_date else time_part\n        return iso_time\n\n\ndef is_within_days(\n    iso_time: str,\n    max_days: int,\n    timezone: str = DEFAULT_TIMEZONE,\n) -> bool:\n    \"\"\"\n    检查 ISO 格式时间是否在指定天数内\n\n    用于 RSS 文章新鲜度过滤，判断文章发布时间是否超过指定天数。\n\n    Args:\n        iso_time: ISO 格式时间字符串（如 '2025-12-29T00:20:00' 或带时区）\n        max_days: 最大天数（文章发布时间距今不超过此天数则返回 True）\n            - max_days > 0: 正常过滤，保留 N 天内的文章\n            - max_days <= 0: 禁用过滤，保留所有文章\n        timezone: 时区名称（用于获取当前时间）\n\n    Returns:\n        True 如果时间在指定天数内（应保留），False 如果超过指定天数（应过滤）\n        如果无法解析时间，返回 True（保留文章）\n    \"\"\"\n    # 无时间戳或禁用过滤时，保留文章\n    if not iso_time:\n        return True\n    if max_days <= 0:\n        return True  # max_days=0 表示禁用过滤\n\n    try:\n        dt = None\n\n        # 尝试解析带时区的格式\n        if \"+\" in iso_time or iso_time.endswith(\"Z\"):\n            iso_time_normalized = iso_time.replace(\"Z\", \"+00:00\")\n            try:\n                dt = datetime.fromisoformat(iso_time_normalized)\n            except ValueError:\n                pass\n\n        # 尝试解析不带时区的格式（假设为 UTC）\n        if dt is None:\n            try:\n                if \"T\" in iso_time:\n                    dt = datetime.fromisoformat(iso_time.replace(\"T\", \" \").split(\".\")[0])\n                else:\n                    dt = datetime.fromisoformat(iso_time.split(\".\")[0])\n                dt = pytz.UTC.localize(dt)\n            except ValueError:\n                pass\n\n        if dt is None:\n            # 无法解析时间，保留文章\n            return True\n\n        # 获取当前时间（配置的时区，带时区信息）\n        now = get_configured_time(timezone)\n\n        # 计算时间差（两个带时区的 datetime 相减会自动处理时区差异）\n        diff = now - dt\n        days_diff = diff.total_seconds() / (24 * 60 * 60)\n\n        return days_diff <= max_days\n\n    except Exception:\n        # 出错时保留文章\n        return True\n\n\ndef calculate_days_old(iso_time: str, timezone: str = DEFAULT_TIMEZONE) -> Optional[float]:\n    \"\"\"\n    计算 ISO 格式时间距今多少天\n\n    Args:\n        iso_time: ISO 格式时间字符串\n        timezone: 时区名称\n\n    Returns:\n        距今天数（浮点数），如果无法解析返回 None\n    \"\"\"\n    if not iso_time:\n        return None\n\n    try:\n        dt = None\n\n        # 尝试解析带时区的格式\n        if \"+\" in iso_time or iso_time.endswith(\"Z\"):\n            iso_time_normalized = iso_time.replace(\"Z\", \"+00:00\")\n            try:\n                dt = datetime.fromisoformat(iso_time_normalized)\n            except ValueError:\n                pass\n\n        # 尝试解析不带时区的格式（假设为 UTC）\n        if dt is None:\n            try:\n                if \"T\" in iso_time:\n                    dt = datetime.fromisoformat(iso_time.replace(\"T\", \" \").split(\".\")[0])\n                else:\n                    dt = datetime.fromisoformat(iso_time.split(\".\")[0])\n                dt = pytz.UTC.localize(dt)\n            except ValueError:\n                pass\n\n        if dt is None:\n            return None\n\n        now = get_configured_time(timezone)\n        diff = now - dt\n        return diff.total_seconds() / (24 * 60 * 60)\n\n    except Exception:\n        return None\n\n\nclass TimeWindowChecker:\n    \"\"\"\n    时间窗口检查器\n\n    统一管理时间窗口控制逻辑，支持：\n    - 推送窗口控制 (push_window)\n    - AI 分析窗口控制 (analysis_window)\n    - once_per_day 功能\n    \"\"\"\n\n    def __init__(\n        self,\n        storage_backend,\n        get_time_func=None,\n        window_name: str = \"时间窗口\",\n    ):\n        \"\"\"\n        初始化时间窗口检查器\n\n        Args:\n            storage_backend: 存储后端实例\n            get_time_func: 获取当前时间的函数\n            window_name: 窗口名称（用于日志输出）\n        \"\"\"\n        self.storage_backend = storage_backend\n        self.get_time_func = get_time_func or (lambda: get_configured_time(DEFAULT_TIMEZONE))\n        self.window_name = window_name\n\n    def is_in_time_range(self, start_time: str, end_time: str) -> bool:\n        \"\"\"\n        检查当前时间是否在指定时间范围内\n\n        支持跨日时间窗口，例如：\n        - 正常窗口：09:00-21:00（当天 9 点到 21 点）\n        - 跨日窗口：22:00-02:00（当天 22 点到次日 2 点）\n\n        Args:\n            start_time: 开始时间（格式：HH:MM）\n            end_time: 结束时间（格式：HH:MM）\n\n        Returns:\n            是否在时间范围内\n        \"\"\"\n        now = self.get_time_func()\n        current_time = now.strftime(\"%H:%M\")\n\n        normalized_start = self._normalize_time(start_time)\n        normalized_end = self._normalize_time(end_time)\n        normalized_current = self._normalize_time(current_time)\n\n        # 判断是否跨日窗口（start > end 表示跨日，如 22:00-02:00）\n        if normalized_start <= normalized_end:\n            # 正常窗口：09:00-21:00\n            result = normalized_start <= normalized_current <= normalized_end\n        else:\n            # 跨日窗口：22:00-02:00\n            # 当前时间 >= 开始时间（如 23:00 >= 22:00）或 当前时间 <= 结束时间（如 01:00 <= 02:00）\n            result = normalized_current >= normalized_start or normalized_current <= normalized_end\n\n        if not result:\n            print(f\"[{self.window_name}] 当前 {normalized_current}，窗口 {normalized_start}-{normalized_end}\")\n\n        return result\n\n    def _normalize_time(self, time_str: str) -> str:\n        \"\"\"将时间字符串标准化为 HH:MM 格式\"\"\"\n        try:\n            parts = time_str.strip().split(\":\")\n            if len(parts) != 2:\n                raise ValueError(f\"时间格式错误: {time_str}\")\n\n            hour = int(parts[0])\n            minute = int(parts[1])\n\n            if not (0 <= hour <= 23 and 0 <= minute <= 59):\n                raise ValueError(f\"时间范围错误: {time_str}\")\n\n            return f\"{hour:02d}:{minute:02d}\"\n        except Exception as e:\n            print(f\"[{self.window_name}] 时间格式化错误 '{time_str}': {e}\")\n            return time_str\n\n    def check_window(\n        self,\n        window_config: dict,\n        check_once_per_day_func=None,\n        record_func=None,\n    ) -> Tuple[bool, str]:\n        \"\"\"\n        统一的时间窗口检查逻辑\n\n        Args:\n            window_config: 窗口配置字典，包含：\n                - ENABLED: 是否启用窗口控制\n                - TIME_RANGE: {\"START\": \"HH:MM\", \"END\": \"HH:MM\"}\n                - ONCE_PER_DAY: 是否每天只执行一次\n            check_once_per_day_func: 检查今天是否已执行的函数\n            record_func: 记录执行的函数（成功后调用）\n\n        Returns:\n            (should_proceed, reason) 元组：\n            - should_proceed: 是否应该继续执行\n            - reason: 原因说明\n        \"\"\"\n        if not window_config.get(\"ENABLED\", False):\n            return True, \"窗口控制未启用\"\n\n        time_range = window_config.get(\"TIME_RANGE\", {})\n        start_time = time_range.get(\"START\", \"00:00\")\n        end_time = time_range.get(\"END\", \"23:59\")\n\n        # 检查时间范围\n        if not self.is_in_time_range(start_time, end_time):\n            now = self.get_time_func()\n            return False, f\"当前时间 {now.strftime('%H:%M')} 不在窗口 {start_time}-{end_time} 内\"\n\n        # 检查 once_per_day\n        if window_config.get(\"ONCE_PER_DAY\", False) and check_once_per_day_func:\n            if check_once_per_day_func():\n                return False, \"今天已执行过\"\n            else:\n                print(f\"[{self.window_name}] 今天首次执行\")\n\n        return True, \"在窗口内\"\n\n    def get_status(self, window_config: dict, check_once_per_day_func=None) -> dict:\n        \"\"\"\n        获取窗口状态信息\n\n        Args:\n            window_config: 窗口配置\n            check_once_per_day_func: 检查今天是否已执行的函数\n\n        Returns:\n            状态信息字典\n        \"\"\"\n        now = self.get_time_func()\n        status = {\n            \"enabled\": window_config.get(\"ENABLED\", False),\n            \"current_time\": now.strftime(\"%H:%M:%S\"),\n            \"current_date\": now.strftime(\"%Y-%m-%d\"),\n            \"timezone\": str(now.tzinfo),\n        }\n\n        if status[\"enabled\"]:\n            time_range = window_config.get(\"TIME_RANGE\", {})\n            status[\"window_start\"] = time_range.get(\"START\", \"00:00\")\n            status[\"window_end\"] = time_range.get(\"END\", \"23:59\")\n            status[\"in_window\"] = self.is_in_time_range(\n                status[\"window_start\"], status[\"window_end\"]\n            )\n            status[\"once_per_day\"] = window_config.get(\"ONCE_PER_DAY\", False)\n\n            if status[\"once_per_day\"] and check_once_per_day_func:\n                status[\"executed_today\"] = check_once_per_day_func()\n\n        return status\n"
  },
  {
    "path": "trendradar/utils/url.py",
    "content": "# coding=utf-8\n\"\"\"\nURL 处理工具模块\n\n提供 URL 标准化功能，用于去重时消除动态参数的影响：\n- normalize_url: 标准化 URL，去除动态参数\n\"\"\"\n\nfrom urllib.parse import urlparse, urlunparse, parse_qs, urlencode\nfrom typing import Dict, Set\n\n\n# 各平台需要移除的特定参数\n#   - weibo: 有 band_rank（排名）和 Refer（来源）动态参数\n#   - 其他平台: URL 为路径格式或简单关键词查询，无需处理\nPLATFORM_PARAMS_TO_REMOVE: Dict[str, Set[str]] = {\n    # 微博：band_rank 是动态排名参数，Refer 是来源参数，t 是时间范围参数\n    # 示例：https://s.weibo.com/weibo?q=xxx&t=31&band_rank=1&Refer=top\n    # 保留：q（关键词）\n    # 移除：band_rank, Refer, t\n    \"weibo\": {\"band_rank\", \"Refer\", \"t\"},\n}\n\n# 通用追踪参数（适用于所有平台）\n# 这些参数通常由分享链接或广告追踪添加，不影响内容识别\nCOMMON_TRACKING_PARAMS: Set[str] = {\n    # UTM 追踪参数\n    \"utm_source\", \"utm_medium\", \"utm_campaign\", \"utm_term\", \"utm_content\",\n    # 常见追踪参数\n    \"ref\", \"referrer\", \"source\", \"channel\",\n    # 时间戳和随机参数\n    \"_t\", \"timestamp\", \"_\", \"random\",\n    # 分享相关\n    \"share_token\", \"share_id\", \"share_from\",\n}\n\n\ndef normalize_url(url: str, platform_id: str = \"\") -> str:\n    \"\"\"\n    标准化 URL，去除动态参数\n\n    用于数据库去重，确保同一条新闻的不同 URL 变体能被正确识别为同一条。\n\n    处理规则：\n    1. 去除平台特定的动态参数（如微博的 band_rank）\n    2. 去除通用追踪参数（如 utm_*）\n    3. 保留核心查询参数（如搜索关键词 q=, wd=, keyword=）\n    4. 对查询参数按字母序排序（确保一致性）\n\n    Args:\n        url: 原始 URL\n        platform_id: 平台 ID，用于应用平台特定规则\n\n    Returns:\n        标准化后的 URL\n\n    Examples:\n        >>> normalize_url(\"https://s.weibo.com/weibo?q=test&band_rank=6&Refer=top\", \"weibo\")\n        'https://s.weibo.com/weibo?q=test'\n\n        >>> normalize_url(\"https://example.com/page?id=1&utm_source=twitter\", \"\")\n        'https://example.com/page?id=1'\n    \"\"\"\n    if not url:\n        return url\n\n    try:\n        # 解析 URL\n        parsed = urlparse(url)\n\n        # 如果没有查询参数，直接返回\n        if not parsed.query:\n            return url\n\n        # 解析查询参数\n        params = parse_qs(parsed.query, keep_blank_values=True)\n\n        # 收集需要移除的参数（使用小写进行比较）\n        params_to_remove: Set[str] = set()\n\n        # 添加通用追踪参数\n        params_to_remove.update(COMMON_TRACKING_PARAMS)\n\n        # 添加平台特定参数\n        if platform_id and platform_id in PLATFORM_PARAMS_TO_REMOVE:\n            params_to_remove.update(PLATFORM_PARAMS_TO_REMOVE[platform_id])\n\n        # 过滤参数（参数名转小写进行比较）\n        filtered_params = {\n            key: values\n            for key, values in params.items()\n            if key.lower() not in {p.lower() for p in params_to_remove}\n        }\n\n        # 如果过滤后没有参数了，返回不带查询字符串的 URL\n        if not filtered_params:\n            return urlunparse((\n                parsed.scheme,\n                parsed.netloc,\n                parsed.path,\n                parsed.params,\n                \"\",  # 空查询字符串\n                \"\"   # 移除 fragment\n            ))\n\n        # 重建查询字符串（按字母序排序以确保一致性）\n        sorted_params = []\n        for key in sorted(filtered_params.keys()):\n            for value in filtered_params[key]:\n                sorted_params.append((key, value))\n\n        new_query = urlencode(sorted_params)\n\n        # 重建 URL（移除 fragment）\n        normalized = urlunparse((\n            parsed.scheme,\n            parsed.netloc,\n            parsed.path,\n            parsed.params,\n            new_query,\n            \"\"  # 移除 fragment\n        ))\n\n        return normalized\n\n    except Exception:\n        # 解析失败时返回原始 URL\n        return url\n\n\ndef get_url_signature(url: str, platform_id: str = \"\") -> str:\n    \"\"\"\n    获取 URL 的签名（用于快速比较）\n\n    基于标准化 URL 生成签名，可用于：\n    - 快速判断两个 URL 是否指向同一内容\n    - 作为缓存键\n\n    Args:\n        url: 原始 URL\n        platform_id: 平台 ID\n\n    Returns:\n        URL 签名字符串\n    \"\"\"\n    return normalize_url(url, platform_id)\n"
  },
  {
    "path": "version",
    "content": "6.5.0"
  },
  {
    "path": "version_configs",
    "content": "config.yaml=2.2.0\ntimeline.yaml=1.2.0\nfrequency_words.txt=1.1.0\nai_interests.txt=1.0.0\nai_analysis_prompt.txt=2.0.0\nai_translation_prompt.txt=1.2.0"
  },
  {
    "path": "version_mcp",
    "content": "4.0.0"
  }
]