㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!🔥🔥
㊗️爬虫难度指数:⭐
🚫声明:数据仅供个人学习数据分析使用,严禁用于商业比价系统或倒卖数据等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议"。

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- [📝 摘要(Abstract)](#📝 摘要(Abstract))
- [1️⃣ 背景与需求(Why)](#1️⃣ 背景与需求(Why))
-
- [为什么选择 Hacker News?](#为什么选择 Hacker News?)
- 目标字段清单
- [2️⃣ 合规与注意事项(必读)](#2️⃣ 合规与注意事项(必读))
- [3️⃣ 技术选型与整体流程(What/How)](#3️⃣ 技术选型与整体流程(What/How))
- [4️⃣ 环境准备与依赖安装](#4️⃣ 环境准备与依赖安装)
- [5️⃣ 核心实现:请求层(Fetcher)](#5️⃣ 核心实现:请求层(Fetcher))
-
- [config.py - 配置管理](#config.py - 配置管理)
- [fetcher.py - 请求封装](#fetcher.py - 请求封装)
- [6️⃣ 核心实现:解析层(Parser)](#6️⃣ 核心实现:解析层(Parser))
- [7️⃣ 数据存储与导出(Storage)](#7️⃣ 数据存储与导出(Storage))
-
- [storage.py - 双存储实现](#storage.py - 双存储实现)
- [8️⃣ 主程序与运行方式(Main)](#8️⃣ 主程序与运行方式(Main))
- [9️⃣ 常见问题与排错(FAQ)](#9️⃣ 常见问题与排错(FAQ))
-
- [Q1: 遇到 403 Forbidden 怎么办?](#Q1: 遇到 403 Forbidden 怎么办?)
- [Q2: API 返回数据为空怎么办?](#Q2: API 返回数据为空怎么办?)
- [Q3: HTML 解析总是报错怎么办?](#Q3: HTML 解析总是报错怎么办?)
- [Q4: 中文乱码如何处理?](#Q4: 中文乱码如何处理?)
- [Q5: 如何判断是否被限流?](#Q5: 如何判断是否被限流?)
- [🔟 进阶优化(Advanced)](#🔟 进阶优化(Advanced))
-
- [1. 异步并发抓取](#1. 异步并发抓取)
- [2. 断点续爬](#2. 断点续爬)
- [3. 日志与监控](#3. 日志与监控)
- [4. 定时任务](#4. 定时任务)
- [5. 数据分析扩展](#5. 数据分析扩展)
- [1️⃣1️⃣ 总结与延伸阅读](#1️⃣1️⃣ 总结与延伸阅读)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》
订阅后更新会优先推送,按目录学习更高效~
📝 摘要(Abstract)
本文将手把手教你构建一个完整的 Hacker News 数据采集系统,通过官方 Firebase API 和HTML 爬虫两种方案对比,最终产出包含标题、分数、评论数、链接的结构化数据集(CSV + SQLite)。
读完本文你将获得:
- 掌握 REST API 调用与 HTML 解析两种数据采集思路的实战经验
- 学会设计健壮的爬虫架构(请求层-解析层-存储层分离)
- 了解合规采集的边界与最佳实践,避免踩坑
1️⃣ 背景与需求(Why)
为什么选择 Hacker News?
Hacker News(简称 HN)作为全球最具影响力的科技社区之一,每天产出大量高质于个人开发者、数据分析师或内容运营者来说,定期采集 HN 的热门内容可以:
- 信息聚合:构建个人技术资讯库,过滤噪音
- 趋势分析:追踪技术热点变化(AI、Web3、开源项目等)
- 学习素材:收集高质量讨论供后续学习
目标字段清单
| 字段名 | 类型 | 说明 | 示例值 |
|---|---|---|---|
| id | int | H唯一 ID | 38967234 |
| title | str | 新闻标题 | "Show HN: I built a..." |
| score | int | 得分(点赞数) | 342 |
| by | str | 发布者用户名 | "pg" |
| time | int | 发布时间戳 | 1706342400 |
| url | str | 外部链接 | "https://example.com" |
| descendants | int | 评论总数 | 128 |
| type | str | 类型 | "story" |
2️⃣ 合规与注意事项(必读)
robots.txt 基本说明
访问 https://news.ycombinator.com/robots.txt 可以看到:
json
User-Agent: *
Disallow: /x?
Disallow: /r?
Disallow: /vote?
Disallow: /reply?
HN 允许 抓取新闻列表和详情页,但禁止对投票、回复等交互接口进行自动化操作。
频率控制原则
- 官方 API :无明确限流文档,但建议控制在 1 req/s 以内
- HTML 爬虫 :必须加 User-Agent ,间隔 2-3 秒,避免被视为攻击
- 严禁并发:初期单线程运行,稳定后再考虑优化
数据使用边界
- ✅ 个人学习、非商业数据分析
- ✅ 开源项目、学术研究
- ❌ 批量采集用户隐私信息(邮箱、IP)
- ❌ 绕过登录墙抓取付费内容(HN 无此限制,但通用原则)
3️⃣ 技术选型与整体流程(What/How)
方案对比
| 维度 | 官方 API | HTML 爬虫 |
|---|---|---|
| 稳定性 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 速度 | 快(JSON 响应) | 慢(需解析 HTML) |
| 合规性 | 官方推荐 | 需遵守 robots.txt |
| 数据完整性 | 字段标准化 | 依赖页面结构 |
| 学习价值 | API 调用 | DOM 解析技巧 |
本文选择:优先使用官方 API,辅以 HTML 爬虫作为对比学习
数据流转流程
json
[API/HTML请求] → [JSON/HTML解析] → [数据清洗] → [去重] → [存储(SQLite/CSV)]
↓ ↓ ↓ ↓
Fetcher层 Parser层 Cleaner层 Storage层
为什么这样设计?
- 分层架构:每层职责单一,便于调试和扩展
- 容错性:某个环节失败不影响其他部分
- 可测试:每层可独立单元测试
4️⃣ 环境准备与依赖安装
Python 版本要求
bash
Python >= 3.8
依赖安装
bash
pip install requests beautifulsoup4 lxml pandas
可选依赖(进阶功能):
bash
pip install schedule # 定时任务
pip install loguru # 日志增强
推荐项目结构
json
hn_scraper/
├── config.py # 配置文件
├── fetcher.py # 请求层
├── parser.py # 解析层
├── storage.py # 存储层
├── main.py # 主入口
├── requirements.txt # 依赖清单
├── data/ # 数据目录
│ ├── hn_news.db # SQLite 数据库
│ └── hn_news.csv # CSV 导出
└── logs/ # 日志目录
5️⃣ 核心实现:请求层(Fetcher)
config.py - 配置管理
python
# config.py
import os
# API 配置
HN_API_BASE = "https://hacker-news.firebaseio.com/v0"
HN_TOP_STORIES = f"{HN_API_BASE}/topstories.json"
HN_ITEM_DETAIL = f"{HN_API_BASE}/item/{{item_id}}.json"
# HTML 配置
HN_WEB_BASE = "https://news.ycombinator.com"
HN_NEWS_PAGE = f"{HN_WEB_BASE}/news"
# 请求配置
TIMEOUT = 10
MAX_RETRIES = 3
RETRY_DELAY = 2 # 秒
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json, text/html',
'Accept-Language': 'en-US,en;q=0.9',
}
# 存储配置
DB_PATH = os.path.join('data', 'hn_news.db')
CSV_PATH = os.path.join('data', 'hn_news.csv')
# 采集配置
TOP_N_STORIES = 100 # 抓取前 N 条新闻
REQUEST_INTERVAL = 1 # 请求间隔(秒)
fetcher.py - 请求封装
python
# fetcher.py
import time
import requests
from typing import Optional, Dict, List
from config import *
class HNFetcher:
"""Hacker News 数据获取器"""
def __init__(self):
self.session = requests.Session()
self.session.headers.update(HEADERS)
def _request_with_retry(self, url: str, method: str = 'GET') -> Optional[requests.Response]:
"""带重试机制的请求"""
for attempt in range(MAX_RETRIES):
try:
resp = self.session.request(
method=method,
url=url,
timeout=TIMEOUT
)
resp.raise_for_status()
return resp
except requests.exceptions.Timeout:
print(f"⏱️ 超时重试 ({attempt + 1}/{MAX_RETRIES}): {url}")
time.sleep(RETRY_DELAY * (attempt + 1)) # 指数退避
except requests.exceptions.HTTPError as e:
if resp.status_code == 429:
print(f"🚦 触发限流,等待 {RETRY_DELAY * 3} 秒...")
time.sleep(RETRY_DELAY * 3)
else:
print(f"❌ HTTP 错误 {resp.status_code}: {url}")
break
except Exception as e:
print(f"⚠️ 请求异常: {e}")
break
return None
def get_top_story_ids(self) -> List[int]:
"""获取热门新闻 ID 列表"""
resp = self._request_with_retry(HN_TOP_STORIES)
if resp and resp.status_code == 200:
return resp.json()[:TOP_N_STORIES]
return []
def get_item_detail(self, item_id: int) -> Optional[Dict]:
"""获取单条新闻详情"""
url = HN_ITEM_DETAIL.format(item_id=item_id)
resp = self._request_with_retry(url)
if resp and resp.status_code == 200:
return resp.json()
return None
def get_html_page(self, page_url: str = HN_NEWS_PAGE) -> Optional[str]:
"""获取 HTML 页面(用于对比)"""
resp = self._request_with_retry(page_url)
if resp:
return resp.text
return None
关键设计说明:
- Session 复用:减少 TCP 握手开销
- 指数退避 :
RETRY_DELAY * (attempt + 1)避免雪崩 - 429 处理:限流时加倍等待时间
- 超时控制:10 秒超时防止挂死
6️⃣ 核心实现:解析层(Parser)
parser.py - 数据解析
python
# parser.py
from typing import Dict, List, Optional
from bs4 import BeautifulSoup
from datetime import datetime
class HNParser:
"""Hacker News 数据解析器"""
@staticmethod
def parse_api_item(item_data: Dict) -> Optional[Dict]:
"""解析 API 返回的单条数据"""
if not item_data or item_data.get('type') != 'story':
return None
try:
return {
'id': item_data.get('id'),
'title': item_data.get('title', '').strip(),
'score': item_data.get('score', 0),
'by': item_data.get('by', 'unknown'),
'time': item_data.get('time', 0),
'url': item_data.get('url', f"https://news.ycombinator.com/item?id={item_data.get('id')}"),
'descendants': item_data.get('descendants', 0), # 评论数
'type': item_data.get('type', 'story'),
'crawl_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
except Exception as e:
print(f"⚠️ 解析 API 数据失败: {e}")
return None
@staticmethod
def parse_html_page(html: str) -> List[Dict]:
"""解析 HTML 页面(对比方案)"""
soup = BeautifulSoup(html, 'lxml')
stories = []
# HN 的 HTML 结构:<tr class="athing"> 包含新闻主体
for idx, item in enumerate(soup.select('tr.athing'), 1):
try:
# 提取标题和链接
title_elem = item.select_one('span.titleline > a')
if not title_elem:
continue
title = title_elem.get_text(strip=True)
url = title_elem.get('href', '')
# 提取 ID
item_id = int(item.get('id', 0))
# 下一行包含分数和评论数
subtext = item.find_next_sibling('tr')
if subtext:
score_elem = subtext.select_one('span.score')
score = int(score_elem.get_text().split()[0]) if score_elem else 0
comment_elem = subtext.select('a')[-1] # 最后一个 a 标签是评论链接
comments_text = comment_elem.get_text()
descendants = int(comments_text.split()[0]) if 'comment' in comments_text else 0
else:
score, descendants = 0, 0
stories.append({
'id': item_id,
'title': title,
'score': score,
'url': url if url.startswith('http') else f"https://news.ycombinator.com/{url}",
'descendants': descendants,
'rank': idx,
'crawl_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
})
except Exception as e:
print(f"⚠️ 解析第 {idx} 条新闻失败: {e}")
continue
return stories
@staticmethod
def clean_data(item: Dict) -> Dict:
"""数据清洗"""
# 标题去除多余空格
if 'title' in item:
item['title'] = ' '.join(item['title'].split())
# URL 补全
if 'url' in item and not item['url'].startswith('http'):
item['url'] = f"https://news.ycombinator.com/item?id={item['id']}"
# 时间戳转换
if 'time' in item and item['time']:
item['publish_date'] = datetime.fromtimestamp(item['time']).strftime('%Y-%m-%d %H:%M:%S')
return item
解析要点:
- CSS 选择器 :
tr.athing定位新闻行,span.titleline > a抓标题 - 容错处理:每条新闻独立 try-except,单条失败不影响整体
- 字段映射:统一 API 和 HTML 的输出格式
7️⃣ 数据存储与导出(Storage)
storage.py - 双存储实现
python
# storage.py
import sqlite3
import pandas as pd
from typing import List, Dict
from config import DB_PATH, CSV_PATH
import os
class HNStorage:
"""Hacker News 数据存储器"""
def __init__(self):
os.makedirs('data', exist_ok=True)
self.conn = sqlite3.connect(DB_PATH)
self._init_db()
def _init_db(self):
"""初始化数据库表"""
cursor = self.conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS hn_stories (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
score INTEGER DEFAULT 0,
by TEXT,
time INTEGER,
publish_date TEXT,
url TEXT,
descendants INTEGER DEFAULT 0,
type TEXT,
crawl_time TEXT,
UNIQUE(id)
)
''')
self.conn.commit()
def save_to_db(self, items: List[Dict]) -> int:
"""批量保存到数据库(去重)"""
cursor = self.conn.cursor()
success_count = 0
for item in items:
try:
cursor.execute('''
INSERT OR REPLACE INTO hn_stories
(id, title, score, by, time, publish_date, url, descendants, type, crawl_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
item.get('id'),
item.get('title'),
item.get('score', 0),
item.get('by'),
item.get('time'),
item.get('publish_date'),
item.get('url'),
item.get('descendants', 0),
item.get('type', 'story'),
item.get('crawl_time')
))
success_count += 1
except sqlite3.IntegrityError:
print(f"⚠️ ID {item.get('id')} 已存在,跳过")
except Exception as e:
print(f"❌ 保存失败: {e}")
self.conn.commit()
return success_count
def export_to_csv(self):
"""导出到 CSV"""
df = pd.read_sql_query("SELECT * FROM hn_stories ORDER BY score DESC", self.conn)
df.to_csv(CSV_PATH, index=False, encoding='utf-8-sig')
print(f"✅ 已导出 {len(df)} 条数据到 {CSV_PATH}")
def get_stats(self) -> Dict:
"""获取统计信息"""
cursor = self.conn.cursor()
total = cursor.execute("SELECT COUNT(*) FROM hn_stories").fetchone()[0]
avg_score = cursor.execute("SELECT AVG(score) FROM hn_stories").fetchone()[0]
top_story = cursor.execute(
"SELECT title, score FROM hn_stories ORDER BY score DESC LIMIT 1"
).fetchone()
return {
'total': total,
'avg_score': round(avg_score, 2) if avg_score else 0,
'top_story': top_story
}
def close(self):
self.conn.close()
字段映射表:
| 字段名 | SQLite 类型 | 示例值 | 说明 |
|---|---|---|---|
| id | INTEGER PRIMARY KEY | 38967234 | 唯一主键 |
| title | TEXT NOT NULL | "Show HN: AI Code Review" | 非空标题 |
| score | INTEGER | 342 | 默认 0 |
| by | TEXT | "pg" | 发布者 |
| time | INTEGER | 1706342400 | Unix 时间戳 |
| publish_date | TEXT | "2025-01-27 14:30:00" | 可读时间 |
| url | TEXT | "https://..." | 外链 |
| descendants | INTEGER | 128 | 评论数 |
| type | TEXT | "story" | 内容类型 |
| crawl_time | TEXT | "2025-01-27 15:00:00" | 采集时间 |
去重策略:
- 使用
INSERT OR REPLACE+UNIQUE(id)约束 - 同一 ID 重复抓取时更新数据(获取最新分数)
8️⃣ 主程序与运行方式(Main)
main.py - 完整流程
python
# main.py
import time
from fetcher import HNFetcher
from parser import HNParser
from storage import HNStorage
from config import REQUEST_INTERVAL
def crawl_with_api():
"""使用 API 方式抓取"""
print("🚀 开始使用 API 抓取 Hacker News...")
fetcher = HNFetcher()
parser = HNParser()
storage = HNStorage()
# 1. 获取热门新闻 ID 列表
story_ids = fetcher.get_top_story_ids()
print(f"📋 获取到 {len(story_ids)} 条新闻 ID")
# 2. 逐条获取详情
stories = []
for idx, story_id in enumerate(story_ids, 1):
print(f"⏳ 正在抓取 {idx}/{len(story_ids)}: ID={story_id}", end='\r')
raw_data = fetcher.get_item_detail(story_id)
if raw_data:
parsed = parser.parse_api_item(raw_data)
if parsed:
cleaned = parser.clean_data(parsed)
stories.append(cleaned)
time.sleep(REQUEST_INTERVAL) # 控制频率
print(f"\n✅ 成功解析 {len(stories)} 条新闻")
# 3. 保存数据
saved_count = storage.save_to_db(stories)
print(f"💾 保存了 {saved_count} 条数据到数据库")
# 4. 导出 CSV
storage.export_to_csv()
# 5. 展示统计
stats = storage.get_stats()
print(f"\n📊 数据统计:")
print(f" 总计: {stats['total']} 条")
print(f" 平均分数: {stats['avg_score']}")
if stats['top_story']:
print(f" 最高分: 《{stats['top_story'][0]}》({stats['top_story'][1]} 分)")
storage.close()
def crawl_with_html():
"""使用 HTML 解析方式(对比)"""
print("\n🔄 尝试 HTML 方式抓取首页...")
fetcher = HNFetcher()
parser = HNParser()
html = fetcher.get_html_page()
if html:
stories = parser.parse_html_page(html)
print(f"✅ HTML 方式解析出 {len(stories)} 条新闻")
# 展示前 3 条对比
for story in stories[:3]:
print(f" - {story['title']} ({story['score']} 分)")
else:
print("❌ HTML 抓取失败")
if __name__ == '__main__':
# 方式一:API 抓取(推荐)
crawl_with_api()
# 方式二:HTML 抓取(对比学习)
crawl_with_html()
运行方式
bash
# 1. 安装依赖
pip install -r requirements.txt
# 2. 运行主程序
python main.py
# 3. 查看输出
# - 数据库: data/hn_news.db
# - CSV 文件: data/hn_news.csv
运行结果示例
json
🚀 开始使用 API 抓取 Hacker News...
📋 获取到 100 条新闻 ID
⏳ 正在抓取 100/100: ID=38967890
✅ 成功解析 98 条新闻
💾 保存了 98 条数据到数据库
✅ 已导出 98 条数据到 data/hn_news.csv
📊 数据统计:
总计: 98 条
平均分数: 127.56
最高分: 《Show HN: I built an AI code reviewer》(542 分)
🔄 尝试 HTML 方式抓取首页...
✅ HTML 方式解析出 30 条新闻
- Ask HN: Best resources for learning Rust? (234 分)
- New Python 3.13 features (189 分)
- Why SQLite is so great (512 分)
CSV 输出示例(前 5 行)
| id | title | score | by | url | descendants |
|---|---|---|---|---|---|
| 38967890 | Show HN: AI Code Reviewer | 542 | john_dev | https://example.com | 234 |
| 38967123 | Why SQLite is so great | 512 | pg | https://sqlite.org/... | 189 |
| 38966456 | New Python 3.13 features | 456 | guido | https://python.org/... | 156 |
| 38965789 | Ask HN: Best Rust resources? | 234 | rustacean | https://news.y... | 98 |
| 38965012 | Show HN: Terminal AI Assistant | 201 | dev_101 | https://github.com/... | 67 |
9️⃣ 常见问题与排错(FAQ)
Q1: 遇到 403 Forbidden 怎么办?
原因:
- 缺少 User-Agent 或使用了默认的
python-requests/x.x.x - 请求频率过高触发防护
解决方案:
python
# ❌ 错误示例
requests.get(url) # 使用默认 UA
# ✅ 正确示例
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://news.ycombinator.com/'
}
requests.get(url, headers=HEADERS)
time.sleep(2) # 加间隔
Q2: API 返回数据为空怎么办?
诊断步骤:
python
# 1. 先测试单个 ID
test_id = 38967234
url = f"https://hacker-news.firebaseio.com/v0/item/{test_id}.json"
resp = requests.get(url)
print(resp.status_code, resp.text)
# 2. 检查是否是 deleted 或 dead 状态
data = resp.json()
if data.get('deleted') or data.get('dead'):
print("该新闻已被删除")
常见原因:
- 新闻被删除(
deleted: true) - 类型不是 story(可能是 comment、poll)
- 网络超时导致返回 None
Q3: HTML 解析总是报错怎么办?
问题现象:
python
AttributeError: 'NoneType' object has no attribute 'get_text'
原因分析:
- HN 的 HTML 结构偶尔会调整
- 某些新闻缺少分数或评论元素
健壮写法:
python
# ❌ 不安全
score = item.select_one('span.score').get_text()
# ✅ 安全
score_elem = item.select_one('span.score')
score = int(score_elem.get_text().split()[0]) if score_elem else 0
Q4: 中文乱码如何处理?
虽然 HN 内容以英文为主,但偶尔会有非英文字符:
python
# 保存 CSV 时指定编码
df.to_csv('output.csv', encoding='utf-8-sig', index=False)
# SQLite 默认支持 UTF-8,无需额外处理
Q5: 如何判断是否被限流?
python
if resp.status_code == 429:
retry_after = resp.headers.get('Retry-After', 60)
print(f"触发限流,需等待 {retry_after} 秒")
time.sleep(int(retry_after))
HN API 通常不会限流,但如果频率过高(如多线程并发),Firebase 后端可能返回 429。
🔟 进阶优化(Advanced)
1. 异步并发抓取
使用 aiohttp 提升速度(API 版本):
python
import asyncio
import aiohttp
async def fetch_item_async(session, item_id):
url = f"https://hacker-news.firebaseio.com/v0/item/{item_id}.json"
async with session.get(url) as resp:
return await resp.json()
async def crawl_concurrent():
async with aiohttp.ClientSession() as session:
story_ids = [38967234, 38967123, ...] # 从 topstories 获取
tasks = [fetch_item_async(session, sid) for sid in story_ids[:20]]
results = await asyncio.gather(*tasks)
return [r for r in results if r]
# 运行
stories = asyncio.run(crawl_concurrent())
注意: 并发数建议不超过 10,避免触发限流。
2. 断点续爬
记录已抓取的 ID,程序中断后继续:
python
import json
CHECKPOINT_FILE = 'data/checkpoint.json'
def save_checkpoint(crawled_ids):
with open(CHECKPOINT_FILE, 'w') as f:
json.dump(list(crawled_ids), f)
def load_checkpoint():
try:
with open(CHECKPOINT_FILE, 'r') as f:
return set(json.load(f))
except FileNotFoundError:
return set()
# 使用
crawled = load_checkpoint()
for story_id in all_ids:
if story_id in crawled:
continue
# ... 抓取逻辑 ...
crawled.add(story_id)
save_checkpoint(crawled)
3. 日志与监控
使用 loguru 替代 print:
python
from loguru import logger
logger.add("logs/hn_scraper_{time}.log", rotation="1 day")
logger.info("开始抓取")
logger.warning("第 {idx} 条解析失败", idx=123)
logger.error("数据库连接失败: {e}", e=str(err))
4. 定时任务
每小时自动抓取最新数据:
python
import schedule
def job():
print("⏰ 定时任务触发")
crawl_with_api()
schedule.every().hour.do(job)
while True:
schedule.run_pending()
time.sleep(60)
或使用系Linux/Mac):
bash
# 每小时执行
0 * * * * cd /path/to/hn_scraper && /usr/bin/python3 main.py
5. 数据分析扩展
python
import matplotlib.pyplot as plt
# 分数分布
df = pd.read_csv('data/hn_news.csv')
plt.hist(df['score'], bins=50)
plt.xlabel('Score')
plt.ylabel('Frequency')
plt.title('HN Story Score Distribution')
plt.savefig('data/score_dist.png')
# 热词分析
from collections import Counter
all_words = ' '.join(df['title']).lower().split()
common = Counter(all_words).most_common(20)
print(common)
1️⃣1️⃣ 总结与延伸阅读
我们完成了什么?
通过这个项目,你已经掌握了:
✅ 两种数据采集思路 :REST API(优雅高效) vs HTML 爬虫(通用灵活)
✅ 完整的数据流水线 :请求 → 解析 → 清洗 → 存储
✅ 生产级代码规范 :分层架构、异常处理、日志记录
✅ 合规意识:遵守 robots.txt、控制频率、不采集敏感信息
下一步可以做什么?
-
迁移到 Scrapy 框架
- 内置去重、中间件、管道等企业级特性
- 适合大规模采集(10万+ 条数据)
-
引入 Playwright 处理动态网站
- 虽然 HN 是静态的,但可以用它练习处理 SPA 应用
- 学习浏览器自动化技巧
-
搭建分布式爬虫集群
- 使用 Scrapy-Redis 实现任务队列
- Docker 部署多节点
-
数据可视化 Dashboard
- 用 Streamlit/Dash 搭建实时监控面板
- 展示热度趋势、话题分类
-
机器学习应用
- 基于标题预测热度
- 话题聚类(Kmeans/LDA)
推荐阅读
- 官方文档:Hacker News API
- Scrapy 教程:docs.scrapy.org
- 《Web Scraping with Python》(Ryan Mitchell)
如果这篇文章对你有帮助,欢迎分享给更多学习爬虫的朋友!有任何问题也可以在评论区讨论。
祝你抓取顺利! 🎉
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。