Python爬虫实战:从零构建 Hacker News 数据采集系统:API vs 爬虫的技术抉择!(附CSV导出 + SQLite 存储)!

㊙️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!🔥🔥

㊗️爬虫难度指数:⭐

🚫声明:数据仅供个人学习数据分析使用,严禁用于商业比价系统或倒卖数据等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议"。

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [📝 摘要(Abstract)](#📝 摘要(Abstract))
    • [1️⃣ 背景与需求(Why)](#1️⃣ 背景与需求(Why))
    • [2️⃣ 合规与注意事项(必读)](#2️⃣ 合规与注意事项(必读))
    • [3️⃣ 技术选型与整体流程(What/How)](#3️⃣ 技术选型与整体流程(What/How))
    • [4️⃣ 环境准备与依赖安装](#4️⃣ 环境准备与依赖安装)
    • [5️⃣ 核心实现:请求层(Fetcher)](#5️⃣ 核心实现:请求层(Fetcher))
    • [6️⃣ 核心实现:解析层(Parser)](#6️⃣ 核心实现:解析层(Parser))
    • [7️⃣ 数据存储与导出(Storage)](#7️⃣ 数据存储与导出(Storage))
    • [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 APIHTML 爬虫两种方案对比,最终产出包含标题、分数、评论数、链接的结构化数据集(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、控制频率、不采集敏感信息

下一步可以做什么?

  1. 迁移到 Scrapy 框架

    • 内置去重、中间件、管道等企业级特性
    • 适合大规模采集(10万+ 条数据)
  2. 引入 Playwright 处理动态网站

    • 虽然 HN 是静态的,但可以用它练习处理 SPA 应用
    • 学习浏览器自动化技巧
  3. 搭建分布式爬虫集群

    • 使用 Scrapy-Redis 实现任务队列
    • Docker 部署多节点
  4. 数据可视化 Dashboard

    • 用 Streamlit/Dash 搭建实时监控面板
    • 展示热度趋势、话题分类
  5. 机器学习应用

    • 基于标题预测热度
    • 话题聚类(Kmeans/LDA)

推荐阅读

如果这篇文章对你有帮助,欢迎分享给更多学习爬虫的朋友!有任何问题也可以在评论区讨论。

祝你抓取顺利! 🎉

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。

相关推荐
喵手2 小时前
Python爬虫零基础入门【第九章:实战项目教学·第15节】搜索页采集:关键词队列 + 结果去重 + 反爬友好策略!
爬虫·python·爬虫实战·python爬虫工程化实战·零基础python爬虫教学·搜索页采集·关键词队列
Suchadar2 小时前
if判断语句——Python
开发语言·python
ʚB҉L҉A҉C҉K҉.҉基҉德҉^҉大2 小时前
自动化机器学习(AutoML)库TPOT使用指南
jvm·数据库·python
喵手3 小时前
Python爬虫零基础入门【第九章:实战项目教学·第14节】表格型页面采集:多列、多行、跨页(通用表格解析)!
爬虫·python·python爬虫实战·python爬虫工程化实战·python爬虫零基础入门·表格型页面采集·通用表格解析
0思必得03 小时前
[Web自动化] 爬虫之API请求
前端·爬虫·python·selenium·自动化
莫问前路漫漫3 小时前
WinMerge v2.16.41 中文绿色版深度解析:文件对比与合并的全能工具
java·开发语言·python·jdk·ai编程
木头左4 小时前
Backtrader框架下的指数期权备兑策略资金管理实现与风险控制
python
玄同7654 小时前
LangChain 核心组件全解析:构建大模型应用的 “乐高积木”
人工智能·python·语言模型·langchain·llm·nlp·知识图谱
测试老哥4 小时前
软件测试之功能测试详解
自动化测试·软件测试·python·功能测试·测试工具·职场和发展·测试用例