Python 爬虫实战:PyQuery + MongoDB 指南

1. 前言

刚入门爬虫的朋友,可能会遇到抓10页电影列表卡半小时、重复运行存满重复垃圾数据、HTML标签乱跳找不到有效字段这三大核心痛点?

本文用一个轻量但覆盖工业级基础逻辑的电影数据爬虫原型,一次性解决这些问题——用 PyQuery 像写 jQuery 一样快速解析,用 MongoDB 的 upsert 自动去重更新,用 Python 内置多进程池绕过 GIL 实现页码级并发,代码量少但可复用性极强。


2. 前置准备

先把环境搭好,别等写代码了才报错!

依赖库安装

# 只需要这三个核心库:请求、解析、NoSQL存储
pip install requests pyquery pymongo

MongoDB 启动

本地安装好 MongoDB 后,通过命令行/可视化工具(比如 MongoDB Compass)启动默认服务(端口 27017)就行。


3. 完整代码 + 模块拆解

直接上带超详细中文注释的代码,逻辑从「配置→通用请求→索引页处理→详情页处理→数据存储→并发调度」一步到底。

import requests
import logging
import re
import pymongo
from pyquery import PyQuery as pq
from urllib.parse import urljoin
import multiprocessing

# ======================== 1. 全局配置 ========================
# 日志配置:带时间戳、级别,方便定位爬取/解析问题
logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s - %(levelname)s: %(message)s')

# 常量配置(所有可修改的都放这里,后续改目标网站不用改核心逻辑)
BASE_URL = 'https://ssr1.scrape.center'  # 崔老师的公开爬虫测试站
TOTAL_PAGE = 10                             # 要爬的总页数
MONGO_URI = 'mongodb://localhost:27017'    # MongoDB 连接地址
MONGO_DB = 'scrape_movies'                  # 数据库名
MONGO_COL = 'movies'                         # 集合名

# ======================== 2. MongoDB 初始化 ========================
client = pymongo.MongoClient(MONGO_URI)
db = client[MONGO_DB]
collection = db[MONGO_COL]

# ======================== 3. 通用请求模块 ========================
def scrape_page(url):
    """
    通用的单页抓取函数:加超时、加异常处理
    只要返回HTML文本,否则None
    """
    logging.info('🚀 开始抓取: %s', url)
    try:
        # 超时设置是必须的!防止网络卡住整个进程
        resp = requests.get(url, timeout=10)
        if resp.status_code == 200:
            return resp.text
        logging.error('❌ 状态码错误: %d, URL: %s', resp.status_code, url)
    except requests.RequestException as e:
        logging.error('❌ 请求异常: %s, URL: %s', str(e), url, exc_info=False)
    return None

# ======================== 4. 列表页(索引页)处理 ========================
def scrape_index(page_num):
    """抓取指定页码的列表页"""
    index_url = f"{BASE_URL}/page/{page_num}"
    return scrape_page(index_url)

def parse_index(html):
    """
    解析列表页:用PyQuery的CSS选择器提取所有详情页URL
    用yield返回迭代器,避免一次性存太多URL占用内存
    """
    doc = pq(html)
    # 定位电影卡片里的标题链接(崔老师的站结构很清晰)
    link_nodes = doc('.el-card .name')
    for node in link_nodes.items():
        # 获取相对路径后转成绝对路径
        detail_url = urljoin(BASE_URL, node.attr('href'))
        logging.info('📍 发现详情页: %s', detail_url)
        yield detail_url

# ======================== 5. 详情页处理 ========================
def scrape_detail(url):
    """抓取详情页(复用通用请求函数)"""
    return scrape_page(url)

def parse_detail(html):
    """
    解析详情页:清洗并提取成结构化的字典
    重点:处理缺失值、用contains定位无特定class的元素
    """
    doc = pq(html)
    # 1. 封面图URL
    cover = doc('img.cover').attr('src')
    # 2. 电影名
    name = doc('a > h2').text()
    # 3. 分类列表
    categories = [item.text() for item in doc('.categories button span').items()]
    # 4. 上映时间(注意:有些电影没上映时间,或者不在标准位置)
    published_info = doc('.info:contains(上映)').text()  # PyQuery独有的contains选择器!
    published_at = None
    if published_info:
        match = re.search(r'\d{4}-\d{2}-\d{2}', published_info)
        published_at = match.group(1) if match else None
    # 5. 剧情简介
    drama = doc('.drama p').text()
    # 6. 评分(转成浮点数,方便后续排序/分析)
    score = doc('p.score').text()
    score = float(score.strip()) if score and score.strip() else None

    return {
        'cover': cover,
        'name': name,
        'categories': categories,
        'published_at': published_at,
        'drama': drama,
        'score': score
    }

# ======================== 6. 数据存储模块 ========================
def save_movie(data):
    """
    存储电影数据:用MongoDB的upsert模式
    核心优势:存在则更新,不存在则插入,彻底解决重复问题
    """
    if not data:
        return
    # 用「电影名」作为唯一索引键(崔老师的站电影名不会重复)
    result = collection.update_one(
        {'name': data['name']},
        {'$set': data},
        upsert=True
    )
    # 日志反馈插入/更新状态
    if result.upserted_id:
        logging.info('✅ 新增电影: %s', data['name'])
    else:
        logging.info('🔄 更新电影: %s', data['name'])

# ======================== 7. 单页完整逻辑 + 多进程调度 ========================
def process_page(page_num):
    """单页的完整流程:抓列表→解析出URL→抓每个详情→解析→存储"""
    index_html = scrape_index(page_num)
    if not index_html:
        return
    detail_urls = parse_index(index_html)
    for url in detail_urls:
        detail_html = scrape_detail(url)
        if not detail_html:
            continue
        movie_data = parse_detail(detail_html)
        save_movie(movie_data)

if __name__ == '__main__':
    """
    多进程启动入口:必须写在if __name__ == '__main__'里!
    Pool()默认开启CPU核心数个进程,I/O密集型爬虫可以开更多
    """
    logging.info('🎬 电影爬虫正式启动!')
    pool = multiprocessing.Pool(processes=5)  # 崔老师的站比较小,开5个足够
    pages = range(1, TOTAL_PAGE + 1)
    pool.map(process_page, pages)  # map会自动把pages里的每个元素传给process_page
    pool.close()  # 关闭进程池,不再接受新任务
    pool.join()   # 等待所有子进程完成
    logging.info('🎉 所有任务完成!')

4. 核心技术亮点(必看)

这套代码的每个模块都藏着爬虫开发的最佳实践,挑3个最重要的讲:

🎨 PyQuery 的「contains 选择器」

解析HTML最头疼的就是没有id/class、只有特定文本的元素,比如崔老师站里的「上映时间」。

PyQuery 直接照搬了 jQuery 的 :contains() 语法,不用写复杂的XPath路径,一行代码就能定位:

# 不用:
# doc('.info').eq(3).text()  # 万一标签顺序变了全错!
# 用:
doc('.info:contains(上映)').text()

🛡️ MongoDB 的 upsert 去重/更新

新手常用的 insert_one 有两个大问题:

  1. 重复运行会报错(如果有唯一索引)
  2. 重复运行会存满重复数据(如果没唯一索引)

update_one(..., upsert=True) 完美解决:

  • 以「电影名」为唯一 Key 先查
  • 查不到就插入新数据
  • 查到了就用 $set 更新所有字段(比如评分更新了会自动同步)

⚡ 多进程池的 I/O 调度原理

Python 因为有 GIL(全局解释器锁),单线程下同一时间只能有一个CPU核心在工作,但爬虫90%的时间都在等待网络响应(属于I/O阻塞,不需要CPU)。

这时候用 multiprocessing.Pool() 开多个进程:

  • 某个进程在等「列表页响应」时,CPU可以调度另一个进程去「解析详情页」
  • 某个进程在等「详情页响应」时,CPU可以调度第三个进程去「存数据」

效率直接翻倍(甚至翻好几倍,看网络情况和目标网站的反爬强度)。


5. 可扩展方向(进阶)

这只是一个基础原型,实际生产中可以继续加这些模块:

  1. 伪装身份:加 User-Agent 池、随机 Referer
  2. 反反爬:加代理 IP 池、处理验证码(比如滑动验证码用 Selenium 或 Playwright)
  3. 增量爬取:存最后一次爬取的页码/时间,只爬新内容
  4. 错误重试:用 tenacity 库给请求模块加重试机制
  5. 数据验证:用 pydantic 库验证每个字段的格式

小提醒:爬取公开数据没问题,但要遵守目标网站的 robots.txt,不要爬取隐私/商业数据,也不要用太高的并发把目标网站搞崩!