commit 2623da6b7eb29364371323e12c2fba04b9ae7ff7 Author: 大师 Date: Tue May 19 01:04:12 2026 +0800 newsminimalist RSS pipeline: browserless scraper + server + Chinese translation diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ff8d95 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# NewsMinimalist RSS + +从 [newsminimalist.com](https://www.newsminimalist.com) 抓取 Gemini AI 评分的新闻,生成 RSS Feed + HTML 页面。 + +## 架构 + +``` +browserless (Chrome headless) + ↓ /content API +scraper.py → 解析
→ 提取 [score] title (source) link + ↓ JSON 缓存 +server.py → RSS 2.0 + HTML + ↓ NPM 反代 +https://rsshub.arabiancloud.online/newsminimalist +``` + +## 文件 + +| 文件 | 说明 | +|:-----|:-----| +| `scraper.py` | 爬虫:browserless 渲染 → BeautifulSoup 解析 → Google 翻译中文 → JSON 缓存 | +| `server.py` | 服务:读 JSON 缓存 → 输出 RSS 2.0 + Atom + HTML | + +## 部署 + +```bash +# 1. 拉取浏览器镜像 +docker pull browserless/chrome + +# 2. 构建并运行 +docker build -t newsminimalist-rss . +docker run -d --name newsminimalist-rss -p 1202:1202 \ + --network rsshub_default \ + -v /root/news_cache.json:/root/news_cache.json \ + newsminimalist-rss + +# 3. 定时抓取(建议 UTC 02:50, 14:50) +crontab -e +50 2,14 * * * docker exec newsminimalist-rss python3 /app/scraper.py +``` diff --git a/scraper.py b/scraper.py new file mode 100644 index 0000000..06d0ae6 --- /dev/null +++ b/scraper.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""newsminimalist-scraper — minimal working + Chinese translation""" +import json, re, time, urllib.parse +from datetime import datetime, timezone +import requests +from bs4 import BeautifulSoup + +CACHE_FILE = '/root/news_cache.json' + + +def translate(text): + if not text: return '' + try: + url = f'https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=zh-CN&dt=t&q={urllib.parse.quote(text[:500])}' + r = requests.get(url, timeout=5) + if r.status_code == 200: + result = r.json() + if result and result[0]: + return ''.join(p[0] for p in result[0] if p[0]) + except: pass + return text + + +# === Main scrape logic (verified working) === +r = requests.post('http://browserless:3000/content', json={'url': 'https://www.newsminimalist.com/'}, timeout=60) +soup = BeautifulSoup(r.text, 'lxml') + +articles = [] +for d in soup.find_all('details'): + summary = d.find('summary') + if not summary: continue + score_span = summary.find('span', title=re.compile('Significance')) + if not score_span: continue + mr = summary.find('div', class_=re.compile('mr-auto')) + if not mr: continue + + spans = mr.find_all('span', recursive=False) + title = '' + source = '' + link = '' + + for s in spans: + cls = ' '.join(s.get('class', [])) + if 'inline-block' in cls: + src_text = s.get_text(strip=True).lstrip('(').rstrip(')') + m = re.match(r'([^\s+]+)', src_text) + if m: source = m.group(1) + else: + if not title: title = s.get_text(strip=True) + + if not title: continue + + for a in d.find_all('a', href=True): + href = a['href'] + if href.startswith('http') and 'newsminimalist.com' not in href: + link = href + break + + score_text = score_span.get_text(strip=True) + sm = re.search(r'(\d+\.?\d*)', score_text) + score = float(sm.group(1)) if sm else 0 + + articles.append({ + 'title': title, + 'link': link or 'https://www.newsminimalist.com', + 'score': score, + 'source': source, + 'summary': '', + 'title_zh': '', + }) + +# Dedup & sort +seen = set() +unique = [a for a in articles if not (a['title'][:80] in seen or seen.add(a['title'][:80]))] +unique.sort(key=lambda a: a['score'], reverse=True) + +print(f'Scraped {len(unique)} articles') + +# Chinese translation (top 30) +for a in unique: + try: + a['title_zh'] = translate(a['title']) + time.sleep(0.15) + except: + a['title_zh'] = a['title'] + +translated = sum(1 for a in unique[:30] if a.get('title_zh')) +print(f' +{translated} Chinese translations') + +data = { + 'date': datetime.now(timezone.utc).strftime('%Y-%m-%d'), + 'updated': datetime.now(timezone.utc).isoformat(), + 'count': len(unique), + 'news': unique, +} + +with open(CACHE_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + +for a in unique[:3]: + zh = a.get('title_zh', '') + print(f' [{a["score"]}] {a["title"][:70]}') + if zh: print(f' 🇨🇳 {zh[:70]}') diff --git a/server.py b/server.py new file mode 100644 index 0000000..f2a36cc --- /dev/null +++ b/server.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""News Minimalist RSS Server — serves RSS/HTML from scraped cache""" +import http.server +import socketserver +import json +import os +import time +from datetime import datetime, timezone +from collections import defaultdict + +PORT = 1202 +CACHE_FILE = '/root/news_cache.json' + + +def load_cache(): + try: + with open(CACHE_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return {'news': [], 'date': 'unknown', 'count': 0} + + +def generate_rss(data): + """Generate RSS 2.0 XML with Atom self-link.""" + news = data.get('news', []) + cat = data.get('category', 'all') + score_range = data.get('score_range', '0-10') + updated = data.get('updated', '') + + cat_label = 'All Categories' if cat == 'all' else cat.title() + + items_xml = '' + for n in news[:50]: + title = n.get('title', '') + title_zh = n.get('title_zh', '') + link = n.get('link', BASE_URL) + score = n.get('score') + source = n.get('source', '') + summary = n.get('summary', '') + + prefix = f'[{score}] ' if score is not None else '' + desc = f'

Significance: {score}/10

' if score is not None else '' + if title_zh: + desc += f'

🇨🇳 中文: {title_zh}

' + if summary: + desc += f'

AI Analysis: {summary}

' + if source: + desc += f'

Source: {source}

' + + items_xml += f''' + {prefix}{title} + {link} + {link} + + {source or 'News Minimalist'} + +''' + + return f''' + + + News Minimalist — {cat_label} [{score_range}] + {BASE_URL} + AI-curated significant news. Category: {cat_label}, Score: {score_range}. Scored 0-10 by Gemini. + en + {updated} + +{items_xml} +''' + + +def generate_html(data): + """Generate beautiful HTML page.""" + news = data.get('news', []) + cache_date = data.get('date', 'unknown') + updated = data.get('updated', '') + cat = data.get('category', 'all') + score_range = data.get('score_range', '0-10') + + if not news: + return ''' +

📭 No articles cached yet

+

Cache will be populated on next scrape cycle.

+ ''' + + # Group by score tiers + hot = [n for n in news if n.get('score') and n['score'] >= 6.5] + notable = [n for n in news if n.get('score') and 6.0 <= n['score'] < 6.5] + rest = [n for n in news if n.get('score') and n['score'] < 6.0] + [n for n in news if n.get('score') is None] + + def render_items(items, color, badge): + html = '' + for n in items: + score = n.get('score') + title = n.get('title', '') + title_zh = n.get('title_zh', '') + link = n.get('link', '') + source = n.get('source', '') + summary = n.get('summary', '') + + display_title = title_zh or title + subtitle = title if title_zh else '' + + html += f'''
+
{badge} {score}
+
+ {display_title} + {f'
{title}
' if subtitle else ''} + {f'

{summary}

' if summary else ''} + {source or 'newsminimalist.com'} +
+
''' + return html + + body = '' + if hot: + body += '

🔥 Trending (6.5+)

' + render_items(hot, '#ef4444', '🔥') + if notable: + body += '

⭐ Notable (6.0-6.4)

' + render_items(notable, '#3b82f6', '⭐') + if rest: + body += '

📰 All Articles

' + render_items(rest, '#22c55e', '📰') + + return f''' + + + + + News Minimalist — RSS Feed + + + +
+

🤖 News Minimalist

+
AI-curated news · {cat} · Score {score_range} · Cache: {cache_date}
+
{len(news)} articles
+ 📡 RSS Feed +
+
+ {body} +
+ +''' + + +BASE_URL = 'https://www.newsminimalist.com' + + +class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + if self.path in ['/health', '/ping']: + data = load_cache() + age = time.time() - os.path.getmtime(CACHE_FILE) if os.path.exists(CACHE_FILE) else 99999 + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({ + 'status': 'ok', + 'cache_age_hours': round(age / 3600, 1), + 'article_count': data.get('count', 0), + 'date': data.get('date', 'unknown'), + }).encode()) + return + + if self.path.startswith('/rss') or self.path == '/feed': + data = load_cache() + rss = generate_rss(data) + self.send_response(200) + self.send_header('Content-Type', 'application/rss+xml; charset=utf-8') + self.send_header('Cache-Control', 'public, max-age=14400') + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + self.wfile.write(rss.encode('utf-8')) + return + + if self.path in ['/', '/home', '/index.html']: + data = load_cache() + html = generate_html(data) + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.end_headers() + self.wfile.write(html.encode('utf-8')) + return + + self.send_response(404) + self.end_headers() + self.wfile.write(b'Not Found') + + +if __name__ == '__main__': + print(f'News Minimalist RSS on :{PORT} — scraping newsminimalist.com') + with socketserver.TCPServer(('', PORT), Handler) as httpd: + httpd.serve_forever()