Skill: noah-1106/rss-fetcher (version 1.1.0)

--- name: rss_fetcher version: 1.1.0 description: | 统一的RSS采集与管理系统 | Unified RSS Feed Fetcher and Manager 支持增量抓取、自动去重、自动标签、源健康监控、HTML报告生成 Incremental fetching, auto-dedup, auto-tagging, source health monitoring, HTML reports metadata: openclaw: emoji: "📰" category: "data-source" tags: ["rss", "feed", "news", "fetcher", "tagging"] requires: bins: ["python3"] --- # RSS Fetcher - 统一RSS采集系统 | Unified RSS Feed Fetcher ## 核心特性 | Core Features - **增量抓取 / Incremental Fetching** - 只抓取新文章，基于URL哈希自动去重 | Only fetch new articles, auto-deduplicate based on URL hash - **自动标签 / Auto-tagging** - 优先使用RSS自带category，无则自动提取标题关键词 | Prioritize RSS category, auto-extract keywords from title if absent - **HTML报告 / HTML Reports** - 生成可筛选的静态HTML页面，支持日期/分类/标签多维度筛选 | Generate filterable static HTML pages with date/category/tag filters - **源健康监控 / Source Health Monitoring** - 检测RSS源可用性，支持批量检查 | Monitor RSS source availability with batch checking - **分类管理 / Category Management** - 文章自动继承源的分类，支持多维度筛选 | Articles inherit source categories, multi-dimensional filtering - **超时设置 / Timeout Setting** - 单源30秒超时，避免长时间阻塞 | 30-second timeout per source to avoid blocking --- ## 数据库设计 | Database Design ### 表结构 | Table Structure | 表名 / Table | 用途 / Purpose | 核心字段 / Core Fields | |-------------|---------------|----------------------| | `articles` | 文章主数据 / Article data | id, source_id, **category**, title, url, published_at | | `tags` | 标签定义 / Tag definitions | id, name | | `article_tags` | 文章-标签关联 / Article-tag relation | article_id, tag_id | | `fetch_logs` | 抓取日志 / Fetch logs | source_id, started_at, found, new, status | **说明 / Note**: RSS源通过 `config/sources.json` 文件管理，不存入数据库 | RSS sources are managed via `config/sources.json` file, not in database ### 关键设计 | Key Design - **时间戳使用 INTEGER (Unix时间戳)** - 查询更快、比较更简单 | INTEGER Unix timestamps for faster queries - **published_at NOT NULL** - 必填，缺失时标记为 UNRELIABLE_TIME (1970-01-01) | Required, marked as UNRELIABLE_TIME if missing - **URL唯一索引 / URL Unique Index** - 确保去重 | Ensure deduplication - **多标签支持 / Multi-tag Support** - 一篇文章可拥有多个标签 | Multiple tags per article - **category字段 / Category Field** - 继承自sources.json的分类配置 | Inherited from sources.json configuration --- ## 快速开始 | Quick Start ### 1. 初始化数据库 | Initialize Database ```bash cd skills/rss_fetcher python3 scripts/init_db.py ``` ### 2. 配置RSS源 | Configure RSS Sources 编辑 `config/sources.json`，添加你的RSS源： Edit `config/sources.json` to add your RSS sources: ```json { "sources": [ { "id": "openai", "name": "OpenAI Blog", "url": "https://openai.com/blog/rss.xml", "category": "tech", "enabled": true } ] } ``` ### 3. 执行抓取 | Execute Fetch ```bash # 抓取所有源（最近24小时）/ Fetch all sources (last 24 hours) python3 scripts/fetch.py # 抓取指定源 / Fetch specific sources python3 scripts/fetch.py --sources openai huggingface # 抓取最近48小时 / Fetch last 48 hours python3 scripts/fetch.py --hours 48 # 使用更多线程（默认20，最大50）/ Use more workers (default 20, max 50) python3 scripts/fetch.py --workers 50 ``` **⚠️ 抓取后记得更新HTML报告** - 新抓取的文章需要重新生成页面才能在浏览器中查看 **⚠️ Remember to update HTML report after fetching** - New articles require regeneration to view in browser ```bash python3 scripts/fetch.py && python3 scripts/generate_html.py ``` ### 4. 生成HTML报告 | Generate HTML Report **注意：每次抓取新文章后，必须重新生成HTML页面才能看到最新内容。** **Note: Must regenerate HTML after fetching new articles to see latest content.** ```bash # 抓取并立即更新HTML（推荐工作流）/ Fetch and update HTML (recommended workflow) python3 scripts/fetch.py && python3 scripts/generate_html.py # 单独生成HTML（已有新数据时）/ Generate HTML only (when new data exists) python3 scripts/generate_html.py # 打开查看 / Open to view open data/index.html # Mac # 或浏览器访问 / Or browser: file:///.../rss_fetcher/data/index.html ``` **HTML报告功能 / HTML Report Features**: - 📅 **日期筛选 / Date Filter** - 起止日期选择 | Start/end date selection - 🏷️ **分类筛选 / Category Filter** - 按文章分类筛选 | Filter by article category - 🔍 **关键词搜索 / Keyword Search** - 实时搜索标题 | Real-time title search - ☑️ **标签多选 / Multi-tag Selection** - 多标签组合筛选（AND逻辑）| Multi-tag combo filter (AND logic) - 📊 **实时统计 / Real-time Stats** - 显示筛选结果数量 | Show filtered results count ### 5. 源管理 | Source Management ```bash # 检查所有源的健康状态 / Check all source health python3 scripts/source.py check # 查看源统计 / View source statistics python3 scripts/source.py stats # 添加新源 / Add new source python3 scripts/source.py add myblog "My Blog" "https://example.com/feed.xml" tech # 禁用/启用/删除源 / Disable/enable/remove source python3 scripts/source.py disable myblog python3 scripts/source.py enable myblog python3 scripts/source.py remove myblog ``` ### 6. 查看文章列表 | View Article List ```bash # 终端表格查看最近文章 / View recent articles in terminal table python3 scripts/list.py # 查看最近48小时 / View last 48 hours python3 scripts/list.py --hours 48 # 按分类查看 / View by category python3 scripts/list.py --category tech # JSON格式输出 / JSON output python3 scripts/list.py --json ``` --- ## 配置文件 | Configuration Files ### sources.json ```json { "_description": "RSS源配置文件 | RSS source config file", "_updated": "2026-03-15", "_total_sources": 111, "sources": [ { "id": "openai", "name": "OpenAI Blog", "url": "https://openai.com/blog/rss.xml", "category": "tech", "enabled": true } ] } ``` **字段说明 / Field Description：** - `id` - 源唯一标识 | Source unique identifier - `name` - 显示名称 | Display name - `url` - RSS订阅地址 | RSS feed URL - `category` - 文章分类 | Article category - `enabled` - 是否启用 | Whether enabled **分类可自由定义**，在 `sources.json` 中使用任意分类名称即可。 **Categories can be freely defined** using any category name in `sources.json`. --- ## 自动标签系统 | Auto-tagging System ### 标签生成逻辑 | Tag Generation Logic 1. **优先使用RSS自带category** - 提取 `<category>` 标签内容 **Prioritize RSS category** - Extract `<category>` tag content 2. **Fallback关键词提取** - 无category时从标题提取： **Fallback keyword extraction** - Extract from title if no category: - 规则匹配（AI/区块链/股票等预定义规则）| Rule matching (AI/blockchain/stocks etc.) - 名词提取（英文大写单词、中文词组）| Noun extraction (English caps, Chinese phrases) ### 预定义标签规则 | Predefined Tag Rules | 关键词 / Keywords | 标签 / Tag | |------------------|-----------| | AI, GPT, 大模型, 机器学习 | AI | | 区块链, 比特币, crypto | 区块链 / Blockchain | | 股票, 股市, equity | 股票 / Stocks | | 游戏, gaming, esports | 游戏 / Gaming | | ... | ... | 规则定义在 `fetch.py` 的 `TAG_RULES` 中，可自由扩展。 Rules defined in `TAG_RULES` in `fetch.py`, freely extensible. --- ## 数据查询示例 | Data Query Examples ### 获取今天所有文章 | Get Today's Articles ```sql SELECT title, url, source_id FROM articles WHERE date(fetched_at, 'unixepoch') = date('now') ORDER BY published_at DESC; ``` ### 获取某分类的文章 | Get Articles by Category ```sql SELECT * FROM articles WHERE category = 'tech' AND published_at > strftime('%s', 'now', '-24 hours'); ``` ### 获取带标签的文章 | Get Articles with Tags ```sql SELECT a.title, a.url, GROUP_CONCAT(t.name) as tags FROM articles a LEFT JOIN article_tags at ON a.id = at.article_id LEFT JOIN tags t ON at.tag_id = t.id WHERE a.category = 'tech' GROUP BY a.id; ``` ### 获取热门标签 | Get Popular Tags ```sql SELECT t.name, COUNT(*) as count FROM tags t JOIN article_tags at ON t.id = at.tag_id GROUP BY t.id ORDER BY count DESC; ``` --- ## 文件位置 | File Locations ``` rss_fetcher/ ├── SKILL.md # 本文档 | This document ├── config/ │ └── sources.json # RSS源配置 | RSS source config ├── scripts/ │ ├── init_db.py # 数据库初始化 | DB initialization │ ├── fetch.py # 核心抓取脚本（含自动标签）| Core fetch script │ ├── generate_html.py # HTML报告生成 | HTML report generation │ ├── source.py # 源健康检查与管理 | Source health check │ ├── list.py # 终端文章列表 | Terminal article list │ └── query.py # 数据查询工具 | Data query tool ├── data/ │ ├── rss_fetcher.db # SQLite数据库 | SQLite database │ └── index.html # 生成的HTML报告 | Generated HTML report └── references/ └── schema.sql # 数据库结构参考 | DB schema reference ``` ## 数据库位置 | Database Location ``` rss_fetcher/data/rss_fetcher.db ``` --- ## 注意事项 | Notes 1. **首次抓取会比较慢** - 需要抓所有历史文章 **First fetch is slow** - Need to fetch all historical articles 2. **SQLite并发** - 单进程访问，避免并发写入 **SQLite concurrency** - Single process access, avoid concurrent writes 3. **时间不可靠文章** - published_at = 0 的文章需人工审核 **Unreliable time articles** - Articles with published_at = 0 need manual review 4. **标签自动累积** - 随着文章增多，标签会自动丰富 **Tags auto-accumulate** - Tags enrich as more articles are fetched 5. **定期重新生成HTML** - 抓取新文章后需重新运行 `generate_html.py` **Regularly regenerate HTML** - Must rerun `generate_html.py` after fetching new articles --- *Part of OpenClaw Daily Research System*

path: config/sources.json
content: {
  "_description": "RSS源配置文件",
  "_updated": "2026-03-16",
  "_total_sources": 131,
  "sources": [
    {
      "id": "simonwillison-rss",
      "name": "Simon Willison",
      "url": "https://simonwillison.net/atom/everything/",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "garymarcus-rss",
      "name": "Gary Marcus",
      "url": "https://garymarcus.substack.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "huggingface-rss",
      "name": "Hugging Face Blog",
      "url": "https://huggingface.co/blog/feed.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "openai-rss",
      "name": "OpenAI Blog",
      "url": "https://openai.com/blog/rss.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "sebas-rss",
      "name": "Sebastian Raschka",
      "url": "https://magazine.sebastianraschka.com/feed",
      "category": "tech",
      "enabled": false
    },
    {
      "id": "lilian-rss",
      "name": "Lil'Log (Lilian Weng)",
      "url": "https://lilianweng.github.io/index.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "gwern-rss",
      "name": "Gwern",
      "url": "https://gwern.substack.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "dwarkesh-rss",
      "name": "Dwarkesh Patel",
      "url": "https://dwarkesh.substack.com/feed",
      "category": "tech",
      "enabled": false
    },
    {
      "id": "minimaxir-rss",
      "name": "minimaxir (Max Woolf)",
      "url": "https://minimaxir.com/index.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "googleai-rss",
      "name": "Google AI Blog",
      "url": "https://blog.google/technology/ai/rss/",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "vitalik-rss",
      "name": "Vitalik Buterin",
      "url": "https://vitalik.eth.limo/feed.xml",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "coindesk-rss",
      "name": "CoinDesk",
      "url": "https://feeds.feedburner.com/CoinDesk",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "theblock-rss",
      "name": "The Block",
      "url": "https://www.theblock.co/feeds/news",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "decrypt-rss",
      "name": "Decrypt",
      "url": "https://decrypt.co/feed",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "cointelegraph-rss",
      "name": "Cointelegraph",
      "url": "https://cointelegraph.com/rss",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "hn-rss",
      "name": "Hacker News Frontpage",
      "url": "https://hnrss.org/frontpage",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "ars-rss",
      "name": "Ars Technica",
      "url": "https://feeds.arstechnica.com/arstechnica/index",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "techcrunch-rss",
      "name": "TechCrunch",
      "url": "https://techcrunch.com/feed/",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "verge-rss",
      "name": "The Verge",
      "url": "https://www.theverge.com/rss/index.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "krebs-rss",
      "name": "Krebs on Security",
      "url": "https://krebsonsecurity.com/feed/",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "daringfireball-rss",
      "name": "Daring Fireball",
      "url": "https://daringfireball.net/feeds/main",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "troyhunt-rss",
      "name": "Troy Hunt",
      "url": "https://www.troyhunt.com/rss/",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "antirez-rss",
      "name": "antirez",
      "url": "http://antirez.com/rss",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "mitchellh-rss",
      "name": "Mitchell Hashimoto",
      "url": "https://mitchellh.com/feed.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "geohot-rss",
      "name": "Geohot",
      "url": "https://geohot.github.io/blog/feed.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "ml-reddit-rss",
      "name": "r/MachineLearning",
      "url": "https://www.reddit.com/r/MachineLearning/.rss",
      "category": "tech",
      "enabled": false
    },
    {
      "id": "36kr-rss",
      "name": "36氪",
      "url": "https://36kr.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "synced-rss",
      "name": "机器之心 Synced",
      "url": "https://www.jiqizhixin.com/rss",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "qbitai-rss",
      "name": "量子位 QbitAI",
      "url": "https://www.qbitai.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "infoq-rss",
      "name": "InfoQ 中文",
      "url": "https://www.infoq.cn/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "mit-tech-review-rss",
      "name": "MIT Technology Review",
      "url": "https://www.technologyreview.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "venturebeat-ai-rss",
      "name": "VentureBeat AI",
      "url": "https://venturebeat.com/category/ai/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "404media-rss",
      "name": "404 Media",
      "url": "https://www.404media.co/rss",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "aisnakeoil-rss",
      "name": "AI Snake Oil",
      "url": "https://aisnakeoil.substack.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "bytebytego-rss",
      "name": "ByteByteGo",
      "url": "https://blog.bytebytego.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "nvidia-ai-rss",
      "name": "NVIDIA AI Blog",
      "url": "https://blogs.nvidia.com/feed/",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "deepmind-rss",
      "name": "Google DeepMind Blog",
      "url": "https://deepmind.google/blog/rss.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "producthunt-rss",
      "name": "Product Hunt",
      "url": "https://www.producthunt.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "messari-rss",
      "name": "Messari",
      "url": "https://messari.io/rss",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "defiant-rss",
      "name": "The Defiant",
      "url": "https://thedefiant.io/feed",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "ifanr-rss",
      "name": "爱范儿",
      "url": "https://www.ifanr.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "sspai-rss",
      "name": "少数派",
      "url": "https://sspai.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "wired-rss",
      "name": "Wired",
      "url": "https://www.wired.com/feed/rss",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "ieee-spectrum-rss",
      "name": "IEEE Spectrum",
      "url": "https://spectrum.ieee.org/feeds/feed.rss",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "bensbites-rss",
      "name": "Ben's Bites",
      "url": "https://www.bensbites.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "the-decoder-rss",
      "name": "The Decoder",
      "url": "https://the-decoder.com/feed/",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "a16zcrypto-rss",
      "name": "a16z Crypto",
      "url": "https://a16zcrypto.substack.com/feed",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "bankless-rss",
      "name": "Bankless",
      "url": "https://newsletter.banklesshq.com/feed",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "overreacted-rss",
      "name": "Overreacted (Dan Abramov)",
      "url": "https://overreacted.io/rss.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "eli-bendersky-rss",
      "name": "Eli Bendersky",
      "url": "https://eli.thegreenplace.net/feeds/all.atom.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "matklad-rss",
      "name": "matklad (Alex Kladov)",
      "url": "https://matklad.github.io/feed.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "lucumr-rss",
      "name": "Armin Ronacher",
      "url": "https://lucumr.pocoo.org/feed.atom",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "oldnewthing-rss",
      "name": "The Old New Thing (Raymond Chen)",
      "url": "https://devblogs.microsoft.com/oldnewthing/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "rachelbythebay-rss",
      "name": "Rachel by the Bay",
      "url": "https://rachelbythebay.com/w/atom.xml",
      "category": "tech",
      "enabled": false
    },
    {
      "id": "xeiaso-rss",
      "name": "Xe Iaso",
      "url": "https://xeiaso.net/blog.rss",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "pluralistic-rss",
      "name": "Pluralistic (Cory Doctorow)",
      "url": "https://pluralistic.net/feed/",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "lcamtuf-rss",
      "name": "lcamtuf (Michal Zalewski)",
      "url": "https://lcamtuf.substack.com/feed",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "hillelwayne-rss",
      "name": "Hillel Wayne",
      "url": "https://buttondown.com/hillelwayne/rss",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "dynomight-rss",
      "name": "Dynomight",
      "url": "https://dynomight.net/feed.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "geoffreylitt-rss",
      "name": "Geoffrey Litt",
      "url": "https://www.geoffreylitt.com/feed.xml",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "fabiensanglard-rss",
      "name": "Fabien Sanglard",
      "url": "https://fabiensanglard.net/rss.xml",
      "category": "tech",
      "enabled": false
    },
    {
      "id": "chinanews-rss",
      "name": "中国新闻网",
      "url": "https://www.chinanews.com.cn/rss/scroll-news.xml",
      "category": "local",
      "enabled": true
    },
    {
      "id": "huxiu-rss",
      "name": "虎嗅",
      "url": "https://rss.huxiu.com",
      "category": "local",
      "enabled": true
    },
    {
      "id": "people-ywkx-rss",
      "name": "人民网要闻快讯",
      "url": "http://www.people.com.cn/rss/ywkx.xml",
      "category": "local",
      "enabled": true
    },
    {
      "id": "people-society-rss",
      "name": "人民网社会频道",
      "url": "http://www.people.com.cn/rss/society.xml",
      "category": "local",
      "enabled": true
    },
    {
      "id": "people-military-rss",
      "name": "人民网军事频道",
      "url": "http://www.people.com.cn/rss/military.xml",
      "category": "local",
      "enabled": true
    },
    {
      "id": "cgtn-china-rss",
      "name": "CGTN中国频道",
      "url": "https://www.cgtn.com/subscribe/rss/section/china.xml",
      "category": "local",
      "enabled": true
    },
    {
      "id": "yystv-rss",
      "name": "游研社",
      "url": "https://www.yystv.cn/rss/feed",
      "category": "local",
      "enabled": true
    },
    {
      "id": "appinn-rss",
      "name": "小众软件",
      "url": "https://www.appinn.com/feed/",
      "category": "local",
      "enabled": true
    },
    {
      "id": "bloomberg-rss",
      "name": "Bloomberg",
      "url": "https://feeds.bloomberg.com/news.rss",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "reuters-rss",
      "name": "Reuters",
      "url": "https://www.reuters.com/tools/rss",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "wsj-rss",
      "name": "Wall Street Journal",
      "url": "https://feeds.a.dj.com/rss/WSJcomUSBusiness.xml",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "caixin-rss",
      "name": "财新网",
      "url": "https://china.caixin.com/rss.xml",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "caijing-rss",
      "name": "财经杂志",
      "url": "http://rss.caijing.com.cn",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "21cbh-rss",
      "name": "21世纪经济报道",
      "url": "https://www.21jingji.com/rss.xml",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "cnbc-rss",
      "name": "CNBC",
      "url": "https://www.cnbc.com/id/100003114/device/rss/rss.html",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "ft-rss",
      "name": "Financial Times",
      "url": "https://www.ft.com/rss/home",
      "category": "finance",
      "enabled": true
    },
    {
      "id": "economist-rss",
      "name": "The Economist",
      "url": "https://www.economist.com/rss",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "investopedia-rss",
      "name": "Investopedia",
      "url": "https://www.investopedia.com/rss",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "xueqiu-rss",
      "name": "雪球",
      "url": "https://xueqiu.com/rss",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "eastmoney-rss",
      "name": "东方财富",
      "url": "https://rss.eastmoney.com",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "nejm-rss",
      "name": "New England Journal of Medicine",
      "url": "https://www.nejm.org/rss",
      "category": "health",
      "enabled": false
    },
    {
      "id": "thelancet-rss",
      "name": "The Lancet",
      "url": "https://www.thelancet.com/rss",
      "category": "health",
      "enabled": false
    },
    {
      "id": "bmj-rss",
      "name": "British Medical Journal",
      "url": "https://www.bmj.com/rss",
      "category": "health",
      "enabled": false
    },
    {
      "id": "nature-medicine",
      "name": "Nature Medicine",
      "url": "https://www.nature.com/nm.rss",
      "category": "health",
      "enabled": true
    },
    {
      "id": "statnews-rss",
      "name": "STAT News",
      "url": "https://www.statnews.com/feed",
      "category": "health",
      "enabled": true
    },
    {
      "id": "medpage-rss",
      "name": "MedPage Today",
      "url": "https://www.medpagetoday.com/rss",
      "category": "health",
      "enabled": false
    },
    {
      "id": "dxy-rss",
      "name": "丁香园",
      "url": "https://www.dxy.cn/rss",
      "category": "health",
      "enabled": true
    },
    {
      "id": "healthnews-rss",
      "name": "健康界",
      "url": "https://www.cn-healthcare.com/rss",
      "category": "health",
      "enabled": false
    },
    {
      "id": "ign-rss",
      "name": "IGN",
      "url": "https://www.ign.com/rss.xml",
      "category": "gaming",
      "enabled": false
    },
    {
      "id": "gamespot-rss",
      "name": "GameSpot",
      "url": "https://www.gamespot.com/rss",
      "category": "gaming",
      "enabled": false
    },
    {
      "id": "polygon-rss",
      "name": "Polygon",
      "url": "https://www.polygon.com/rss",
      "category": "gaming",
      "enabled": false
    },
    {
      "id": "kotaku-rss",
      "name": "Kotaku",
      "url": "https://kotaku.com/rss",
      "category": "gaming",
      "enabled": false
    },
    {
      "id": "eurogamer-rss",
      "name": "Eurogamer",
      "url": "https://www.eurogamer.net/rss",
      "category": "gaming",
      "enabled": true
    },
    {
      "id": "gameres-rss",
      "name": "游戏葡萄",
      "url": "https://youxiputao.com/rss",
      "category": "gaming",
      "enabled": false
    },
    {
      "id": "gamersky-rss",
      "name": "游民星空",
      "url": "https://www.gamersky.com/rss",
      "category": "gaming",
      "enabled": false
    },
    {
      "id": "retaildive-rss",
      "name": "Retail Dive",
      "url": "https://www.retaildive.com/rss",
      "category": "ecommerce",
      "enabled": false
    },
    {
      "id": "ecommercebytes-rss",
      "name": "EcommerceBytes",
      "url": "https://www.ecommercebytes.com/rss",
      "category": "ecommerce",
      "enabled": true
    },
    {
      "id": "digitalcommerce-rss",
      "name": "Digital Commerce 360",
      "url": "https://www.digitalcommerce360.com/rss",
      "category": "ecommerce",
      "enabled": false
    },
    {
      "id": "36kr-ecommerce",
      "name": "36氪电商",
      "url": "https://36kr.com/rss",
      "category": "ecommerce",
      "enabled": false
    },
    {
      "id": "tmall-rss",
      "name": "天猫",
      "url": "https://www.tmall.com/rss",
      "category": "ecommerce",
      "enabled": false
    },
    {
      "id": "jd-rss",
      "name": "京东",
      "url": "https://www.jd.com/rss",
      "category": "ecommerce",
      "enabled": false
    },
    {
      "id": "chronicle-rss",
      "name": "Chronicle of Higher Education",
      "url": "https://www.chronicle.com/rss",
      "category": "education",
      "enabled": false
    },
    {
      "id": "insidehighered-rss",
      "name": "Inside Higher Ed",
      "url": "https://www.insidehighered.com/rss",
      "category": "education",
      "enabled": false
    },
    {
      "id": "edweek-rss",
      "name": "Education Week",
      "url": "https://www.edweek.org/rss",
      "category": "education",
      "enabled": true
    },
    {
      "id": "edtech-rss",
      "name": "EdTech Magazine",
      "url": "https://edtechmagazine.com/rss",
      "category": "education",
      "enabled": false
    },
    {
      "id": "arxiv-rss",
      "name": "arXiv",
      "url": "http://export.arxiv.org/rss/cs",
      "category": "tech",
      "enabled": true
    },
    {
      "id": "nature-rss",
      "name": "Nature",
      "url": "https://www.nature.com/nature.rss",
      "category": "education",
      "enabled": true
    },
    {
      "id": "science-rss",
      "name": "Science Magazine",
      "url": "https://www.science.org/rss",
      "category": "education",
      "enabled": false
    },
    {
      "id": "jyb-rss",
      "name": "中国教育报",
      "url": "http://www.jyb.cn/rss",
      "category": "education",
      "enabled": false
    },
    {
      "id": "people-world-rss",
      "name": "人民网国际频道",
      "url": "http://www.people.com.cn/rss/world.xml",
      "category": "local",
      "enabled": true
    },
    {
      "id": "people-finance-rss",
      "name": "人民网财经频道",
      "url": "http://www.people.com.cn/rss/finance.xml",
      "category": "local",
      "enabled": true
    },
    {
      "id": "people-it-rss",
      "name": "人民网科技频道",
      "url": "http://www.people.com.cn/rss/it.xml",
      "category": "local",
      "enabled": true
    },
    {
      "id": "people-sports-rss",
      "name": "人民网体育频道",
      "url": "http://www.people.com.cn/rss/sports.xml",
      "category": "local",
      "enabled": true
    },
    {
      "id": "people-edu-rss",
      "name": "人民网教育频道",
      "url": "http://www.people.com.cn/rss/edu.xml",
      "category": "local",
      "enabled": true
    },
    {
      "id": "reddit-stocks",
      "name": "Reddit r/stocks",
      "url": "https://www.reddit.com/r/stocks/.rss",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "reddit-wsb",
      "name": "Reddit r/wallstreetbets",
      "url": "https://www.reddit.com/r/wallstreetbets/.rss",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "reddit-crypto",
      "name": "Reddit r/CryptoCurrency",
      "url": "https://www.reddit.com/r/CryptoCurrency/.rss",
      "category": "finance",
      "enabled": false
    },
    {
      "id": "reddit-medicine",
      "name": "Reddit r/medicine",
      "url": "https://www.reddit.com/r/medicine/.rss",
      "category": "health",
      "enabled": false
    },
    {
      "id": "reddit-science",
      "name": "Reddit r/science",
      "url": "https://www.reddit.com/r/science/.rss",
      "category": "health",
      "enabled": false
    },
    {
      "id": "reddit-gaming",
      "name": "Reddit r/gaming",
      "url": "https://www.reddit.com/r/gaming/.rss",
      "category": "gaming",
      "enabled": false
    },
    {
      "id": "reddit-games",
      "name": "Reddit r/games",
      "url": "https://www.reddit.com/r/games/.rss",
      "category": "gaming",
      "enabled": false
    },
    {
      "id": "reddit-indiegaming",
      "name": "Reddit r/IndieGaming",
      "url": "https://www.reddit.com/r/IndieGaming/.rss",
      "category": "gaming",
      "enabled": false
    },
    {
      "id": "reddit-ecommerce",
      "name": "Reddit r/ecommerce",
      "url": "https://www.reddit.com/r/ecommerce/.rss",
      "category": "ecommerce",
      "enabled": false
    },
    {
      "id": "reddit-shopify",
      "name": "Reddit r/shopify",
      "url": "https://www.reddit.com/r/shopify/.rss",
      "category": "ecommerce",
      "enabled": false
    },
    {
      "id": "reddit-amazonfba",
      "name": "Reddit r/AmazonFBA",
      "url": "https://www.reddit.com/r/AmazonFBA/.rss",
      "category": "ecommerce",
      "enabled": false
    },
    {
      "id": "reddit-education",
      "name": "Reddit r/education",
      "url": "https://www.reddit.com/r/education/.rss",
      "category": "education",
      "enabled": false
    },
    {
      "id": "reddit-professors",
      "name": "Reddit r/Professors",
      "url": "https://www.reddit.com/r/Professors/.rss",
      "category": "education",
      "enabled": false
    },
    {
      "id": "reddit-china",
      "name": "Reddit r/China",
      "url": "https://www.reddit.com/r/China/.rss",
      "category": "local",
      "enabled": false
    },
    {
      "id": "reddit-shanghai",
      "name": "Reddit r/shanghai",
      "url": "https://www.reddit.com/r/shanghai/.rss",
      "category": "local",
      "enabled": false
    },
    {
      "id": "reddit-beijing",
      "name": "Reddit r/beijing",
      "url": "https://www.reddit.com/r/beijing/.rss",
      "category": "local",
      "enabled": false
    }
  ]
}

path: references/schema.sql
content: -- RSS Fetcher 数据库结构参考
-- 用于手动创建或理解表结构

-- 1. 文章表
CREATE TABLE IF NOT EXISTS articles (
    id TEXT PRIMARY KEY,              -- 文章ID (URL的SHA256)
    source_id TEXT NOT NULL,          -- RSS源ID
    category TEXT,                    -- 文章分类 (继承自sources.json)
    title TEXT NOT NULL,              -- 文章标题
    url TEXT UNIQUE NOT NULL,         -- 文章链接
    author TEXT,                      -- 作者
    published_at INTEGER NOT NULL,    -- 发布时间 (Unix时间戳, 0表示不可信)
    fetched_at INTEGER DEFAULT (strftime('%s', 'now')), -- 抓取时间
    content TEXT,                     -- 正文内容
    status INTEGER DEFAULT 0          -- 0新 1已处理 2已归档
);

-- 2. 标签表
CREATE TABLE IF NOT EXISTS tags (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT UNIQUE NOT NULL,        -- 标签名: ai, llm, finance...
    type INTEGER DEFAULT 0,           -- 0自动 1人工 2系统
    article_count INTEGER DEFAULT 0   -- 关联文章数（冗余统计）
);

-- 3. 文章-标签关联表
CREATE TABLE IF NOT EXISTS article_tags (
    article_id TEXT NOT NULL,
    tag_id INTEGER NOT NULL,
    confidence REAL DEFAULT 1.0,      -- 标签置信度 0.0-1.0
    created_at INTEGER DEFAULT (strftime('%s', 'now')),
    PRIMARY KEY (article_id, tag_id),
    FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE,
    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);

-- 4. 抓取日志表
CREATE TABLE IF NOT EXISTS fetch_logs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    source_id TEXT,                   -- RSS源ID
    started_at INTEGER DEFAULT (strftime('%s', 'now')),
    ended_at INTEGER,
    found INTEGER DEFAULT 0,          -- 发现文章数
    new INTEGER DEFAULT 0,            -- 新增文章数
    status INTEGER,                   -- 0成功 1部分 2失败
    error TEXT                        -- 错误详情
);

-- 索引优化
CREATE INDEX IF NOT EXISTS idx_articles_pub ON articles(published_at);
CREATE INDEX IF NOT EXISTS idx_articles_fetch ON articles(fetched_at);
CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status);
CREATE INDEX IF NOT EXISTS idx_article_tags_tag ON article_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_fetch_logs_time ON fetch_logs(started_at);

-- 常用查询示例

-- 查看今天所有文章
SELECT a.title, a.url, a.source_id
FROM articles a
WHERE date(a.fetched_at, 'unixepoch', 'localtime') = date('now', 'localtime')
ORDER BY a.published_at DESC;

-- 查看时间不可靠的文章
SELECT title, url FROM articles WHERE published_at = 0;

-- 查看抓取统计
SELECT 
    source_id,
    COUNT(*) as article_count,
    MAX(fetched_at) as last_fetch
FROM articles
GROUP BY source_id;


path: scripts/generate_html.py
content: #!/usr/bin/env python3
"""
RSS Fetcher - 生成可筛选的HTML文章列表
所有数据内联，无需服务器
"""

import sqlite3
import json
from datetime import datetime, timedelta
from pathlib import Path

DB_PATH = Path(__file__).parent.parent / "data" / "rss_fetcher.db"
OUTPUT_PATH = Path(__file__).parent.parent / "data" / "index.html"


def generate_html():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    cursor = conn.cursor()
    
    # 获取所有文章（最多500篇）
    cursor.execute('''
        SELECT a.id, a.source_id, a.category, a.title, a.url, a.author, a.published_at,
               GROUP_CONCAT(t.name) as tags
        FROM articles a
        LEFT JOIN article_tags at ON a.id = at.article_id
        LEFT JOIN tags t ON at.tag_id = t.id
        GROUP BY a.id
        ORDER BY a.published_at DESC
        LIMIT 500
    ''')
    rows = cursor.fetchall()
    
    # 获取高频标签（出现次数>=3）
    cursor.execute('''
        SELECT t.name, COUNT(*) as count 
        FROM tags t 
        JOIN article_tags at ON t.id = at.tag_id 
        GROUP BY t.id 
        HAVING count >= 3
        ORDER BY count DESC
    ''')
    all_tags = [row[0] for row in cursor.fetchall()]
    
    conn.close()
    
    # 转换为JS可用的数据
    articles = []
    for row in rows:
        tags_list = row['tags'].split(',') if row['tags'] else []
        articles.append({
            'id': row['id'],
            'title': row['title'],
            'url': row['url'],
            'category': row['category'] or 'unknown',
            'source': row['source_id'],
            'date': datetime.fromtimestamp(row['published_at']).strftime('%Y-%m-%d'),
            'time': datetime.fromtimestamp(row['published_at']).strftime('%H:%M'),
            'tags': tags_list
        })
    
    # 计算日期范围
    if articles:
        dates = [a['date'] for a in articles]
        min_date = min(dates)
        max_date = max(dates)
    else:
        min_date = max_date = datetime.now().strftime('%Y-%m-%d')
    
    # 分类列表
    categories = sorted(set(a['category'] for a in articles))
    
    # 生成HTML
    html = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>RSS Reader</title>
    <style>
        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        body {{ 
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            background: #f5f5f5; 
            line-height: 1.6;
        }}
        .header {{
            background: #1a73e8;
            color: white;
            padding: 20px;
            position: sticky;
            top: 0;
            z-index: 100;
        }}
        .header h1 {{ font-size: 20px; margin-bottom: 5px; }}
        .header .meta {{ font-size: 12px; opacity: 0.9; }}
        
        .filters {{
            background: white;
            padding: 15px 20px;
            border-bottom: 1px solid #e0e0e0;
            display: flex;
            gap: 15px;
            flex-wrap: wrap;
            align-items: flex-start;
        }}
        .filter-group {{ display: flex; align-items: center; gap: 8px; }}
        .filter-group label {{ font-size: 13px; color: #666; font-weight: 500; white-space: nowrap; }}
        .filter-group input, .filter-group select {{
            padding: 6px 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            font-size: 13px;
        }}
        .filter-group input[type="date"] {{ width: 130px; }}
        .filter-group select {{ min-width: 100px; }}
        
        .tag-input-wrapper {{ position: relative; }}
        .tag-suggestions {{
            display: none;
            position: absolute;
            top: 100%;
            left: 0;
            right: 0;
            background: white;
            border: 1px solid #ddd;
            border-radius: 4px;
            max-height: 200px;
            overflow-y: auto;
            z-index: 1000;
            box-shadow: 0 4px 8px rgba(0,0,0,0.1);
        }}
        .tag-suggestions div {{
            padding: 8px 12px;
            cursor: pointer;
            font-size: 13px;
            border-bottom: 1px solid #f0f0f0;
        }}
        .tag-suggestions div:hover {{ background: #f5f5f5; }}
        .selected-tags {{
            display: flex;
            flex-wrap: wrap;
            gap: 5px;
            margin-top: 5px;
        }}
        .selected-tag {{
            background: #e3f2fd;
            color: #1976d2;
            padding: 2px 8px;
            border-radius: 12px;
            font-size: 12px;
            display: inline-flex;
            align-items: center;
            gap: 4px;
        }}
        .selected-tag .remove {{ cursor: pointer; font-weight: bold; }}
        
        .stats {{
            padding: 10px 20px;
            background: #f0f7ff;
            font-size: 13px;
            color: #666;
            border-bottom: 1px solid #e0e0e0;
        }}
        
        .articles {{
            max-width: 900px;
            margin: 0 auto;
            padding: 20px;
        }}
        
        .article {{
            background: white;
            border-radius: 8px;
            padding: 16px 20px;
            margin-bottom: 12px;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
        }}
        
        .article-header {{
            display: flex;
            gap: 10px;
            margin-bottom: 8px;
            align-items: center;
            flex-wrap: wrap;
        }}
        
        .category-badge {{
            font-size: 11px;
            padding: 2px 8px;
            border-radius: 12px;
            background: #e3f2fd;
            color: #1976d2;
            font-weight: 500;
            text-transform: uppercase;
        }}
        
        .tag-badge {{
            font-size: 11px;
            padding: 2px 8px;
            border-radius: 12px;
            background: #f3e5f5;
            color: #7b1fa2;
            font-weight: 500;
        }}
        
        .date-badge {{
            font-size: 12px;
            color: #999;
        }}
        
        .article-title {{
            font-size: 15px;
            color: #202124;
            text-decoration: none;
            display: block;
            line-height: 1.5;
        }}
        
        .article-title:hover {{ color: #1a73e8; }}
        
        .article-source {{
            font-size: 12px;
            color: #666;
            margin-top: 8px;
        }}
        
        .empty {{
            text-align: center;
            padding: 60px 20px;
            color: #999;
        }}
        
        @media (max-width: 600px) {{
            .filters {{ flex-direction: column; align-items: stretch; }}
            .filter-group {{ justify-content: space-between; }}
        }}
    </style>
</head>
<body>
    <div class="header">
        <h1>📰 RSS Reader</h1>
        <div class="meta">更新时间: {datetime.now().strftime('%Y-%m-%d %H:%M')} | 共 {len(articles)} 篇文章</div>
    </div>
    
    <div class="filters">
        <div class="filter-group">
            <label>开始日期</label>
            <input type="date" id="startDate" value="{min_date}" min="{min_date}" max="{max_date}">
        </div>
        <div class="filter-group">
            <label>结束日期</label>
            <input type="date" id="endDate" value="{max_date}" min="{min_date}" max="{max_date}">
        </div>
        <div class="filter-group">
            <label>分类</label>
            <select id="categoryFilter">
                <option value="">全部</option>
'''
    
    for cat in categories:
        html += f'                <option value="{cat}">{cat}</option>\n'
    
    html += f'''            </select>
        </div>
        <div class="filter-group">
            <label>关键词</label>
            <input type="text" id="keywordFilter" placeholder="搜索标题..." style="width: 150px;">
        </div>
        <div class="filter-group" style="flex: 1; min-width: 200px;">
            <label>标签</label>
            <div class="tag-input-wrapper">
                <input type="text" id="tagInput" placeholder="输入标签，回车添加..." style="width: 200px;" autocomplete="off">
                <div class="tag-suggestions" id="tagSuggestions"></div>
            </div>
            <div class="selected-tags" id="selectedTags"></div>
        </div>
        
        <div class="filter-group">
            <button onclick="resetFilters()" style="padding: 6px 15px; background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; font-size: 13px; margin-top: 20px;">重置</button>
        </div>
    </div>
    
    <div class="stats" id="stats">显示全部 {len(articles)} 篇文章</div>
    
    <div class="articles" id="articleList"></div>

    <script>
        // 文章数据（内联）
        const articles = {json.dumps(articles, ensure_ascii=False)};
        
        // 所有可用标签
        const allTags = {json.dumps(all_tags, ensure_ascii=False)};
        let selectedTags = [];
        
        // 默认显示最近24小时
        const now = new Date();
        const yesterday = new Date(now - 24 * 60 * 60 * 1000);
        document.getElementById('startDate').value = yesterday.toISOString().split('T')[0];
        
        // 标签输入处理
        const tagInput = document.getElementById('tagInput');
        const tagSuggestions = document.getElementById('tagSuggestions');
        const selectedTagsContainer = document.getElementById('selectedTags');
        
        function updateSelectedTags() {{
            selectedTagsContainer.innerHTML = selectedTags.map(tag => `
                <span class="selected-tag">
                    ${{tag}}
                    <span class="remove" onclick="removeTag('${{tag}}')">×</span>
                </span>
            `).join('');
            renderArticles();
        }}
        
        function removeTag(tag) {{
            selectedTags = selectedTags.filter(t => t !== tag);
            updateSelectedTags();
        }}
        
        tagInput.addEventListener('input', function() {{
            const value = this.value.toLowerCase().trim();
            if (!value) {{
                tagSuggestions.style.display = 'none';
                return;
            }}
            
            // 过滤已选和匹配的
            const matches = allTags.filter(tag => 
                tag.toLowerCase().includes(value) && !selectedTags.includes(tag)
            ).slice(0, 10);
            
            if (matches.length > 0) {{
                tagSuggestions.innerHTML = matches.map(tag => `
                    <div onclick="selectTag('${{tag}}')">${{tag}}</div>
                `).join('');
                tagSuggestions.style.display = 'block';
            }} else {{
                tagSuggestions.style.display = 'none';
            }}
        }});
        
        tagInput.addEventListener('keydown', function(e) {{
            if (e.key === 'Enter') {{
                const value = this.value.trim();
                if (value && !selectedTags.includes(value) && allTags.includes(value)) {{
                    selectedTags.push(value);
                    updateSelectedTags();
                    this.value = '';
                    tagSuggestions.style.display = 'none';
                }}
                e.preventDefault();
            }}
        }});
        
        document.addEventListener('click', function(e) {{
            if (!e.target.closest('.tag-input-wrapper')) {{
                tagSuggestions.style.display = 'none';
            }}
        }});
        
        function selectTag(tag) {{
            if (!selectedTags.includes(tag)) {{
                selectedTags.push(tag);
                updateSelectedTags();
            }}
            tagInput.value = '';
            tagSuggestions.style.display = 'none';
        }}
        
        function renderArticles() {{
            const startDate = document.getElementById('startDate').value;
            const endDate = document.getElementById('endDate').value;
            const category = document.getElementById('categoryFilter').value;
            const keyword = document.getElementById('keywordFilter').value.toLowerCase().trim();
            
            // 过滤
            let filtered = articles.filter(a => {{
                if (startDate && a.date < startDate) return false;
                if (endDate && a.date > endDate) return false;
                if (category && a.category !== category) return false;
                if (keyword && !a.title.toLowerCase().includes(keyword)) return false;
                // 标签多选：文章必须包含所有选中的标签
                if (selectedTags.length > 0) {{
                    const hasAllTags = selectedTags.every(tag => a.tags.includes(tag));
                    if (!hasAllTags) return false;
                }}
                return true;
            }});
            
            // 更新统计
            document.getElementById('stats').textContent = 
                `显示 ${{filtered.length}} / ${{articles.length}} 篇文章`;
            
            // 渲染列表
            const container = document.getElementById('articleList');
            if (filtered.length === 0) {{
                container.innerHTML = '<div class="empty">没有找到文章</div>';
                return;
            }}
            
            container.innerHTML = filtered.map(a => `
                <div class="article" data-category="${{a.category}}" data-date="${{a.date}}">
                    <div class="article-header">
                        <span class="category-badge">${{a.category}}</span>
                        <span class="date-badge">${{a.date}} ${{a.time}}</span>
                        ${{a.tags.map(t => `<span class="tag-badge">${{t}}</span>`).join('')}}
                    </div>
                    <a href="${{a.url}}" class="article-title" target="_blank">${{a.title}}</a>
                    <div class="article-source">📄 ${{a.source}}</div>
                </div>
            `).join('');
        }}
        
        function resetFilters() {{
            const dates = articles.map(a => a.date);
            document.getElementById('startDate').value = Math.min(...dates);
            document.getElementById('endDate').value = Math.max(...dates);
            document.getElementById('categoryFilter').value = '';
            document.getElementById('keywordFilter').value = '';
            selectedTags = [];
            updateSelectedTags();
        }}
        
        // 监听变化
        document.getElementById('startDate').addEventListener('change', renderArticles);
        document.getElementById('endDate').addEventListener('change', renderArticles);
        document.getElementById('categoryFilter').addEventListener('change', renderArticles);
        document.getElementById('keywordFilter').addEventListener('input', renderArticles);
        
        // 初始渲染
        renderArticles();
    </script>
</body>
</html>'''
    
    OUTPUT_PATH.write_text(html, encoding='utf-8')
    print(f"✅ HTML已生成: {OUTPUT_PATH}")
    print(f"   共 {len(articles)} 篇文章")
    print(f"   日期范围: {min_date} ~ {max_date}")
    print(f"   分类: {', '.join(categories)}")
    print(f"   高频标签: {len(all_tags)} 个")


if __name__ == '__main__':
    generate_html()


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