Skill: russellfei/claw-news (version 1.0.0)

--- name: claw-news description: | 智能每日新闻简报 Skill。支持两种新闻获取模式：(1) 主动搜索 - 基于用户关注的话题/关键词/人名，通过多 AI API 检索全网信息；(2) RSS 订阅 - 从配置的 RSS/Atom 源自动抓取新闻。 支持智能去重、兴趣管理、AI 摘要、定时推送。 触发关键词: "newsman", "新闻简报", "每日简报", "claw-news", "添加关注", "interest", "rss" --- # Claw-News (Newsman) 智能每日新闻简报生成工具，支持**主动搜索**、**RSS 订阅**和**定向爬虫**三种模式。 ## 核心功能 ### 模式一：主动搜索 (AI Search) 基于用户关注的话题/关键词，通过多 AI API 主动检索全网信息。 - **定时简报** - 每日 9:00 自动生成昨日新闻简报 - **兴趣管理** - 动态添加/删除/查看关注列表 - **多源搜索** - Kimi、MiniMax、Claude API 轮询搜索 - **智能整合** - 自动去重、分类、排序 ### 模式二：RSS 订阅 (RSS Feed) 从配置的 RSS/Atom 源自动抓取新闻。 - **RSS 抓取** - 从配置的 RSS 源获取新闻 - **AI 摘要** - 自动生成新闻摘要 - **分类筛选** - 支持按类别过滤 (tech/finance/world/science/sports) ### 模式三：定向爬虫 (Custom Tracker) 针对特定站点的定制化数据抓取。 - **Kickstarter 追踪** - 每周抓取 funding >1000% 的超热门众筹项目 - **可扩展** - 支持添加更多站点追踪器 ### 通用功能 - **推送投递** - 通过 Slack/Channel 推送简报 - **定时任务** - 支持 Cron 定时执行 --- ## ⚙️ 依赖安装与环境初始化 ### 首次安装 ```bash # 进入 skill 目录 cd ~/.openclaw/workspace/skills/claw-news # 创建虚拟环境（推荐） python -m venv .venv # 激活虚拟环境 # Windows: .venv\Scripts\activate # Linux/Mac: # source .venv/bin/activate # 安装依赖 pip install -r requirements.txt ``` ### 依赖列表 主要依赖包： - `openai` >= 1.0.0 (Kimi API) - `anthropic` >= 0.18.0 (Claude API) - `requests` >= 2.31.0 - `python-dateutil` >= 2.8.0 - `feedparser` (RSS 支持) - `tqdm`, `python-dotenv` --- ## 快速开始 ### 用户交互命令 ``` # === 主动搜索模式 === @claw newsman add <关键词> # 添加关注话题 @claw newsman add <人名> --type person # 添加人名关注 @claw newsman remove <id> # 删除关注 @claw newsman list # 查看关注列表 @claw newsman run # 立即执行简报生成 @claw newsman settings # 查看设置 # === RSS 订阅模式 === @claw newsman rss fetch # 抓取 RSS 新闻 @claw newsman rss digest # 生成 RSS 简报 @claw newsman rss sources # 查看 RSS 源列表 # === Kickstarter 追踪模式 === @claw newsman kickstarter # 立即执行 Kickstarter 热门项目抓取 ``` ### 手动执行 - 主动搜索模式 ```bash # 执行简报生成 python scripts/newsman.py --mode digest # 仅搜索特定关键词 python scripts/newsman.py --mode search --query "人工智能" # 测试模式（不发送推送） python scripts/newsman.py --mode digest --dry-run ``` ### 手动执行 - Kickstarter 追踪模式 ```bash # 执行 Kickstarter 追踪（默认阈值 1000%） python scripts/kickstarter_tracker.py # 自定义阈值（例如 500%） python scripts/kickstarter_tracker.py --threshold 500 # 只显示新增项目 python scripts/kickstarter_tracker.py --new-only # 测试模式（不保存缓存） python scripts/kickstarter_tracker.py --dry-run ``` ### 手动执行 - RSS 订阅模式 ```bash # 抓取所有 RSS 源 python rss/rss_fetcher.py # 抓取特定类别 python rss/rss_fetcher.py --category tech # 生成 RSS 简报 python rss/rss_digest.py --category tech --hours 24 # 使用 Kimi API 生成摘要 python rss/rss_summarize.py --input news.json --method api ``` --- ## 配置 ### 环境变量 创建 `.env` 文件在 skill 根目录： ```bash # === API Keys (至少配置一个) === KIMI_API_KEY=sk-xxx MINIMAX_API_KEY=xxx ANTHROPIC_API_KEY=sk-ant-xxx # === OpenClaw Gateway (用于推送) === GATEWAY_URL=http://localhost:3000 GATEWAY_TOKEN=your-token # === 推送配置 === DELIVERY_CHANNEL=slack SLACK_CHANNEL=#general # === 可选：自定义时间 === DAILY_DIGEST_TIME=09:00 TIMEZONE=Asia/Shanghai # === RSS 配置 === NEWSMAN_CACHE_DIR=./.cache NEWSMAN_MAX_ITEMS=10 NEWSMAN_SUMMARY_LENGTH=150 ``` ### 关注列表示例 (主动搜索) ```json { "interests": [ { "id": "uuid-1234", "type": "topic", "value": "人工智能", "keywords": ["AI", "大模型", "AGI"], "priority": "high" }, { "id": "uuid-5678", "type": "person", "value": "马斯克", "keywords": ["Elon Musk", "Tesla", "SpaceX"], "priority": "medium" } ] } ``` ### RSS 源配置 编辑 `references/rss_sources.md` 添加新的 RSS/Atom feeds。 --- ## Cron 配置 ### 主动搜索模式 - 每日定时任务 ```bash openclaw cron add \ --name "claw-news-daily" \ --schedule "0 9 * * *" \ --timezone "Asia/Shanghai" \ --command "python ~/.openclaw/workspace/skills/claw-news/scripts/newsman.py --mode digest" ``` ### Kickstarter 追踪模式 - 每周定时任务 ```bash # 每周一早上 9 点抓取 Kickstarter 超热门项目 openclaw cron add \ --name "kickstarter-weekly" \ --schedule "0 9 * * 1" \ --timezone "Asia/Shanghai" \ --command "python ~/.openclaw/workspace/skills/claw-news/scripts/kickstarter_tracker.py --threshold 1000" ``` ### RSS 订阅模式 - 定时任务 ```bash # 每天早上 8 点生成 RSS 简报 openclaw cron add \ --name "rss-morning-news" \ --schedule "0 8 * * *" \ --command "python ~/.openclaw/workspace/skills/claw-news/rss/rss_digest.py --category all" ``` --- ## 文件结构 ``` claw-news/ ├── SKILL.md # 主文档 ├── .env # 环境变量配置 ├── scripts/ # 主动搜索模式 │ ├── newsman.py │ ├── interest_manager.py │ ├── search_engine.py │ ├── result_aggregator.py │ ├── digest_generator.py │ └── config.py ├── rss/ # RSS 订阅模式 │ ├── rss_fetcher.py │ ├── rss_summarize.py │ ├── rss_digest.py │ ├── kimi_client.py │ └── config.toml ├── references/ # 参考文档 │ ├── kimi_api.md │ ├── minimax_api.md │ ├── claude_api.md │ └── rss_sources.md ├── assets/ # 资源文件 │ └── digest_template.md └── data/ # 数据文件 ``` --- ## RSS 新闻分类 默认分类 (可在 `references/rss_sources.md` 中配置)： | 分类 | 说明 | |------|------| | **tech** | 科技、AI、软件、硬件 | | **finance** | 金融、市场、经济、加密货币 | | **world** | 国际新闻、政治 | | **science** | 科学、太空、研究 | | **sports** | 体育新闻和赛事 |

path: assets/digest_template.md
content: # 📰 Claw-News 每日简报

**时间范围**: {{start_time}} ~ {{end_time}}
**生成时间**: {{generated_at}}

---

## 📋 今日概览

- **关注主题**: {{total_interests}} 个
- **新闻总数**: {{total_news}} 条
- **涉及来源**: {{total_sources}} 个

---

{{content}}

---

## 📊 简报统计

| 指标 | 数值 |
|------|------|
| 关注主题 | {{total_interests}} 个 |
| 新闻总数 | {{total_news}} 条 |
| 涉及来源 | {{total_sources}} 个 |
| API 使用 | {{api_usage}} |

---

💡 **提示**: 回复 `newsman add <关键词>` 添加新关注 | `newsman list` 查看列表 | `newsman run` 立即执行


path: data/interest_list.json
content: {
  "version": "1.0",
  "updated_at": "2026-02-22T20:34:14.197174",
  "interests": [
    {
      "id": "ai-topic-01",
      "type": "topic",
      "value": "人工智能",
      "keywords": [
        "AI",
        "大模型",
        "AGI",
        "机器学习",
        "深度学习"
      ],
      "priority": "high",
      "sources": [
        "tech",
        "news"
      ],
      "created_at": "2026-02-22T20:30:00+08:00"
    },
    {
      "id": "tech-topic-01",
      "type": "topic",
      "value": "科技动态",
      "keywords": [
        "科技",
        "互联网",
        "初创公司",
        "融资",
        "IPO"
      ],
      "priority": "medium",
      "sources": [
        "tech"
      ],
      "created_at": "2026-02-22T20:30:00+08:00"
    },
    {
      "id": "person-01",
      "type": "person",
      "value": "马斯克",
      "keywords": [
        "Elon Musk",
        "Musk",
        "Tesla",
        "SpaceX",
        "X",
        "Twitter"
      ],
      "priority": "medium",
      "sources": [
        "all"
      ],
      "created_at": "2026-02-22T20:30:00+08:00"
    },
    {
      "id": "12a5fcb8",
      "type": "topic",
      "value": "区块链",
      "keywords": [
        "区块链"
      ],
      "priority": "medium",
      "sources": [
        "all"
      ],
      "created_at": "2026-02-22T20:34:14.197148"
    },
    {
      "id": "kickstarter-01",
      "type": "source",
      "value": "Kickstarter 超热门项目",
      "keywords": [
        "crowdfunding",
        "kickstarter"
      ],
      "priority": "medium",
      "sources": [
        "kickstarter"
      ],
      "created_at": "2026-02-22T21:45:00+08:00"
    }
  ],
  "settings": {
    "daily_digest_time": "09:00",
    "timezone": "Asia/Shanghai",
    "max_results_per_interest": 10,
    "search_lookback_hours": 24,
    "delivery_channel": "slack",
    "slack_channel": "#general"
  }
}

path: data/kickstarter_cache.json

path: data/search_cache.json
content: {
  "version": "1.0",
  "searches": {},
  "last_cleanup": "2026-02-22T20:30:00+08:00"
}


path: output/digest_20260222.md
content: # 📰 Claw-News 每日简报

**时间范围**: 2026-02-21 22:00 ~ 2026-02-22 22:00
**生成时间**: 2026-02-22 22:00:00

---

## 🔥 人工智能 (8条) 🔴

### 1. [Sam Altman 回应 AI 能源消耗争议](https://techcrunch.com/2026/02/21/sam-altman-would-like-remind-you-that-humans-use-a-lot-of-energy-too/)
**来源**: TechCrunch | **时间**: 02-21 21:38

Sam Altman 表示训练一个人类也需要大量能源，试图为 AI 系统的能源消耗进行辩护。

---

### 2. [Google VP 警告两类 AI 初创公司可能无法生存](https://techcrunch.com/2026/02/21/google-vp-warns-that-two-types-of-ai-startups-may-not-survive/)
**来源**: TechCrunch | **时间**: 02-21 16:00

随着生成式 AI 的发展，Google 高管警告称 LLM 包装器和 AI 聚合器面临越来越大的压力，利润率下降和差异化有限威胁着它们的长期生存能力。

---

### 3. [OpenAI 讨论是否报警处理疑似加拿大枪手聊天记录](https://techcrunch.com/2026/02/21/openai-debated-calling-police-about-suspected-canadian-shooters-chats/)
**来源**: TechCrunch | **时间**: 02-21 15:25

Jesse Van Rootselaar 描述暴力内容的聊天记录被监测 ChatGPT 滥用的工具标记，OpenAI 内部曾讨论是否报警。

---

### 4. [印度 Sarvam 推出 Indus AI 聊天应用](https://techcrunch.com/2026/02/20/indias-sarvam-launches-indus-ai-chat-app-as-competition-heats-up/)
**来源**: TechCrunch | **时间**: 02-21 01:00

印度 AI 公司 Sarvam 推出 Indus 聊天应用，目前处于 beta 测试阶段，AI 竞争日益激烈。

---

### 5. [Google AI 负责人称需要紧急研究应对 AI 威胁](https://www.bbc.com/news/articles/c0q3g0ln274o)
**来源**: BBC Tech | **时间**: 02-20 10:32

但美国 AI 影响峰会德里代表团团长表示："我们完全拒绝 AI 全球治理"。

---

### 6. [微软错误导致机密邮件暴露于 AI 工具 Copilot](https://www.bbc.com/news/articles/c8jxevd8mdyo)
**来源**: BBC Tech | **时间**: 02-19 18:16

微软表示已解决此问题，没有向任何未经授权的人提供信息访问权限。

---

### 7. [微软新游戏 CEO 承诺不会向生态系统中投放"无尽的 AI 垃圾"](https://techcrunch.com/2026/02/21/microsofts-new-gaming-ceo-vows-not-to-flood-the-ecosystem-with-endless-ai-slop/)
**来源**: TechCrunch | **时间**: 02-21 17:41

微软游戏部门是否在 AI方面加倍 投入？

---

### 8. [Wikipedia 封禁 Archive.today](https://techcrunch.com/2026/02/21/wikipedia-blacklists-archive-today-after-alleged-ddos-attack/)
**来源**: TechCrunch | **时间**: 02-21 20:20

维基百科编辑决定封禁 Archive.today，这个网络存档服务在百科全书中被链接超过 695,000 次。

---

## 👤 马斯克 / 特斯拉 (2条) 🟡

### 1. [乔治亚州称 Elon Musk 的 America PAC 违反选举法](https://www.theverge.com/tech/882838/georgia-elon-musk-america-pac-voter-fraud)
**来源**: The Verge | **时间**: 02-21 11:55

尽管 Elon Musk 大肆宣扬选举舞弊，但他却是最明目张胆违反美国选举法的人之一。现在他的 America PAC 受到乔治亚州选举委员会的谴责。

---

## 🚀 科技动态 (2条) 🟡

### 1. [研究显示火箭发射如何污染大气层](https://arstechnica.com/space/2026/02/study-shows-how-rocket-launches-pollute-the-atmosphere/)
**来源**: Ars Technica | **时间**: 02-22 11:20

全球大气公共领域是否注定要成为工业废物倾倒场？

---

### 2. [NASA 表示需要将 Artemis II 火箭拉回机库进行维修](https://arstechnica.com/space/2026/02/nasa-says-it-needs-to-haul-the-artemis-ii-rocket-back-to-the-hangar-for-repairs/)
**来源**: Ars Technica | **时间**: 02-21 23:54

访问和修复这些问题只能在火箭组装大楼（VAB）中进行。

---

## 🔑 苹果 / App Store (1条) 🟢

### 1. [欧盟替代应用商店一览](https://techcrunch.com/2026/02/22/move-over-apple-meet-the-alternative-app-stores-available-in-the-eu-and-elsewhere/)
**来源**: TechCrunch | **时间**: 02-22 09:00

欧盟 iPhone 用户今天可以尝试的一些替代应用商店列表。

---

## 📊 简报统计

| 指标 | 数值 |
|------|------|
| 关注主题 | 5 个 |
| 新闻总数 | 12 条 |
| 涉及来源 | 5 个 |

---

💡 **提示**: 回复 `newsman add <关键词>` 添加新关注 | `newsman list` 查看列表 | `newsman run` 立即执行

---
*📅 每日简报由 Claw-News 自动生成*


path: output/digest_20260222_2206.md
content: # 📰 Claw-News 每日简报

**生成时间**: 2026年02月22日 22:06  
**数据来源**: RSS 订阅 (TechCrunch, BBC Tech, Ars Technica, The Verge)

---

## 🔥 今日热点

### 1. Microsoft Copilot 泄露机密邮件
📰 BBC Tech  
微软确认其 AI 工具 Copilot 出现错误，导致机密邮件被意外暴露。公司表示已修复该问题，且未造成未授权访问。

### 2. 火箭发射对大气层的影响
🚀 Ars Technica  
新研究显示火箭发射对大气层造成污染，引发关于全球大气层作为工业废物倾倒场的担忧。

### 3. 欧盟 iPhone 第三方应用商店指南
📱 TechCrunch  
苹果在欧盟被迫开放第三方应用商店，TechCrunch 整理了可供 iPhone 用户尝试的替代应用商店列表。

### 4. NASA Artemis II 火箭需返厂维修
🌙 Ars Technica  
NASA 表示需要将 Artemis II 火箭运回装配大楼进行维修，相关修复工作只能在 VAB 内进行。

### 5. Sam Altman 回应 AI 能耗争议
🤖 TechCrunch  
OpenAI CEO Sam Altman 针对 AI 训练和运行的高能耗问题回应称："训练人类也需要大量能源"。

### 6. Wikipedia 封禁 Archive.today
📚 TechCrunch  
维基百科编辑决定移除所有指向 Archive.today（网络归档服务）的链接，据称该网站涉嫌 DDoS 攻击。

### 7. 微软游戏 CEO 承诺不滥用 AI
🎮 TechCrunch  
微软新任游戏 CEO 誓言不会让游戏生态充斥"无尽的 AI 垃圾内容"。

### 8. Google VP 警告两类 AI 初创公司恐难存活
⚠️ TechCrunch  
Google 副总裁警告：随着生成式 AI 发展，单纯的大模型包装器和 AI 聚合器面临生存压力，利润微薄且缺乏差异化。

### 9. OpenAI 曾考虑举报加拿大枪手聊天记录
🚨 TechCrunch  
加拿大 Tumbler Ridge 校园枪击案嫌疑人在作案前曾与 ChatGPT 讨论暴力内容，OpenAI 内部曾就是否报警进行讨论。

### 10. 印度 Sarvam 推出 Indus AI 聊天应用
🇮🇳 TechCrunch  
印度 AI 公司 Sarvam 推出 Indus AI 聊天应用，目前已开放 Beta 测试。

### 11. 马斯克 PAC 被指违反选举法
⚖️ The Verge  
乔治亚州选举委员会谴责马斯克的 America PAC，因其发送预填写的缺席投票申请表，涉嫌违反州选举法。

### 12. Google AI 负责人呼吁加强 AI 威胁研究
🔬 BBC Tech  
Google AI 负责人呼吁进行紧急研究以应对 AI 威胁，但美国代表团在 AI 影响峰会上表示"完全拒绝全球 AI 治理"。

---

## 📊 关注列表概览

当前关注 **5 个兴趣点**:

| 兴趣 | 类型 | 优先级 |
|------|------|--------|
| 🤖 人工智能 | 话题 | 高 |
| 💻 科技动态 | 话题 | 中 |
| 🚀 马斯克 | 人物 | 中 |
| ⛓️ 区块链 | 话题 | 中 |
| 🎯 Kickstarter | 来源 | 中 |

---

## 💡 使用提示

- 使用 `newsman add <关键词>` 添加更多关注
- 使用 `newsman list` 查看完整关注列表
- 使用 `newsman rss fetch` 手动抓取 RSS 新闻

---

*Powered by Claw-News* | 📡 RSS 模式 | 12 条精选新闻


path: output/news_20260222.json
content: {
  "fetched_at": "2026-02-22T22:06:30.627298",
  "total_items": 25,
  "sources": 1,
  "items": [
    {
      "title": "Microsoft error sees confidential emails exposed to AI tool Copilot",
      "link": "https://www.bbc.com/news/articles/c8jxevd8mdyo",
      "summary": "The company says it has addressed the issue and it did not provide anyone access to information they weren't already authorised to see.",
      "published": "Thu, 19 Feb 2026 18:16:43 GMT",
      "source": "bbc-tech",
      "category": "tech"
    },
    {
      "title": "Study shows how rocket launches pollute the atmosphere",
      "link": "https://arstechnica.com/space/2026/02/study-shows-how-rocket-launches-pollute-the-atmosphere/",
      "summary": "Is the global atmospheric commons destined to be an industrial waste dumping ground?",
      "published": "Sun, 22 Feb 2026 11:20:03 +0000",
      "source": "ars-technica",
      "category": "tech"
    },
    {
      "title": "Move over, Apple: Meet the alternative app stores available in the EU",
      "link": "https://techcrunch.com/2026/02/22/move-over-apple-meet-the-alternative-app-stores-available-in-the-eu-and-elsewhere/",
      "summary": "A list of some of the alternative app stores iPhone users in the EU can try today.",
      "published": "Sun, 22 Feb 2026 09:00:21 +0000",
      "source": "techcrunch",
      "category": "tech"
    },
    {
      "title": "NASA says it needs to haul the Artemis II rocket back to the hangar for repairs",
      "link": "https://arstechnica.com/space/2026/02/nasa-says-it-needs-to-haul-the-artemis-ii-rocket-back-to-the-hangar-for-repairs/",
      "summary": "Accessing and remediating any of these issues can only be performed in the VAB.",
      "published": "Sat, 21 Feb 2026 23:54:38 +0000",
      "source": "ars-technica",
      "category": "tech"
    },
    {
      "title": "Sam Altman would like remind you that humans use a lot of energy, too",
      "link": "https://techcrunch.com/2026/02/21/sam-altman-would-like-remind-you-that-humans-use-a-lot-of-energy-too/",
      "summary": "It also takes a lot of energy to train a human.",
      "published": "Sat, 21 Feb 2026 21:38:01 +0000",
      "source": "techcrunch",
      "category": "tech"
    },
    {
      "title": "Wikipedia blacklists Archive.today after alleged DDoS attack",
      "link": "https://techcrunch.com/2026/02/21/wikipedia-blacklists-archive-today-after-alleged-ddos-attack/",
      "summary": "Wikipedia editors have decided to remove all links to Archive.today, a web archiving service that they said has been linked to more than 695,000 times across the online encyclopedia.",
      "published": "Sat, 21 Feb 2026 20:20:15 +0000",
      "source": "techcrunch",
      "category": "tech"
    },
    {
      "title": "Microsoft's new gaming CEO vows not to flood the ecosystem with 'endless AI slop'",
      "link": "https://techcrunch.com/2026/02/21/microsofts-new-gaming-ceo-vows-not-to-flood-the-ecosystem-with-endless-ai-slop/",
      "summary": "Is Microsoft's gaming division doubling down on AI?",
      "published": "Sat, 21 Feb 2026 17:41:27 +0000",
      "source": "techcrunch",
      "category": "tech"
    },
    {
      "title": "Google VP warns that two types of AI startups may not survive",
      "link": "https://techcrunch.com/2026/02/21/google-vp-warns-that-two-types-of-ai-startups-may-not-survive/",
      "summary": "As generative AI evolves, a Google VP warns that LLM wrappers and AI aggregators face mounting pressure, with shrinking margins and limited differentiation threatening their long-term viability.",
      "published": "Sat, 21 Feb 2026 16:00:00 +0000",
      "source": "techcrunch",
      "category": "tech"
    },
    {
      "title": "OpenAI debated calling police about suspected Canadian shooter's chats",
      "link": "https://techcrunch.com/2026/02/21/openai-debated-calling-police-about-suspected-canadian-shooters-chats/",
      "summary": "Jesse Van Rootselaar's descriptions of gun violence were flagged by tools that monitor ChatGPT for misuse.",
      "published": "Sat, 21 Feb 2026 15:25:44 +0000",
      "source": "techcrunch",
      "category": "tech"
    },
    {
      "title": "India's Sarvam launches Indus AI chat app as competition heats up",
      "link": "https://techcrunch.com/2026/02/20/indias-sarvam-launches-indus-ai-chat-app-as-competition-heats-up/",
      "summary": "Sarvam's Indus chat app is currently available in beta.",
      "published": "Sat, 21 Feb 2026 01:00:00 +0000",
      "source": "techcrunch",
      "category": "tech"
    },
    {
      "title": "Georgia says Elon Musk's America PAC violated election law",
      "link": "https://www.theverge.com/tech/882838/georgia-elon-musk-america-pac-voter-fraud",
      "summary": "For all his bluster about voter fraud, Elon Musk has been one of the most flagrant flaunters of US election law. Now his America PAC has been slapped with a reprimand by the Georgia State Election Board.",
      "published": "2026-02-21T11:55:36-05:00",
      "source": "the-verge",
      "category": "tech"
    },
    {
      "title": "Urgent research needed to tackle AI threats, says Google AI boss",
      "link": "https://www.bbc.com/news/articles/c0q3g0ln274o",
      "summary": "But the head of the US delegation at the AI Impact Summit in Delhi says: We totally reject global governance of AI.",
      "published": "Fri, 20 Feb 2026 10:32:40 GMT",
      "source": "bbc-tech",
      "category": "tech"
    }
  ]
}


path: README.md
content: # Claw-News Skill

智能每日新闻简报生成工具，已完整接入 OpenClaw。

## 快速开始

### 1. 安装依赖

```bash
cd ~/.openclaw/workspace/skills/claw-news
pip install -r requirements.txt
```

### 2. 配置 API Keys

```bash
cp .env.example .env
# 编辑 .env 文件，添加你的 API Keys
```

### 3. 测试运行

```bash
# 查看关注列表
python scripts/newsman.py --mode list

# 搜索测试
python scripts/newsman.py --mode search -q "人工智能"

# 生成简报（预览模式）
python scripts/newsman.py --mode digest --dry-run
```

## OpenClaw 使用方式

### 用户交互命令

在 Slack 中直接发送消息：

```
@claw newsman add <关键词>              # 添加关注
@claw newsman add <人名> --type person  # 添加人名关注
@claw newsman remove <id>               # 删除关注
@claw newsman list                      # 查看关注列表
@claw newsman run                       # 立即执行简报生成
```

### Cron 定时任务

添加每日 9:00 定时简报：

```bash
openclaw cron add \
  --name "claw-news-daily" \
  --schedule "0 9 * * *" \
  --timezone "Asia/Shanghai" \
  --sessionTarget isolated \
  --payload '{"kind": "agentTurn", "message": "运行 claw-news skill 生成每日简报: python ~/.openclaw/workspace/skills/claw-news/scripts/newsman.py --mode digest", "model": "kimi-coding/k2p5"}'
```

或使用 cron 工具：

```python
cron.add(
    name="claw-news-daily",
    schedule="0 9 * * *",
    timezone="Asia/Shanghai",
    payload={
        "kind": "agentTurn",
        "message": "python ~/.openclaw/workspace/skills/claw-news/scripts/newsman.py --mode digest",
        "model": "kimi-coding/k2p5"
    }
)
```

## 项目结构

```
claw-news/
├── SKILL.md                    # Skill 主文档
├── README.md                   # 本文件
├── requirements.txt            # Python 依赖
├── .env.example               # 环境变量示例
├── scripts/                   # 核心脚本
│   ├── newsman.py            # 主入口
│   ├── interest_manager.py   # 兴趣管理
│   ├── search_engine.py      # 多 API 搜索
│   ├── result_aggregator.py  # 结果聚合
│   ├── digest_generator.py   # 简报生成
│   └── config.py             # 配置管理
├── references/               # API 文档
│   ├── kimi_api.md
│   ├── minimax_api.md
│   └── claude_api.md
├── assets/                   # 资源文件
│   └── digest_template.md   # 简报模板
├── data/                    # 数据文件
│   ├── interest_list.json   # 关注列表
│   └── search_cache.json    # 搜索缓存
└── output/                  # 输出目录
```

## 已实现功能

- [x] 多 API 搜索轮询 (Kimi → MiniMax → Claude)
- [x] 智能去重和结果聚合
- [x] Markdown 简报生成
- [x] 关注列表管理 (添加/删除/查看)
- [x] 优先级分类
- [x] 文件缓存和输出
- [x] 配置管理

## 待优化

- [ ] Slack 消息推送集成
- [ ] 更智能的摘要生成 (LLM 优化)
- [ ] 相似度去重算法优化
- [ ] Web UI 管理界面


path: references/claude_api.md
content: # Claude API 参考文档

## 基本信息

- **API Base URL**: `https://api.anthropic.com`
- **文档**: https://docs.anthropic.com

## 认证

在请求头中添加:
```
x-[REDACTED_SECRET]
anthropic-version: 2023-06-01
```

## 联网搜索

Claude 3.5 Sonnet 支持 Web Search 工具:

```python
import anthropic

client = anthropic.Anthropic([REDACTED_SECRET]")

response = client.messages.create(
    model="claude-3-5-sonnet-20241022",
    max_tokens=4096,
    tools=[{"type": "web_search"}],
    messages=[{
        "role": "user",
        "content": "搜索关于人工智能的最新新闻"
    }]
)
```

## 支持的模型

| 模型 | 描述 |
|------|------|
| `claude-3-5-sonnet-20241022` | 支持联网搜索 |
| `claude-3-opus-20240229` | 旗舰模型 |

## 环境变量

```bash
export ANTHROPIC_API_KEY=sk-ant-xxx
```

## 响应格式

```python
{
  "content": [
    {"type": "text", "text": "搜索结果..."}
  ]
}
```

## 注意事项

- Claude API 需要特定模型版本才支持联网
- 部分地区的 API Key 可能不支持 web_search 工具


path: references/kimi_api.md
content: # Kimi API 参考文档

## 基本信息

- **API Base URL**: `https://api.moonshot.cn/v1`
- **文档**: https://platform.moonshot.cn/docs

## 认证

在请求头中添加:
```
Authorization: Bearer {KIMI_API_KEY}
```

## 联网搜索

Kimi K2.5 支持内置联网搜索工具:

```python
from openai import OpenAI

client = OpenAI(
    [REDACTED_SECRET]",
    base_url="https://api.moonshot.cn/v1"
)

response = client.chat.completions.create(
    model="kimi-k2-5",
    messages=[
        {"role": "system", "content": "你是一个新闻搜索助手..."},
        {"role": "user", "content": "搜索关于人工智能的最新新闻"}
    ],
    tools=[{
        "type": "builtin_function",
        "function": {"name": "web_search"}
    }]
)
```

## 支持的模型

| 模型 | 描述 |
|------|------|
| `kimi-k2-5` | 旗舰模型，支持联网 |
| `kimi-k2` | 标准模型 |

## 环境变量

```bash
export KIMI_API_KEY=sk-xxx
```

## 响应格式

模型会返回搜索结果的自然语言描述，通常包含:
- 新闻标题
- 来源网站
- URL 链接
- 发布时间
- 内容摘要


path: references/minimax_api.md
content: # MiniMax API 参考文档

## 基本信息

- **API Base URL**: `https://api.minimax.chat/v1`
- **文档**: https://www.minimaxi.com/document

## 认证

在请求头中添加:
```
Authorization: Bearer {MINIMAX_API_KEY}
```

## 联网搜索

MiniMax M2.5 支持联网搜索:

```python
import requests

response = requests.post(
    "https://api.minimax.chat/v1/text/chatcompletion_v2",
    headers={"Authorization": f"Bearer {MINIMAX_API_KEY}"},
    json={
        "model": "MiniMax-M2.5",
        "messages": [
            {"role": "system", "content": "搜索最新新闻..."},
            {"role": "user", "content": "搜索人工智能新闻"}
        ],
        "tools": [{"type": "web_search"}]
    }
)
```

## 支持的模型

| 模型 | 描述 |
|------|------|
| `MiniMax-M2.5` | 旗舰模型，支持联网 |

## 环境变量

```bash
export MINIMAX_API_KEY=xxx
```

## 响应格式

```json
{
  "choices": [{
    "message": {
      "content": "搜索结果..."
    }
  }]
}
```


path: references/rss_examples.md
content: # Newsman Skill - 使用示例

## 快速开始

### 1. 手动运行

```bash
# 获取最新科技新闻
python scripts/fetch_news.py --category tech --max-items 5 --output tech_news.json

# 生成摘要
python scripts/summarize.py --input tech_news.json --output tech_summaries.json

# 创建 Markdown 简报
python scripts/digest.py --input tech_summaries.json --format markdown --output tech_digest.md
```

### 2. 在 OpenClaw 中使用

```
获取今天的科技新闻
生成一个科技新闻简报
搜索关于 AI 的新闻
```

### 3. 设置定时简报 (Cron)

```bash
# 每天早上 8 点发送科技简报
openclaw cron add \
  --name "morning-tech-digest" \
  --schedule "0 8 * * *" \
  --command "python skills/newsman/scripts/fetch_news.py --category tech --max-items 10 | python skills/newsman/scripts/summarize.py | python skills/newsman/scripts/digest.py --format slack"

# 每 4 小时获取财经新闻
openclaw cron add \
  --name "finance-updates" \
  --schedule "0 */4 * * *" \
  --command "python skills/newsman/scripts/fetch_news.py --category finance --hours 4"
```

## 输出格式

### Markdown (默认)
适合阅读、邮件、文档

### JSON
适合程序处理、API 响应

### Slack
适合发送到 Slack 频道

### Plain Text
适合短信、命令行显示

## 添加自定义新闻源

编辑 `references/sources.md`，添加新的 RSS 源：

```yaml
- name: my-custom-source
  url: https://example.com/feed.xml
  category: tech
  language: en
```

## 集成到消息推送

```python
# 示例：获取新闻并通过 Slack 发送
import subprocess
import json

# Fetch news
result = subprocess.run(
    ['python', 'scripts/fetch_news.py', '--category', 'tech', '--max-items', '5'],
    capture_output=True, text=True
)
news = json.loads(result.stdout)

# Generate digest digest_result = subprocess.run(     ['python', 'scripts/digest.py', '--format', 'slack'],
    input=result.stdout, capture_output=True, text=True
)

# Send via OpenClaw message tool
# (use message tool to send to Slack/Telegram/Discord)
```

---

**文件位置**: `skills/newsman/`
**打包文件**: `skills/newsman.skill`


path: references/rss_sources.md
content: # News Sources Configuration

Default RSS/Atom sources for the newsman skill.

Add, remove, or modify sources as needed. Each source needs:
- `name`: Unique identifier
- `url`: RSS/Atom feed URL
- `category`: Category for filtering (tech, finance, world, science, sports)
- `language`: Language code (optional, default: en)

## Technology

```yaml
- name: techcrunch
  url: https://techcrunch.com/feed/
  category: tech
  language: en

- name: the-verge
  url: https://www.theverge.com/rss/index.xml
  category: tech
  language: en

- name: ars-technica
  url: http://feeds.arstechnica.com/arstechnica/index
  category: tech
  language: en

- name: wired
  url: https://www.wired.com/feed/rss
  category: tech
  language: en

- name: bbc-tech
  url: http://feeds.bbci.co.uk/news/technology/rss.xml
  category: tech
  language: en
```

## Finance

```yaml
- name: bloomberg
  url: https://feeds.bloomberg.com/bloomberg/markets.rss
  category: finance
  language: en

- name: reuters-finance
  url: https://www.reutersagency.com/feed/?taxonomy=markets&post_type=reuters-best
  category: finance
  language: en

- name: coindesk
  url: https://www.coindesk.com/arc/outboundfeeds/rss/
  category: finance
  language: en
```

## World News

```yaml
- name: bbc-world
  url: http://feeds.bbci.co.uk/news/world/rss.xml
  category: world
  language: en

- name: reuters-world
  url: https://www.reutersagency.com/feed/?taxonomy=markets&post_type=reuters-best
  category: world
  language: en
```

## Science

```yaml
- name: nature-news
  url: https://www.nature.com/nature.rss
  category: science
  language: en

- name: scientific-american
  url: https://www.scientificamerican.com/rss/news.cfm
  category: science
  language: en

- name: arxiv-cs
  url: http://export.arxiv.org/rss/cs
  category: science
  language: en
```

## Sports

```yaml
- name: espn
  url: https://www.espn.com/espn/rss/news
  category: sports
  language: en

- name: bbc-sport
  url: http://feeds.bbci.co.uk/sport/rss.xml
  category: sports
  language: en
```

## Chinese Sources

```yaml
- name: 36kr
  url: https://36kr.com/feed
  category: tech
  language: zh

- name: solidot
  url: https://www.solidot.org/index.rss
  category: tech
  language: zh
```

## Notes

- Some feeds may require custom parsing due to format variations
- Rate limiting: Most RSS servers accept 1 request per minute per feed
- Cache recommended: Store fetched items to avoid re-fetching
- Fallback: Use `web_search` or `web_fetch` for sites without RSS


path: references/rss_templates.md
content: # Output Templates

Templates for formatting news digests.

## Markdown Digest Template

```markdown
# 📰 News Digest

*Generated: {timestamp}*

## {category}

{articles}

---

### {title}
**Source:** {source} | **Time:** {published}

{summary}

[Read more]({link})

---

*Powered by Newsman*
```

## Slack Message Template

```json
{
  "blocks": [
    {
      "type": "header",
      "text": {
        "type": "plain_text",
        "text": "📰 News Digest - {category}",
        "emoji": true
      }
    },
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*{title}*\n{summary}\n\n<{link}|Read more> · {source}"
      }
    },
    {
      "type": "divider"
    }
  ]
}
```

## Plain Text Template

```
📰 News Digest - {category}
Generated: {timestamp}

━━━━━━━━━━━━━━━━━━━━

{title}
Source: {source} | {published}

{summary}

Link: {link}

━━━━━━━━━━━━━━━━━━━━

Powered by Newsman
```

## JSON Output Format

```json
{
  "digest": {
    "generated_at": "{timestamp}",
    "category": "{category}",
    "total_items": {count},
    "articles": [
      {
        "title": "{title}",
        "summary": "{summary}",
        "source": "{source}",
        "published": "{published}",
        "link": "{link}",
        "category": "{category}"
      }
    ]
  }
}
```

## Customization

Edit the templates above to match your preferred output format.
Available placeholders:
- `{timestamp}` - Generation time
- `{category}` - News category
- `{title}` - Article title
- `{summary}` - Article summary
- `{source}` - Source name
- `{published}` - Publication time
- `{link}` - Article URL
- `{count}` - Total article count


path: requirements.txt
content: openai>=1.0.0
anthropic>=0.18.0
requests>=2.31.0
python-dateutil>=2.8.0
beautifulsoup4>=4.12.0


path: rss/config.toml
content: # Newsman Configuration
# Kimi Coding API 配置 (Allegretto 订阅)

[api]
# API 类型: kimi | openai | local
provider = "kimi"

[kimi]
# Kimi API 配置
# 使用 Moonshot AI 开放平台 (中国区)
base_url = "https://api.moonshot.cn/v1"
# 或使用 Kimi Code 平台
# base_url = "https://api.kimi.com/coding/v1"

# API Key - 从环境变量读取 (推荐)
# 在环境变量中设置: $env:KIMI_API_KEY="your-api-key"
[REDACTED_SECRET]"

# 模型选择
# 可选: moonshot-v1-8k, moonshot-v1-32k, moonshot-v1-128k
# 或 kimi-k2, kimi-for-coding 等
model = "moonshot-v1-8k"

# 摘要生成参数
max_tokens = 500
temperature = 0.3

[summarization]
# 默认摘要方法: extractive | api
default_method = "api"

# 摘要长度 (词数)
max_length = 150

# 是否生成中文摘要
chinese_output = true

[cache]
# 缓存目录
dir = "./.cache/newsman"

# 缓存过期时间 (小时)
expire_hours = 24


path: rss/kimi_client.py
content: """
Kimi API Client for Newsman
Supports Allegretto subscription and various Moonshot models.
"""

import os
import sys
import json
import urllib.request
import urllib.error
from typing import List, Dict, Optional
from pathlib import Path


def load_config(config_path: Optional[str] = None) -> Dict:
    """Load configuration from TOML file."""
    if config_path is None:
        # 查找配置文件
        script_dir = Path(__file__).parent.parent
        config_path = script_dir / "config" / "config.toml"
    
    config = {
        'api': {'provider': 'local'},
        'kimi': {
            'base_url': 'https://api.moonshot.cn/v1',
            'api_key': '',
            'model': 'moonshot-v1-8k',
            'max_tokens': 500,
            'temperature': 0.3
        },
        'summarization': {
            'default_method': 'extractive',
            'max_length': 150,
            'chinese_output': True
        }
    }
    
    # 解析 TOML (简化版)
    if Path(config_path).exists():
        with open(config_path, 'r', encoding='utf-8') as f:
            content = f.read()
        
        current_section = None
        for line in content.split('\n'):
            line = line.strip()
            if not line or line.startswith('#'):
                continue
            
            if line.startswith('[') and line.endswith(']'):
                current_section = line[1:-1]
                continue
            
            if '=' in line and current_section:
                key, value = line.split('=', 1)
                key = key.strip()
                value = value.strip().strip('"').strip("'")
                
                # 处理环境变量
                if value.startswith('${') and value.endswith('}'):
                    env_var = value[2:-1]
                    value = os.getenv(env_var, '')
                
                if current_section in config:
                    config[current_section][key] = value
    
    # 从环境变量覆盖
    if os.getenv('KIMI_API_KEY'):
        config['kimi']['api_key'] = os.getenv('KIMI_API_KEY')
    if os.getenv('KIMI_BASE_URL'):
        config['kimi']['base_url'] = os.getenv('KIMI_BASE_URL')
    if os.getenv('KIMI_MODEL'):
        config['kimi']['model'] = os.getenv('KIMI_MODEL')
    
    return config


def create_summary_prompt(title: str, content: str, max_words: int = 150, chinese: bool = True) -> str:
    """Create prompt for news summarization."""
    if chinese:
        return f"""请为以下新闻生成简洁的摘要，限制在 {max_words} 个词以内：

标题：{title}

内容：
{content}

请生成一个简洁、准确的中文摘要，突出关键信息："""
    else:
        return f"""Please generate a concise summary of the following news article, limited to {max_words} words:

Title: {title}

Content:
{content}

Generate a brief, accurate summary highlighting the key information:"""


def summarize_with_kimi(title: str, content: str, config: Dict) -> str:
    """Summarize news using Kimi API."""
    kimi_config = config.get('kimi', {})
    
    [REDACTED_SECRET]'api_key', '')
    base_url = kimi_config.get('base_url', 'https://api.moonshot.cn/v1')
    model = kimi_config.get('model', 'moonshot-v1-8k')
    max_tokens = int(kimi_config.get('max_tokens', 500))
    temperature = float(kimi_config.get('temperature', 0.3))
    chinese = config.get('summarization', {}).get('chinese_output', True)
    max_words = config.get('summarization', {}).get('max_length', 150)
    
    if not api_key:
        raise ValueError("Kimi API key not configured. Set KIMI_API_KEY environment variable.")
    
    # 构建请求
    prompt = create_summary_prompt(title, content, max_words, chinese)
    
    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {api_key}'
    }
    
    data = {
        'model': model,
        'messages': [
            {'role': 'system', 'content': '你是一个专业的新闻摘要助手。请生成简洁、准确的新闻摘要。'},
            {'role': 'user', 'content': prompt}
        ],
        'max_tokens': max_tokens,
        'temperature': temperature
    }
    
    req = urllib.request.Request(
        f'{base_url}/chat/completions',
        data=json.dumps(data).encode('utf-8'),
        headers=headers,
        method='POST'
    )
    
    try:
        with urllib.request.urlopen(req, timeout=60) as response:
            result = json.loads(response.read().decode('utf-8'))
            return result['choices'][0]['message']['content'].strip()
    except urllib.error.HTTPError as e:
        error_body = e.read().decode('utf-8')
        raise Exception(f"Kimi API error: {e.code} - {error_body}")
    except Exception as e:
        raise Exception(f"Failed to call Kimi API: {e}")


def batch_summarize_with_kimi(items: List[Dict], config: Dict) -> List[Dict]:
    """Summarize multiple items using Kimi API."""
    results = []
    max_words = config.get('summarization', {}).get('max_length', 150)
    
    for item in items:
        title = item.get('title', '')
        content = item.get('summary', '') or item.get('description', '')
        
        # 合并标题和内容进行摘要
        full_text = f"{title}\n\n{content}" if content else title
        
        try:
            summary = summarize_with_kimi(title, full_text, config)
            item['ai_summary'] = summary
            item['summary_method'] = 'kimi-api'
        except Exception as e:
            # 如果 API 失败，保留原始内容
            item['ai_summary'] = content[:200] + '...' if len(content) > 200 else content
            item['summary_method'] = 'fallback'
            item['summary_error'] = str(e)
        
        results.append(item)
    
    return results


if __name__ == '__main__':
    # 测试 API 连接
    config = load_config()
    
    print("Kimi API Configuration:")
    print(f"  Base URL: {config['kimi']['base_url']}")
    print(f"  Model: {config['kimi']['model']}")
    print(f"  API Key: {'*' * 10 if config['kimi']['api_key'] else 'NOT SET'}")
    
    if config['kimi']['api_key']:
        # 测试 API 调用
        test_item = {
            'title': 'Test News Article',
            'summary': 'This is a test article to verify the Kimi API connection is working properly.'
        }
        
        try:
            result = batch_summarize_with_kimi([test_item], config)
            print("\n✅ API test successful!")
            print(f"Generated summary: {result[0].get('ai_summary', 'N/A')}")
        except Exception as e:
            print(f"\n❌ API test failed: {e}")
            sys.exit(1)
    else:
        print("\n⚠️ API key not configured. Set KIMI_API_KEY environment variable.")


path: rss/rss_digest.py
content: #!/usr/bin/env python3
"""
Digest Generator - Format news into readable digests.

Usage:
    python digest.py [options]

Options:
    --input FILE        Input JSON file from fetch_news.py or summarize.py
    --output FILE       Output file (default: stdout)
    --format FORMAT     Output format: markdown|json|text|slack (default: markdown)
    --category CAT      Filter by category
    --limit N           Max items to include (default: 10)
    --title TITLE       Custom digest title
    --template FILE     Custom template file
    --verbose, -v       Verbose output
"""

import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, List


def get_markdown_template() -> str:
    """Default markdown template."""
    return """# {title}

*Generated: {timestamp}*

{content}

---

*Powered by Newsman* | {total} articles
"""


def get_text_template() -> str:
    """Default plain text template."""
    return """{title}
Generated: {timestamp}

{content}

---
Powered by Newsman | {total} articles
"""


def get_json_template_structure() -> Dict:
    """Default JSON structure."""
    return {
        "digest": {
            "title": "{title}",
            "generated_at": "{timestamp}",
            "total_articles": "{total}",
            "articles": []
        }
    }


def get_slack_template() -> str:
    """Default Slack Block Kit template."""
    return """{{
  "blocks": [
    {{
      "type": "header",
      "text": {{
        "type": "plain_text",
        "text": "{title}",
        "emoji": true
      }}
    }},
    {{
      "type": "section",
      "text": {{
        "type": "mrkdwn",
        "text": "*Generated:* {timestamp}\\n*{total} articles*"
      }}
    }},{content}
  ]
}}"""


def format_article_markdown(item: Dict, index: int) -> str:
    """Format single article as markdown."""
    title = item.get('title', 'Untitled')
    source = item.get('source', 'Unknown')
    published = item.get('published', '')
    link = item.get('link', '')
    
    # Use AI summary if available, otherwise use original summary
    summary = item.get('ai_summary', item.get('summary', ''))
    if not summary:
        summary = '*No summary available*'
    
    return f"""### {index}. {title}

**Source:** {source} | **Published:** {published}

{summary}

[Read more]({link})

---
"""


def format_article_text(item: Dict, index: int) -> str:
    """Format single article as plain text."""
    title = item.get('title', 'Untitled')
    source = item.get('source', 'Unknown')
    published = item.get('published', '')
    link = item.get('link', '')
    
    summary = item.get('ai_summary', item.get('summary', ''))
    if not summary:
        summary = 'No summary available'
    
    return f"""━━━━━━━━━━━━━━━━━━━━
{index}. {title}
Source: {source} | Published: {published}

{summary}

Link: {link}

"""


def format_article_slack(item: Dict, index: int) -> str:
    """Format single article as Slack Block Kit block."""
    title = item.get('title', 'Untitled')
    source = item.get('source', 'Unknown')
    link = item.get('link', '')
    
    summary = item.get('ai_summary', item.get('summary', ''))
    if not summary:
        summary = 'No summary available'
    
    # Truncate summary for Slack
    if len(summary) > 300:
        summary = summary[:297] + '...'
    
    return f"""    {{
      "type": "section",
      "text": {{
        "type": "mrkdwn",
        "text": "*{index}. {title}*\\n{summary}\\n\\nSource: {source} | <{link}|Read more>"
      }}
    }},
    {{
      "type": "divider"
    }}"""


def generate_markdown_digest(items: List[Dict], title: str) -> str:
    """Generate markdown format digest."""
    template = get_markdown_template()
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M')
    
    articles_content = '\n'.join(
        format_article_markdown(item, i + 1) for i, item in enumerate(items)
    )
    
    return template.format(
        title=title,
        timestamp=timestamp,
        content=articles_content,
        total=len(items)
    )


def generate_text_digest(items: List[Dict], title: str) -> str:
    """Generate plain text format digest."""
    template = get_text_template()
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M')
    
    articles_content = '\n'.join(
        format_article_text(item, i + 1) for i, item in enumerate(items)
    )
    
    return template.format(
        title=title,
        timestamp=timestamp,
        content=articles_content,
        total=len(items)
    )


def generate_json_digest(items: List[Dict], title: str) -> str:
    """Generate JSON format digest."""
    output = {
        "digest": {
            "title": title,
            "generated_at": datetime.now().isoformat(),
            "total_articles": len(items),
            "articles": items
        }
    }
    return json.dumps(output, ensure_ascii=False, indent=2)


def generate_slack_digest(items: List[Dict], title: str) -> str:
    """Generate Slack Block Kit format digest."""
    template = get_slack_template()
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M')
    
    articles_content = ',\n'.join(
        format_article_slack(item, i + 1) for i, item in enumerate(items)
    )
    
    return template.format(
        title=title,
        timestamp=timestamp,
        content=articles_content,
        total=len(items)
    )


def get_default_title(category: str = None) -> str:
    """Generate default digest title."""
    now = datetime.now()
    date_str = now.strftime('%Y-%m-%d')
    
    if category:
        category_display = category.capitalize()
        return f"📰 {category_display} News Digest - {date_str}"
    return f"📰 Daily News Digest - {date_str}"


def filter_items(items: List[Dict], category: str = None) -> List[Dict]:
    """Filter items by category."""
    if not category:
        return items
    return [item for item in items if item.get('category') == category]


def main():
    parser = argparse.ArgumentParser(description='Generate news digest')
    parser.add_argument('--input', '-i', help='Input JSON file')
    parser.add_argument('--output', '-o', help='Output file (default: stdout)')
    parser.add_argument('--format', '-f', default='markdown',
                        choices=['markdown', 'md', 'text', 'json', 'slack'],
                        help='Output format')
    parser.add_argument('--category', '-c', help='Filter by category')
    parser.add_argument('--limit', '-n', type=int, default=10,
                        help='Max items to include')
    parser.add_argument('--title', '-t', help='Custom digest title')
    parser.add_argument('--template', help='Custom template file')
    parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
    
    args = parser.parse_args()
    
    # Read input
    if args.input:
        with open(args.input, 'r', encoding='utf-8') as f:
            data = json.load(f)
    else:
        data = json.load(sys.stdin)
    
    # Extract items
    if 'items' in data:
        items = data['items']
    elif 'digest' in data and 'articles' in data['digest']:
        items = data['digest']['articles']
    else:
        items = data if isinstance(data, list) else [data]
    
    # Filter by category
    if args.category:
        items = filter_items(items, args.category)
    
    # Limit items
    items = items[:args.limit]
    
    if args.verbose:
        print(f"Generating digest with {len(items)} items...", file=sys.stderr)
    
    # Get title
    title = args.title or get_default_title(args.category)
    
    # Generate digest
    format_map = {
        'md': 'markdown',
        'markdown': 'markdown',
        'text': 'text',
        'json': 'json',
        'slack': 'slack'
    }
    fmt = format_map.get(args.format, args.format)
    
    if fmt == 'markdown':
        output = generate_markdown_digest(items, title)
    elif fmt == 'text':
        output = generate_text_digest(items, title)
    elif fmt == 'json':
        output = generate_json_digest(items, title)
    elif fmt == 'slack':
        output = generate_slack_digest(items, title)
    else:
        output = generate_markdown_digest(items, title)
    
    # Write output
    if args.output:
        with open(args.output, 'w', encoding='utf-8') as f:
            f.write(output)
        if args.verbose:
            print(f"Digest written to: {args.output}", file=sys.stderr)
    else:
        print(output)


if __name__ == '__main__':
    main()


path: rss/rss_fetcher.py
content: #!/usr/bin/env python3
"""
News Fetcher - RSS/Atom feed aggregator for Newsman skill.

Usage:
    python fetch_news.py [options]

Options:
    --source NAME       Fetch specific source only
    --category CAT      Filter by category (tech, finance, world, science, sports)
    --search QUERY      Search for keywords in title/description
    --hours N           Only fetch items published within last N hours
    --max-items N       Max items per source (default: 10)
    --output FILE       Output JSON file (default: stdout)
    --cache-dir DIR     Cache directory (default: ./.cache/newsman)
    --no-cache          Disable caching
    --verbose, -v       Verbose output
"""

import argparse
import json
import os
import re
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional
from urllib.parse import urlparse

# Try to import feedparser, fallback to simple XML parsing
try:
    import feedparser
    HAS_FEEDPARSER = True
except ImportError:
    HAS_FEEDPARSER = False
    import xml.etree.ElementTree as ET

try:
    import requests
    HAS_REQUESTS = True
except ImportError:
    HAS_REQUESTS = False
    import urllib.request


def parse_date(date_str: str) -> Optional[datetime]:
    """Parse various date formats to datetime."""
    formats = [
        "%a, %d %b %Y %H:%M:%S %z",  # RFC 2822
        "%Y-%m-%dT%H:%M:%S%z",       # ISO 8601
        "%Y-%m-%dT%H:%M:%SZ",        # ISO 8601 UTC
        "%Y-%m-%d %H:%M:%S",         # Simple format
    ]
    
    for fmt in formats:
        try:
            return datetime.strptime(date_str, fmt)
        except ValueError:
            continue
    
    return None


def fetch_feed_requests(url: str, timeout: int = 30) -> str:
    """Fetch feed content using requests."""
    headers = {
        'User-Agent': 'Newsman/1.0 (OpenClaw News Aggregator)'
    }
    response = requests.get(url, headers=headers, timeout=timeout)
    response.raise_for_status()
    return response.text


def fetch_feed_urllib(url: str, timeout: int = 30) -> str:
    """Fetch feed content using urllib."""
    headers = {
        'User-Agent': 'Newsman/1.0 (OpenClaw News Aggregator)'
    }
    req = urllib.request.Request(url, headers=headers)
    with urllib.request.urlopen(req, timeout=timeout) as response:
        return response.read().decode('utf-8')


def fetch_feed(url: str) -> str:
    """Fetch feed content with fallback methods."""
    if HAS_REQUESTS:
        return fetch_feed_requests(url)
    return fetch_feed_urllib(url)


def parse_feed_feedparser(content: str) -> List[Dict]:
    """Parse feed using feedparser."""
    feed = feedparser.parse(content)
    items = []
    
    for entry in feed.entries:
        item = {
            'title': entry.get('title', ''),
            'link': entry.get('link', ''),
            'summary': entry.get('summary', entry.get('description', '')),
            'published': entry.get('published', entry.get('updated', '')),
            'author': entry.get('author', ''),
        }
        items.append(item)
    
    return items


def parse_feed_xml(content: str) -> List[Dict]:
    """Parse RSS/Atom feed using ElementTree."""
    items = []
    
    try:
        root = ET.fromstring(content)
    except ET.ParseError as e:
        print(f"Error parsing XML: {e}", file=sys.stderr)
        return items
    
    # Determine feed type
    if root.tag == 'rss' or root.tag.endswith('rss'):
        channel = root.find('.//channel')
        if channel is None:
            return items
        entries = channel.findall('item')
    else:  # Atom
        entries = root.findall('.//{http://www.w3.org/2005/Atom}entry')
        if not entries:
            entries = root.findall('.//entry')
    
    for entry in entries:
        item = {}
        
        title_elem = entry.find('title')
        item['title'] = title_elem.text if title_elem is not None else ''
        
        link_elem = entry.find('link')
        if link_elem is not None:
            item['link'] = link_elem.get('href', link_elem.text or '')
        else:
            item['link'] = ''
        
        desc_elem = entry.find('description')
        if desc_elem is None:
            desc_elem = entry.find('summary')
        item['summary'] = desc_elem.text if desc_elem is not None else ''
        
        date_elem = entry.find('pubDate')
        if date_elem is None:
            date_elem = entry.find('published')
        if date_elem is None:
            date_elem = entry.find('updated')
        item['published'] = date_elem.text if date_elem is not None else ''
        
        author_elem = entry.find('author')
        if author_elem is None:
            author_elem = entry.find('name')
        item['author'] = author_elem.text if author_elem is not None else ''
        
        items.append(item)
    
    return items


def parse_feed(content: str) -> List[Dict]:
    """Parse feed with fallback methods."""
    if HAS_FEEDPARSER:
        return parse_feed_feedparser(content)
    return parse_feed_xml(content)


def get_default_sources() -> List[Dict]:
    """Get default news sources."""
    return [
        {'name': 'techcrunch', 'url': 'https://techcrunch.com/feed/', 'category': 'tech', 'language': 'en'},
        {'name': 'the-verge', 'url': 'https://www.theverge.com/rss/index.xml', 'category': 'tech', 'language': 'en'},
        {'name': 'ars-technica', 'url': 'http://feeds.arstechnica.com/arstechnica/index', 'category': 'tech', 'language': 'en'},
        {'name': 'bbc-tech', 'url': 'http://feeds.bbci.co.uk/news/technology/rss.xml', 'category': 'tech', 'language': 'en'},
        {'name': 'bbc-world', 'url': 'http://feeds.bbci.co.uk/news/world/rss.xml', 'category': 'world', 'language': 'en'},
        {'name': 'scientific-american', 'url': 'https://www.scientificamerican.com/rss/news.cfm', 'category': 'science', 'language': 'en'},
        {'name': 'espn', 'url': 'https://www.espn.com/espn/rss/news', 'category': 'sports', 'language': 'en'},
    ]


def load_cache(cache_file: Path) -> List[Dict]:
    """Load cached news items."""
    if cache_file.exists():
        try:
            with open(cache_file, 'r', encoding='utf-8') as f:
                return json.load(f)
        except (json.JSONDecodeError, IOError):
            pass
    return []


def save_cache(cache_file: Path, items: List[Dict]):
    """Save news items to cache."""
    cache_file.parent.mkdir(parents=True, exist_ok=True)
    with open(cache_file, 'w', encoding='utf-8') as f:
        json.dump(items, f, ensure_ascii=False, indent=2)


def clean_html(html: str) -> str:
    """Remove HTML tags from text."""
    # Simple HTML tag removal
    clean = re.sub(r'<[^>]+>', '', html)
    # Decode common HTML entities
    clean = clean.replace('&nbsp;', ' ')
    clean = clean.replace('&amp;', '&')
    clean = clean.replace('&lt;', '<')
    clean = clean.replace('&gt;', '>')
    clean = clean.replace('&quot;', '"')
    return clean.strip()


def main():
    parser = argparse.ArgumentParser(description='Fetch news from RSS/Atom feeds')
    parser.add_argument('--source', help='Fetch specific source only')
    parser.add_argument('--category', help='Filter by category')
    parser.add_argument('--search', help='Search for keywords')
    parser.add_argument('--hours', type=int, help='Only items from last N hours')
    parser.add_argument('--max-items', type=int, default=10, help='Max items per source')
    parser.add_argument('--output', help='Output JSON file (default: stdout)')
    parser.add_argument('--cache-dir', default='./.cache/newsman', help='Cache directory')
    parser.add_argument('--no-cache', action='store_true', help='Disable caching')
    parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
    
    args = parser.parse_args()
    
    # Setup cache
    cache_file = None
    if not args.no_cache:
        cache_path = Path(args.cache_dir)
        cache_file = cache_path / 'fetched_news.json'
    
    # Load cached items
    cached_items = []
    if cache_file:
        cached_items = load_cache(cache_file)
    
    # Get sources
    sources = get_default_sources()
    
    if args.source:
        sources = [s for s in sources if s['name'] == args.source]
        if not sources:
            print(f"Source not found: {args.source}", file=sys.stderr)
            sys.exit(1)
    
    if args.category:
        sources = [s for s in sources if s['category'] == args.category]
    
    all_items = []
    cutoff_time = None
    if args.hours:
        cutoff_time = datetime.now() - timedelta(hours=args.hours)
    
    # Fetch from each source
    for source in sources:
        if args.verbose:
            print(f"Fetching: {source['name']} ({source['url']})", file=sys.stderr)
        
        try:
            content = fetch_feed(source['url'])
            items = parse_feed(content)
            
            # Add metadata
            for item in items[:args.max_items]:
                item['source'] = source['name']
                item['category'] = source['category']
                item['language'] = source.get('language', 'en')
                item['fetched_at'] = datetime.now().isoformat()
                
                # Clean HTML in summary
                if item.get('summary'):
                    item['summary'] = clean_html(item['summary'])
                
                # Check time filter
                if cutoff_time and item.get('published'):
                    pub_date = parse_date(item['published'])
                    if pub_date and pub_date < cutoff_time:
                        continue
                
                # Check search filter
                if args.search:
                    search_lower = args.search.lower()
                    text = f"{item.get('title', '')} {item.get('summary', '')}".lower()
                    if search_lower not in text:
                        continue
                
                all_items.append(item)
            
            if args.verbose:
                print(f"  -> Got {len(items[:args.max_items])} items", file=sys.stderr)
            
            # Rate limiting
            time.sleep(1)
            
        except Exception as e:
            if args.verbose:
                print(f"  -> Error: {e}", file=sys.stderr)
            continue
    
    # Merge with cache (deduplicate by link)
    seen_links = {item['link'] for item in all_items}
    for cached in cached_items:
        if cached['link'] not in seen_links:
            all_items.append(cached)
            seen_links.add(cached['link'])
    
    # Sort by published date (newest first)
    all_items.sort(key=lambda x: x.get('published', ''), reverse=True)
    
    # Save cache
    if cache_file:
        save_cache(cache_file, all_items)
    
    # Output
    output = {
        'fetched_at': datetime.now().isoformat(),
        'total_items': len(all_items),
        'sources': len(sources),
        'items': all_items
    }
    
    json_output = json.dumps(output, ensure_ascii=False, indent=2)
    
    if args.output:
        with open(args.output, 'w', encoding='utf-8') as f:
            f.write(json_output)
        if args.verbose:
            print(f"Output written to: {args.output}", file=sys.stderr)
    else:
        print(json_output)


if __name__ == '__main__':
    main()


path: rss/rss_summarize.py
content: #!/usr/bin/env python3
"""
News Summarizer - Generate AI-powered summaries for news articles.

This script can work in two modes:
1. Local mode: Uses simple extractive summarization (no external API)
2. API mode: Calls OpenClaw or external LLM for abstractive summaries

Usage:
    python summarize.py [options]

Options:
    --input FILE        Input JSON file from fetch_news.py
    --output FILE       Output JSON file (default: stdout)
    --max-length N      Max summary length in words (default: 150)
    --method METHOD     Summarization method: extractive|api (default: extractive)
    --fields FIELDS     Comma-separated fields to summarize (default: title,summary)
    --verbose, -v       Verbose output
"""

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List

# Import Kimi API client
try:
    from kimi_client import load_config, batch_summarize_with_kimi
    HAS_KIMI_CLIENT = True
except ImportError:
    HAS_KIMI_CLIENT = False


def clean_text(text: str) -> str:
    """Clean and normalize text."""
    # Remove HTML tags
    text = re.sub(r'<[^>]+>', ' ', text)
    # Normalize whitespace
    text = re.sub(r'\s+', ' ', text)
    # Decode HTML entities
    text = text.replace('&nbsp;', ' ')
    text = text.replace('&amp;', '&')
    text = text.replace('&lt;', '<')
    text = text.replace('&gt;', '>')
    text = text.replace('&quot;', '"')
    return text.strip()


def sentence_tokenize(text: str) -> List[str]:
    """Simple sentence tokenization."""
    # Split on sentence endings
    sentences = re.split(r'(?<=[.!?])\s+', text)
    return [s.strip() for s in sentences if s.strip()]


def word_tokenize(text: str) -> List[str]:
    """Simple word tokenization."""
    return text.split()


def calculate_sentence_scores(sentences: List[str]) -> Dict[int, float]:
    """Calculate importance scores for sentences using simple heuristics."""
    scores = {}
    
    # Word frequency
    word_freq = {}
    for sentence in sentences:
        words = word_tokenize(sentence.lower())
        for word in words:
            if len(word) > 3:  # Ignore short words
                word_freq[word] = word_freq.get(word, 0) + 1
    
    # Score each sentence
    for i, sentence in enumerate(sentences):
        score = 0
        words = word_tokenize(sentence.lower())
        
        # Frequency score
        for word in words:
            if word in word_freq:
                score += word_freq[word]
        
        # Position bonus (earlier sentences often more important)
        if i < 3:
            score *= 1.5
        
        # Length penalty (avoid very short/long sentences)
        word_count = len(words)
        if word_count < 5:
            score *= 0.5
        elif word_count > 50:
            score *= 0.8
        
        scores[i] = score / max(len(words), 1)
    
    return scores


def extractive_summarize(text: str, max_words: int = 150) -> str:
    """Generate extractive summary using sentence scoring."""
    text = clean_text(text)
    
    if not text:
        return ""
    
    sentences = sentence_tokenize(text)
    
    if len(sentences) <= 2:
        return text
    
    # Calculate scores
    scores = calculate_sentence_scores(sentences)
    
    # Sort by score, keep original order
    ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    
    # Select top sentences until max_words
    selected_indices = []
    total_words = 0
    
    for idx, score in ranked:
        sentence_words = len(word_tokenize(sentences[idx]))
        if total_words + sentence_words <= max_words:
            selected_indices.append(idx)
            total_words += sentence_words
        else:
            break
    
    # Sort by original position
    selected_indices.sort()
    
    # Reconstruct summary
    summary = ' '.join(sentences[i] for i in selected_indices)
    return summary


def summarize_item(item: Dict, max_words: int = 150, fields: List[str] = None) -> Dict:
    """Summarize a single news item."""
    if fields is None:
        fields = ['title', 'summary']
    
    # Combine fields to summarize
    text_parts = []
    for field in fields:
        if field in item and item[field]:
            text_parts.append(item[field])
    
    text = ' '.join(text_parts)
    
    if not text:
        return item
    
    # Generate summary
    summary = extractive_summarize(text, max_words)
    
    # Create result
    result = item.copy()
    result['ai_summary'] = summary
    result['summary_length'] = len(word_tokenize(summary))
    result['original_length'] = len(word_tokenize(text))
    result['compression_ratio'] = round(result['summary_length'] / max(result['original_length'], 1), 2)
    
    return result


def batch_summarize(items: List[Dict], max_words: int = 150, fields: List[str] = None) -> List[Dict]:
    """Summarize multiple news items."""
    results = []
    for i, item in enumerate(items):
        result = summarize_item(item, max_words, fields)
        results.append(result)
    return results


def format_summary_output(items: List[Dict]) -> Dict:
    """Format output with summary statistics."""
    total_original = sum(item.get('original_length', 0) for item in items)
    total_summary = sum(item.get('summary_length', 0) for item in items)
    
    return {
        'summarized_at': __import__('datetime').datetime.now().isoformat(),
        'total_items': len(items),
        'total_original_words': total_original,
        'total_summary_words': total_summary,
        'overall_compression': round(total_summary / max(total_original, 1), 2),
        'items': items
    }


def main():
    parser = argparse.ArgumentParser(description='Summarize news articles')
    parser.add_argument('--input', '-i', help='Input JSON file')
    parser.add_argument('--output', '-o', help='Output JSON file (default: stdout)')
    parser.add_argument('--max-length', '-l', type=int, default=150, help='Max summary length in words')
    parser.add_argument('--method', '-m', default='extractive', choices=['extractive', 'api'],
                        help='Summarization method')
    parser.add_argument('--fields', '-f', default='title,summary',
                        help='Comma-separated fields to summarize')
    parser.add_argument('--limit', '-n', type=int, help='Limit number of items to process')
    parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
    
    args = parser.parse_args()
    
    # Read input
    if args.input:
        with open(args.input, 'r', encoding='utf-8') as f:
            data = json.load(f)
    else:
        data = json.load(sys.stdin)
    
    # Extract items
    if 'items' in data:
        items = data['items']
    else:
        items = data if isinstance(data, list) else [data]
    
    if args.limit:
        items = items[:args.limit]
    
    if args.verbose:
        print(f"Processing {len(items)} items...", file=sys.stderr)
    
    # Parse fields
    fields = [f.strip() for f in args.fields.split(',')]
    
    # Summarize
    if args.method == 'extractive':
        results = batch_summarize(items, args.max_length, fields)
    else:
        # API method using Kimi
        if not HAS_KIMI_CLIENT:
            print("Kimi client not available, falling back to extractive", file=sys.stderr)
            results = batch_summarize(items, args.max_length, fields)
        else:
            try:
                config = load_config()
                if not config.get('kimi', {}).get('api_key'):
                    print("Kimi API key not configured, falling back to extractive", file=sys.stderr)
                    results = batch_summarize(items, args.max_length, fields)
                else:
                    if args.verbose:
                        print(f"Using Kimi API for summarization (model: {config['kimi']['model']})...", file=sys.stderr)
                    results = batch_summarize_with_kimi(items, config)
            except Exception as e:
                print(f"Kimi API error: {e}, falling back to extractive", file=sys.stderr)
                results = batch_summarize(items, args.max_length, fields)
    
    # Format output
    output = format_summary_output(results)
    
    # Write output
    json_output = json.dumps(output, ensure_ascii=False, indent=2)
    
    if args.output:
        with open(args.output, 'w', encoding='utf-8') as f:
            f.write(json_output)
        if args.verbose:
            print(f"Output written to: {args.output}", file=sys.stderr)
            print(f"Summary: {output['total_original_words']} -> {output['total_summary_words']} words", file=sys.stderr)
    else:
        print(json_output)


if __name__ == '__main__':
    main()


path: rss/SETUP.md
content: # Newsman Kimi API 配置指南

## 概述

Newsman 现已支持使用 Kimi Coding API (Allegretto 订阅) 进行 AI 驱动的新闻摘要生成。

## 快速配置

### 1. 设置 API Key

**方式一：环境变量 (推荐)**

```powershell
# PowerShell
$env:KIMI_API_KEY="your-allegretto-api-key"

# 永久设置 (用户级别)
[Environment]::SetEnvironmentVariable("KIMI_API_KEY", "your-allegretto-api-key", "User")
```

**方式二：修改配置文件**

编辑 `config/config.toml`：

```toml
[kimi]
[REDACTED_SECRET]"  # 直接填写 (不推荐用于共享环境)
```

### 2. 选择 API 端点

Allegretto 订阅支持以下端点：

| 平台 | Base URL | 说明 |
|------|----------|------|
| Moonshot AI (中国) | `https://api.moonshot.cn/v1` | 中国区 API |
| Kimi Code | `https://api.kimi.com/coding/v1` | Coding 专用 |

修改 `config/config.toml`：

```toml
[kimi]
base_url = "https://api.moonshot.cn/v1"  # 或 "https://api.kimi.com/coding/v1"
```

### 3. 选择模型

| 模型 | 上下文长度 | 适用场景 |
|------|-----------|----------|
| `moonshot-v1-8k` | 8K | 短新闻摘要 (推荐) |
| `moonshot-v1-32k` | 32K | 长文章摘要 |
| `moonshot-v1-128k` | 128K | 超长文档 |
| `kimi-k2` | 256K | 深度分析 |
| `kimi-for-coding` | 256K | 技术内容 |

### 4. 测试配置

```bash
# 测试 API 连接
cd scripts
python kimi_client.py

# 测试摘要功能
python summarize.py --method api --verbose
```

## 使用方法

### 命令行使用

```bash
# 使用 API 模式生成摘要
python scripts/summarize.py --input news.json --method api --output summaries.json

# 生成中文摘要 (默认)
python scripts/summarize.py --input news.json --method api -v

# 获取科技新闻摘要
python scripts/fetch_news.py --category tech --output tech_news.json
python scripts/summarize.py --input tech_news.json --method api --output tech_summary.json
```

### 完整工作流

```bash
# 1. 获取新闻
python scripts/fetch_news.py --hours 24 --output news.json

# 2. AI 摘要 (Kimi API)
python scripts/summarize.py --input news.json --method api --output news_summary.json

# 3. 生成 Markdown 简报
python scripts/digest.py --input news_summary.json --format markdown --output digest.md
```

## 配置选项

编辑 `config/config.toml`：

```toml
[api]
provider = "kimi"  # 使用 Kimi API

[kimi]
base_url = "https://api.moonshot.cn/v1"
[REDACTED_SECRET]"  # 从环境变量读取
model = "moonshot-v1-8k"     # 模型选择
max_tokens = 500             # 最大生成 token 数
temperature = 0.3            # 创造性 (0-1)

[summarization]
default_method = "api"       # 默认使用 API
max_length = 150             # 摘要长度 (词数)
chinese_output = true        # 生成中文摘要
```

## 故障排除

### API Key 无效

```
❌ API test failed: Kimi API error: 401 - {"error":"Unauthorized"}
```

**解决**: 检查 `KIMI_API_KEY` 环境变量是否正确设置

### 模型不存在

```
❌ API test failed: Kimi API error: 404 - {"error":"Model not found"}
```

**解决**: 确认 Allegretto 订阅包含该模型访问权限

### 速率限制

```
❌ API test failed: Kimi API error: 429 - Rate limit exceeded
```

**解决**: Allegretto 订阅有较高的速率限制，如仍触发可添加延迟重试

## Allegretto 订阅权益

作为次顶配订阅，Allegretto 提供：

- ✅ 高速 API 访问
- ✅ 更高的请求频率限制
- ✅ 优先模型访问
- ✅ 专业技术支持

如需查看具体配额，请访问 [Moonshot AI 控制台](https://platform.moonshot.cn/)。

## 更多信息

- [Kimi CLI 文档](https://moonshotai.github.io/kimi-cli/)
- [Moonshot API 文档](https://platform.moonshot.cn/docs/)


path: scripts/config.py
content: """
Claw-News Configuration Manager
配置管理模块
"""

import os
import json
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import List, Optional


def _load_dotenv():
    """从 .env 文件加载环境变量"""
    env_file = Path(__file__).parent.parent / ".env"
    if env_file.exists():
        with open(env_file, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, value = line.split("=", 1)
                    key = key.strip()
                    value = value.strip().strip('"').strip("'")
                    if key and value and key not in os.environ:
                        os.environ[key] = value


# 模块加载时执行
_load_dotenv()


@dataclass
class Interest:
    """关注项数据结构"""
    id: str
    type: str  # 'topic', 'person', 'keyword'
    value: str
    keywords: List[str] = field(default_factory=list)
    priority: str = "medium"  # 'high', 'medium', 'low'
    sources: List[str] = field(default_factory=lambda: ["all"])
    created_at: Optional[str] = None
    
    def to_dict(self):
        return asdict(self)
    
    @classmethod
    def from_dict(cls, data):
        return cls(**data)


@dataclass
class Settings:
    """全局设置"""
    daily_digest_time: str = "09:00"
    timezone: str = "Asia/Shanghai"
    max_results_per_interest: int = 10
    search_lookback_hours: int = 24
    delivery_channel: str = "slack"
    slack_channel: str = "#general"
    api_timeout: int = 30
    enable_fallback: bool = True
    
    def to_dict(self):
        return asdict(self)


class Config:
    """配置管理器"""
    
    # 路径配置
    BASE_DIR = Path(__file__).parent.parent
    DATA_DIR = BASE_DIR / "data"
    INTEREST_FILE = DATA_DIR / "interest_list.json"
    CACHE_FILE = DATA_DIR / "search_cache.json"
    
    # API 优先级（依次尝试）
    API_PRIORITY = ["minimax", "tavily"]
    
    def __init__(self):
        self._ensure_dirs()
        self._api_keys = self._load_api_keys()
        self._settings = self._load_settings()
    
    def _ensure_dirs(self):
        """确保目录存在"""
        self.DATA_DIR.mkdir(parents=True, exist_ok=True)
    
    def _load_api_keys(self) -> dict:
        """从环境变量加载 API Keys"""
        return {
            "minimax": os.getenv("MINIMAX_API_KEY", ""),
            "tavily": os.getenv("TAVILY_API_KEY", ""),
        }
    
    def _load_settings(self) -> Settings:
        """从环境变量加载设置"""
        return Settings(
            daily_digest_time=os.getenv("DAILY_DIGEST_TIME", "09:00"),
            timezone=os.getenv("TIMEZONE", "Asia/Shanghai"),
            max_results_per_interest=int(os.getenv("MAX_RESULTS", "10")),
            search_lookback_hours=int(os.getenv("LOOKBACK_HOURS", "24")),
            delivery_channel=os.getenv("DELIVERY_CHANNEL", "slack"),
            slack_channel=os.getenv("SLACK_CHANNEL", "#general"),
        )
    
    @property
    def api_keys(self) -> dict:
        """获取 API Keys"""
        return self._api_keys
    
    @property
    def settings(self) -> Settings:
        """获取设置"""
        return self._settings
    
    def get_available_apis(self) -> List[str]:
        """获取可用的 API 列表"""
        return [name for name, key in self._api_keys.items() if key]
    
    def load_interests(self) -> dict:
        """加载关注列表"""
        if not self.INTEREST_FILE.exists():
            return self._create_default_interests()
        
        try:
            with open(self.INTEREST_FILE, "r", encoding="utf-8") as f:
                return json.load(f)
        except (json.JSONDecodeError, IOError):
            return self._create_default_interests()
    
    def save_interests(self, data: dict):
        """保存关注列表"""
        with open(self.INTEREST_FILE, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
    
    def _create_default_interests(self) -> dict:
        """创建默认关注列表"""
        import uuid
        from datetime import datetime
        
        default_data = {
            "version": "1.0",
            "updated_at": datetime.now().isoformat(),
            "interests": [
                {
                    "id": str(uuid.uuid4())[:8],
                    "type": "topic",
                    "value": "人工智能",
                    "keywords": ["AI", "大模型", "AGI", "机器学习"],
                    "priority": "high",
                    "sources": ["tech", "news"],
                    "created_at": datetime.now().isoformat()
                },
                {
                    "id": str(uuid.uuid4())[:8],
                    "type": "topic",
                    "value": "科技动态",
                    "keywords": ["科技", "互联网", "初创公司", "融资"],
                    "priority": "medium",
                    "sources": ["tech"],
                    "created_at": datetime.now().isoformat()
                }
            ],
            "settings": self._settings.to_dict()
        }
        self.save_interests(default_data)
        return default_data
    
    def load_cache(self) -> dict:
        """加载搜索缓存"""
        if not self.CACHE_FILE.exists():
            return {"version": "1.0", "searches": {}}
        
        try:
            with open(self.CACHE_FILE, "r", encoding="utf-8") as f:
                return json.load(f)
        except (json.JSONDecodeError, IOError):
            return {"version": "1.0", "searches": {}}
    
    def save_cache(self, data: dict):
        """保存搜索缓存"""
        with open(self.CACHE_FILE, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)


# 全局配置实例
config = Config()


path: scripts/digest_generator.py
content: """
Claw-News Digest Generator
简报生成模块
"""

import json
from typing import List, Dict
from datetime import datetime, timedelta
from search_engine import SearchResult
from result_aggregator import ResultAggregator


class DigestGenerator:
    """简报生成器"""
    
    def __init__(self, template_path: str = None):
        self.template_path = template_path or self._get_default_template_path()
    
    def _get_default_template_path(self) -> str:
        import os
        base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
        return os.path.join(base_dir, "assets", "digest_template.md")
    
    def generate(self, categorized_results: Dict[str, List[SearchResult]], 
                 interests_data: Dict, settings: Dict = None) -> str:
        """
        生成 Markdown 简报
        
        Args:
            categorized_results: 分类后的搜索结果
            interests_data: 关注列表数据
            settings: 设置信息
        """
        settings = settings or {}
        
        # 获取时间范围
        lookback_hours = settings.get('search_lookback_hours', 24)
        end_time = datetime.now()
        start_time = end_time - timedelta(hours=lookback_hours)
        
        # 构建统计信息
        stats = ResultAggregator().get_statistics(categorized_results)
        
        # 构建 interests 映射
        interests_map = {
            i['id']: i for i in interests_data.get('interests', [])
        }
        
        # 开始生成简报
        lines = []
        
        # 标题
        lines.append("# 📰 Claw-News 每日简报\n")
        lines.append(f"**时间范围**: {start_time.strftime('%Y-%m-%d %H:%M')} ~ {end_time.strftime('%Y-%m-%d %H:%M')}")
        lines.append(f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        lines.append("")
        
        # 按优先级排序分类
        sorted_categories = self._sort_categories(categorized_results, interests_map)
        
        # 生成各分类内容
        total_news = 0
        for interest_id in sorted_categories:
            results = categorized_results.get(interest_id, [])
            if not results:
                continue
            
            interest_info = interests_map.get(interest_id, {})
            interest_value = interest_info.get('value', '其他')
            interest_type = interest_info.get('type', 'topic')
            
            # 类型 emoji
            type_emoji = {"topic": "🔥", "person": "👤", "keyword": "🔑"}.get(interest_type, "📌")
            priority = interest_info.get('priority', 'medium')
            priority_emoji = {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(priority, "⚪")
            
            lines.append(f"---\n")
            lines.append(f"## {type_emoji} {interest_value} ({len(results)}条) {priority_emoji}\n")
            
            for i, result in enumerate(results[:10], 1):  # 每类最多 10 条
                lines.append(f"### {i}. [{result.title}]({result.url})")
                lines.append(f"**来源**: {result.source} | **时间**: {self._format_time(result.published_at)}")
                
                # 摘要
                summary = result.summary.strip()
                if len(summary) > 200:
                    summary = summary[:200] + "..."
                if summary:
                    lines.append(f"\n{summary}\n")
                else:
                    lines.append("")
                
                total_news += 1
            
            # 如果有更多结果被截断
            if len(results) > 10:
                lines.append(f"*...还有 {len(results) - 10} 条相关新闻*\n")
        
        # 统计信息
        lines.append("---\n")
        lines.append("## 📊 简报统计\n")
        lines.append(f"| 指标 | 数值 |")
        lines.append(f"|------|------|")
        lines.append(f"| 关注主题 | {len(categorized_results)} 个 |")
        lines.append(f"| 新闻总数 | {stats['total_results']} 条 |")
        lines.append(f"| 涉及来源 | {len(stats['by_source'])} 个 |")
        
        # API 使用情况
        api_usage = []
        for api, count in sorted(stats['by_api'].items(), key=lambda x: -x[1]):
            api_display = (api or "unknown").capitalize()
            api_usage.append(f"{api_display} ({count}次)")
        if api_usage:
            lines.append(f"| API 使用 | {' / '.join(api_usage)} |")
        
        lines.append("")
        
        # 页脚
        lines.append("---\n")
        lines.append("💡 **提示**: 回复 `newsman add <关键词>` 添加新关注 | `newsman list` 查看列表 | `newsman run` 立即执行")
        lines.append("")
        
        return "\n".join(lines)
    
    def _sort_categories(self, categorized_results: Dict, 
                         interests_map: Dict) -> List[str]:
        """按优先级排序分类"""
        priority_order = {"high": 0, "medium": 1, "low": 2}
        
        categories = list(categorized_results.keys())
        categories.sort(key=lambda cid: (
            priority_order.get(interests_map.get(cid, {}).get('priority', 'medium'), 2),
            -len(categorized_results.get(cid, []))  # 同优先级按数量倒序
        ))
        
        return categories
    
    def _format_time(self, time_str: str) -> str:
        """格式化时间字符串"""
        if not time_str:
            return "未知"
        
        try:
            # 尝试多种格式
            for fmt in ['%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S', 
                       '%Y-%m-%d', '%m-%d %H:%M']:
                try:
                    dt = datetime.strptime(time_str[:19], fmt)
                    return dt.strftime('%m-%d %H:%M')
                except:
                    continue
            
            # 尝试 ISO 格式
            from dateutil import parser
            dt = parser.parse(time_str)
            return dt.strftime('%m-%d %H:%M')
        except:
            return time_str[:16] if len(time_str) > 16 else time_str


def main():
    """测试简报生成"""
    import sys
    import os
    
    # 创建测试数据
    test_results = {
        "abc123": [
            SearchResult(
                title="OpenAI 发布 GPT-5 预览版",
                url="https://example.com/1",
                summary="OpenAI 今日凌晨意外放出 GPT-5 技术预览，据称在推理能力上实现重大突破...",
                source="TechCrunch",
                published_at="2026-02-21T14:30:00",
                search_api="kimi"
            ),
            SearchResult(
                title="Google DeepMind 推出新模型",
                url="https://example.com/2",
                summary="DeepMind 研究团队发布了新一代多模态模型...",
                source="The Verge",
                published_at="2026-02-21T22:15:00",
                search_api="kimi"
            )
        ]
    }
    
    test_interests = {
        "interests": [
            {"id": "abc123", "type": "topic", "value": "人工智能", "priority": "high"}
        ]
    }
    
    generator = DigestGenerator()
    digest = generator.generate(test_results, test_interests)
    
    print(digest)


if __name__ == "__main__":
    main()


path: scripts/generate_digest.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Claw-News 简报生成脚本"""

import os
from openai import OpenAI
from datetime import datetime

def generate_digest():
    client = OpenAI(
        [REDACTED_SECRET]',
        base_url='https://api.moonshot.cn/v1'
    )

    interests = [
        ('🤖 人工智能', ['AI', '大模型', 'AGI', '机器学习']),
        ('💻 科技动态', ['科技', '互联网', '初创公司', '融资']),
        ('🚀 马斯克', ['Elon Musk', 'Tesla', 'SpaceX']),
        ('⛓️ 区块链', ['区块链', '加密货币', 'Web3'])
    ]
    
    print('='*70)
    print('📰 Claw-News 每日新闻简报')
    print(f'📅 {datetime.now().strftime("%Y年%m月%d日 %H:%M")}')
    print('='*70)
    print()

    for title, keywords in interests:
        print(f'\n{title}')
        print('-'*70)
        
        try:
            query = ' '.join(keywords[:3])
            response = client.chat.completions.create(
                model='kimi-k2-5',
                messages=[
                    {'role': 'system', 'content': '你是新闻助手，用中文总结最新新闻。每条新闻用"• 标题 - 摘要"格式，简洁明了。'},
                    {'role': 'user', 'content': f'搜索"{query}"的最新新闻（过去24小时），列出3-4条最重要的，格式：标题+一句话摘要。'}
                ],
                tools=[{'type': 'builtin_function', 'function': {'name': 'web_search'}}],
                temperature=0.3,
                timeout=60
            )
            
            content = response.choices[0].message.content
            print(content)
        except Exception as e:
            print(f'  ⚠️ 获取失败: {e}')
        print()

    print('='*70)
    print('🤖 Powered by Claw-News & Kimi AI')
    print('💡 使用 newsman add <关键词> 添加更多关注')
    print('='*70)

if __name__ == '__main__':
    generate_digest()


path: scripts/interest_manager.py
content: """
Claw-News Interest Manager
兴趣列表管理模块
"""

import uuid
import json
import argparse
from datetime import datetime
from typing import List, Optional, Dict
from config import Config, Interest


class InterestManager:
    """关注列表管理器"""
    
    def __init__(self, config: Config = None):
        self.config = config or Config()
        self._data = self.config.load_interests()
    
    @property
    def interests(self) -> List[Interest]:
        """获取所有关注项"""
        return [Interest.from_dict(i) for i in self._data.get("interests", [])]
    
    def get_by_id(self, interest_id: str) -> Optional[Interest]:
        """通过 ID 获取关注项"""
        for item in self.interests:
            if item.id == interest_id:
                return item
        return None
    
    def add(self, value: str, interest_type: str = "topic", 
            keywords: List[str] = None, priority: str = "medium") -> Interest:
        """添加新关注项"""
        # 检查是否已存在
        for existing in self.interests:
            if existing.value == value:
                print(f"⚠️ 关注项 '{value}' 已存在 (ID: {existing.id})")
                return existing
        
        # 生成关键词（如果没有提供）
        if keywords is None or len(keywords) == 0:
            keywords = [value]
            if interest_type == "person":
                # 为人名添加英文名变体
                keywords.extend(self._generate_person_keywords(value))
        
        new_interest = Interest(
            id=str(uuid.uuid4())[:8],
            type=interest_type,
            value=value,
            keywords=keywords,
            priority=priority,
            created_at=datetime.now().isoformat()
        )
        
        self._data["interests"].append(new_interest.to_dict())
        self._data["updated_at"] = datetime.now().isoformat()
        self.config.save_interests(self._data)
        
        print(f"✅ 已添加关注: {value} (ID: {new_interest.id})")
        return new_interest
    
    def remove(self, interest_id: str) -> bool:
        """删除关注项"""
        original_count = len(self._data["interests"])
        self._data["interests"] = [
            i for i in self._data["interests"] 
            if i.get("id") != interest_id
        ]
        
        if len(self._data["interests"]) < original_count:
            self._data["updated_at"] = datetime.now().isoformat()
            self.config.save_interests(self._data)
            print(f"✅ 已删除关注项 (ID: {interest_id})")
            return True
        else:
            print(f"⚠️ 未找到关注项 (ID: {interest_id})")
            return False
    
    def update(self, interest_id: str, **kwargs) -> Optional[Interest]:
        """更新关注项"""
        for i, item in enumerate(self._data["interests"]):
            if item.get("id") == interest_id:
                for key, value in kwargs.items():
                    if key in item:
                        item[key] = value
                item["updated_at"] = datetime.now().isoformat()
                self._data["updated_at"] = datetime.now().isoformat()
                self.config.save_interests(self._data)
                print(f"✅ 已更新关注项 (ID: {interest_id})")
                return Interest.from_dict(item)
        
        print(f"⚠️ 未找到关注项 (ID: {interest_id})")
        return None
    
    def list_all(self, interest_type: str = None) -> List[Interest]:
        """列出所有关注项"""
        interests = self.interests
        if interest_type:
            interests = [i for i in interests if i.type == interest_type]
        return interests
    
    def get_search_queries(self) -> List[Dict]:
        """获取用于搜索的查询列表"""
        queries = []
        for interest in self.interests:
            # 主查询词
            queries.append({
                "id": interest.id,
                "type": interest.type,
                "value": interest.value,
                "priority": interest.priority,
                "query": interest.value
            })
            # 关键词查询（对于高优先级）
            if interest.priority == "high" and len(interest.keywords) > 1:
                for kw in interest.keywords[1:3]:  # 最多额外 2 个关键词
                    queries.append({
                        "id": interest.id,
                        "type": interest.type,
                        "value": interest.value,
                        "priority": interest.priority,
                        "query": kw
                    })
        return queries
    
    def _generate_person_keywords(self, name: str) -> List[str]:
        """为人名生成相关关键词"""
        # 简单规则：中文字符 > 可能是中文人名
        keywords = []
        
        # 常见英文名映射（简单示例）
        name_mapping = {
            "马斯克": ["Elon Musk", "Musk"],
            "乔布斯": ["Steve Jobs", "Jobs"],
            "贝索斯": ["Jeff Bezos", "Bezos"],
            "扎克伯格": ["Mark Zuckerberg", "Zuckerberg"],
            "奥特曼": ["Sam Altman", "Altman"],
            "盖茨": ["Bill Gates", "Gates"],
        }
        
        if name in name_mapping:
            keywords.extend(name_mapping[name])
        
        return keywords
    
    def print_list(self, interest_type: str = None):
        """打印关注列表"""
        interests = self.list_all(interest_type)
        
        if not interests:
            print("📭 关注列表为空")
            return
        
        print(f"\n📋 关注列表 ({len(interests)} 项):\n")
        print(f"{'ID':<10} {'类型':<10} {'优先级':<8} {'关注内容':<20} {'关键词'}")
        print("-" * 70)
        
        for i in interests:
            type_emoji = {"topic": "📁", "person": "👤", "keyword": "🔑"}.get(i.type, "📌")
            keywords_str = ", ".join(i.keywords[:3])
            if len(i.keywords) > 3:
                keywords_str += " ..."
            print(f"{i.id:<10} {type_emoji} {i.type:<8} {'🔴' if i.priority=='high' else '🟡' if i.priority=='medium' else '🟢'} {i.priority:<6} {i.value:<18} {keywords_str}")
        
        print()


def main():
    """命令行入口"""
    parser = argparse.ArgumentParser(description="Claw-News Interest Manager")
    parser.add_argument("--list", "-l", action="store_true", help="列出所有关注项")
    parser.add_argument("--add", "-a", type=str, help="添加关注项")
    parser.add_argument("--remove", "-r", type=str, help="删除关注项 (ID)")
    parser.add_argument("--type", "-t", type=str, default="topic", 
                       choices=["topic", "person", "keyword"],
                       help="关注项类型 (默认: topic)")
    parser.add_argument("--priority", "-p", type=str, default="medium",
                       choices=["high", "medium", "low"],
                       help="优先级 (默认: medium)")
    
    args = parser.parse_args()
    
    manager = InterestManager()
    
    if args.list or (not args.add and not args.remove):
        manager.print_list()
    
    if args.add:
        manager.add(args.add, args.type, priority=args.priority)
        manager.print_list()
    
    if args.remove:
        manager.remove(args.remove)
        manager.print_list()


if __name__ == "__main__":
    main()


path: scripts/kickstarter_tracker.py
content: """
Kickstarter 热门项目追踪器
抓取 funding 比例超过阈值的项目
"""

import requests
import json
import os
import re
from datetime import datetime
from bs4 import BeautifulSoup
from typing import List, Dict, Optional
import argparse

# 配置
KICKSTARTER_DISCOVER_URL = "https://www.kickstarter.com/discover/advanced"
DEFAULT_FUNDING_THRESHOLD = 1000  # 1000% funded
CACHE_FILE = os.path.join(os.path.dirname(__file__), "..", "data", "kickstarter_cache.json")

class KickstarterTracker:
    def __init__(self, threshold: int = DEFAULT_FUNDING_THRESHOLD):
        self.threshold = threshold
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
            'Accept-Language': 'en-US,en;q=0.5',
        })
    
    def fetch_projects(self, sort: str = "popular", state: str = "live") -> List[Dict]:
        """
        从 Kickstarter 获取项目列表
        
        Args:
            sort: 排序方式 - popular, newest, end_date, most_backed
            state: 项目状态 - live, successful, submitted
        """
        projects = []
        page = 1
        max_pages = 5  # 最多抓取5页
        
        print(f"Fetching Kickstarter projects (threshold: {self.threshold}%)...")
        
        while page <= max_pages:
            url = f"{KICKSTARTER_DISCOVER_URL}?sort={sort}&state={state}&seed={int(datetime.now().timestamp())}&page={page}"
            
            try:
                response = self.session.get(url, timeout=30)
                response.raise_for_status()
                
                soup = BeautifulSoup(response.text, 'html.parser')
                
                # 查找项目卡片
                project_cards = soup.find_all('div', class_=re.compile('project-card'))
                
                if not project_cards:
                    # 尝试其他选择器
                    project_cards = soup.select('[data-project]')
                
                if not project_cards:
                    print(f"No more projects found on page {page}")
                    break
                
                for card in project_cards:
                    project = self._parse_project_card(card)
                    if project:
                        projects.append(project)
                
                print(f"Page {page}: Found {len(project_cards)} projects")
                page += 1
                
            except Exception as e:
                print(f"Error fetching page {page}: {e}")
                break
        
        return projects
    
    def _parse_project_card(self, card) -> Optional[Dict]:
        """解析项目卡片，提取关键信息"""
        try:
            # 提取项目名称
            name_elem = card.find('h3') or card.find('a', class_=re.compile('project-title'))
            name = name_elem.get_text(strip=True) if name_elem else "Unknown"
            
            # 提取项目链接
            link_elem = card.find('a', href=True)
            if link_elem:
                href = link_elem['href']
                url = f"https://www.kickstarter.com{href}" if href.startswith('/') else href
            else:
                url = ""
            
            # 提取描述
            desc_elem = card.find('p', class_=re.compile('description|blurb'))
            description = desc_elem.get_text(strip=True) if desc_elem else ""
            
            # 提取 funding 百分比
            funding_elem = card.find(string=re.compile(r'\d+%'))
            funding_percent = 0
            
            if funding_elem:
                match = re.search(r'(\d+)%', str(funding_elem))
                if match:
                    funding_percent = int(match.group(1))
            else:
                # 尝试从 progress bar 或其他元素提取
                progress_elem = card.find('div', class_=re.compile('progress'))
                if progress_elem:
                    style = progress_elem.get('style', '')
                    match = re.search(r'width:\s*(\d+)%', style)
                    if match:
                        funding_percent = int(match.group(1))
            
            # 提取已筹金额
            pledged_elem = card.find(string=re.compile(r'\$[\d,]+'))
            pledged = ""
            if pledged_elem:
                match = re.search(r'\$[\d,]+', str(pledged_elem))
                if match:
                    pledged = match.group(0)
            
            # 提取 backers 数量
            backers_elem = card.find(string=re.compile(r'\d+\s*backer'))
            backers = ""
            if backers_elem:
                match = re.search(r'(\d+)\s*backer', str(backers_elem))
                if match:
                    backers = match.group(1)
            
            return {
                'name': name,
                'url': url,
                'description': description,
                'funding_percent': funding_percent,
                'pledged': pledged,
                'backers': backers,
                'scraped_at': datetime.now().isoformat()
            }
            
        except Exception as e:
            print(f"Error parsing project card: {e}")
            return None
    
    def filter_hot_projects(self, projects: List[Dict]) -> List[Dict]:
        """筛选出 funding 比例超过阈值的项目"""
        hot_projects = [
            p for p in projects 
            if p.get('funding_percent', 0) >= self.threshold
        ]
        # 按 funding 比例排序
        hot_projects.sort(key=lambda x: x.get('funding_percent', 0), reverse=True)
        return hot_projects
    
    def load_cache(self) -> List[Dict]:
        """加载缓存的项目数据"""
        if os.path.exists(CACHE_FILE):
            try:
                with open(CACHE_FILE, 'r', encoding='utf-8') as f:
                    return json.load(f)
            except Exception as e:
                print(f"Error loading cache: {e}")
        return []
    
    def save_cache(self, projects: List[Dict]):
        """保存项目数据到缓存"""
        try:
            os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
            with open(CACHE_FILE, 'w', encoding='utf-8') as f:
                json.dump(projects, f, indent=2, ensure_ascii=False)
        except Exception as e:
            print(f"Error saving cache: {e}")
    
    def get_new_projects(self, current_projects: List[Dict]) -> List[Dict]:
        """获取新增的项目（与缓存对比）"""
        cached = self.load_cache()
        cached_urls = {p['url'] for p in cached}
        
        new_projects = [
            p for p in current_projects 
            if p['url'] and p['url'] not in cached_urls
        ]
        return new_projects
    
    def generate_report(self, projects: List[Dict], new_only: bool = False) -> str:
        """生成简报文本"""
        if not projects:
            return f"🎯 Kickstarter 热门项目追踪\n\n本周没有发现 funding 超过 {self.threshold}% 的热门项目。"
        
        report_lines = [
            f"🚀 Kickstarter 热门项目追踪",
            f"📊 Funding 阈值: {self.threshold}%",
            f"📅 {datetime.now().strftime('%Y-%m-%d %H:%M')}",
            f"\n{'='*50}\n"
        ]
        
        for i, p in enumerate(projects[:10], 1):  # 最多显示10个
            status_emoji = "🔥" if p.get('funding_percent', 0) >= 5000 else "⭐" if p.get('funding_percent', 0) >= 2000 else "✨"
            
            report_lines.append(f"{status_emoji} #{i} {p['name']}")
            report_lines.append(f"   💰 Funding: {p.get('funding_percent', 'N/A')}% {p.get('pledged', '')}")
            report_lines.append(f"   👥 Backers: {p.get('backers', 'N/A')}")
            report_lines.append(f"   🔗 {p['url']}")
            if p.get('description'):
                # 截断描述
                desc = p['description'][:150] + "..." if len(p['description']) > 150 else p['description']
                report_lines.append(f"   📝 {desc}")
            report_lines.append("")
        
        return "\n".join(report_lines)


def main():
    parser = argparse.ArgumentParser(description='Kickstarter 热门项目追踪器')
    parser.add_argument('--threshold', type=int, default=1000, help='Funding 百分比阈值 (默认: 1000)')
    parser.add_argument('--new-only', action='store_true', help='只显示新增项目')
    parser.add_argument('--dry-run', action='store_true', help='测试模式，不保存缓存')
    parser.add_argument('--output', type=str, help='输出文件路径')
    
    args = parser.parse_args()
    
    tracker = KickstarterTracker(threshold=args.threshold)
    
    # 抓取项目
    all_projects = tracker.fetch_projects()
    print(f"Total projects fetched: {len(all_projects)}")
    
    # 筛选热门项目
    hot_projects = tracker.filter_hot_projects(all_projects)
    print(f"Hot projects (>{args.threshold}%): {len(hot_projects)}")
    
    # 如果是 new-only 模式，只显示新增项目
    if args.new_only:
        display_projects = tracker.get_new_projects(hot_projects)
        print(f"New hot projects: {len(display_projects)}")
    else:
        display_projects = hot_projects
    
    # 生成报告
    report = tracker.generate_report(display_projects, args.new_only)
    print("\n" + "="*50)
    print(report)
    print("="*50)
    
    # 保存到文件
    if args.output:
        with open(args.output, 'w', encoding='utf-8') as f:
            f.write(report)
        print(f"\nReport saved to: {args.output}")
    
    # 保存缓存（如果不是 dry-run）
    if not args.dry_run:
        tracker.save_cache(hot_projects)
        print(f"\nCache saved: {len(hot_projects)} projects")
    
    return hot_projects


if __name__ == "__main__":
    main()


path: scripts/newsman.py
content: """
Claw-News Main Entry Point
主入口脚本
"""

import os
import sys
import json
import argparse
from datetime import datetime
from typing import List, Dict

# 添加脚本目录到路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from config import Config, Settings
from interest_manager import InterestManager
from search_engine import SearchEngine, SearchResult
from result_aggregator import ResultAggregator
from digest_generator import DigestGenerator


class NewsmanAgent:
    """Newsman 主代理"""
    
    def __init__(self):
        self.config = Config()
        self.interest_manager = InterestManager(self.config)
        self.search_engine = SearchEngine(self.config)
        self.aggregator = ResultAggregator()
        self.generator = DigestGenerator()
    
    def run_daily_digest(self, dry_run: bool = False) -> str:
        """执行每日简报生成"""
        print("🚀 启动 Claw-News 每日简报生成\n")
        
        # 1. 加载关注列表
        interests_data = self.config.load_interests()
        interests = self.interest_manager.interests
        
        if not interests:
            print("⚠️ 关注列表为空，请先添加关注项")
            return "关注列表为空"
        
        print(f"📋 加载了 {len(interests)} 个关注项\n")
        
        # 2. 执行搜索
        search_results = []
        queries = self.interest_manager.get_search_queries()
        
        for query_info in queries:
            print(f"🔍 搜索: {query_info['value']} (查询: {query_info['query']})")
            
            result = self.search_engine.search(query_info['query'])
            
            # 标记 interest_id
            if result['success']:
                for r in result['results']:
                    r.interest_id = query_info['id']
            
            search_results.append(result)
            
            # 简单延时避免请求过快
            import time
            time.sleep(0.5)
        
        # 3. 聚合结果
        print(f"\n📊 聚合搜索结果...")
        categorized = self.aggregator.aggregate(search_results)
        
        if not categorized:
            print("⚠️ 未找到任何新闻")
            return "未找到新闻"
        
        total_count = sum(len(v) for v in categorized.values())
        print(f"✅ 聚合完成: {total_count} 条新闻，{len(categorized)} 个分类\n")
        
        # 4. 生成简报
        print("📝 生成简报...")
        digest = self.generator.generate(
            categorized, 
            interests_data,
            self.config.settings.to_dict()
        )
        
        # 5. 输出或推送
        if dry_run:
            print("\n" + "="*60)
            print("📄 简报预览 (Dry Run)")
            print("="*60)
            print(digest)
            return digest
        else:
            # 保存到文件
            self._save_digest(digest)
            
            # 推送
            self._push_digest(digest, interests_data)
            
            print(f"\n✅ 简报已生成并推送")
            return digest
    
    def _save_digest(self, digest: str):
        """保存简报到文件"""
        from pathlib import Path
        
        # 创建输出目录
        output_dir = self.config.BASE_DIR / "output"
        output_dir.mkdir(exist_ok=True)
        
        # 生成文件名
        filename = f"digest_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
        filepath = output_dir / filename
        
        with open(filepath, "w", encoding="utf-8") as f:
            f.write(digest)
        
        print(f"💾 简报已保存: {filepath}")
    
    def _push_digest(self, digest: str, interests_data: Dict):
        """推送简报到 Channel"""
        channel = self.config.settings.delivery_channel
        
        if channel == "slack":
            self._push_to_slack(digest)
        elif channel == "terminal":
            print("\n" + "="*60)
            print(digest)
            print("="*60)
        else:
            print(f"⚠️ 未知的推送渠道: {channel}")
    
    def _push_to_slack(self, digest: str):
        """推送到 Slack"""
        # 这里可以调用 OpenClaw 的 message 工具
        # 简化实现：写入文件并提示
        print(f"📤 推送到 Slack 频道: {self.config.settings.slack_channel}")
        
        # 如果运行在 OpenClaw 环境中，可以使用 message 工具
        # 否则提示用户手动发送
        if os.getenv("OPENCLAW_ENV"):
            try:
                # 调用 OpenClaw message 工具
                import subprocess
                result = subprocess.run(
                    ["openclaw", "message", "send", "--channel", "slack", digest],
                    capture_output=True,
                    text=True
                )
                if result.returncode == 0:
                    print("✅ Slack 推送成功")
                else:
                    print(f"⚠️ Slack 推送失败: {result.stderr}")
            except Exception as e:
                print(f"⚠️ Slack 推送失败: {e}")
    
    def search_and_show(self, query: str):
        """搜索并直接显示结果"""
        print(f"🔍 搜索: {query}\n")
        
        result = self.search_engine.search(query)
        
        if result['success']:
            print(f"✅ 使用 {result['source']} 搜索成功\n")
            print(f"找到 {len(result['results'])} 条结果:\n")
            
            for i, r in enumerate(result['results'][:5], 1):
                print(f"{i}. {r.title}")
                print(f"   📰 {r.source} | 🕐 {r.published_at[:16] if r.published_at else '未知'}")
                print(f"   🔗 {r.url}")
                if r.summary:
                    summary = r.summary[:150] + "..." if len(r.summary) > 150 else r.summary
                    print(f"   📝 {summary}")
                print()
        else:
            print(f"❌ 搜索失败: {result.get('error', '未知错误')}")


def main():
    """主入口"""
    parser = argparse.ArgumentParser(
        description="Claw-News - 智能每日新闻简报",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  python newsman.py --mode digest              # 生成每日简报
  python newsman.py --mode digest --dry-run    # 预览模式
  python newsman.py --mode search -q "AI"      # 搜索关键词
  python newsman.py --mode list                # 查看关注列表
        """
    )
    
    parser.add_argument("--mode", "-m", 
                       choices=["digest", "search", "list"],
                       default="digest",
                       help="运行模式 (默认: digest)")
    parser.add_argument("--query", "-q", type=str,
                       help="搜索关键词 (search 模式)")
    parser.add_argument("--dry-run", "-d", action="store_true",
                       help="预览模式，不实际推送")
    
    args = parser.parse_args()
    
    # 创建 agent
    agent = NewsmanAgent()
    
    if args.mode == "digest":
        agent.run_daily_digest(dry_run=args.dry_run)
    
    elif args.mode == "search":
        if not args.query:
            print("❌ 请提供搜索关键词: --query <关键词>")
            sys.exit(1)
        agent.search_and_show(args.query)
    
    elif args.mode == "list":
        agent.interest_manager.print_list()


if __name__ == "__main__":
    main()


path: scripts/result_aggregator.py
content: """
Claw-News Result Aggregator
搜索结果聚合与去重模块
"""

import json
from typing import List, Dict, Set
from datetime import datetime
from search_engine import SearchResult


class ResultAggregator:
    """结果聚合器 - 去重、分类、排序"""
    
    def __init__(self):
        self.seen_urls: Set[str] = set()
        self.seen_hashes: Set[str] = set()
    
    def aggregate(self, search_results: List[Dict]) -> Dict[str, List[SearchResult]]:
        """
        聚合多个搜索结果
        
        Args:
            search_results: 多个搜索返回的结果列表
                [{"source": "kimi", "results": [SearchResult, ...]}, ...]
        
        Returns:
            按 interest_id 分类的结果字典
        """
        all_results = []
        
        for sr in search_results:
            if sr.get("success"):
                for result in sr.get("results", []):
                    if self._is_unique(result):
                        all_results.append(result)
        
        # 按 interest_id 分类
        categorized = {}
        for result in all_results:
            interest_id = getattr(result, 'interest_id', 'general')
            if interest_id not in categorized:
                categorized[interest_id] = []
            categorized[interest_id].append(result)
        
        # 对每个分类内的结果排序（按时间 + 相关性）
        for interest_id in categorized:
            categorized[interest_id] = self._sort_results(categorized[interest_id])
        
        return categorized
    
    def _is_unique(self, result: SearchResult) -> bool:
        """检查结果是否唯一（去重）"""
        # URL 去重
        normalized_url = self._normalize_url(result.url)
        if normalized_url in self.seen_urls:
            return False
        
        # Hash 去重
        if result.hash in self.seen_hashes:
            return False
        
        self.seen_urls.add(normalized_url)
        self.seen_hashes.add(result.hash)
        return True
    
    def _normalize_url(self, url: str) -> str:
        """规范化 URL 用于去重"""
        # 移除跟踪参数
        import re
        url = re.sub(r'[?&](utm_source|utm_medium|utm_campaign|ref|source)=[^&]*', '', url)
        # 移除末尾斜杠
        url = url.rstrip('/')
        # 转小写
        return url.lower()
    
    def _sort_results(self, results: List[SearchResult]) -> List[SearchResult]:
        """对结果排序（时间倒序 + 质量评分）"""
        def score_result(r: SearchResult) -> float:
            score = 0.0
            
            # 时间因素（越新越高分）
            try:
                pub_date = datetime.fromisoformat(r.published_at.replace('Z', '+00:00'))
                hours_ago = (datetime.now() - pub_date).total_seconds() / 3600
                score += max(0, 24 - hours_ago)  # 24 小时内的新闻
            except:
                score += 12  # 默认中等分数
            
            # 内容质量因素
            if len(r.title) > 20:  # 标题足够长
                score += 2
            if len(r.summary) > 50:  # 摘要有内容
                score += 3
            if r.source and r.source not in ['unknown', '']:  # 有明确来源
                score += 2
            
            # 来源可信度（简单规则）
            trusted_sources = ['reuters', 'bloomberg', 'techcrunch', 'the verge', 
                             'bbc', 'cnn', '华尔街日报', '财新', '36kr', '知乎']
            if any(ts in r.source.lower() for ts in trusted_sources):
                score += 3
            
            return score
        
        return sorted(results, key=score_result, reverse=True)
    
    def deduplicate_by_similarity(self, results: List[SearchResult], 
                                   threshold: float = 0.8) -> List[SearchResult]:
        """
        基于标题相似度的去重（简单实现）
        
        Args:
            results: 原始结果列表
            threshold: 相似度阈值
        """
        if not results:
            return results
        
        unique_results = [results[0]]
        
        for result in results[1:]:
            is_duplicate = False
            for unique in unique_results:
                similarity = self._calculate_similarity(result.title, unique.title)
                if similarity >= threshold:
                    is_duplicate = True
                    break
            
            if not is_duplicate:
                unique_results.append(result)
        
        return unique_results
    
    def _calculate_similarity(self, text1: str, text2: str) -> float:
        """计算两个文本的相似度（简单 Jaccard 实现）"""
        # 转小写并分词
        words1 = set(text1.lower().split())
        words2 = set(text2.lower().split())
        
        if not words1 or not words2:
            return 0.0
        
        intersection = words1 & words2
        union = words1 | words2
        
        return len(intersection) / len(union)
    
    def merge_with_cache(self, new_results: List[SearchResult], 
                         cache_file: str = None) -> List[SearchResult]:
        """与缓存合并，避免重复推送"""
        # 简化实现：暂时直接返回新结果
        # 未来可以实现：读取已推送记录，过滤已推送的 URL
        return new_results
    
    def get_statistics(self, categorized_results: Dict[str, List[SearchResult]]) -> Dict:
        """生成统计信息"""
        total = sum(len(results) for results in categorized_results.values())
        by_source = {}
        by_api = {}
        
        for results in categorized_results.values():
            for r in results:
                by_source[r.source] = by_source.get(r.source, 0) + 1
                by_api[r.search_api] = by_api.get(r.search_api, 0) + 1
        
        return {
            "total_results": total,
            "categories": len(categorized_results),
            "by_source": by_source,
            "by_api": by_api
        }


path: scripts/search_engine.py
content: """
Claw-News Search Engine
多 API 搜索封装模块
支持 Kimi、MiniMax、Claude 搜索
"""

import os
import json
import time
import argparse
from typing import List, Dict, Optional
from datetime import datetime, timedelta
from config import Config


class SearchResult:
    """搜索结果项"""
    
    def __init__(self, title: str, url: str, summary: str, 
                 source: str, published_at: str = None, 
                 search_api: str = None, query: str = None):
        self.title = title
        self.url = url
        self.summary = summary
        self.source = source
        self.published_at = published_at or datetime.now().isoformat()
        self.search_api = search_api
        self.query = query
        self._hash = None
    
    @property
    def hash(self) -> str:
        """生成唯一标识（用于去重）"""
        if self._hash is None:
            import hashlib
            self._hash = hashlib.md5(self.url.encode()).hexdigest()[:12]
        return self._hash
    
    def to_dict(self) -> dict:
        return {
            "title": self.title,
            "url": self.url,
            "summary": self.summary,
            "source": self.source,
            "published_at": self.published_at,
            "search_api": self.search_api,
            "query": self.query,
            "hash": self.hash
        }


class SearchEngine:
    """搜索引擎 - 多 API 轮询"""
    
    def __init__(self, config: Config = None):
        self.config = config or Config()
        self.timeout = self.config.settings.api_timeout
        self.lookback_hours = self.config.settings.search_lookback_hours
    
    def search(self, query: str, time_range: str = None) -> Dict:
        """
        执行搜索，自动轮询多个 API
        
        Returns:
            {
                "success": bool,
                "source": str,  # 成功使用的 API
                "results": List[SearchResult],
                "error": str (可选)
            }
        """
        if time_range is None:
            # 默认搜索过去 24 小时
            end_date = datetime.now()
            start_date = end_date - timedelta(hours=self.lookback_hours)
            time_range = f"{start_date.strftime('%Y-%m-%d')}_to_{end_date.strftime('%Y-%m-%d')}"
        
        available_apis = self.config.get_available_apis()
        
        if not available_apis:
            return {
                "success": False,
                "source": None,
                "results": [],
                "error": "未配置任何 API Key"
            }
        
        # 按优先级尝试 API
        for api_name in self.config.API_PRIORITY:
            if api_name not in available_apis:
                continue
            
            try:
                print(f"🔍 尝试使用 {api_name.upper()} 搜索: {query}")
                results = self._search_with_api(api_name, query, time_range)
                
                if results and len(results) > 0:
                    print(f"✅ {api_name.upper()} 返回 {len(results)} 条结果")
                    return {
                        "success": True,
                        "source": api_name,
                        "results": results
                    }
                else:
                    print(f"⚠️ {api_name.upper()} 未返回结果，尝试下一个 API")
                    
            except Exception as e:
                print(f"❌ {api_name.upper()} 搜索失败: {e}")
                continue
        
        return {
            "success": False,
            "source": None,
            "results": [],
            "error": "所有 API 均搜索失败"
        }
    
    def _search_with_api(self, api_name: str, query: str, time_range: str) -> List[SearchResult]:
        """使用指定 API 搜索"""
        if api_name == "minimax":
            return self._search_minimax(query, time_range)
        elif api_name == "tavily":
            return self._search_tavily(query, time_range)
        else:
            raise ValueError(f"不支持的 API: {api_name}")
    
    def _search_kimi(self, query: str, time_range: str) -> List[SearchResult]:
        """使用 Kimi API 搜索"""
        try:
            from openai import OpenAI
            
            client = OpenAI(
                [REDACTED_SECRET]"kimi"],
                base_url="https://api.moonshot.cn/v1"
            )
            
            # 构建搜索提示
            prompt = f"""搜索关于"{query}"的最新新闻和动态。
时间范围：过去{self.lookback_hours}小时内。
请返回 5-10 条相关新闻，每条包含：标题、来源网站、URL链接、简短摘要、发布时间。
请确保结果格式化为 JSON 数组。"""

            response = client.chat.completions.create(
                model="kimi-k2-5",  # 使用正确的模型名
                messages=[
                    {"role": "system", "content": "你是一个新闻搜索助手，专门搜索和整理最新资讯。请以结构化格式返回搜索结果。"},
                    {"role": "user", "content": prompt}
                ],
                tools=[{
                    "type": "builtin_function",
                    "function": {"name": "web_search"}
                }],
                temperature=0.3,
                timeout=self.timeout
            )
            
            # 解析结果
            content = response.choices[0].message.content
            return self._parse_search_results(content, "kimi", query)
            
        except Exception as e:
            print(f"Kimi API 错误: {e}")
            return []
    
    def _search_minimax(self, query: str, time_range: str) -> List[SearchResult]:
        """使用 MiniMax MCP web_search 搜索（通过 Coding Plan）"""
        if not self.config.api_keys.get('minimax'):
            print("⚠️ MiniMax API 未配置，跳过")
            return []
        
        try:
            import subprocess
            
            # MCP client 路径
            mcp_client = os.path.join(
                os.path.expanduser("~"), ".openclaw", "workspace",
                "skills", "minimax-mcp-call", "scripts", "mcp_client.mjs"
            )
            
            if not os.path.exists(mcp_client):
                print("⚠️ MiniMax MCP client 未找到，跳过")
                return []
            
            env = os.environ.copy()
            env["MINIMAX_API_KEY"] = self.config.api_keys['minimax']
            env["MINIMAX_API_HOST"] = "https://api.minimaxi.com"
            
            result = subprocess.run(
                ["node", mcp_client, "web_search", query],
                capture_output=True, text=True, timeout=60, env=env
            )
            
            if result.returncode != 0:
                print(f"⚠️ MiniMax MCP 错误: {result.stderr[:200]}")
                return []
            
            # 解析 MCP 返回的 JSON（stdout 前面有日志行，需要精确提取）
            output = result.stdout.strip()
            # 找到最外层 JSON 对象（MCP 返回 {"content": [...], ...}）
            brace_start = output.find('{')
            if brace_start == -1:
                print("⚠️ MiniMax MCP 无 JSON 输出")
                return []
            
            # 从第一个 { 开始，找到匹配的 }
            depth = 0
            json_end = -1
            for i in range(brace_start, len(output)):
                if output[i] == '{':
                    depth += 1
                elif output[i] == '}':
                    depth -= 1
                    if depth == 0:
                        json_end = i + 1
                        break
            
            if json_end == -1:
                print("⚠️ MiniMax MCP JSON 不完整")
                return []
            
            json_str = output[brace_start:json_end]
            data = json.loads(json_str)
            
            # MCP web_search 返回格式: {"content": [{"type": "text", "text": "<json>"}]}
            # text 内部是 JSON: {"organic": [{"title", "link", "snippet", "date"}], "base_resp": ...}
            results = []
            content_items = data.get("content", [])
            for item in content_items:
                if item.get("type") != "text":
                    continue
                text = item.get("text", "")
                try:
                    inner = json.loads(text)
                    organic = inner.get("organic", [])
                    for entry in organic:
                        result = SearchResult(
                            title=entry.get("title", "").strip(),
                            url=entry.get("link", ""),
                            summary=entry.get("snippet", "").strip(),
                            source="minimax",
                            published_at=entry.get("date", ""),
                            query=query
                        )
                        if result.title and result.url:
                            results.append(result)
                except (json.JSONDecodeError, AttributeError):
                    # fallback: 用通用解析
                    parsed = self._parse_search_results(text, "minimax", query)
                    if parsed:
                        results.extend(parsed)
            
            return results
            
        except subprocess.TimeoutExpired:
            print("⚠️ MiniMax MCP 超时")
            return []
        except Exception as e:
            print(f"MiniMax MCP 错误: {e}")
            return []
    
    def _search_tavily(self, query: str, time_range: str) -> List[SearchResult]:
        """使用 Tavily AI Search API 搜索"""
        if not self.config.api_keys.get('tavily'):
            print("⚠️ Tavily API 未配置，跳过")
            return []
        
        try:
            import requests
            
            url = "https://api.tavily.com/search"
            payload = {
                "api_key": self.config.api_keys['tavily'],
                "query": query,
                "search_depth": "basic",
                "max_results": 10,
                "include_answer": False,
                "include_raw_content": False,
            }
            
            response = requests.post(url, json=payload, timeout=self.timeout)
            response.raise_for_status()
            
            data = response.json()
            results = []
            
            for item in data.get("results", []):
                result = SearchResult(
                    title=item.get("title", "").strip(),
                    url=item.get("url", ""),
                    summary=item.get("content", "").strip(),
                    source="tavily",
                    published_at=item.get("published_date", ""),
                    query=query
                )
                if result.title and result.url:
                    results.append(result)
            
            return results
            
        except Exception as e:
            print(f"Tavily API 错误: {e}")
            return []
    
    def _search_claude(self, query: str, time_range: str) -> List[SearchResult]:
        """使用 Brave Search + Claude API 搜索（已弃用）"""
        # Claude API 未配置，跳过
        if not self.config.api_keys.get('claude'):
            print("⚠️ Claude API 未配置，跳过")
            return []
        
        # 首先使用 Brave Search 获取搜索结果
        brave_results = self._brave_search(query, time_range)
        if not brave_results:
            print("⚠️ Brave 搜索未配置或无结果，跳过 Claude 搜索")
            return []
        
        try:
            import anthropic
            
            client = anthropic.Anthropic([REDACTED_SECRET]"claude"])
            
            # 将 Brave 结果格式化为文本供 Claude 分析
            search_context = "\n\n".join([
                f"[{i+1}] {r.get('title', '')}\nURL: {r.get('url', '')}\n{r.get('description', '')}"
                for i, r in enumerate(brave_results[:10])
            ])
            
            prompt = f"""基于以下搜索结果，提取关于"{query}"的最新新闻信息。

搜索结果：
{search_context}

请分析以上结果，提取最相关的新闻，返回 JSON 数组格式：
[
  {{
    "title": "新闻标题",
    "url": "完整URL",
    "summary": "简短摘要",
    "source": "来源网站",
    "published_at": "发布时间"
  }}
]

注意：
- 只返回 JSON 数组，不要其他说明文字
- 最多返回 5 条最相关的结果
- 确保 URL 是完整的"""

            response = client.messages.create(
                model="claude-sonnet-4-5",
                max_tokens=4096,
                messages=[{
                    "role": "user",
                    "content": prompt
                }]
            )
            
            content = response.content[0].text if response.content else ""
            return self._parse_search_results(content, "claude", query)
            
        except Exception as e:
            print(f"Claude API 错误: {e}")
            # 如果 Claude 处理失败，直接返回 Brave 结果
            return self._brave_results_to_search_results(brave_results, query)
    
    def _brave_search(self, query: str, time_range: str) -> List[Dict]:
        """使用 Brave Search API 搜索"""
        [REDACTED_SECRET]"BRAVE_API_KEY", "")
        if not [REDACTED_SECRET] []
        
        try:
            import requests
            
            url = "https://api.search.brave.com/res/v1/web/search"
            headers = {
                "X-Subscription-Token": api_key,
                "Accept": "application/json"
            }
            
            params = {
                "q": query,
                "count": 10,
                "freshness": "day"  # 过去 24 小时
            }
            
            response = requests.get(url, headers=headers, params=params, timeout=self.timeout)
            response.raise_for_status()
            
            data = response.json()
            results = []
            
            for item in data.get("web", {}).get("results", []):
                results.append({
                    "title": item.get("title", ""),
                    "url": item.get("url", ""),
                    "description": item.get("description", ""),
                    "source": item.get("meta", {}).get("domain", "")
                })
            
            return results
            
        except Exception as e:
            print(f"Brave Search 错误: {e}")
            return []
    
    def _brave_results_to_search_results(self, brave_results: List[Dict], query: str) -> List[SearchResult]:
        """将 Brave 结果直接转换为 SearchResult"""
        results = []
        for item in brave_results:
            result = SearchResult(
                title=item.get("title", "未知标题"),
                url=item.get("url", ""),
                summary=item.get("description", ""),
                source=item.get("source", "web"),
                search_api="brave",
                query=query
            )
            results.append(result)
        return results
    
    def _parse_search_results(self, content: str, api_name: str, query: str) -> List[SearchResult]:
        """解析搜索结果文本为结构化数据"""
        results = []
        
        if not content:
            return results
        
        # 尝试提取 JSON
        try:
            # 查找 JSON 代码块
            import re
            json_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', content)
            if json_match:
                json_str = json_match.group(1)
            else:
                json_str = content
            
            # 尝试解析 JSON
            data = json.loads(json_str.strip())
            
            if isinstance(data, list):
                items = data
            elif isinstance(data, dict) and "results" in data:
                items = data["results"]
            elif isinstance(data, dict) and "news" in data:
                items = data["news"]
            else:
                items = [data]
            
            for item in items:
                if isinstance(item, dict):
                    result = SearchResult(
                        title=item.get("title", item.get("标题", "未知标题")),
                        url=item.get("url", item.get("link", item.get("URL", "#"))),
                        summary=item.get("summary", item.get("摘要", item.get("description", ""))),
                        source=item.get("source", item.get("来源", api_name)),
                        published_at=item.get("published_at", item.get("时间", item.get("date", ""))),
                        search_api=api_name,
                        query=query
                    )
                    results.append(result)
                    
        except json.JSONDecodeError:
            # JSON 解析失败，尝试文本解析
            print(f"⚠️ JSON 解析失败，尝试文本解析...")
            results = self._parse_text_results(content, api_name, query)
        
        return results
    
    def _parse_text_results(self, content: str, api_name: str, query: str) -> List[SearchResult]:
        """从文本中提取搜索结果"""
        results = []
        import re
        
        # 简单的标题-URL 匹配模式
        lines = content.split('\n')
        current_item = {}
        
        for line in lines:
            line = line.strip()
            if not line:
                continue
            
            # 匹配 URL
            url_match = re.search(r'https?://[^\s\)]+', line)
            if url_match:
                if current_item.get('title'):
                    results.append(SearchResult(
                        title=current_item.get('title', '未知标题'),
                        url=url_match.group(),
                        summary=current_item.get('summary', ''),
                        source=api_name,
                        search_api=api_name,
                        query=query
                    ))
                    current_item = {}
                current_item['url'] = url_match.group()
            elif len(line) > 10 and not line.startswith('http'):
                if not current_item.get('title'):
                    current_item['title'] = line[:100]
                else:
                    current_item['summary'] = line[:500]
        
        return results


def main():
    """命令行测试"""
    parser = argparse.ArgumentParser(description="Claw-News Search Engine")
    parser.add_argument("--query", "-q", type=str, required=True, help="搜索关键词")
    parser.add_argument("--api", "-a", type=str, choices=["minimax", "tavily"],
                       help="指定 API (默认自动轮询)")
    
    args = parser.parse_args()
    
    engine = SearchEngine()
    result = engine.search(args.query)
    
    if result["success"]:
        print(f"\n✅ 搜索成功 (使用 {result['source']})")
        print(f"找到 {len(result['results'])} 条结果:\n")
        
        for i, r in enumerate(result['results'][:5], 1):
            print(f"{i}. {r.title}")
            print(f"   来源: {r.source}")
            print(f"   URL: {r.url}")
            print(f"   摘要: {r.summary[:100]}..." if len(r.summary) > 100 else f"   摘要: {r.summary}")
            print()
    else:
        print(f"❌ 搜索失败: {result.get('error', '未知错误')}")


if __name__ == "__main__":
    main()


path: scripts/test_kimi_api.py
content: #!/usr/bin/env python3
"""
测试 Kimi API 搜索功能
"""
import os
import sys

# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from openai import OpenAI

def test_kimi_search():
    """测试 Kimi API 搜索"""
    # 从 .env 加载
    env_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")
    if os.path.exists(env_file):
        with open(env_file, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    key, value = line.split("=", 1)
                    key = key.strip()
                    value = value.strip().strip('"').strip("'")
                    if key and value and key not in os.environ:
                        os.environ[key] = value
    
    [REDACTED_SECRET]"KIMI_API_KEY", "")
    print(f"Kimi API Key 设置: {'是' if api_key else '否'}")
    print(f"Key 长度: {len(api_key)}")
    print(f"Key 前缀: {api_key[:20]}..." if api_key else "Key 为空")
    
    if not [REDACTED_SECRET]"❌ 错误: KIMI_API_KEY 未设置")
        return False
    
    try:
        client = OpenAI(
            [REDACTED_SECRET],
            base_url="https://api.moonshot.cn/v1"
        )
        
        # 测试简单对话（不带搜索工具）
        print("\n--- 测试 1: 简单对话 ---")
        response = client.chat.completions.create(
            model="kimi-k2-5",
            messages=[
                {"role": "system", "content": "你是一个助手"},
                {"role": "user", "content": "你好，请回复'测试成功'"}
            ],
            temperature=0.3,
            timeout=30
        )
        print(f"✅ 简单对话成功: {response.choices[0].message.content[:50]}...")
        
        # 测试带 web_search 工具的调用
        print("\n--- 测试 2: 带 web_search 工具的搜索 ---")
        response = client.chat.completions.create(
            model="kimi-k2-5",
            messages=[
                {"role": "system", "content": "你是一个新闻搜索助手"},
                {"role": "user", "content": "搜索关于'人工智能'的最新新闻，返回3条"}
            ],
            tools=[{
                "type": "builtin_function",
                "function": {"name": "web_search"}
            }],
            temperature=0.3,
            timeout=60
        )
        
        content = response.choices[0].message.content
        print(f"✅ 搜索成功，返回内容长度: {len(content)}")
        print(f"内容预览: {content[:200]}...")
        return True
        
    except Exception as e:
        print(f"\n❌ 错误类型: {type(e).__name__}")
        print(f"❌ 错误信息: {e}")
        
        # 尝试获取更详细的错误
        if hasattr(e, 'response'):
            print(f"\n--- HTTP 响应详情 ---")
            print(f"Status: {e.response.status_code if hasattr(e.response, 'status_code') else 'N/A'}")
            if hasattr(e.response, 'text'):
                print(f"Body: {e.response.text[:500]}")
        
        # 检查是否是认证错误
        error_str = str(e).lower()
        if "auth" in error_str or "key" in error_str or "unauthorized" in error_str:
            print("\n⚠️ 可能是 API Key 无效或已过期")
        elif "rate" in error_str or "limit" in error_str:
            print("\n⚠️ 可能是 API 速率限制")
        elif "timeout" in error_str or "time out" in error_str:
            print("\n⚠️ 请求超时")
        
        return False

if __name__ == "__main__":
    success = test_kimi_search()
    sys.exit(0 if success else 1)


path: test_minimax.py
content: #!/usr/bin/env python3
import os
import requests

[REDACTED_SECRET]'

url = 'https://api.minimax.chat/v1/text/chatcompletion_v2'
headers = {
    'Authorization': 'Bearer ' + api_key,
    'Content-Type': 'application/json'
}

payload = {
    'model': 'MiniMax-M2.5',
    'messages': [
        {'role': 'system', 'content': '你是一个新闻搜索助手。请搜索并返回结构化结果。'},
        {'role': 'user', 'content': '搜索关于"人工智能"的最新新闻，返回 JSON 格式：标题、URL、摘要、来源'}
    ],
    'tools': [{'type': 'web_search'}],
    'temperature': 0.3
}

response = requests.post(url, headers=headers, json=payload, timeout=30)
print('Status:', response.status_code)
print('Response:', response.text[:2000])


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