Files
nanocode/xhs_trending_automation/scripts/tech_blog_fetcher.py
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

254 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
技术博客文章抓取器
从指定技术博客源抓取最近3天内的短文章
"""
import requests
import json
import random
import re
from datetime import datetime, timedelta
from typing import List, Dict, Optional
from urllib.parse import urljoin, urlparse
import feedparser
# 可用技术博客源(2026-03-30 检测)
TECH_SOURCES = [
{"name": "Coding Horror", "url": "https://blog.codinghorror.com/", "type": "rss"},
{"name": "Joel on Software", "url": "https://www.joelonsoftware.com/", "type": "rss"},
{"name": "TechCrunch", "url": "https://techcrunch.com/", "type": "rss"},
{"name": "Stratechery", "url": "https://stratechery.com/", "type": "rss"},
{"name": "Wired", "url": "https://wired.com/", "type": "rss"},
{"name": "Dan Luu", "url": "https://danluu.com/", "type": "rss"},
{"name": "Simon Willison", "url": "https://simonwillison.net/", "type": "rss"},
{"name": "Martin Fowler", "url": "https://martinfowler.com/", "type": "rss"},
{"name": "WorryDream", "url": "https://worrydream.com/", "type": "html"},
{"name": "Microsoft DevBlogs", "url": "https://devblogs.microsoft.com/", "type": "rss"},
{"name": "Flavio Copes", "url": "https://flaviocopes.com/", "type": "rss"},
{"name": "Gwern", "url": "https://gwern.net/", "type": "rss"},
{"name": "GitHub Blog", "url": "https://github.blog/", "type": "rss"},
{"name": "InfoQ中文", "url": "https://www.infoq.cn/", "type": "api"},
{"name": "NVIDIA Blog", "url": "https://developer.nvidia.com/blog/", "type": "rss"},
]
class TechBlogFetcher:
"""技术博客文章抓取器"""
def __init__(self):
self.headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
}
self.max_days = 7 # 放宽到7天内
self.max_chars = 5000 # 放宽字数限制到5000
self.min_chars = 300 # 最小字数
def get_random_sources(self, count: int = 3) -> List[Dict]:
"""随机选择n个数据源"""
return random.sample(TECH_SOURCES, k=min(count, len(TECH_SOURCES)))
def fetch_from_rss(self, source: Dict) -> List[Dict]:
"""从RSS源抓取文章"""
articles = []
try:
# 已知可用的RSS feed(2026-03-30验证)
known_feeds = {
"Coding Horror": "https://blog.codinghorror.com/rss/",
"Joel on Software": "https://www.joelonsoftware.com/feed/",
"TechCrunch": "https://techcrunch.com/feed/",
"Wired": "https://www.wired.com/feed/",
"Dan Luu": "https://danluu.com/atom.xml",
"Simon Willison": "https://simonwillison.net/atom/everything/",
"Martin Fowler": "https://martinfowler.com/feed.atom",
"Gwern": "https://gwern.net/atom.xml",
"Flavio Copes": "https://flaviocopes.com/rss.xml",
"GitHub Blog": "https://github.blog/feed/",
"Microsoft DevBlogs": "https://devblogs.microsoft.com/feed",
"NVIDIA Blog": "https://developer.nvidia.com/blog/feed/",
}
# 获取RSS URL
rss_url = known_feeds.get(source["name"])
if not rss_url:
# 尝试通用路径
rss_url = urljoin(source["url"], "/feed")
print(f" 尝试RSS: {rss_url}")
feed = feedparser.parse(rss_url)
if not feed.entries:
print(f" ⚠️ RSS无条目")
return []
print(f" ✅ 获取到 {len(feed.entries)} 篇RSS条目")
cutoff_date = datetime.now() - timedelta(days=self.max_days)
for entry in feed.entries[:10]: # 最近10篇
# 解析日期
pub_date = None
for date_field in ['published_parsed', 'updated_parsed', 'created_parsed']:
if hasattr(entry, date_field) and getattr(entry, date_field):
pub_date = datetime(*getattr(entry, date_field)[:6])
break
if not pub_date or pub_date < cutoff_date:
continue
article = {
"title": entry.get("title", ""),
"url": entry.get("link", ""),
"source": source["name"],
"published": pub_date.isoformat(),
"summary": entry.get("summary", entry.get("description", ""))[:500],
}
articles.append(article)
except Exception as e:
print(f"RSS抓取失败 {source['name']}: {e}")
return articles
def fetch_from_infoq_cn(self) -> List[Dict]:
"""从InfoQ中文抓取"""
articles = []
try:
url = "https://www.infoq.cn/public/v1/my/recommends"
headers = {
**self.headers,
"Accept": "application/json",
"Referer": "https://www.infoq.cn/",
}
resp = requests.post(url, headers=headers, json={"size": 20}, timeout=15)
data = resp.json()
if data.get("code") == 0:
cutoff_date = datetime.now() - timedelta(days=self.max_days)
for item in data.get("data", []):
pub_time = item.get("publish_time", "")
if pub_time:
try:
pub_date = datetime.fromtimestamp(pub_time / 1000)
if pub_date < cutoff_date:
continue
except:
pass
articles.append({
"title": item.get("article_title", ""),
"url": f"https://www.infoq.cn/article/{item.get('uuid', '')}",
"source": "InfoQ中文",
"published": pub_time,
"summary": item.get("article_summary", "")[:500],
})
except Exception as e:
print(f"InfoQ抓取失败: {e}")
return articles
def fetch_full_content(self, url: str) -> str:
"""抓取文章完整内容"""
try:
resp = requests.get(url, headers=self.headers, timeout=15)
# 使用readability或简单提取
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.text, 'html.parser')
# 移除脚本和样式
for tag in soup(["script", "style", "nav", "header", "footer"]):
tag.decompose()
# 提取正文
article = soup.find('article') or soup.find('main') or soup.find('div', class_=re.compile('content|post|entry'))
if article:
text = article.get_text(separator='\n', strip=True)
else:
text = soup.get_text(separator='\n', strip=True)
# 清理
lines = [line.strip() for line in text.split('\n') if line.strip()]
text = '\n'.join(lines)
return text
except Exception as e:
print(f"抓取全文失败 {url}: {e}")
return ""
def filter_short_articles(self, articles: List[Dict]) -> List[Dict]:
"""筛选短文章(< 5000字符)并获取完整内容"""
result = []
for article in articles:
# 优先使用RSS摘要
content = article.get("summary", "")
# 如果摘要太短,尝试抓取全文
if len(content) < self.min_chars:
content = self.fetch_full_content(article["url"])
if content and len(content) < self.max_chars and len(content) > self.min_chars:
article["content"] = content
result.append(article)
if len(result) >= 3: # 最多3篇
break
return result
def fetch_daily_articles(self) -> Optional[Dict]:
"""
每日抓取主流程
返回一篇适合发布的文章
"""
print("🔍 开始抓取技术博客...")
# 随机选择3个源
sources = self.get_random_sources(3)
all_articles = []
for source in sources:
print(f" 📡 检查 {source['name']}...")
if source["name"] == "InfoQ中文":
articles = self.fetch_from_infoq_cn()
elif source["type"] == "rss":
articles = self.fetch_from_rss(source)
else:
continue
all_articles.extend(articles)
print(f" 找到 {len(articles)} 篇候选")
if not all_articles:
print("❌ 没有找到候选文章")
return None
print(f"\n📄 共 {len(all_articles)} 篇候选,筛选短文章...")
# 筛选短文章
short_articles = self.filter_short_articles(all_articles)
if not short_articles:
print("❌ 没有符合字数要求的文章")
return None
# 随机选择一篇
selected = random.choice(short_articles)
print(f"\n✅ 选中文章: {selected['title'][:50]}...")
print(f" 来源: {selected['source']}")
print(f" 字数: {len(selected['content'])}")
return selected
if __name__ == "__main__":
fetcher = TechBlogFetcher()
article = fetcher.fetch_daily_articles()
if article:
print("\n" + "="*50)
print(f"标题: {article['title']}")
print(f"URL: {article['url']}")
print(f"内容预览:\n{article['content'][:300]}...")