Skill: osgood001/hn-daily (version 1.0.1)

--- name: hn-daily description: Fetch and filter Hacker News top stories. Use when user asks for tech news, HN updates, daily news digest, or wants to set up automated HN fetching. Supports keyword filtering (AI, programming, startups) and caching. --- # Hacker News Daily Fetch top stories from Hacker News with keyword filtering and caching. ## Quick Start ```bash python3 scripts/fetch_hn.py ``` ## Options ```bash python3 scripts/fetch_hn.py --limit 5 # Return 5 stories python3 scripts/fetch_hn.py --min-score 100 # Only stories with 100+ points python3 scripts/fetch_hn.py --no-cache # Force fresh fetch python3 scripts/fetch_hn.py --format json # JSON output ``` ## Proxy Set `HTTP_PROXY` or `HTTPS_PROXY` environment variable if needed: ```bash export HTTPS_PROXY=http://127.0.0.1:7890 python3 scripts/fetch_hn.py ``` ## Output Text format (default): ``` 📰 Hacker News 精选 1. [AI] OpenAI releases new model 🔗 https://... 👍 892 分 | 💬 234 评论 ``` JSON format (`--format json`): Array of story objects with id, title, url, score, etc. ## Caching Results cached for 4 hours at `~/.cache/hn-daily/hn_cache.json`. Use `--no-cache` to bypass. ## Keywords Default filter keywords: ai, llm, gpt, claude, openai, programming, python, rust, startup, machine learning, github, etc.

path: references/hn_api.md
content: # Hacker News API Reference

Official API documentation: https://github.com/HackerNews/API

## Base URL
```
https://hacker-news.firebaseio.com/v0
```

## Endpoints

### Get Top Stories
```
GET /topstories.json
```
Returns: Array of ~500 story IDs (integers), ordered by rank

### Get Story Details
```
GET /item/{id}.json
```
Returns: Story object

Example response:
```json
{
  "by": "dhouston",
  "descendants": 71,
  "id": 8863,
  "kids": [9224, 8917, 8952, 8884, 8887, 8943, 8870, ...],
  "score": 104,
  "time": 1175714200,
  "title": "My YC app: Dropbox - Throw away your USB drive",
  "type": "story",
  "url": "http://www.getdropbox.com/u/2/screencast.html"
}
```

### Get New Stories
```
GET /newstories.json
```

### Get Ask Stories
```
GET /askstories.json
```

### Get Show Stories
```
GET /showstories.json
```

### Get Job Stories
```
GET /jobstories.json
```

## Data Types

### Item (Story/Comment/Job/Poll)
| Field | Type | Description |
|-------|------|-------------|
| id | int | Unique ID |
| type | string | "story", "comment", "job", "poll", "pollopt" |
| by | string | Username of submitter |
| time | int | Unix timestamp |
| title | string | Title (for stories/jobs) |
| text | string | Text content |
| url | string | URL (for stories) |
| score | int | Vote count |
| descendants | int | Comment count |
| kids | [int] | Child comment IDs |

## Rate Limiting

Officially: "There is no rate limit, but please be respectful."

Best practices:
- Cache results for at least 1-4 hours
- Don't request more data than needed
- Use gzip compression (add `Accept-Encoding: gzip` header)

## Python Example

```python
import requests

def get_top_stories(limit=10):
    """获取Top Stories"""
    response = requests.get(
        "https://hacker-news.firebaseio.com/v0/topstories.json"
    )
    story_ids = response.json()[:limit]
    return story_ids

def get_story(story_id):
    """获取单个Story详情"""
    response = requests.get(
        f"https://hacker-news.firebaseio.com/v0/item/{story_id}.json"
    )
    return response.json()

# 使用示例
story_ids = get_top_stories(5)
for sid in story_ids:
    story = get_story(sid)
    print(f"{story['title']} - {story.get('url', 'No URL')}")
```


path: scripts/fetch_hn.py
content: #!/usr/bin/env python3
"""
Hacker News Top Stories Fetcher
抓取 HN Top Stories，支持关键词过滤和缓存
"""

import requests
import json
import os
import time
from datetime import datetime
from pathlib import Path

HN_API_BASE = "https://hacker-news.firebaseio.com/v0"

# 从环境变量读取代理，支持 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY
def get_proxies():
    proxy = os.environ.get('HTTPS_PROXY') or os.environ.get('HTTP_PROXY') or os.environ.get('ALL_PROXY')
    if proxy:
        return {"http": proxy, "https": proxy}
    return None

DEFAULT_CONFIG = {
    "limit": 10,
    "min_score": 50,
    "filter_keywords": [
        "ai", "artificial intelligence", "llm", "gpt", "claude", "openai", "anthropic",
        "programming", "python", "javascript", "rust", "go", "typescript",
        "startup", "entrepreneur", "business", "productivity",
        "machine learning", "deep learning", "neural",
        "open source", "github", "developer", "coding"
    ],
    "cache_duration_hours": 4
}

def get_cache_path():
    cache_dir = Path.home() / ".cache" / "hn-daily"
    cache_dir.mkdir(parents=True, exist_ok=True)
    return cache_dir / "hn_cache.json"

def load_cache():
    cache_path = get_cache_path()
    if not cache_path.exists():
        return None
    try:
        with open(cache_path, 'r', encoding='utf-8') as f:
            cache = json.load(f)
        cached_time = cache.get('timestamp', 0)
        if time.time() - cached_time > DEFAULT_CONFIG['cache_duration_hours'] * 3600:
            return None
        return cache.get('stories', [])
    except Exception:
        return None

def save_cache(stories):
    try:
        with open(get_cache_path(), 'w', encoding='utf-8') as f:
            json.dump({'timestamp': time.time(), 'stories': stories}, f, ensure_ascii=False, indent=2)
    except Exception:
        pass

def fetch_top_stories(limit=50):
    try:
        resp = requests.get(f"{HN_API_BASE}/topstories.json", timeout=10, proxies=get_proxies())
        resp.raise_for_status()
        return resp.json()[:limit]
    except Exception as e:
        print(f"[HN] 获取 Top Stories 失败: {e}")
        return []

def fetch_story_details(story_id):
    try:
        resp = requests.get(f"{HN_API_BASE}/item/{story_id}.json", timeout=10, proxies=get_proxies())
        resp.raise_for_status()
        return resp.json()
    except Exception:
        return None

def matches_keywords(story, keywords):
    text = (story.get('title', '') + ' ' + story.get('text', '')[:200]).lower()
    for kw in keywords:
        if kw.lower() in text:
            return True, kw
    return False, None

def filter_stories(stories, config=None):
    config = config or DEFAULT_CONFIG
    filtered = []
    for story in stories:
        if not story or story.get('score', 0) < config['min_score']:
            continue
        matches, kw = matches_keywords(story, config['filter_keywords'])
        filtered.append({
            'id': story.get('id'),
            'title': story.get('title'),
            'url': story.get('url'),
            'score': story.get('score', 0),
            'by': story.get('by'),
            'time': story.get('time'),
            'descendants': story.get('descendants', 0),
            'matched_keyword': kw if matches else None,
            'is_tech_related': matches
        })
    filtered.sort(key=lambda x: x['score'], reverse=True)
    tech = [s for s in filtered if s['is_tech_related']]
    other = [s for s in filtered if not s['is_tech_related']][:3]
    return (tech + other)[:config['limit']]

def format_output(stories, format='text'):
    if not stories:
        return "📰 今日 HN：暂无匹配的技术新闻"
    
    if format == 'json':
        return json.dumps(stories, ensure_ascii=False, indent=2)
    
    lines = ["📰 Hacker News 精选\n"]
    for i, s in enumerate(stories[:10], 1):
        tag = f"[{s['matched_keyword'].upper()}] " if s.get('matched_keyword') else ""
        lines.append(f"{i}. {tag}{s['title']}")
        if s.get('url'):
            lines.append(f"   🔗 {s['url']}")
        lines.append(f"   👍 {s['score']} 分 | 💬 {s.get('descendants', 0)} 评论\n")
    return "\n".join(lines)

def main():
    import argparse
    parser = argparse.ArgumentParser(description='Fetch Hacker News top stories')
    parser.add_argument('--limit', type=int, default=10, help='Number of stories to return')
    parser.add_argument('--min-score', type=int, default=50, help='Minimum score filter')
    parser.add_argument('--no-cache', action='store_true', help='Skip cache')
    parser.add_argument('--format', choices=['text', 'json'], default='text', help='Output format')
    args = parser.parse_args()

    config = {**DEFAULT_CONFIG, 'limit': args.limit, 'min_score': args.min_score}

    if not args.no_cache:
        cached = load_cache()
        if cached:
            print(format_output(cached[:args.limit], args.format))
            return

    story_ids = fetch_top_stories(50)
    if not story_ids:
        print("[HN] 获取失败")
        return

    stories = [fetch_story_details(sid) for sid in story_ids]
    stories = [s for s in stories if s]
    filtered = filter_stories(stories, config)
    save_cache(filtered)
    print(format_output(filtered, args.format))

if __name__ == "__main__":
    main()


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