Skill: 17oko/ai-news-brief (version 1.5.1)

--- name: ai-news-brief version: 1.0.3 description: 自动抓取 AI/算力/大模型/GPU 相关最新资讯简报，使用 Chrome 浏览器自动化绕过反爬，支持多个科技媒体来源，支持PDF生成和邮件推送 metadata: { "openclaw": { "requires": { "bins": ["python", "chrome"], "python_packages": ["requests", "beautifulsoup4", "websocket-client", "fpdf2"] }, "install": [ { "id": "python_deps", "kind": "pip", "packages": ["requests", "beautifulsoup4", "websocket-client", "fpdf2"], "label": "安装 Python 依赖" }, { "id": "chrome", "kind": "system", "label": "Chrome 浏览器 (系统已有)" } ], "autoInstall": true } } --- # AI 资讯简报 > 自动抓取国内外 AI/算力/大模型/GPU/芯片相关最新资讯，生成简报 --- ## 📊 网站配置管理 本 Skill 使用配置文件管理各网站的获取方式，文件位于 `scripts/sites_config.json`。 ### 获取方式优先级 | 方式 | 说明 | 速度 | |------|------|------| | **rss** | RSS订阅，最快最稳定 | ⚡⚡⚡ | | **http** | HTTP直接请求 | ⚡⚡ | | **chrome** | Chrome CDP自动化 | ⚡ | ### 状态说明 - **working**: 正常工作 - **failed**: 之前失败，暂不尝试 - **unreachable**: 网站无法访问 ### 自动调度逻辑 ``` 1. 读取 sites_config.json 配置文件 2. 对每个网站，按 priority 顺序尝试： - 首选：优先级最高且状态为 working 的方式 - 备选：如果首选失败，尝试其他可用的方式 3. 抓取完成后，自动更新各方式的状态到配置文件 4. 下次运行时，使用更新后的配置 ``` ### 手动更新配置 如需手动更新网站配置，可编辑 `scripts/sites_config.json`： ```json { "sites": { "网站key": { "name": "网站名", "url": "主页面URL", "rss": "RSS地址", "http": "HTTP地址", "chrome": "Chrome地址", "priority": ["rss", "http", "chrome"], "status": { "rss": "working/failed/unreachable", "http": "working/failed/unreachable", "chrome": "working/failed/unreachable" } } } } ``` --- ## 🎯 用户反馈和关键词优化 本 Skill 支持根据用户反馈调整获取的资讯内容。 ### 用户反馈方式 当用户给出以下反馈时，系统会自动调整： | 用户输入 | 系统行为 | |----------|----------| | "我喜欢GPU/显卡相关" | 添加关键词 `gpu`, `显卡`, `nvidia` | | "不喜欢自动驾驶" | 排除关键词 `自动驾驶`, `智驾` | | "想看华为昇腾" | 添加关键词 `华为`, `昇腾` | | "不要抖音字节" | 屏蔽来源 `字节`, `抖音` | ### 配置文件 用户配置保存在 `scripts/user_config.json`： ```json { "user_preferences": { "liked_keywords": ["gpu", "华为"], "disliked_keywords": ["自动驾驶"], "liked_sources": [], "disliked_sources": [] }, "default_keywords": { "AI基础": ["ai", "人工智能", "大模型", "gpt", ...], "GPU硬件": ["gpu", "nvidia", "amd", "cuda", ...], ... } } ``` ### 关键词分类 | 分类 | 关键词示例 | |------|------------| | AI基础 | ai, 人工智能, 大模型, gpt, llm, openai | | GPU硬件 | gpu, nvidia, amd, intel, cuda, h100 | | 算力芯片 | 算力, 芯片, 半导体, 处理器, cpu, npu | | 自动驾驶 | 自动驾驶, 智能驾驶, 特斯拉, fsd | | 大厂动态 | 华为, 昇腾, 阿里, 百度, 字节, 腾讯 | --- ## 🔍 内容可信度验证 每条资讯都会经过可信度评估，帮助用户判断信息质量。 ### 可信度等级 | 等级 | 分数 | 说明 | 来源示例 | |------|------|------|----------| | **A级** | 90+ | 权威来源，可信度最高 | TechCrunch, The Verge, 政府官网 | | **B级** | 70-89 | 专业媒体，可信度较高 | 36kr, 量子位, 虎嗅, 爱范儿 | | **C级** | 50-69 | 一般来源，需核实 | HackerNews, 综合新闻 | | **D级** | <50 | 较低可信，仅供参考 | 论坛, 自媒体 | ### 验证规则 1. **来源可信度** - 根据来源类型给基础分 2. **内容长度** - 超过200字 +10分 3. **敏感词检测** - 含"谣言""震惊"等词 -15分 4. **时效性** - 有日期标注 +5分 ### 使用可信度过滤 ```bash # 至少B级可信度 python fetch_ai_news.py --min-credibility B # 至少70分 python fetch_ai_news.py --min-score 70 ``` ### 输出示例 ```json { "title": "OpenAI新模型曝光", "source": "量子位", "credibility": { "score": 80, "level": "B", "reasons": ["权威来源", "内容详细"] } } ``` --- ## 🔥 热点排序 资讯按热点程度排序，GPU相关资讯权重最高： ### 关键词热度权重 | 类别 | 关键词 | 权重 | |------|--------|------| | 🟢 **GPU/显卡** | gpu, nvidia, amd, h100, 4090, 5090, RTX | 15 (最高) | | 🔵 **大模型** | 大模型, llm, gpt, openai, claude, deepseek, moE | 12 | | 🟡 **AI基础** | ai, 人工智能, 模型, 训练, 推理 | 10 | | 🟠 **芯片/算力** | 算力, 芯片, 半导体, npu, 华为, 昇腾 | 10 | | 🔴 **自动驾驶** | 自动驾驶, 智驾, 特斯拉, fsd | 8 | ### 排序规则 1. **关键词热度** - 匹配热门关键词越多，分数越高 2. **可信度等级** - A级来源 +10分，B级 +8分 3. **内容详细度** - 摘要超过100字 +3分 4. **关键点数量** - 有2个以上关键点 +2分 --- ## 🔄 智能重试机制 当某个获取方式失败时： | 失败次数 | 处理方式 | |----------|----------| | 第1-2次 | 继续重试（最多2次） | | 3次以上 | 自动降低该方式优先级 | | 后续 | 跳过该方式，尝试其他方式 | ### 失败记录 失败记录保存在 `scripts/failure_log.json`： - 记录每个网站每种方式的失败次数 - 每天自动重置 - 影响排序优先级 --- ## 🧹 智能去重 使用标题相似度算法去除重复文章： | 相似度 | 处理 | |--------|------| | ≥0.6 | 视为重复，保留可信度高的 | | <0.6 | 视为不同文章 | 相似度检测基于： - 共同关键词（中文词组、英文单词） - 核心词匹配（GPT、RTX、AI等） - 字符重叠率 --- ## 📊 政策资讯 Skill 支持抓取政府官网的政策通知： ### 来源列表 | 来源 | 类别 | 说明 | |------|------|------| | 中国政府网 | 中央政策 | 国务院、部委重要政策 | | 工信部 | 部委政策 | 工业和信息化相关 | | 科技部 | 部委政策 | 科技创新、项目申报 | | 网信办 | 部委政策 | 网络安全、AI监管 | | 发改委 | 部委政策 | 项目批复、产业政策 | | 教育部 | 部委政策 | AI教育相关 | | 财政部 | 部委政策 | 补贴、专项资金 | | 国家数据局 | 新机构 | 数据、AI政策 | ### 使用方式 在获取AI资讯时，可选择是否同时获取政策资讯： - 自动获取：每天定时任务会同时抓取AI资讯和政策 --- ## 📈 增量抓取与数据持久化 ### 增量抓取 - 首次运行：获取全部内容 - 后续运行：只获取新增内容 - 自动去重：已抓取的文章不会重复 ### 数据保存 | 类型 | 文件 | 位置 | |------|------|------| | AI资讯历史 | `news_history.json` | `scripts/data/` | | 政策资讯历史 | `policy_history.json` | `scripts/data/` | ### 查询历史 ```bash # 查询最近3天的资讯 python scripts/incremental_fetch.py --days 3 # 查看统计 python scripts/incremental_fetch.py --stats ``` --- ## 🤖 AI摘要生成（可选） 使用大模型为文章生成更好的摘要： ### 配置 文件：`scripts/llm_config.json` ```json { "config": { "enabled": true, "provider": "deepseek", // deepseek / qwen / openai "deepseek": { "api_key": "your_api_key" } } } ``` ### 支持的模型 | 服务商 | 模型 | 特点 | |--------|------|------| | DeepSeek | deepseek-chat | 便宜量大 | | 阿里Qwen | qwen-plus | 有免费额度 | | OpenAI | gpt-3.5-turbo | 稳定性好 | ### 使用 ```bash python scripts/llm_summarizer.py ``` --- ## 📄 自动报告生成 每天自动生成资讯报告： ### 支持格式 - **HTML报告** - 可在浏览器查看，包含样式 - **Markdown报告** - 便于分享和编辑 - **PDF报告** - 支持中文，样式整洁（新增） ### 输出位置 ``` scripts/reports/ ├── ai_news_20260406.html ├── ai_news_20260406.md └── ai_news_20260406.pdf ``` ### PDF 生成 需要安装 fpdf2： ```bash pip install fpdf2 ``` 在 `pdf_config.json` 中启用： ```json { "config": { "enabled": true, "output_dir": "./reports" } } ``` ### 生成报告 ```bash python scripts/report_generator.py ``` --- ## 🌐 多语言翻译（预留接口） 翻译英文AI资讯为中文： ### 配置 文件：`scripts/translator_config.json` ```json { "config": { "enabled": true, "provider": "baidu" // baidu / deep } } ``` ### 注意 - 需要配置翻译API才能使用 - 目前是预留接口，需要开发者自行接入 --- ## 📧 邮件推送（可选） Skill 支持将简报发送到邮箱，需要使用者自行配置。 ### ⚠️ 重要：配置存放位置 为防止项目更新时覆盖用户配置，请按以下步骤配置： 1. **创建配置目录**（如果不存在）： - Windows: `C:\Users\你的用户名\.openclaw\config\` - macOS/Linux: `~/.openclaw/config/` 2. **复制配置模板**：将 `scripts/email_config.json.default` 复制到上述目录，并重命名为 `ai-news-email.json` 3. **填写配置**：编辑 `ai-news-email.json`，填写你的邮箱信息 ### 配置步骤 1. 创建目录：`~/.openclaw/config/` 2. 复制模板： ```bash # Windows copy scripts\email_config.json.default %USERPROFILE%\.openclaw\config\ai-news-email.json # macOS/Linux cp scripts/email_config.json.default ~/.openclaw/config/ai-news-email.json ``` 3. 编辑 `ai-news-email.json`，设置 `smtp_config.enabled: true`，填写发件人邮箱和授权码 4. 设置 `recipient_config.enabled: true`，添加收件人邮箱 ### 配置示例 ```json { "smtp_config": { "enabled": true, "smtp_server": "smtp.qq.com", "smtp_port": 465, "use_ssl": true, "sender_email": "[REDACTED_SECRET]", "sender_password": "your_auth_code", "sender_name": "AI资讯小助手" }, "recipient_config": { "enabled": true, "recipients": ["[REDACTED_SECRET]"] } } ``` ### 支持的邮箱 | 邮箱 | SMTP服务器 | 端口 | 授权码获取 | |------|-----------|------|----------| | QQ邮箱 | smtp.qq.com | 465 | 邮箱设置→账户→开启IMAP | | 163邮箱 | smtp.163.com | 465 | 邮箱设置→POP3/SMTP | | Gmail | smtp.gmail.com | 465 | Google账户→安全→应用密码 | ### 测试邮件 ```bash python scripts/email_sender.py ``` ### ⏰ 定时发送（手动添加） 定时任务需要在 OpenClaw 中手动添加，步骤如下： #### 1. 添加早间任务（每天 7:00） ```bash openclaw cron add --name "AI资讯简报-早间版" \ --schedule "0 7 * * *" \ --agent main \ --message "请运行 AI 资讯简报技能，获取昨日7点至今日7点的AI/算力/GPU/政策资讯，生成简报，并自动生成 PDF 附件发送到配置好的邮箱" \ --delivery wechat ``` #### 2. 添加午间任务（每天 14:00） ```bash openclaw cron add --name "AI资讯简报-午间版" \ --schedule "0 14 * * *" \ --agent main \ --message "请运行 AI 资讯简报技能，获取今日最新的AI/算力/GPU/政策资讯，生成简报，并自动生成 PDF 附件发送到配置好的邮箱" \ --delivery wechat ``` #### 3. 查看和管理定时任务 ```bash # 查看所有定时任务 openclaw cron list # 删除定时任务 openclaw cron rm <任务ID> # 立即运行定时任务（测试） openclaw cron run <任务ID> ``` > **注意**：定时任务由 OpenClaw 管理，不是 Skill 代码的一部分。如果需要修改或删除定时任务，请使用上述命令。 --- ## 📁 项目结构 ``` ai-news-brief/ ├── SKILL.md # Skill 定义文件 ├── scripts/ # 代码文件（更新时会被覆盖） │ ├── *.py # 功能代码 │ └── *.json.default # 默认配置模板（只读，不要修改） ├── user_config/ # ⚠️ 已弃用，请使用外部配置 │ └── *.json # 历史配置（不再使用） └── data/ # 数据缓存（不会被覆盖） # 👇 用户配置存放位置（项目外，更新时不会被覆盖） ~/.openclaw/config/ ├── ai-news-email.json # 邮件配置 ├── ai-news-llm.json # LLM API 配置 ├── ai-news-pdf.json # PDF 配置 ├── ai-news-user.json # 用户偏好 └── ai-news-sites.json # 网站配置 ``` ### ⚠️ 重要：为什么配置放项目外？ - 项目更新时会覆盖 `scripts/` 目录下的所有文件 - 如果配置放在项目内，更新后会被覆盖 - 因此，**用户配置必须放在 `~/.openclaw/config/` 目录** ### 配置流程 1. 首次使用：从 `scripts/*.json.default` 复制模板到 `~/.openclaw/config/` 2. 编辑配置：填写自己的邮箱、API密钥等 3. 后续更新：项目代码会更新，但你的配置不会受影响 ### 方式一：自动安装（推荐） 首次使用前，可选择自动安装依赖。**AI 会询问你是否安装**： ``` 我需要安装一些依赖才能运行： - requests, beautifulsoup4, websocket-client (Python 包) 是否现在安装？请回复"是"或"安装" ``` ### 方式二：手动安装 在终端运行： ```bash pip install requests beautifulsoup4 websocket-client ``` ### 环境要求 - Python 3.8+ - Chrome 浏览器（已安装在系统中） - Windows/macOS/Linux 均可 --- ## 📋 功能说明 ### 核心能力 1. **Chrome 自动化抓取** - 使用 Chrome DevTools Protocol (CDP) 绕过反爬 2. **多源资讯聚合** - 同时抓取 20+ 个科技媒体网站 3. **智能关键词过滤** - 仅保留 AI/算力/GPU/大模型 相关内容 4. **自动摘要提取** - 从文章页面提取关键信息 5. **来源多样化** - 国内+国外，确保资讯全面 ### 抓取来源 #### 国内 - 36kr、量子位、机器之心、虎嗅、爱范儿、极客公园 - 网易科技、新浪科技、搜狐科技、腾讯科技、凤凰网科技 - 驱动之家、超能网、中关村在线、快科技 #### 国外 - TechCrunch、The Verge、VentureBeat、HackerNews --- ## 🎯 触发方式 当用户说出以下关键词时激活： - "最新AI资讯" - "AI新闻" - "算力新闻" - "GPU资讯" - "大模型动态" - "AI简报" - "科技资讯" --- ## 📊 输出格式 ### 标准简报 ```markdown # 🤖 AI/算力/GPU 资讯简报 **查询日期**: 2026年4月6日 **数据范围**: 2026年4月5日 --- ### 🔥 重点新闻 | 来源 | 标题 | 概要 | |------|------|------| | 36kr | 标题 | 概要... | | 量子位 | 标题 | 概要... | --- ### 📊 来源分布 | 来源 | 数量 | |------|------| | 36kr | 10条 | | 搜狐科技 | 8条 | --- ### 💡 趋势总结 1. 趋势1... 2. 趋势2... --- ``` --- ## 🔧 工作流程 ``` Phase 1: 启动 Chrome └─ 检查 Chrome 是否已运行，若无则启动 ↓ Phase 2: 多源抓取 └─ 依次访问20+网站，获取文章标题和链接 ↓ Phase 3: 内容过滤 └─ 过滤保留 AI/算力/GPU 相关内容 ↓ Phase 4: 摘要提取 └─ 打开每篇文章，获取内容摘要（仅处理前15条） ↓ Phase 5: 整理输出 └─ 分类、去重、生成简报 ``` --- ## ⚡ 使用示例 ### 示例1：用户查询 **用户**: "最新AI资讯" **AI**: \[自动执行以下操作] 1. 启动/连接 Chrome 2. 抓取各网站资讯 3. 提取文章摘要 4. 生成简报输出 --- ## ⚠️ 注意事项 1. **首次使用需安装依赖** - AI 会询问是否安装 2. **首次会打开 Chrome** - 首次运行时 Chrome 窗口会打开 3. **运行时间** - 完整抓取约需 2-3 分钟 4. **网络要求** - 需能访问国内科技网站 5. **Chrome 保持打开** - 运行后 Chrome 会保持打开状态，便于后续快速运行 --- ## 🔄 更新日志 ### 2026-04-06 (v1.0) - 初始版本 - 支持 Chrome 自动化抓取 - 支持 20+ 科技媒体来源 - 自动提取文章摘要 - 提供来源分布统计 --- ## 📞 支持 如遇问题，请检查： 1. Python 3.8+ 已安装 2. Chrome 浏览器已安装 3. 依赖包已安装（requests, beautifulsoup4, websocket-client） 4. 网络可以访问目标网站

path: scripts/auto_run.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AI 资讯简报 - 自动执行脚本
功能：获取资讯 → 生成报告 → 发送邮件
"""

import os
import sys
from datetime import datetime

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))

# 添加 scripts 目录到路径
sys.path.insert(0, SCRIPT_DIR)

def main():
    print("=" * 50)
    print("AI 资讯简报 - 自动执行")
    print("=" * 50)
    
    # 1. 获取资讯
    print("\n[1/4] 正在获取AI资讯...")
    from fetch_ai_news import fetch_all_news
    articles = fetch_all_news()
    print(f"    获取到 {len(articles)} 条资讯")
    
    if not articles:
        print("    警告：未获取到任何资讯")
        articles = []
    
    # 2. 生成 HTML 报告（暂时跳过，有bug）
    print("\n[2/4] 正在生成报告...")
    html_path = None
    try:
        # 暂时跳过 HTML 报告生成，等待修复
        print("    HTML报告: 暂时跳过")
    except Exception as e:
        print(f"    HTML报告跳过: {e}")
    
    # 3. 生成 PDF（如启用）
    pdf_path = None
    from pdf_generator import generate_pdf_with_attachment
    try:
        from pdf_config import load_pdf_config
        pdf_config = load_pdf_config()
        if pdf_config and pdf_config.get('config', {}).get('enabled', False):
            print("\n[3/4] 正在生成PDF...")
            pdf_path, pdf_msg = generate_pdf_with_attachment(articles, date_range, pdf_config)
            print(f"    PDF生成: {pdf_msg}")
    except Exception as e:
        print(f"    PDF生成跳过: {e}")
    
    # 4. 发送邮件（如启用）
    try:
        from email_sender import send_ai_news_email
        try:
            from email_config import load_email_config
            email_config = load_email_config()
        except:
            email_config = None
        
        if email_config:
            smtp = email_config.get('smtp_config', {})
            recipient = email_config.get('recipient_config', {})
            
            if smtp.get('enabled', False) and recipient.get('enabled', False):
                sys.stdout = sys.__stdout__
                sys.stderr = sys.__stderr__
                print("\n[4/4] 正在发送邮件...")
                success, msg = send_ai_news_email(articles, date_range, email_config, pdf_path)
                print(f"    邮件发送: {msg}")
            else:
                print("\n[4/4] 邮件未启用")
        else:
            print("\n[4/4] 邮件配置未找到")
    except Exception as e:
        print(f"    邮件发送跳过: {e}")
    
    print("\n" + "=" * 50)
    print("执行完成!")
    print("=" * 50)
    
    return articles


if __name__ == "__main__":
    main()

path: scripts/categorizer.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
资讯分类模块
按模块分类展示资讯
"""

import json


# 模块定义和关键词
MODULE_CONFIG = {
    "政策": {
        "keywords": ["政策", "通知", "公告", "意见", "方案", "规划", "纲要", "监管", "治理",
                    "国务院", "办公厅", "工信部", "科技部", "网信办", "发改委", "教育部",
                    "财政部", "办法", "规定", "实施细则", "工作方案", "AI治理"],
        "icon": "🔔",
        "priority": 1
    },
    "硬件": {
        "keywords": ["gpu", "nvidia", "amd", "intel", "cuda", "显卡", "显存", "RTX", "geforce",
                    "内存", "ddr5", "ddr4", "ddr6", "hbm", "gddr", "dram", "sram",
                    "存储", "固态", "ssd", "硬盘", "三星", "海力士", "美光",
                    "h100", "h200", "a100", "b100", "4090", "5080", "5090", "blackwell", "rubin",
                    "芯片", "半导体", "处理器", "cpu", "npu", "tpu"],
        "icon": "💻",
        "priority": 2
    },
    "市场": {
        "keywords": ["融资", "投资", "收购", "上市", "估值", "财报", "营收", "出货量",
                    "市场份额", "增长", "下降", "突破", "超过", "补贴", "专项资金",
                    "项目", "申报", "中标", "合作", "签约", "工厂", "产能"],
        "icon": "📈",
        "priority": 3
    },
    "行业": {
        "keywords": ["大模型", "gpt", "llm", "chatgpt", "openai", "claude", "gemini",
                    "deepseek", "模型", "训练", "推理", "部署", "参数", "token",
                    "AI模型", "语言模型", "多模态", "Sora", "视频生成",
                    "人工智能", "AI", "算法", "技术", "突破", "研究"],
        "icon": "🏛️",
        "priority": 4
    },
    "自动驾驶": {
        "keywords": ["自动驾驶", "智驾", "特斯拉", "fsd", "智驾", "无人驾驶",
                    "车载", "车机", "汽车", "电动车", "蔚来", "小鹏", "理想",
                    "比亚迪", "小米汽车", "华为车", "问界"],
        "icon": "🚗",
        "priority": 5
    }
}


def classify_article(title, summary=""):
    """
    根据标题和摘要对文章进行分类
    返回: 模块名称
    """
    text = (title + " " + (summary or "")).lower()
    
    # 按优先级顺序检查
    for module_name in ["政策", "硬件", "市场", "行业", "自动驾驶"]:
        config = MODULE_CONFIG[module_name]
        for keyword in config["keywords"]:
            if keyword.lower() in text:
                return module_name
    
    # 没有匹配到任何模块
    return "其他"


def categorize_articles(articles):
    """
    将文章列表按模块分类
    返回: {模块名: [文章列表], ...}
    """
    categorized = {
        "政策": [],
        "硬件": [],
        "市场": [],
        "行业": [],
        "自动驾驶": [],
        "其他": []
    }
    
    for article in articles:
        module = classify_article(
            article.get('title', ''),
            article.get('summary', '')
        )
        categorized[module].append(article)
    
    return categorized


def get_module_order():
    """获取模块显示顺序（按priority排序）"""
    modules = []
    for name, config in MODULE_CONFIG.items():
        modules.append((name, config["priority"], config["icon"]))
    modules.append(("其他", 99, "📋"))  # 其他放最后
    
    modules.sort(key=lambda x: x[1])
    return [(m[0], m[2]) for m in modules]


def generate_categorized_html(articles, date_range):
    """生成分模块的HTML报告"""
    categorized = categorize_articles(articles)
    module_order = get_module_order()
    
    html = f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>AI资讯简报</title>
    <style>
        body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 
               line-height: 1.6; color: #333; max-width: 900px; margin: 0 auto; padding: 20px; 
               background: #f5f5f5; }}
        h1 {{ color: #1a73e8; border-bottom: 3px solid #1a73e8; padding-bottom: 15px; }}
        .info {{ background: #e8f5e9; padding: 15px; border-radius: 8px; margin-bottom: 20px; }}
        .module {{ margin: 20px 0; }}
        .module h2 {{ color: #333; border-left: 5px solid #1a73e8; padding-left: 15px; 
                      background: white; padding: 10px 15px; border-radius: 8px; }}
        .article {{ background: white; border-radius: 8px; padding: 15px; margin: 10px 0; 
                    box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
        .title {{ font-size: 16px; font-weight: bold; color: #1a73e8; }}
        .title a {{ color: inherit; text-decoration: none; }}
        .title a:hover {{ text-decoration: underline; }}
        .meta {{ font-size: 12px; color: #666; margin: 5px 0; }}
        .source {{ background: #e3f2fd; padding: 2px 8px; border-radius: 4px; }}
        .hot {{ background: #ea4335; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px; }}
        .summary {{ color: #444; margin: 8px 0; font-size: 14px; }}
        .key-points {{ background: #e8f0fe; padding: 8px; border-radius: 4px; margin-top: 8px; }}
        .key-points li {{ font-size: 12px; color: #333; }}
        .cred-A {{ background: #34a853; color: white; }}
        .cred-B {{ background: #fbbc04; color: #333; }}
        .cred-C {{ background: #ea4335; color: white; }}
        .cred-D {{ background: #9e9e9e; color: white; }}
        .credibility {{ display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 10px; }}
        .empty {{ color: #999; font-style: italic; padding: 20px; text-align: center; }}
    </style>
</head>
<body>
    <h1>🤖 AI 资讯简报</h1>
    <div class="info">
        <strong>📅 日期范围</strong>: {date_range}<br>
        <strong>📊 资讯总数</strong>: {len(articles)} 条
    </div>
"""
    
    # 按顺序生成各模块
    for module_name, icon in module_order:
        module_articles = categorized.get(module_name, [])
        
        if not module_articles:
            continue  # 跳过空的模块
        
        html += f'\n    <div class="module">\n'
        html += f'        <h2>{icon} {module_name} ({len(module_articles)}条)</h2>\n'
        
        for article in module_articles:
            title = article.get('title', '')
            url = article.get('url', '#')
            source = article.get('source', '')
            summary = article.get('summary', '')
            key_points = article.get('key_points', [])
            credibility = article.get('credibility', {})
            hot_score = article.get('hot_score', 0)
            
            level = credibility.get('level', 'D')
            
            html += f'''        <div class="article">
            <div class="title">
                <span class="hot">🔥{hot_score}</span>
                <a href="{url}" target="_blank">{title}</a>
            </div>
            <div class="meta">
                <span class="source">{source}</span>
                <span class="credibility cred-{level}">{level}</span>
            </div>
'''
            if summary and len(summary) > 10:
                html += f'            <div class="summary">{summary[:150]}...</div>\n'
            
            if key_points:
                html += '            <div class="key-points"><ul>\n'
                for kp in key_points[:2]:
                    html += f'                <li>{kp[:80]}</li>\n'
                html += '            </ul></div>\n'
            
            html += '        </div>\n'
        
        html += '    </div>\n'
    
    html += '''
    <div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; 
                text-align: center; color: #666; font-size: 12px;">
        <p>🤖 由 AI 资讯简报自动生成</p>
    </div>
</body>
</html>'''
    
    return html


if __name__ == "__main__":
    # 测试
    test_articles = [
        {"title": "工信部发布AI新政策", "summary": "测试内容", "source": "36kr"},
        {"title": "NVIDIA发布RTX 5090", "summary": "显卡发布", "source": "驱动之家"},
        {"title": "某公司融资10亿", "summary": "融资新闻", "source": "36kr"},
        {"title": "GPT-5新模型发布", "summary": "大模型更新", "source": "量子位"},
    ]
    
    categorized = categorize_articles(test_articles)
    for module, articles in categorized.items():
        if articles:
            print(f"{module}: {len(articles)}条")

path: scripts/check_deps.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
依赖检查和安装脚本
"""

import subprocess
import sys
import os

# 需要检查的依赖
DEPENDENCIES = {
    'python': {
        'check': lambda: sys.version_info >= (3, 8),
        'install': None,
        'name': 'Python 3.8+'
    },
    'requests': {
        'check': lambda: __import__('requests', fromlist=['']) is not None,
        'install': 'pip install requests',
        'name': 'requests'
    },
    'beautifulsoup4': {
        'check': lambda: __import__('bs4', fromlist=['']) is not None,
        'install': 'pip install beautifulsoup4',
        'name': 'beautifulsoup4'
    },
    'websocket-client': {
        'check': lambda: __import__('websocket', fromlist=['']) is not None,
        'install': 'pip install websocket-client',
        'name': 'websocket-client'
    }
}


def check_chrome():
    """检查 Chrome 是否可用"""
    import requests
    
    try:
        resp = requests.get('http://localhost:9222/json/list', timeout=3)
        pages = resp.json()
        for p in pages:
            if p['type'] == 'page':
                return True, "Chrome 已在运行"
    except:
        pass
    
    # 检查 Chrome 是否安装
    if sys.platform == 'win32':
        paths = [
            r"C:\Program Files\Google\Chrome\Application\chrome.exe",
            r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
            os.path.expanduser(r"~\AppData\Local\Google\Chrome\Application\chrome.exe")
        ]
        for path in paths:
            if os.path.exists(path):
                return True, f"Chrome 已安装，可通过 CDP 连接"
        return False, "Chrome 未安装"
    else:
        # macOS/Linux
        result = subprocess.run(['which', 'google-chrome'], capture_output=True)
        if result.returncode == 0:
            return True, "Chrome 已安装"
        return False, "Chrome 未安装"


def check_dependencies():
    """检查所有依赖"""
    missing = []
    
    print("=" * 50)
    print("依赖检查")
    print("=" * 50)
    
    # 检查 Python
    if sys.version_info < (3, 8):
        print(f"[FAIL] Python 版本过低: {sys.version_info.major}.{sys.version_info.minor}")
        print(f"   需要 Python 3.8+")
        missing.append('python')
    else:
        print(f"[OK] Python: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
    
    # 检查 pip 包
    for name, info in DEPENDENCIES.items():
        if name == 'python':
            continue
        try:
            if info['check']():
                print(f"[OK] {info['name']}: 已安装")
            else:
                print(f"[FAIL] {info['name']}: 未安装")
                missing.append(name)
        except Exception as e:
            print(f"[FAIL] {info['name']}: 检查失败 - {str(e)[:30]}")
            missing.append(name)
    
    # 检查 Chrome
    chrome_ok, chrome_msg = check_chrome()
    if chrome_ok:
        print(f"[OK] Chrome: {chrome_msg}")
    else:
        print(f"[WARN]  Chrome: {chrome_msg}")
    
    print("=" * 50)
    
    return missing


def install_dependencies(deps):
    """安装缺失的依赖"""
    print(f"\n正在安装依赖: {', '.join(deps)}")
    print("-" * 50)
    
    for dep in deps:
        info = DEPENDENCIES.get(dep)
        if info and info.get('install'):
            print(f"安装 {info['name']}...")
            try:
                result = subprocess.run(
                    info['install'].split(),
                    capture_output=True,
                    text=True
                )
                if result.returncode == 0:
                    print(f"  [OK] {info['name']} 安装成功")
                else:
                    print(f"  [FAIL] {info['name']} 安装失败: {result.stderr[:100]}")
            except Exception as e:
                print(f"  [FAIL] 安装失败: {str(e)[:50]}")


def main():
    """主函数"""
    print("\n" + "=" * 50)
    print("AI 资讯简报 - 依赖检查工具")
    print("=" * 50 + "\n")
    
    # 检查依赖
    missing = check_dependencies()
    
    if not missing:
        print("\n[OK] 所有依赖已满足，可以使用 Skill！")
        print("运行命令: python scripts/fetch_ai_news.py")
    else:
        print(f"\n[WARN]  缺少 {len(missing)} 个依赖")
        print("\n请选择安装方式：")
        print("  1. 自动安装（推荐）")
        print("  2. 手动安装")
        print("\n请回复数字或选项：")
        
        # 这里只是提示，实际安装由 AI 引导用户完成
        choice = input("> ").strip()
        
        if choice in ['1', 'auto', '自动', '是', '安装']:
            install_dependencies(missing)
            
            # 再次检查
            missing = check_dependencies()
            if not missing:
                print("\n[OK] 所有依赖已安装完成！")
        else:
            print("\n请手动运行以下命令安装依赖：")
            for dep in missing:
                info = DEPENDENCIES.get(dep)
                if info and info.get('install'):
                    print(f"  {info['install']}")


if __name__ == "__main__":
    main()

path: scripts/content_processor.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
改进的内容获取和摘要生成模块
"""

import re
import json
import requests
import websocket
import time

# ========== 内容获取 ==========

def fetch_page_content(ws, url):
    """
    获取页面完整内容
    策略：
    1. 获取页面标题
    2. 获取meta描述
    3. 获取文章正文（更精确的选择器）
    4. 提取第一段有意义的文字
    """
    try:
        # 导航
        msg = json.dumps({"id": 1, "method": "Page.navigate", "params": {"url": url}})
        ws.send(msg)
        time.sleep(3)
        
        # 获取页面完整信息
        script = """
        (function() {
            var result = {
                title: document.title,
                url: window.location.href,
                description: '',
                content: '',
                publishDate: '',
                author: ''
            };
            
            // 获取 meta 描述
            var metaDesc = document.querySelector('meta[name="description"]');
            if (metaDesc) result.description = metaDesc.content;
            
            // 获取 OG 描述
            if (!result.description) {
                var ogDesc = document.querySelector('meta[property="og:description"]');
                if (ogDesc) result.description = ogDesc.content;
            }
            
            // 获取文章正文 - 尝试多种选择器
            var contentSelectors = [
                'article',
                '.article-content',
                '.post-content',
                '.entry-content',
                '.content',
                '#content',
                '.text',
                'main',
                '.main-text'
            ];
            
            for (var i = 0; i < contentSelectors.length; i++) {
                var elem = document.querySelector(contentSelectors[i]);
                if (elem && elem.innerText.length > 200) {
                    result.content = elem.innerText;
                    break;
                }
            }
            
            // 如果没找到，尝试获取 body
            if (!result.content) {
                result.content = document.body.innerText;
            }
            
            // 获取发布日期
            var dateSelectors = [
                'time[datetime]',
                '.publish-time',
                '.pub-date',
                '.date',
                '.timestamp',
                '[class*="date"]',
                '[class*="time"]'
            ];
            
            for (var i = 0; i < dateSelectors.length; i++) {
                var elem = document.querySelector(dateSelectors[i]);
                if (elem) {
                    result.publishDate = elem.innerText || elem.getAttribute('datetime') || elem.getAttribute('content');
                    break;
                }
            }
            
            // 获取作者
            var authorSelectors = [
                '.author',
                '.writer',
                '[rel="author"]',
                '.name'
            ];
            
            for (var i = 0; i < authorSelectors.length; i++) {
                var elem = document.querySelector(authorSelectors[i]);
                if (elem && elem.innerText.length < 50) {
                    result.author = elem.innerText;
                    break;
                }
            }
            
            return JSON.stringify(result);
        })();
        """
        
        msg = json.dumps({"id": 1, "method": "Runtime.evaluate", "params": {"expression": script}})
        ws.send(msg)
        
        response = ws.recv()
        data = json.loads(response)
        
        if 'result' in data:
            content = json.loads(data['result']['result']['value'])
            return content
        
        return {}
        
    except Exception as e:
        return {}


# ========== 摘要生成 ==========

def clean_text(text):
    """清理文本"""
    if not text:
        return ""
    
    # 移除多余空白
    text = re.sub(r'\s+', ' ', text)
    
    # 移除常见的无用字符
    text = text.replace('\u200b', '')  # 零宽空格
    text = text.replace('\xa0', ' ')   # 不间断空格
    
    return text.strip()


def extract_summary(content_data, max_length=300):
    """
    从内容中提取摘要
    策略：
    1. 如果有 meta 描述，使用它
    2. 否则，从正文中提取第一段有意义的文字
    3. 清理并截断到合理长度
    """
    # 优先使用 meta 描述
    description = content_data.get('description', '')
    if description and len(description) > 20:
        return clean_text(description)[:max_length]
    
    # 从正文提取
    content = content_data.get('content', '')
    if not content:
        return "暂无摘要"
    
    # 清理文本
    content = clean_text(content)
    
    # 尝试找到第一段有意义的文字
    # 分割成段落
    paragraphs = re.split(r'[\n\r]+', content)
    
    for para in paragraphs:
        para = para.strip()
        # 跳过太短的段落
        if len(para) < 50:
            continue
        # 跳过看起来像导航/菜单的段落
        if any(x in para.lower() for x in ['登录', '注册', '关注', '分享', '评论', '版权所有', '客服', '微信']):
            continue
        
        # 找到第一段有意义的
        return para[:max_length]
    
    # 如果没找到合适的，返回前200字符
    return content[:max_length]


def generate_key_points(content_data):
    """
    生成文章关键点
    策略：
    1. 从正文中提取关键句子
    2. 包含数字的句子通常是重点
    3. 包含"将"、"计划"、"发布"等动词的句子可能是重点
    """
    content = content_data.get('content', '')
    if not content or len(content) < 100:
        return []
    
    # 清理
    content = clean_text(content)
    
    # 分割成句子
    sentences = re.split(r'[。！？\n\r]+', content)
    
    key_points = []
    important_keywords = [
        '亿', '万', '千', '百分', '%',  # 数字
        '发布', '推出', '上线', '发布',  # 动作
        '首次', '首个', '第一', '首次',  # 最高级
        '增长', '下降', '突破', '超过',  # 变化
        '融资', '投资', '收购', '上市',  # 商业
        '新模型', '新版本', '新产品', '新功能',  # 新产品
    ]
    
    for sentence in sentences:
        sentence = sentence.strip()
        if len(sentence) < 20 or len(sentence) > 200:
            continue
        
        # 检查是否包含关键词
        if any(kw in sentence for kw in important_keywords):
            key_points.append(sentence)
        
        # 如果已经找到3个关键点，就停止
        if len(key_points) >= 3:
            break
    
    # 如果不够3个，补充一些其他的句子
    if len(key_points) < 3:
        for sentence in sentences:
            sentence = sentence.strip()
            if sentence and sentence not in key_points and len(sentence) > 30:
                key_points.append(sentence)
                if len(key_points) >= 3:
                    break
    
    # 去重并返回
    return list(dict.fromkeys(key_points))[:3]


def process_article(url, ws=None):
    """
    处理单篇文章，获取完整内容和摘要
    """
    if not ws:
        # 需要Chrome连接
        return None
    
    # 获取内容
    content_data = fetch_page_content(ws, url)
    
    if not content_data:
        return {
            'title': '',
            'summary': '获取内容失败',
            'key_points': [],
            'publish_date': '',
            'author': ''
        }
    
    # 生成摘要
    summary = extract_summary(content_data)
    
    # 生成关键点
    key_points = generate_key_points(content_data)
    
    return {
        'title': content_data.get('title', ''),
        'summary': summary,
        'key_points': key_points,
        'publish_date': content_data.get('publishDate', ''),
        'author': content_data.get('author', '')
    }


# ========== 测试 ==========

def test_content_fetch():
    """测试内容获取"""
    # 连接 Chrome
    try:
        resp = requests.get('http://localhost:9222/json/list', timeout=5)
        pages = resp.json()
        page = [p for p in pages if p['type'] == 'page'][0]
        ws_url = page['webSocketDebuggerUrl']
        
        ws = websocket.WebSocket()
        ws.settimeout(20)
        ws.connect(ws_url)
        
        # 测试获取
        test_urls = [
            'https://www.qbitai.com/2026/04/396535.html',
            'https://www.36kr.com/p/3751046517129735'
        ]
        
        for url in test_urls:
            print(f"\n=== 测试: {url} ===")
            result = process_article(url, ws)
            print(f"标题: {result['title'][:60]}")
            print(f"摘要: {result['summary'][:150]}...")
            print(f"关键点: {result['key_points']}")
        
        ws.close()
        
    except Exception as e:
        print(f"测试失败: {e}")


if __name__ == "__main__":
    test_content_fetch()

path: scripts/email_config.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
邮件配置加载模块
"""

import os
import json
import sys

# 外部配置路径（不会被 skill 更新覆盖）
EXTERNAL_CONFIG_DIR = os.path.expanduser("~/.openclaw/config")
EXTERNAL_CONFIG_FILE = os.path.join(EXTERNAL_CONFIG_DIR, "ai-news-email.json")

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) if "__file__" in dir() else os.getcwd()
USER_CONFIG_DIR = os.path.join(os.path.dirname(SCRIPT_DIR), "user_config") if SCRIPT_DIR else os.getcwd()
CONFIG_FILE = os.path.join(USER_CONFIG_DIR, "email_config.json") if USER_CONFIG_DIR else ""
DEFAULT_CONFIG_FILE = os.path.join(SCRIPT_DIR, "email_config.json.default") if SCRIPT_DIR else ""


def ensure_external_config():
    """确保外部配置文件存在，如果不存在则自动创建"""
    if os.path.exists(EXTERNAL_CONFIG_FILE):
        return False
    
    os.makedirs(EXTERNAL_CONFIG_DIR, exist_ok=True)
    
    template = None
    if os.path.exists(DEFAULT_CONFIG_FILE):
        try:
            with open(DEFAULT_CONFIG_FILE, 'r', encoding='utf-8') as f:
                template = json.load(f)
        except:
            pass
    
    if template:
        try:
            if 'smtp_config' in template:
                template['smtp_config']['enabled'] = False
            if 'recipient_config' in template:
                template['recipient_config']['enabled'] = False
            
            with open(EXTERNAL_CONFIG_FILE, 'w', encoding='utf-8') as f:
                json.dump(template, f, ensure_ascii=False, indent=2)
            return True
        except:
            pass
    
    return False


def load_email_config():
    """加载邮件配置（优先级：外部配置 > user_config > 默认配置）"""
    
    if not os.path.exists(EXTERNAL_CONFIG_FILE):
        ensure_external_config()
    
    if os.path.exists(EXTERNAL_CONFIG_FILE):
        try:
            with open(EXTERNAL_CONFIG_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
        except:
            pass
    
    if CONFIG_FILE and os.path.exists(CONFIG_FILE):
        try:
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
        except:
            pass
    
    return None
    
    # 3. 读取默认配置
    if os.path.exists(DEFAULT_CONFIG_FILE):
        try:
            with open(DEFAULT_CONFIG_FILE, 'r', encoding='utf-8') as f:
                print("[INFO] 使用默认邮件配置")
                return json.load(f)
        except Exception as e:
            print(f"[ERROR] 无法加载默认配置: {e}")
            return None
    
    return None


if __name__ == "__main__":
    config = load_email_config()
    print("邮件配置:", config)

path: scripts/email_sender.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
邮件推送模块
功能：读取邮件配置，将AI资讯简报发送到指定邮箱
"""

import sys
import io
import os
import json
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from email.header import Header

if sys.platform == 'win32':
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# 优先从 user_config 目录读取用户配置，如果没有则使用默认配置
USER_CONFIG_DIR = os.path.join(os.path.dirname(SCRIPT_DIR), "user_config")
CONFIG_FILE = os.path.join(USER_CONFIG_DIR, "email_config.json")
DEFAULT_CONFIG_FILE = os.path.join(SCRIPT_DIR, "email_config.json.default")


def load_email_config():
    """加载邮件配置（优先用户配置）"""
    # 优先读取用户配置目录
    if os.path.exists(CONFIG_FILE):
        try:
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
        except Exception as e:
            print(f"[WARN] 读取用户配置失败: {e}")
    
    # 读取默认配置
    if os.path.exists(DEFAULT_CONFIG_FILE):
        try:
            with open(DEFAULT_CONFIG_FILE, 'r', encoding='utf-8') as f:
                print("[INFO] 使用默认邮件配置，请复制到 user_config 目录进行自定义")
                return json.load(f)
        except Exception as e:
            print(f"[ERROR] 无法加载默认邮件配置: {e}")
            return None
    
    return None


def send_email(subject, html_content, config=None, attachment_path=None):
    """
    发送邮件
    attachment_path: 可选的 PDF 附件路径
    """
    if config is None:
        config = load_email_config()
        if config is None:
            return False, "配置加载失败"
    
    smtp_config = config.get('smtp_config', {})
    recipient_config = config.get('recipient_config', {})
    
    # 检查是否启用
    if not smtp_config.get('enabled', False):
        return False, "邮件推送未启用，请在 email_config.json 中设置 enabled: true"
    
    if not recipient_config.get('enabled', False):
        return False, "收件人未配置，请在 email_config.json 中设置 recipients"
    
    # 获取配置
    smtp_server = smtp_config.get('smtp_server', 'smtp.qq.com')
    smtp_port = smtp_config.get('smtp_port', 465)
    use_ssl = smtp_config.get('use_ssl', True)
    sender_email = smtp_config.get('sender_email', '')
    sender_password = smtp_config.get('sender_password', '')
    sender_name = smtp_config.get('sender_name', 'AI资讯小助手')
    recipients = recipient_config.get('recipients', [])
    
    # 验证配置
    if not sender_email or not sender_password:
        return False, "发件人邮箱或密码未配置"
    
    if not recipients:
        return False, "收件人列表为空"
    
    try:
        # 创建邮件
        msg = MIMEMultipart('alternative')
        msg['From'] = f"{sender_name} <{sender_email}>"
        msg['To'] = ', '.join(recipients)
        msg['Subject'] = Header(subject, 'utf-8')
        
        # 添加HTML内容
        html_part = MIMEText(html_content, 'html', 'utf-8')
        msg.attach(html_part)
        
        # 添加 PDF 附件（如果有）
        if attachment_path and os.path.exists(attachment_path):
            try:
                with open(attachment_path, 'rb') as f:
                    part = MIMEBase('application', 'octet-stream')
                    part.set_payload(f.read())
                encoders.encode_base64(part)
                filename = os.path.basename(attachment_path)
                part.add_header('Content-Disposition', f'attachment; filename="{filename}"')
                msg.attach(part)
                print(f"[INFO] 已添加附件: {filename}")
            except Exception as e:
                print(f"[WARN] 添加附件失败: {e}")
        
        # 连接SMTP服务器并发送
        if use_ssl:
            server = smtplib.SMTP_SSL(smtp_server, smtp_port)
        else:
            server = smtplib.SMTP(smtp_server, smtp_port)
            server.starttls()
        
        server.login(sender_email, sender_password)
        server.sendmail(sender_email, recipients, msg.as_string())
        server.quit()
        
        return True, f"邮件已发送到 {len(recipients)} 个收件人"
        
    except Exception as e:
        return False, f"发送失败: {str(e)}"


def generate_email_html(articles, date_range):
    """
    生成邮件HTML内容
    """
    template = config = load_email_config()
    email_template = template.get('email_template', {}) if template else {}
    
    subject_prefix = email_template.get('subject_prefix', '[AI资讯简报]')
    include_summary = email_template.get('include_summary', True)
    include_key_points = email_template.get('include_key_points', True)
    include_credibility = email_template.get('include_source_credibility', True)
    max_articles = email_template.get('max_articles', 15)
    
    articles = articles[:max_articles]
    
    html = f"""
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 0 auto; padding: 20px; }}
        h1 {{ color: #1a73e8; border-bottom: 2px solid #1a73e8; padding-bottom: 10px; }}
        h2 {{ color: #5f6368; margin-top: 30px; }}
        .article {{ background: #f8f9fa; border-radius: 8px; padding: 15px; margin: 15px 0; }}
        .article-title {{ font-size: 18px; font-weight: bold; color: #1a73e8; margin-bottom: 8px; }}
        .article-title a {{ color: inherit; text-decoration: none; }}
        .article-title a:hover {{ text-decoration: underline; }}
        .article-meta {{ font-size: 12px; color: #666; margin-bottom: 8px; }}
        .article-summary {{ color: #444; margin: 10px 0; }}
        .key-points {{ background: #e8f0fe; padding: 10px; border-radius: 4px; margin-top: 10px; }}
        .key-points-title {{ font-weight: bold; color: #1a73e8; font-size: 12px; }}
        .key-points ul {{ margin: 5px 0; padding-left: 20px; }}
        .key-points li {{ font-size: 13px; color: #333; }}
        .credibility {{ display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: bold; }}
        .credibility-A {{ background: #34a853; color: white; }}
        .credibility-B {{ background: #fbbc04; color: #333; }}
        .credibility-C {{ background: #ea4335; color: white; }}
        .credibility-D {{ background: #ccc; color: #333; }}
        .footer {{ margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; font-size: 12px; color: #666; text-align: center; }}
        .hot-score {{ background: #ea4335; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px; margin-left: 8px; }}
    </style>
</head>
<body>
    <h1>🤖 AI 资讯简报</h1>
    <p style="color: #666;">📅 数据范围: {date_range}</p>
    
    <h2>📊 热点资讯 TOP {len(articles)}</h2>
"""
    
    for i, article in enumerate(articles, 1):
        title = article.get('title', '无标题')
        url = article.get('url', '#')
        source = article.get('source', '未知来源')
        summary = article.get('summary', '暂无摘要')
        key_points = article.get('key_points', [])
        credibility = article.get('credibility', {})
        hot_score = article.get('hot_score', 0)
        
        level = credibility.get('level', 'D')
        
        html += f"""
    <div class="article">
        <div class="article-title">
            <span class="hot-score">🔥 {hot_score}</span>
            <a href="{url}" target="_blank">{i}. {title}</a>
        </div>
        <div class="article-meta">
            📰 {source} 
            <span class="credibility credibility-{level}">可信度 {level}</span>
        </div>
"""
        
        if include_summary and summary:
            html += f'        <div class="article-summary">{summary[:200]}...</div>\n'
        
        if include_key_points and key_points:
            html += f"""
        <div class="key-points">
            <div class="key-points-title">📌 关键点</div>
            <ul>
"""
            for kp in key_points[:3]:
                html += f'                <li>{kp[:100]}</li>\n'
            html += """            </ul>
        </div>
"""
        
        html += """    </div>
"""
    
    html += f"""
    <div class="footer">
        <p>🤖 由 AI 资讯简报自动生成</p>
        <p>📧 此邮件由系统自动发送，请勿回复</p>
    </div>
</body>
</html>
"""
    
    return html


def send_ai_news_email(articles, date_range, config=None, pdf_path=None):
    """
    发送AI资讯简报邮件
    pdf_path: 可选的 PDF 附件路径
    """
    # 生成HTML内容
    html_content = generate_email_html(articles, date_range)
    
    # 生成主题
    template = config if config else load_email_config()
    email_template = template.get('email_template', {}) if template else {}
    subject_prefix = email_template.get('subject_prefix', '[AI资讯简报]')
    subject = f"{subject_prefix} {date_range} - 共{len(articles)}条"
    
    # 发送邮件（带上 PDF 附件）
    return send_email(subject, html_content, config, attachment_path=pdf_path)


def test_email_config():
    """测试邮件配置"""
    config = load_email_config()
    
    if not config:
        print("❌ 配置加载失败")
        return
    
    smtp = config.get('smtp_config', {})
    recipient = config.get('recipient_config', {})
    
    print("=" * 50)
    print("邮件配置检查")
    print("=" * 50)
    print(f"SMTP服务器: {smtp.get('smtp_server', '未配置')}")
    print(f"SMTP端口: {smtp.get('smtp_port', '未配置')}")
    print(f"发件人: {smtp.get('sender_email', '未配置')}")
    print(f"收件人: {recipient.get('recipients', [])[0] if recipient.get('recipients') else '未配置'}")
    print(f"推送状态: {'✅ 已启用' if smtp.get('enabled') else '❌ 未启用'}")
    print("=" * 50)
    
    if not smtp.get('enabled'):
        print("\n⚠️ 邮件推送未启用!")
        print("请编辑 email_config.json，设置 enabled: true 并填写邮箱配置")
    else:
        print("\n正在发送测试邮件...")
        test_html = "<h1>测试邮件</h1><p>如果收到这封邮件，说明配置正确!</p>"
        success, msg = send_email("AI资讯简报 - 测试邮件", test_html, config)
        if success:
            print(f"✅ {msg}")
        else:
            print(f"❌ {msg}")


if __name__ == "__main__":
    test_email_config()

path: scripts/enhanced_summary.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
改进的摘要生成 - 不依赖Chrome
使用RSS中的描述和关键词匹配来生成更好的摘要
"""

import re
import json

def generate_summary_from_rss(title, description, url, source):
    """
    从RSS内容生成更好的摘要
    """
    summary = ""
    
    # 1. 优先使用meta描述
    if description and len(description) > 20:
        summary = description.strip()
        # 清理HTML标签
        summary = re.sub(r'<[^>]+>', '', summary)
        summary = summary.strip()
    
    # 2. 如果没有描述，从标题提取关键信息
    if not summary or len(summary) < 30:
        # 标题通常包含核心信息
        summary = title
    
    # 3. 清理
    summary = re.sub(r'\s+', ' ', summary)
    summary = summary[:350]
    
    return summary


def extract_key_points(title, content, source):
    """
    从内容中提取关键点
    """
    if not content:
        return []
    
    # 合并标题和内容
    text = title + " " + content
    
    # 关键词模式 - 包含这些词往往是重点
    patterns = [
        r'(\d+[亿万千百])',  # 数字
        r'(发布|推出|上线|公布|宣布)',  # 动作
        r'(首次|首个|第一|首次|首次)',  # 最高级
        r'(增长|下降|突破|超过|领先)',  # 变化
        r'(融资|投资|收购|上市|估值)',  # 商业
        r'(新模型|新产品|新功能|新版)',  # 新产品
        r'(免费|限时|优惠|特价)',  # 促销
        r'(AI|人工智能|模型|算法)',  # AI相关
    ]
    
    key_points = []
    
    # 分割成句子
    sentences = re.split(r'[。！？\n\r]+', text)
    
    for sentence in sentences:
        sentence = sentence.strip()
        if len(sentence) < 15 or len(sentence) > 150:
            continue
        
        # 检查是否包含数字（往往是重点）
        has_number = any(p in sentence for p in ['亿', '万', '千', '%', '年', '月', '日'])
        
        # 检查是否是重点句子
        is_important = any(re.search(p, sentence) for p in patterns)
        
        if has_number or is_important:
            if sentence not in key_points:
                key_points.append(sentence)
        
        if len(key_points) >= 4:
            break
    
    return key_points[:3]


def enhance_summary(article):
    """
    增强摘要 - 组合多个来源的信息
    """
    title = article.get('title', '')
    url = article.get('url', '')
    source = article.get('source', '')
    
    # 原始摘要
    original_summary = article.get('summary', '')
    
    # 尝试从URL获取更多信息（简化版）
    # 在实际环境中，这里可以调用更详细的内容获取
    
    # 生成更好的摘要
    better_summary = generate_summary_from_rss(
        title, 
        original_summary, 
        url, 
        source
    )
    
    # 提取关键点
    key_points = extract_key_points(title, original_summary, source)
    
    return {
        'title': title,
        'url': url,
        'source': source,
        'summary': better_summary,
        'key_points': key_points,
        'method': article.get('method', 'unknown'),
        'credibility': article.get('credibility', {})
    }


def main():
    """测试"""
    # 测试数据
    test_articles = [
        {
            'title': 'OpenAI新模型不是GPTX！全新预训练"土豆"曝光，Sora成弃子的原因找到了',
            'url': 'https://www.qbitai.com/2026/04/396535.html',
            'source': '量子位',
            'summary': '首页 资讯 智能车 智库 活动 MEET大会 AIGC OpenAI新模型不是GPTX！全新预训练"土豆"曝光，Sora成弃子的原因找到了 henry 2026-04-05 17:06:59 来源：量子位 正面回应了和Anthropic的竞争 一水 发自 凹非寺 量子位',
            'method': 'rss'
        },
        {
            'title': '神州数码2025年营收超1400亿，AI相关业务增长近五成',
            'url': 'https://www.36kr.com/p/3751046517129735',
            'source': '36kr',
            'summary': '作者｜黄楠编辑｜袁斯来神州数码（000034.SZ）2025年全年业绩报告近日发布。财报显示，公司全年实现营业收入1438亿元，同比增长12%，营收规模连续三年稳步攀升。其中，AI相关业务成为核心增长引擎，全年收入达330亿元，同比增长了48%',
            'method': 'rss'
        },
        {
            'title': 'NVIDIA发布新一代GPU，性能提升3倍',
            'url': 'https://example.com/nvidia-gpu',
            'source': '驱动之家',
            'summary': '刚刚发布的Blackwell架构GPU将在2026年上市，预计性能提升3倍，功耗降低30%',
            'method': 'chrome'
        }
    ]
    
    print("=" * 60)
    print("摘要增强测试")
    print("=" * 60)
    
    for article in test_articles:
        enhanced = enhance_summary(article)
        
        print(f"\n标题: {enhanced['title'][:60]}")
        print(f"来源: {enhanced['source']}")
        print(f"摘要: {enhanced['summary'][:200]}...")
        
        if enhanced['key_points']:
            print("关键点:")
            for i, kp in enumerate(enhanced['key_points'], 1):
                print(f"  {i}. {kp[:80]}")
        else:
            print("关键点: 无")


if __name__ == "__main__":
    main()

path: scripts/get_content.js
content: // 获取页面内容的脚本
(function() {
    var result = {
        title: document.title,
        description: '',
        body_length: 0,
        first_paragraph: ''
    };
    
    // 获取 meta 描述
    var metaDesc = document.querySelector('meta[name="description"]');
    if (metaDesc) {
        result.description = metaDesc.content;
    }
    
    // 如果没有 meta 描述，尝试 og 描述
    if (!result.description) {
        var ogDesc = document.querySelector('meta[property="og:description"]');
        if (ogDesc) {
            result.description = ogDesc.content;
        }
    }
    
    // 获取正文长度
    result.body_length = document.body.innerText.length;
    
    // 尝试获取第一段有意义的文字
    var selectors = [
        'article',
        '.article-content',
        '.post-content',
        '.entry-content',
        '.content',
        'main',
        '#content'
    ];
    
    var content = '';
    for (var i = 0; i < selectors.length; i++) {
        var elem = document.querySelector(selectors[i]);
        if (elem) {
            var text = elem.innerText || '';
            if (text.length > 100) {
                content = text;
                break;
            }
        }
    }
    
    // 如果没有找到，使用 body
    if (!content) {
        content = document.body.innerText;
    }
    
    // 分割成段落，获取第一段有意义的
    var paragraphs = content.split(/[\n\r]+/);
    for (var i = 0; i < paragraphs.length; i++) {
        var p = paragraphs[i].trim();
        // 跳过太短或太长的
        if (p.length > 30 && p.length < 400) {
            // 跳过看起来像导航的
            if (p.indexOf('登录') === -1 && p.indexOf('注册') === -1) {
                result.first_paragraph = p;
                break;
            }
        }
    }
    
    return JSON.stringify(result);
})();

path: scripts/incremental_fetch.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据持久化和增量抓取模块
功能：
1. 保存抓取结果到本地
2. 增量抓取 - 只抓取新内容
3. 历史查询支持
"""

import sys
import io
import os
import json
from datetime import datetime, timedelta

if sys.platform == 'win32':
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(SCRIPT_DIR, "data")
HISTORY_FILE = os.path.join(DATA_DIR, "news_history.json")
POLICY_HISTORY_FILE = os.path.join(DATA_DIR, "policy_history.json")


def ensure_data_dir():
    """确保数据目录存在"""
    os.makedirs(DATA_DIR, exist_ok=True)


def load_history(file_path=HISTORY_FILE):
    """加载历史记录"""
    ensure_data_dir()
    try:
        with open(file_path, 'r', encoding='utf-8') as f:
            return json.load(f)
    except:
        return {"articles": [], "last_update": ""}


def save_history(history, articles, file_path=HISTORY_FILE):
    """保存历史记录"""
    ensure_data_dir()
    history["articles"] = articles[:1000]  # 最多保留1000条
    history["last_update"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    
    with open(file_path, 'w', encoding='utf-8') as f:
        json.dump(history, f, ensure_ascii=False, indent=2)


def get_new_articles(articles, history_file=HISTORY_FILE):
    """
    获取新增的文章（增量抓取）
    只返回之前没有抓取过的
    """
    history = load_history(history_file)
    existing_titles = set()
    
    for article in history.get("articles", []):
        key = article.get("title", "")[:30]
        existing_titles.add(key)
    
    new_articles = []
    for article in articles:
        title_key = article.get("title", "")[:30]
        if title_key not in existing_titles:
            new_articles.append(article)
    
    return new_articles, history


def save_with_incremental(new_articles, history_file=HISTORY_FILE):
    """保存新增文章并更新历史"""
    if not new_articles:
        return 0
    
    history = load_history(history_file)
    
    # 合并新旧文章（新的在前）
    all_articles = new_articles + history.get("articles", [])
    
    # 限制保存数量
    if "policy" in history_file:
        max_articles = 500
    else:
        max_articles = 1000
    
    all_articles = all_articles[:max_articles]
    
    # 保存
    history["articles"] = all_articles
    history["last_update"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    
    with open(history_file, 'w', encoding='utf-8') as f:
        json.dump(history, f, ensure_ascii=False, indent=2)
    
    return len(new_articles)


def get_articles_by_date(days=7, history_file=HISTORY_FILE):
    """
    查询最近几天的资讯
    """
    history = load_history(history_file)
    cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
    
    recent = []
    for article in history.get("articles", []):
        # 从URL或内容中提取日期
        date = article.get("date", "")
        if date and date >= cutoff:
            recent.append(article)
        elif not date:
            # 如果没有日期，也返回（可能是最近的）
            recent.append(article)
    
    return recent


def get_article_stats(history_file=HISTORY_FILE):
    """获取资讯统计"""
    history = load_history(history_file)
    articles = history.get("articles", [])
    
    # 按来源统计
    source_counts = {}
    for a in articles:
        src = a.get("source", "未知")
        source_counts[src] = source_counts.get(src, 0) + 1
    
    # 按类型统计
    type_counts = {}
    for a in articles:
        t = a.get("type", "news")
        type_counts[t] = type_counts.get(t, 0) + 1
    
    return {
        "total": len(articles),
        "by_source": source_counts,
        "by_type": type_counts,
        "last_update": history.get("last_update", "未知")
    }


# ========== 对外接口 ==========

def fetch_with_incremental(fetch_func, fetch_args, use_incremental=True):
    """
    带增量抓取的获取接口
    
    参数:
        fetch_func: 获取函数
        fetch_args: 获取函数参数
        use_incremental: 是否使用增量模式
    
    返回:
        (new_articles, all_articles)
    """
    print("\n" + "="*50)
    if use_incremental:
        print("📌 增量抓取模式 - 只获取新内容")
        print("="*50)
        
        # 先抓取
        all_articles = fetch_func(*fetch_args)
        
        # 获取新增
        new_articles = get_new_articles(all_articles)
        
        # 保存
        if new_articles:
            saved = save_with_incremental(new_articles)
            print(f"\n✅ 新增 {saved} 条，已保存到历史记录")
        else:
            print(f"\n✅ 没有新增内容")
        
        return new_articles, all_articles
    
    else:
        print("📌 全量抓取模式 - 获取所有内容")
        print("="*50)
        
        articles = fetch_func(*fetch_args)
        
        # 全量保存
        history = load_history()
        save_history(history, articles)
        
        return articles, articles


# ========== 测试 ==========

def test_history():
    """测试历史功能"""
    print("=" * 50)
    print("历史记录测试")
    print("=" * 50)
    
    # 测试统计
    stats = get_article_stats()
    print(f"\n📊 资讯统计:")
    print(f"  总数: {stats['total']}")
    print(f"  最后更新: {stats['last_update']}")
    print(f"  来源分布: {stats['by_source']}")
    
    # 测试查询最近3天
    recent = get_articles_by_date(days=3)
    print(f"\n📅 最近3天资讯: {len(recent)}条")


if __name__ == "__main__":
    test_history()

path: scripts/llm_summarizer.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
大模型摘要生成模块
功能：使用大模型生成更好的文章摘要
"""

import sys
import io
import os
import json

if sys.platform == 'win32':
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')

import requests
import time

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
USER_CONFIG_DIR = os.path.join(os.path.dirname(SCRIPT_DIR), "user_config")
CONFIG_FILE = os.path.join(USER_CONFIG_DIR, "llm_config.json")
DEFAULT_CONFIG_FILE = os.path.join(SCRIPT_DIR, "llm_config.json.default")


def load_llm_config():
    """加载LLM配置（优先用户配置）"""
    # 优先读取用户配置目录
    if os.path.exists(CONFIG_FILE):
        try:
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
        except Exception as e:
            print(f"[WARN] 读取用户配置失败: {e}")
    
    # 读取默认配置
    if os.path.exists(DEFAULT_CONFIG_FILE):
        try:
            with open(DEFAULT_CONFIG_FILE, 'r', encoding='utf-8') as f:
                print("[INFO] 使用默认LLM配置，请复制到 user_config 目录进行自定义")
                return json.load(f)
        except Exception as e:
            print(f"[ERROR] 无法加载默认配置: {e}")
            return None
    
    return None


def generate_summary_with_llm(title, content, config=None):
    """
    使用大模型生成摘要
    """
    if config is None:
        config = load_llm_config()
        if config is None:
            return None, "配置加载失败"
    
    llm_config = config.get('config', {})
    
    if not llm_config.get('enabled', False):
        return None, "LLM未启用，请在llm_config.json中设置enabled:true"
    
    provider = llm_config.get('provider', 'deepseek')
    provider_config = llm_config.get(provider, {})
    
    [REDACTED_SECRET]'api_key', '')
    api_url = provider_config.get('api_url', '')
    model = provider_config.get('model', '')
    
    if not api_key or api_key == "your_api_key_here":
        return None, "API Key未配置"
    
    # 构建prompt
    prompt_template = config.get('prompt_template', 
        "请为以下文章生成50字以内的核心摘要：\n标题：{title}\n内容：{content}\n摘要：")
    
    prompt = prompt_template.format(title=title[:100], content=content[:2000])
    
    try:
        headers = {
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {api_key}'
        }
        
        data = {
            "model": model,
            "messages": [
                {"role": "user", "content": prompt}
            ],
            "max_tokens": 150,
            "temperature": 0.3
        }
        
        response = requests.post(api_url, headers=headers, json=data, timeout=30)
        
        if response.status_code == 200:
            result = response.json()
            summary = result['choices'][0]['message']['content'].strip()
            return summary, "success"
        else:
            return None, f"API错误: {response.status_code}"
    
    except Exception as e:
        return None, f"请求失败: {str(e)[:50]}"


def generate_bulk_summaries(articles, config=None, max_at_once=5):
    """
    批量生成摘要
    - 为了节省token，每次最多处理N条
    - 只对没有成功摘要的文章进行处理
    """
    if config is None:
        config = load_llm_config()
    
    llm_config = config.get('config', {}) if config else {}
    if not llm_config.get('enabled', False):
        print("  ℹ️ LLM未启用，跳过AI摘要生成")
        return articles
    
    print(f"  🤖 正在使用LLM生成摘要 (最多 {max_at_once} 条)...")
    
    count = 0
    for i, article in enumerate(articles[:max_at_once]):
        # 如果已有较好摘要，跳过
        existing_summary = article.get('summary', '')
        if existing_summary and len(existing_summary) > 50:
            continue
        
        title = article.get('title', '')
        content = article.get('summary', '') or article.get('content', '')[:2000]
        
        if not content:
            continue
        
        summary, status = generate_summary_with_llm(title, content, config)
        
        if status == "success" and summary:
            article['llm_summary'] = summary
            count += 1
            print(f"    {i+1}. ✅ {title[:30]}...")
            time.sleep(0.5)  # 避免请求过快
        else:
            print(f"    {i+1}. ❌ {title[:30]}... ({status})")
    
    if count > 0:
        print(f"  📝 已为 {count} 篇文章生成LLM摘要")
    else:
        print(f"  ℹ️ 没有需要生成摘要的文章")
    
    return articles


def test_llm_config():
    """测试LLM配置"""
    config = load_llm_config()
    
    if not config:
        print("❌ 配置加载失败")
        return
    
    llm = config.get('config', {})
    
    print("=" * 50)
    print("LLM 配置检查")
    print("=" * 50)
    print(f"启用状态: {'✅ 已启用' if llm.get('enabled') else '❌ 未启用'}")
    print(f"服务商: {llm.get('provider', '未选择')}")
    
    provider = llm.get(llm.get('provider', 'deepseek'), {})
    [REDACTED_SECRET]'api_key', '')
    
    if api_key and api_key != "your_api_key_here":
        print(f"API Key: {'✅ 已配置' if api_key else '❌ 未配置'}")
    else:
        print(f"API Key: ❌ 未配置")
    
    print("=" * 50)
    
    if not llm.get('enabled'):
        print("\n请编辑 llm_config.json 设置 enabled: true 和 API Key")


if __name__ == "__main__":
    test_llm_config()

path: scripts/pdf_config.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PDF 配置加载模块
"""

import os
import json

# 外部配置路径
EXTERNAL_CONFIG_DIR = os.path.expanduser("~/.openclaw/config")
EXTERNAL_CONFIG_FILE = os.path.join(EXTERNAL_CONFIG_DIR, "ai-news-pdf.json")

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) if "__file__" in dir() else os.getcwd()
USER_CONFIG_DIR = os.path.join(os.path.dirname(SCRIPT_DIR), "user_config") if SCRIPT_DIR else os.getcwd()
CONFIG_FILE = os.path.join(USER_CONFIG_DIR, "pdf_config.json") if USER_CONFIG_DIR else ""
DEFAULT_CONFIG_FILE = os.path.join(SCRIPT_DIR, "pdf_config.json.default") if SCRIPT_DIR else ""


def ensure_external_config():
    """确保外部配置文件存在，如果不存在则自动创建"""
    if os.path.exists(EXTERNAL_CONFIG_FILE):
        return False
    
    os.makedirs(EXTERNAL_CONFIG_DIR, exist_ok=True)
    
    template = None
    if os.path.exists(DEFAULT_CONFIG_FILE):
        try:
            with open(DEFAULT_CONFIG_FILE, 'r', encoding='utf-8') as f:
                template = json.load(f)
        except:
            pass
    
    if template:
        try:
            if 'config' in template:
                template['config']['enabled'] = False
            
            with open(EXTERNAL_CONFIG_FILE, 'w', encoding='utf-8') as f:
                json.dump(template, f, ensure_ascii=False, indent=2)
            return True
        except:
            pass
    
    return False


def load_pdf_config():
    """加载PDF配置（优先级：外部配置 > user_config > 默认配置）"""
    
    if not os.path.exists(EXTERNAL_CONFIG_FILE):
        ensure_external_config()
    
    if os.path.exists(EXTERNAL_CONFIG_FILE):
        try:
            with open(EXTERNAL_CONFIG_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
        except:
            pass
    
    if CONFIG_FILE and os.path.exists(CONFIG_FILE):
        try:
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
        except:
            pass
    
    return None

path: scripts/pdf_generator.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PDF 报告生成模块 - FPDF2 版本
支持中文，样式更智能
"""

import os
import sys

# 尝试导入 fpdf2
try:
    from fpdf import FPDF
    from fpdf.enums import XPos, YPos
    FPDF2_AVAILABLE = True
except ImportError:
    FPDF2_AVAILABLE = False
    print("Note: fpdf2 not installed. PDF generation disabled.")
    print("Install with: pip install fpdf2")


class PDFReport(FPDF):
    """自定义 PDF 报告类"""
    
    def __init__(self):
        super().__init__()
        # 设置中文字体支持
        self.add_font('SimSun', '', 'C:/Windows/Fonts/simsun.ttc', uni=True)
        self.add_font('SimHei', '', 'C:/Windows/Fonts/simhei.ttf', uni=True)
        self.set_font('SimSun', '', 12)
        
    def header(self):
        # 可在子类中重写
        pass
        
    def footer(self):
        self.set_y(-15)
        self.set_font('SimSun', '', 9)
        self.set_text_color(128)
        self.cell(0, 10, f'— 第 {self.page_no()} 页 —', align='C')


class AIReportPDF(PDFReport):
    """AI 资讯简报 PDF"""
    
    def __init__(self):
        super().__init__()
        self.chapter_title_font = ('SimHei', '', 16)
        self.chapter_font = ('SimSun', '', 11)
        self.title_color = (26, 115, 232)  # Google Blue
        self.heading_color = (33, 33, 33)
        self.text_color = (51, 51, 51)
        self.gray_color = (95, 99, 104)
        
    def create_title_page(self, date_range):
        """创建封面页"""
        self.add_page()
        
        # 标题
        self.set_font('SimHei', '', 32)
        self.set_text_color(*self.title_color)
        self.cell(0, 40, '[AI] AI 资讯简报', align='C', new_x=XPos.LMARGIN, new_y=YPos.NEXT)
        
        # 日期
        self.set_font('SimSun', '', 16)
        self.set_text_color(*self.gray_color)
        self.cell(0, 20, f'日期: {date_range}', align='C', new_x=XPos.LMARGIN, new_y=YPos.NEXT)
        
        # 分隔线
        self.ln(15)
        self.set_draw_color(*self.title_color)
        self.set_line_width(1)
        self.line(50, self.get_y(), 160, self.get_y())
        
        # 底部信息
        self.ln(30)
        self.set_font('SimSun', '', 10)
        self.set_text_color(*self.gray_color)
        self.cell(0, 10, '[AI] 由 AI 资讯简报自动生成', align='C')
        
    def chapter_title(self, title):
        """章节标题"""
        self.set_font('SimHei', '', 14)
        self.set_text_color(*self.title_color)
        self.cell(0, 12, title, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
        self.ln(2)
        
    def chapter_body(self, text):
        """正文内容"""
        self.set_font('SimSun', '', 10)
        self.set_text_color(*self.text_color)
        self.multi_cell(0, 6, text)
        self.ln()


def generate_pdf_report(articles, date_range, output_path):
    """
    使用 FPDF2 生成 PDF 报告
    """
    if not FPDF2_AVAILABLE:
        print("PDF generation requires: pip install fpdf2")
        return None
    
    try:
        # 创建 PDF
        pdf = AIReportPDF()
        
        # 封面
        pdf.create_title_page(date_range)
        
        # 导入分类模块
        try:
            sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
            from categorizer import categorize_articles, get_module_order
            categorized = categorize_articles(articles)
            module_order = get_module_order()
        except Exception:
            categorized = {"热点资讯": articles[:15]}
            module_order = [("热点资讯", "🔥")]
        
        # 按模块生成
        for module_name, icon in module_order:
            module_articles = categorized.get(module_name, [])
            if not module_articles:
                continue
            
            # 新页面
            pdf.add_page()
            
            # 模块标题
            pdf.chapter_title(f"[{module_name}] {module_name} ({len(module_articles)}条)")
            
            # 文章列表
            for i, article in enumerate(module_articles[:10], 1):
                title = article.get('title', '')[:60]
                source = article.get('source', '未知来源')
                hot_score = article.get('hot_score', 0)
                summary = article.get('summary', '')[:100]
                level = article.get('credibility', {}).get('level', 'D')
                
                # 标题
                pdf.set_font('SimHei', '', 11)
                pdf.set_text_color(26, 115, 232)
                pdf.cell(0, 8, f"{i}. {title}", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
                
                # 元信息
                pdf.set_font('SimSun', '', 9)
                pdf.set_text_color(95, 99, 104)
                meta = f"来源: {source}  |  可信度: {level}  |  热度: {hot_score}"
                pdf.cell(0, 6, meta, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
                
                # 摘要
                if summary and len(summary) > 10:
                    pdf.set_font('SimSun', '', 9)
                    pdf.set_text_color(51, 51, 51)
                    pdf.cell(0, 6, f"摘要: {summary}...", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
                
                pdf.ln(8)
        
        # 保存
        pdf.output(output_path)
        return output_path
        
    except Exception as e:
        print(f"Error generating PDF: {e}")
        import traceback
        traceback.print_exc()
        return None


def generate_pdf_with_attachment(articles, date_range, config=None):
    """
    生成PDF并作为附件发送
    """
    if not FPDF2_AVAILABLE:
        return None, "请先安装: pip install fpdf2"
    
    # 确保目录存在
    output_dir = config.get('config', {}).get('output_dir', './reports') if config else './reports'
    os.makedirs(output_dir, exist_ok=True)
    
    # 生成文件名
    from datetime import datetime
    filename = f"ai_news_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
    output_path = os.path.join(output_dir, filename)
    
    # 生成PDF
    try:
        pdf_path = generate_pdf_report(articles, date_range, output_path)
        return pdf_path, "success"
    except Exception as e:
        return None, str(e)


if __name__ == "__main__":
    # 测试
    if FPDF2_AVAILABLE:
        print("[OK] FPDF2 PDF module ready!")
        
        # 测试数据
        test_articles = [
            {'title': '测试文章1：OpenAI发布新模型GPT-5震撼发布', 'source': '36kr', 'hot_score': 95, 
             'summary': '这是测试摘要内容，OpenAI发布了最新的GPT-5模型，性能大幅提升...', 
             'credibility': {'level': 'A'}, 'url': 'https://example.com/1'},
            {'title': '测试文章2：英伟达发布5090新显卡', 'source': '量子位', 'hot_score': 88, 
             'summary': '英伟达发布5090显卡，性能提升显著，AI计算能力大幅增强...', 
             'credibility': {'level': 'A'}, 'url': 'https://example.com/2'},
            {'title': '测试文章3：AI创业公司完成亿元融资', 'source': '爱范儿', 'hot_score': 75, 
             'summary': '某AI创业公司完成亿元融资，估值达到10亿美元...', 
             'credibility': {'level': 'B'}, 'url': 'https://example.com/3'},
            {'title': '测试文章4：国产大模型发布性能接近GPT-4', 'source': '机器之心', 'hot_score': 82, 
             'summary': '国产大模型发布，性能接近GPT-4水平...', 
             'credibility': {'level': 'A'}, 'url': 'https://example.com/4'},
        ]
        
        output_path = 'test_fpdf2.pdf'
        result = generate_pdf_report(test_articles, '2026-04-07', output_path)
        
        if result and os.path.exists(result):
            print(f"[OK] PDF generated: {result}")
            print(f"     File size: {os.path.getsize(result)} bytes")
        else:
            print("[ERROR] PDF generation failed")
    else:
        print("Please install fpdf2: pip install fpdf2")

path: user_config/pdf_config.json
content: {
  "version": "1.0.0",
  "description": "PDF生成配置 - 请使用外部配置 ~/.openclaw/config/ai-news-pdf.json",
  "config": {
    "enabled": false,
    "output_dir": "./reports"
  }
}

Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)