1. 从零开始:Python爬虫到底能做什么?

如果你刚接触编程,或者听说过Python爬虫但不知道它具体能干嘛,那我用大白话给你解释一下。你可以把互联网想象成一个巨大的、没有围墙的图书馆,里面的信息浩如烟海。爬虫,就像是你派去这个图书馆的一个不知疲倦的、超级高效的“小机器人”。你告诉它:“去帮我找所有关于‘Python入门教程’的书籍信息,包括书名、作者和简介。” 这个小机器人就会自动地、一页一页地去浏览,把你要的信息精准地抓取回来,整理成表格或者文档。这个过程,就是爬虫的核心工作——自动化地获取和整理网络公开数据

我刚开始学爬虫的时候,也觉得它很神秘,甚至有点“黑客”的感觉。但实际用下来,你会发现它其实是一个超级实用的工具,能帮你解决很多实际生活中的“信息焦虑”。比如,你想买一个笔记本电脑,但各个电商平台的评价看得眼花缭乱,手动整理太费时间。这时候,写一个爬虫,让它自动去抓取某东、某宝上关于这款电脑的几千条评论,然后你再用程序分析一下好评和差评的关键词,是不是瞬间就清晰了?再比如,你想追踪某个行业的最新动态,每天手动去刷新闻网站效率太低,爬虫可以定时帮你抓取指定网站的最新文章标题和链接,自动推送到你的邮箱。

所以,爬虫的本质是一个提高信息获取效率的自动化工具。它非常适合数据分析师、市场研究人员、学生(写论文找数据)、甚至是对某个领域充满好奇心的爱好者。学习爬虫,你不需要是编程大神,只要你有清晰的逻辑和一点点耐心,就能上手。接下来,我会通过7个由浅入深的实战案例,手把手带你体验爬虫的魔力,每个案例我都会附上详细的源码和踩坑心得,保证你看得懂、学得会、用得上。

2. 环境搭建与核心库扫盲

工欲善其事,必先利其器。在开始我们的爬虫冒险之前,得先把“装备”准备好。别担心,安装过程非常简单,就像给你的电脑装几个新软件一样。

2.1 安装Python与必备库

首先,确保你的电脑上已经安装了Python。我强烈建议使用Python 3.7或以上的版本,因为很多新特性对爬虫更友好。你可以去Python官网下载安装包,记得安装时勾选“Add Python to PATH”,这样在命令行里就能直接使用了。

安装好Python后,我们就需要几个核心的“武器库”。打开你的命令行(Windows上是CMD或PowerShell,Mac/Linux上是终端),输入以下命令,一行一行执行:

pip install requests
pip install lxml
pip install beautifulsoup4
pip install selenium

我来简单说说这几个库是干嘛的:

  • requests:这是爬虫的“双手”,负责向网站发出“请求”(说“你好,我想看看你的内容”),并把网站的“回应”(网页内容)拿回来。没有它,爬虫寸步难行。
  • lxml:这是一个非常高效和强大的解析库,特别是它的XPath功能,就像一把精准的“手术刀”,能从复杂的网页HTML代码中,快速、准确地“切”出你想要的那部分数据。
  • beautifulsoup4:我们通常叫它Beautiful Soup或BS4。它和lxml功能类似,也是用来解析网页的,但它的语法更接近自然语言,对新手更友好,像是一把“智能镊子”,用起来很顺手。
  • selenium:这是一个“大杀器”。有些网站的数据不是直接写在网页源代码里的,而是通过JavaScript代码在浏览器里动态加载出来的(比如很多评论区、瀑布流页面)。用requests直接抓取,只能拿到一个空壳。Selenium可以模拟一个真实的浏览器去打开网页,等所有数据都加载完毕后再去抓取,相当于派了一个“机器人浏览器”。

2.2 理解网页结构与数据定位

在开始写代码前,我们得先知道怎么在网页的“源代码森林”里找到我们想要的“数据果实”。这里有两个最重要的概念:HTML开发者工具

每个网页都是由HTML代码构成的,它定义了网页的结构和内容。你可以通过右键点击网页空白处,选择“检查”或“审查元素”来打开开发者工具(快捷键通常是F12)。这个工具是爬虫工程师的“眼睛”。

打开后,你会看到Elements(元素)标签页,里面就是当前网页的HTML代码树状结构。当你把鼠标移动到某行代码上时,网页上对应的区域就会高亮显示。我们的任务,就是找到我们想要的数据(比如一个商品标题、一段评论)被包裹在哪一段HTML标签里。

举个例子,一个电影标题在HTML里可能长这样:<span class="title">肖申克的救赎</span>。这里,<span>是标签,class="title"是这个标签的属性,肖申克的救赎就是标签内的文本,也就是我们要的数据。我们写爬虫,就是要告诉程序:“去网页里找到所有class属性为titlespan标签,然后把里面的文本给我拿出来。”

常用的定位方法有两种:

  1. XPath:一种在XML/HTML文档中查找信息的语言,路径表达式非常灵活强大。比如,上面的例子用XPath可以写成://span[@class="title"]/text()
  2. CSS选择器:Beautiful Soup主要使用的方式,语法和前端CSS选择元素很像。上面的例子用CSS选择器就是:span.title

在接下来的案例里,我会反复教你怎么使用开发者工具找到这些“路径”,这是爬虫最核心的基本功。

3. 案例一:用正则表达式抓取论坛帖子

第一个案例,我们从最基础也最直接的工具——正则表达式开始。正则表达式(Regular Expression,简称regex或re)是一套处理字符串的强力规则,可以用来检索、替换那些符合某个模式(规则)的文本。在网页结构简单、数据规律明显的时候,用正则表达式“硬匹配”往往最快。

3.1 目标分析与思路拆解

我们的目标是爬取某个论坛帖子里所有的回复内容,包括回复用户、回复时间和回复正文,并且这个帖子至少有5页。我们以某个篮球论坛的热门讨论帖为例。

思路很简单:

  1. 观察规律:首先手动打开帖子的第一页、第二页,看看浏览器地址栏的URL有什么变化。通常会发现页码(pn)参数在变,比如 pn=1pn=2。这样我们就能通过循环生成所有页面的链接。
  2. 查看源码:在帖子页面右键“查看网页源代码”,找到一条回复的HTML代码块,观察用户、时间、内容这三部分信息前后有什么独特的、可识别的文本特征。
  3. 编写正则:根据观察到的特征,设计正则表达式来“捕捉”这些信息。
  4. 循环抓取与保存:用一个循环,依次请求每一页,用正则提取数据,并保存到文件(比如CSV)中。

3.2 实战代码与逐行解析

下面是我写的代码,我加了非常详细的注释,你几乎可以照着抄,只需要替换一下URL和正则表达式里的特征字符串。

import csv
import requests
import re
import time

def main(page):
    # 1. 构建每一页的URL,{page}是页码变量
    url = f'https://tieba.baidu.com/p/7882177660?pn={page}'
    # 2. 设置请求头,模拟真实浏览器访问,这是绕过简单反爬的基础
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36'
    }
    # 3. 发送GET请求,获取网页HTML文本
    resp = requests.get(url, headers=headers)
    html = resp.text

    # 4. 使用正则表达式提取数据
    # 注意:这里的正则模式需要根据你目标网页的实际源码进行调整!
    # 查找所有评论内容。模式意思是:找到 `style="display:;">` 和 `</div>` 之间的所有内容(非贪婪模式)
    comments = re.findall('style="display:;"> (.*?)</div>', html)
    # 查找所有评论用户。模式:找到包含 `class="p_author_name j_user_card"` 的a标签里的文本
    users = re.findall('class="p_author_name j_user_card" href=".*?" target="_blank">(.*?)</a>', html)
    # 查找所有评论时间。模式:找到 `楼</span><span class="tail-info">` 和 `</span><div` 之间的文本
    comment_times = re.findall('楼</span><span class="tail-info">(.*?)</span><div', html)

    # 5. 将同一楼层的用户、时间、评论内容对应起来,并写入文件
    for u, c, t in zip(users, comments, comment_times):
        # 简单的数据清洗:过滤掉内容里包含img/div标签的异常数据,以及用户名过长的异常数据
        if 'img' in c or 'div' in c or len(u) > 50:
            continue
        csvwriter.writerow((u, t, c)) # 写入一行数据
        print(u, t, c) # 同时在控制台打印,方便查看进度
    print(f'第{page}页爬取完毕')

if __name__ == '__main__':
    # 打开(或创建)一个CSV文件,用于追加写入数据
    with open('tieba_comments.csv', 'a', encoding='utf-8', newline='') as f:
        csvwriter = csv.writer(f)
        # 写入CSV文件的表头
        csvwriter.writerow(('评论用户', '评论时间', '评论内容'))
        # 循环爬取前7页
        for page in range(1, 8):
            main(page)
            time.sleep(2) # 每爬一页暂停2秒,礼貌访问,避免给服务器造成压力

关键点与踩坑提醒

  • 请求头(Headers):一定要加上User-Agent,这是浏览器的“身份证”。没有它,很多网站会直接拒绝你的请求,认为你是机器人。
  • 正则表达式:这是本案例的难点和核心。.*?中的问号表示“非贪婪匹配”,意思是匹配尽可能少的字符,否则可能会一下子匹配到整个页面最后一个</div>,导致抓取失败。写正则时,最好先用开发者工具复制一小段包含目标的源码,在Python交互环境或在线正则测试工具里反复调试。
  • 异常处理:代码里简单的if判断是一种数据清洗。实际爬取中,可能会遇到楼层结构不一致(比如广告楼)、数据缺失等情况,更健壮的做法是用try...except包裹起来。
  • 延时(time.sleep)time.sleep(2)非常重要!毫无节制地快速请求网站,轻则IP被暂时封禁,重则可能触犯法律。这是爬虫工程师的职业道德和自我保护。

4. 案例二:多线程爬取小说并存入数据库

当你需要爬取大量数据,比如一本好几百章的小说时,一页一页顺序爬取会非常慢。这时候,多线程就派上用场了。它可以让你同时发起多个请求,速度提升非常明显。同时,我们把数据存到MySQL数据库里,方便后续的管理和查询。

4.1 项目架构与数据库设计

这个案例的流程比第一个复杂一些,我们先把架子搭好:

  1. 入口页分析:找到小说目录页,分析章节链接的规律。
  2. 章节链接提取:使用XPath从目录页提取所有章节的标题和完整链接,存入列表。
  3. 内容抓取函数:编写一个函数,负责根据单个章节链接,抓取该章节的正文内容。
  4. 数据库操作封装:编写连接、关闭数据库的函数。
  5. 多线程调度:创建线程池,让多个“工人”(线程)同时去执行“抓取章节内容并存入数据库”这个任务。

数据库设计很简单,我们只需要一张表,包含小说名、章节名和章节内容三个字段。

CREATE TABLE novel (
    id INT AUTO_INCREMENT PRIMARY KEY,
    novel_name VARCHAR(255),
    chapter_name VARCHAR(255),
    content TEXT
);

4.2 完整代码实现与线程池应用

下面是完整的代码,我使用了concurrent.futures模块中的ThreadPoolExecutor,这是Python中非常方便易用的线程池实现。

import requests
from lxml import etree
import re
import pymysql
from time import sleep
from concurrent.futures import ThreadPoolExecutor

# ---------- 数据库相关函数 ----------
def get_conn():
    """创建数据库连接和游标"""
    conn = pymysql.connect(
        host="127.0.0.1",  # 你的数据库地址
        user="root",        # 你的数据库用户名
        password="your_password", # 你的数据库密码
        db="novels",        # 数据库名
        charset="utf8mb4"   # 使用utf8mb4以支持更全的字符
    )
    cursor = conn.cursor()
    return conn, cursor

def close_conn(conn, cursor):
    """关闭游标和连接"""
    if cursor:
        cursor.close()
    if conn:
        conn.close()

# ---------- 网页请求与解析函数 ----------
def get_xpath_resp(url):
    """发送请求并返回etree对象和响应文本"""
    headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36'}
    resp = requests.get(url, headers=headers)
    # 将响应文本解析为lxml的etree对象,方便后续使用XPath
    tree = etree.HTML(resp.text)
    return tree, resp.text

def get_chapters(url):
    """从小说目录页获取所有章节的标题和链接"""
    tree, _ = get_xpath_resp(url)
    # 使用XPath获取小说书名
    novel_name = tree.xpath('//*[@id="info"]/h1/text()')[0]
    print(f"开始爬取小说:《{novel_name}》")

    # 使用XPath获取所有章节的dd标签(根据实际网站结构调整)
    dds = tree.xpath('/html/body/div[4]/dl/dd')
    title_list = []
    link_list = []

    # 这里只爬取前15章作为演示,你可以去掉[:15]爬取全部
    for d in dds[:15]:
        title = d.xpath('./a/text()')[0]  # 章节标题
        link = d.xpath('./a/@href')[0]    # 章节相对链接
        chapter_url = url + link           # 拼接成完整链接
        title_list.append(title)
        link_list.append(chapter_url)
        print(f"发现章节:{title}")

    return title_list, link_list, novel_name

def get_content(novel_name, title, url):
    """爬取单个章节内容并存入数据库"""
    conn = None
    cursor = None
    try:
        conn, cursor = get_conn()
        # SQL插入语句
        sql = 'INSERT INTO novel (novel_name, chapter_name, content) VALUES (%s, %s, %s)'
        tree, resp_text = get_xpath_resp(url)

        # 方法1:使用正则表达式提取内容(适用于内容在特定id的div内)
        content = re.findall('<div id="content">(.*?)</div>', resp_text, re.S)[0] # re.S让.匹配包括换行符的所有字符

        # 方法2:使用XPath提取内容(如果结构清晰)
        # content_list = tree.xpath('//*[@id="content"]//text()')
        # content = '\n'.join(content_list)

        # 数据清洗:替换掉HTML换行符和空格实体
        content = content.replace('<br />', '\n').replace('&nbsp;', ' ').strip()
        # 移除可能存在的广告文本
        content = re.sub(r'全本小说网.*最新章节!', '', content)

        print(f"正在保存章节:【{title}】")
        cursor.execute(sql, [novel_name, title, content])
        conn.commit()  # 提交事务
    except Exception as e:
        print(f"章节【{title}】爬取或保存失败:{e}")
        if conn:
            conn.rollback()  # 发生错误时回滚
    finally:
        sleep(1)  # 每个章节抓取后暂停1秒
        close_conn(conn, cursor)

if __name__ == '__main__':
    # 小说目录页URL
    start_url = 'https://www.qb5.tw/book_116659/'
    # 获取所有章节信息
    title_list, link_list, novel_name = get_chapters(start_url)

    # 使用线程池,最大线程数设为5
    with ThreadPoolExecutor(max_workers=5) as executor:
        # 提交任务到线程池
        for title, link in zip(title_list, link_list):
            # submit方法将函数和参数提交,线程池会自动安排线程执行
            executor.submit(get_content, novel_name, title, link)

    print("所有章节爬取任务已提交到线程池,请等待完成...")
    # with语句块结束时会等待所有线程执行完毕
    print("小说爬取完成!")

核心技巧与注意事项

  • XPath路径获取:在开发者工具中,右键点击你想要定位的元素,选择“Copy” -> “Copy XPath”,可以快速获得一个绝对路径。但通常我们需要手动修改为更简洁、稳定的相对路径。
  • 多线程数量ThreadPoolExecutor(max_workers=5)中的5表示同时运行5个线程。不是越多越好,太多线程会加重本地和服务器负担,可能被反爬。一般5-10个是常用范围。
  • 数据库连接管理:每个线程内部自己创建和关闭数据库连接。虽然创建连接有一定开销,但这样可以避免多线程共享一个连接导致的混乱。对于爬虫这种IO密集型任务,这点开销可以接受。
  • 异常处理:在get_content函数中,我用try...except...finally包裹了核心操作。这样即使某个章节爬取出错(比如网络波动、页面结构变化),也不会影响其他章节的爬取,并且能确保数据库连接被正确关闭。

5. 案例三:XPath与Beautiful Soup4的正面较量

同一个目标,用不同的工具来实现,是学习编程的好方法。这个案例,我们就用两种最流行的解析库——XPathBeautiful Soup4,来爬取同一个电影排行榜的数据,并对比它们的异同和优劣。

5.1 XPath版本:精准的路径选择器

XPath的语法就像在文件系统中定位文件,//表示从任意位置开始查找,[@class="title"]表示属性过滤,/text()表示获取文本。

import requests
from lxml import etree
import csv
import time
import random

def get_movie_info_xpath(url, name):
    """使用XPath解析电影详情页"""
    headers = {'User-Agent': '你的User-Agent'}
    resp = requests.get(url, headers=headers)
    tree = etree.HTML(resp.text)

    try:
        # 导演:XPath路径需要根据实际网页调整
        director = tree.xpath('//*[@id="info"]/span[1]/span[2]/a/text()')[0]
        # 评分
        rating = tree.xpath('//*[@id="interest_sectl"]/div[1]/div[2]/strong/text()')[0]
        # 评价人数
        rating_num = tree.xpath('//*[@id="interest_sectl"]/div[1]/div[2]/div/div[2]/a/span/text()')[0]
        # 上映年份(从标题中提取)
        year = tree.xpath('//*[@id="content"]/h1/span[2]/text()')[0]
        year = year.strip('()')

        print(f"XPath抓取:{name} | 导演:{director} | 评分:{rating} | 评价人数:{rating_num} | 年份:{year}")
        return [name, director, rating, rating_num, year]
    except IndexError as e:
        print(f"电影《{name}》信息提取失败,可能页面结构有变:{e}")
        return None

def crawl_with_xpath():
    base_url = 'https://movie.douban.com/top250?start={}&filter='
    with open('douban_top250_xpath.csv', 'w', encoding='utf-8', newline='') as f:
        writer = csv.writer(f)
        writer.writerow(['电影名称', '导演', '评分', '评价人数', '上映年份'])

        for i in range(0, 250, 25):  # 总共10页
            page_url = base_url.format(i)
            resp = requests.get(page_url, headers={'User-Agent': '你的User-Agent'})
            tree = etree.HTML(resp.text)

            # 获取当前页所有电影的链接和名称
            movie_items = tree.xpath('//ol[@class="grid_view"]/li')
            for item in movie_items:
                detail_link = item.xpath('.//div[@class="hd"]/a/@href')[0]
                movie_name = item.xpath('.//span[@class="title"][1]/text()')[0]

                info = get_movie_info_xpath(detail_link, movie_name)
                if info:
                    writer.writerow(info)
                time.sleep(random.uniform(1, 3))  # 随机延时,更模拟人工
            print(f"XPath版:第{i//25 + 1}页完成。")
            time.sleep(2)

if __name__ == '__main__':
    crawl_with_xpath()

5.2 Beautiful Soup4版本:灵活的Python式查询

Beautiful Soup的API更“Pythonic”,支持通过标签名、属性、CSS选择器等多种方式查找。

from bs4 import BeautifulSoup
import requests
import csv
import time
import random

def get_movie_info_bs4(url, name):
    """使用Beautiful Soup解析电影详情页"""
    headers = {'User-Agent': '你的User-Agent'}
    resp = requests.get(url, headers=headers)
    soup = BeautifulSoup(resp.text, 'html.parser')

    try:
        # 使用CSS选择器查找导演信息
        director_tag = soup.select_one('#info span:nth-of-type(1) span:nth-of-type(2) a')
        director = director_tag.text if director_tag else '未知'

        # 查找评分
        rating_tag = soup.select_one('#interest_sectl .rating_num')
        rating = rating_tag.text if rating_tag else '0.0'

        # 查找评价人数
        num_tag = soup.select_one('#interest_sectl .rating_people span')
        rating_num = num_tag.text if num_tag else '0'

        # 查找年份(假设在标题的第二个span里)
        year_span = soup.find('span', property='v:initialReleaseDate')
        year = year_span.text[:4] if year_span else '未知'

        print(f"BS4抓取:{name} | 导演:{director} | 评分:{rating} | 评价人数:{rating_num} | 年份:{year}")
        return [name, director, rating, rating_num, year]
    except Exception as e:
        print(f"电影《{name}》信息提取失败:{e}")
        return None

def crawl_with_bs4():
    base_url = 'https://movie.douban.com/top250?start={}&filter='
    with open('douban_top250_bs4.csv', 'w', encoding='utf-8', newline='') as f:
        writer = csv.writer(f)
        writer.writerow(['电影名称', '导演', '评分', '评价人数', '上映年份'])

        for i in range(0, 250, 25):
            page_url = base_url.format(i)
            resp = requests.get(page_url, headers={'User-Agent': '你的User-Agent'})
            soup = BeautifulSoup(resp.text, 'html.parser')

            # 使用CSS选择器找到所有电影项目
            movie_items = soup.select('ol.grid_view li')
            for item in movie_items:
                detail_link = item.select_one('.hd a')['href']
                movie_name = item.select_one('.title').text

                info = get_movie_info_bs4(detail_link, movie_name)
                if info:
                    writer.writerow(info)
                time.sleep(random.uniform(1, 3))
            print(f"BS4版:第{i//25 + 1}页完成。")
            time.sleep(2)

if __name__ == '__main__':
    crawl_with_bs4()

对比与选择建议

  • XPath优势在于路径表达式非常强大和灵活,尤其在处理复杂的、嵌套深的页面结构时,可以写出非常精准的定位语句。劣势是语法相对独立,需要学习一套新规则,且路径容易因页面微小改动而失效。
  • Beautiful Soup优势是学习曲线平缓,对于熟悉Python和HTML/CSS的人来说更直观。它的find()find_all()方法非常易懂,配合CSS选择器也很强大。劣势是在处理极其复杂的解析任务时,性能可能略逊于lxml(但通常感觉不到),且某些复杂路径用CSS选择器表达不如XPath直接。

我的经验:在实际项目中,我通常会混合使用。对于简单的、结构稳定的页面,用Beautiful Soup写起来快。对于需要精确定位或结构复杂的页面,我会用XPath。很多时候,我会先用浏览器复制XPath,然后在Beautiful Soup里用select_one()(支持CSS选择器)来实现,取两者之长。

6. 案例四:抓取动态加载的电商评论数据

很多现代网站,尤其是电商、社交平台,为了用户体验和性能,会采用**异步加载(Ajax)**技术。这意味着你第一次用requests获取的网页源代码里,并没有评论数据。数据是后来由JavaScript代码动态请求一个接口(API)并填充到页面上的。对付这种网站,有两种主流方法:分析接口直接请求使用Selenium模拟浏览器。这里我们先讲更高效的第一种方法。

6.1 抓包分析与接口寻找

我们以某电商平台的商品评论为例。打开商品页,按F12打开开发者工具,切换到 Network(网络) 标签页。然后,在页面上点击“查看全部评论”或者滚动到评论区域触发加载。

这时,Network面板里会刷出很多新的请求。我们的目标是找到那个真正携带评论数据的请求。通常可以按照以下步骤筛选:

  1. 点击 XHRFetch 过滤器,这些通常是数据接口。
  2. 在请求列表中,寻找名称包含commentreviewapi等关键词的请求。
  3. 点击一个可疑的请求,查看它的 Headers(请求头)Preview/Response(响应)
  4. 在响应里,如果你看到了格式规整的JSON数据,里面包含评论内容、时间、用户昵称等信息,恭喜你,找到目标了!

找到接口后,重点看它的 Request URL(请求地址)Query String Parameters(查询参数)。你会发现,翻页通常是通过改变pageoffset参数实现的。

6.2 模拟请求与JSON数据解析

一旦找到了接口和参数规律,用requests模拟这个请求就非常简单了,因为数据已经是结构化的JSON格式,比解析HTML简单得多。

import requests
import csv
import time
import random

def fetch_comments(page):
    """抓取指定页的评论数据"""
    # 这是通过抓包分析找到的真实评论接口(示例URL,需替换)
    api_url = 'https://club.jd.com/comment/productPageComments.action'

    # 构造请求参数,这些参数都是从抓包中复制过来的
    params = {
        'productId': 100011483893,  # 商品ID
        'score': 0,                  # 0表示所有评价,3表示好评,2表示中评,1表示差评
        'sortType': 5,               # 排序类型,5通常是推荐排序
        'page': page,                # 页码,从0开始
        'pageSize': 10,              # 每页条数
        'isShadowSku': 0,
        'fold': 1
    }

    # 请求头至关重要,特别是Referer,很多API会校验
    headers = {
        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.35 Safari/537.36',
        'referer': 'https://item.jd.com/100011483893.html'  # 表示请求来自哪个页面
    }

    try:
        # 发送GET请求,并直接解析返回的JSON数据
        resp = requests.get(api_url, params=params, headers=headers, timeout=10)
        resp.raise_for_status()  # 如果状态码不是200,抛出异常
        data = resp.json()

        # 解析JSON结构,提取评论列表
        comments = data.get('comments', [])
        comment_data = []
        for comment in comments:
            content = comment.get('content', '').replace('\n', ' ').strip()  # 评论内容,清理换行
            creation_time = comment.get('creationTime', '')  # 评论时间
            score = comment.get('score', 0)                   # 评分(如5分制)
            nickname = comment.get('nickname', '匿名用户')     # 用户昵称

            comment_info = (score, creation_time, nickname, content)
            comment_data.append(comment_info)
            print(f"评分{score}星 | 时间{creation_time} | 用户{nickname[:5]}... | 内容{content[:30]}...")

        return comment_data

    except requests.exceptions.RequestException as e:
        print(f"第{page+1}页请求失败:{e}")
        return []
    except ValueError as e:
        print(f"第{page+1}页JSON解析失败:{e}")
        return []

if __name__ == '__main__':
    all_comments = []
    total_pages = 10  # 计划爬取10页,每页10条,共100条

    with open('jd_comments.csv', 'w', encoding='utf-8-sig', newline='') as f: # utf-8-sig解决Excel中文乱码
        writer = csv.writer(f)
        writer.writerow(['评分', '评论时间', '用户昵称', '评论内容'])

        for page in range(total_pages):
            print(f"正在爬取第 {page + 1} 页...")
            page_comments = fetch_comments(page)
            if page_comments:
                writer.writerows(page_comments)  # 一次性写入多行
                all_comments.extend(page_comments)
            else:
                print(f"第 {page + 1} 页未获取到数据,可能已爬完或接口失效。")
                break

            # 随机延时,模拟人类操作,避免被封
            sleep_time = random.uniform(2, 5)
            time.sleep(sleep_time)

    print(f"爬取结束,共获取到 {len(all_comments)} 条评论数据。")

关键技巧与反爬应对

  • 参数分析productId是关键,不同商品不一样。score参数可以控制抓取好评、中评、差评。
  • 请求头模拟referer字段非常重要,它告诉服务器这个请求是从哪个页面发起的。不加referer,很多接口会返回错误。
  • JSON处理:使用.get(‘key’, default)方法比直接[‘key’]更安全,如果键不存在会返回默认值而不是报错。
  • 延时与随机性time.sleep(random.uniform(2, 5)) 比固定的sleep(2)更好,更不容易被识别为机器人。
  • 异常处理:网络请求可能失败,JSON结构可能变化,良好的异常处理能让程序更健壮,不会因为一条评论失败而停止整个任务。

7. 案例五:使用Selenium模拟登录与复杂交互

当网站登录需要复杂的验证(如滑动验证码、点选验证码),或者数据加载逻辑极其复杂,难以通过分析接口还原时,Selenium就是终极解决方案。它通过程序控制一个真实的浏览器(如Chrome)来完成所有操作,包括点击、输入、滚动等。

7.1 Selenium环境配置与基础操作

首先,你需要安装浏览器驱动。以Chrome为例:

  1. 查看你电脑上Chrome的版本(帮助 -> 关于Google Chrome)。
  2. 去ChromeDriver官网下载对应版本的驱动。
  3. 将下载的chromedriver.exe文件放在Python安装目录下,或者放在项目目录里,并在代码中指定路径。

Selenium的基本操作就像你在教一个机器人使用浏览器:

  • driver.get(url):打开网页。
  • driver.find_element(By.XX, ‘value’):找到一个元素(输入框、按钮等)。
  • element.send_keys(‘text’):向输入框输入文字。
  • element.click():点击元素。
  • driver.implicitly_wait(10):隐式等待,让驱动在查找元素时,如果没立刻找到,会等待最多10秒。

7.2 模拟登录与信息抓取实战

我们模拟登录某个知识分享平台,并搜索、抓取相关问题。这里以扫码登录为例,因为这是目前很多平台的主流登录方式。

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import time

def crawl_with_selenium():
    # 1. 初始化浏览器驱动
    # 指定chromedriver路径,如果已在环境变量中可省略
    driver = webdriver.Chrome(executable_path='./chromedriver') # 或使用 Service(ChromeDriverManager().install()) 自动管理

    # 2. 打开登录页面
    login_url = 'https://www.zhihu.com/signin'
    driver.get(login_url)
    print("请在弹出的浏览器窗口中扫码登录...")
    # 等待用户手动扫码登录,这里设置一个较长的等待时间
    time.sleep(30)  # 给你30秒时间扫码

    # 3. 登录后,进行搜索
    try:
        # 更稳健的方式:使用显式等待,直到搜索框出现并可点击
        search_button = WebDriverWait(driver, 10).until(
            EC.element_to_be_clickable((By.ID, 'Popover1-toggle'))
        )
        search_button.click()
        search_box = driver.find_element(By.ID, 'Popover1-toggle')
        search_box.send_keys('Python爬虫学习')  # 输入搜索关键词
        # 定位搜索按钮并点击(需要根据实际页面调整XPath)
        submit_btn = driver.find_element(By.XPATH, '//button[@type="submit"]')
        submit_btn.click()
    except Exception as e:
        print(f"搜索过程出错:{e}")
        driver.save_screenshot('error_screenshot.png')  # 出错时截图

    # 4. 等待结果加载并抓取
    time.sleep(5)  # 等待结果渲染
    # 使用显式等待确保内容区域加载出来
    try:
        content_area = WebDriverWait(driver, 10).until(
            EC.presence_of_element_located((By.CLASS_NAME, 'Search-result'))
        )
        # 查找所有结果条目
        items = driver.find_elements(By.CSS_SELECTOR, '.Search-result .List-item')
        for index, item in enumerate(items[:5]):  # 只取前5条结果
            try:
                title = item.find_element(By.CSS_SELECTOR, '.ContentItem-title a').text
                excerpt = item.find_element(By.CSS_SELECTOR, '.RichText').text[:100]  # 截取前100字摘要
                print(f"结果 {index+1}: {title}")
                print(f"摘要: {excerpt}...\n")
            except:
                continue  # 如果某条结果结构不同,跳过
    except Exception as e:
        print(f"抓取结果时出错:{e}")

    # 5. 关闭浏览器
    input("按回车键关闭浏览器...")  # 程序暂停,方便查看结果
    driver.quit()

if __name__ == '__main__':
    crawl_with_selenium()

Selenium的优缺点与注意事项

  • 优点:能处理几乎所有网页交互,所见即所得,无需深究复杂的网络请求。
  • 缺点速度慢,因为要启动和渲染整个浏览器;资源消耗大;容易被网站检测到自动化工具(虽然可以通过options.add_argument(‘–disable-blink-features=AutomationControlled’)等参数进行一些隐藏)。
  • 关键技巧
    1. 多用等待:不要用固定的time.sleep,优先使用WebDriverWait配合expected_conditions进行显式等待,这样效率更高。
    2. 元素定位:优先使用IDName,其次CSS SelectorXPath。XPath尽量用相对路径,避免用包含索引的绝对路径(如/html/body/div[3]/div[2]),因为页面结构一变就失效。
    3. 处理弹窗和iframe:如果元素在iframe(内嵌框架)里,需要用driver.switch_to.frame(frame_element)切换进去才能操作。
    4. 无头模式:在代码调试好后,可以添加options.add_argument(‘–headless’)让浏览器在后台运行,不显示界面,节省资源。

8. 案例六:应对反爬策略与数据存储优化

爬虫工程师和网站维护者之间常常在进行一场“博弈”。随着你爬取规模的增大,一定会遇到各种反爬虫机制。这个案例,我们以一个社交媒体平台为例,来探讨常见的反爬策略及应对方法,并优化我们的数据存储。

8.1 常见反爬策略与破解之道

  1. User-Agent检测:这是最基本的。我们的每个案例都设置了UA,但高级反爬会检查UA是否来自常见浏览器且是否合理。对策:使用fake_useragent库随机生成UA,或者维护一个UA池轮流使用。

    from fake_useragent import UserAgent
    ua = UserAgent()
    headers = {'User-Agent': ua.random}
    
  2. IP限制与封禁:短时间内从一个IP发出大量请求,服务器会拒绝服务或封禁IP。对策

    • 设置合理延时:这是最基本的道德和策略。time.sleep(random.uniform(1, 3))
    • 使用代理IP池:这是应对IP封锁最有效的方法。可以从一些服务商购买,或者寻找免费的代理(但稳定性差)。代码中更换代理:
      proxies = {
          'http': 'http://your_proxy_ip:port',
          'https': 'https://your_proxy_ip:port'
      }
      resp = requests.get(url, headers=headers, proxies=proxies)
      
  3. Cookie/Session验证:登录状态和会话标识。对策:使用requests.Session()对象保持会话,并在首次登录后保存Cookie,后续请求都带上。

    session = requests.Session()
    login_data = {'username': '...', 'password': '...'}
    session.post(login_url, data=login_data) # 登录
    # 后续请求都用这个session,它会自动管理cookies
    resp = session.get(target_url)
    
  4. 请求头完整性检查:除了UA,还可能检查Referer(来源页)、Accept-Language(接受语言)、Accept-Encoding等。对策:通过浏览器开发者工具,把真实请求的所有Headers都复制下来,模拟得越像越好。

  5. JavaScript渲染与加密参数:数据由JS生成,或请求参数被加密。对策:这是最棘手的情况。首先尝试用Selenium。如果不想用Selenium,就需要用PyExecJS等库执行JS代码,或者用逆向工程思维,在浏览器调试器中跟踪JS执行,找到参数生成逻辑并用Python复现。这需要较强的JS功底。

8.2 稳健爬虫示例与数据存储

结合以上策略,我们写一个更稳健的爬取某社交媒体用户微博的示例,并将数据存储到更强大的SQLite数据库中(比MySQL轻量,无需安装服务器)。

import requests
import sqlite3
import time
import random
from fake_useragent import UserAgent

class WeiboCrawler:
    def __init__(self, user_id):
        self.user_id = user_id
        self.base_url = f'https://weibo.com/ajax/statuses/mymblog'
        self.ua = UserAgent()
        self.session = requests.Session()
        self.init_db()

    def init_db(self):
        """初始化SQLite数据库和表"""
        self.conn = sqlite3.connect('weibo_data.db')
        self.cursor = self.conn.cursor()
        # 创建表,IF NOT EXISTS 避免重复创建
        self.cursor.execute('''
            CREATE TABLE IF NOT EXISTS weibo (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                created_at TEXT,
                author TEXT,
                text_raw TEXT,
                reposts_count INTEGER,
                comments_count INTEGER,
                attitudes_count INTEGER,
                crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        ''')
        self.conn.commit()

    def get_headers(self):
        """生成随机的请求头"""
        return {
            'User-Agent': self.ua.random,
            'Accept': 'application/json, text/plain, */*',
            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
            'Referer': f'https://weibo.com/u/{self.user_id}',
            # 注意:实际爬取可能需要更复杂的Cookie,这里仅为示例
        }

    def fetch_page(self, page):
        """抓取单页微博数据"""
        params = {
            'uid': self.user_id,
            'page': page,
            'feature': '0'
        }
        headers = self.get_headers()

        try:
            # 使用session,并加入随机延时
            time.sleep(random.uniform(3, 7))
            resp = self.session.get(self.base_url, params=params, headers=headers, timeout=15)
            resp.raise_for_status()
            data = resp.json()

            if data.get('ok') == 1:
                weibo_list = data.get('data', {}).get('list', [])
                return weibo_list
            else:
                print(f"第{page}页返回数据异常:{data.get('msg')}")
                return []
        except requests.exceptions.RequestException as e:
            print(f"第{page}页请求失败:{e}")
            return None  # 返回None表示网络错误,可能需要重试
        except ValueError as e:
            print(f"第{page}页JSON解析失败:{e}")
            return []

    def save_to_db(self, weibo_item):
        """将一条微博数据存入数据库"""
        sql = '''
            INSERT INTO weibo (created_at, author, text_raw, reposts_count, comments_count, attitudes_count)
            VALUES (?, ?, ?, ?, ?, ?)
        '''
        data = (
            weibo_item.get('created_at'),
            weibo_item.get('user', {}).get('screen_name'),
            weibo_item.get('text_raw'),
            weibo_item.get('reposts_count', 0),
            weibo_item.get('comments_count', 0),
            weibo_item.get('attitudes_count', 0)
        )
        try:
            self.cursor.execute(sql, data)
        except sqlite3.Error as e:
            print(f"数据入库失败:{e}")

    def crawl(self, max_pages=5):
        """主爬取函数"""
        for page in range(1, max_pages + 1):
            print(f"开始爬取第 {page} 页...")
            weibo_list = self.fetch_page(page)

            if weibo_list is None:
                print(f"第{page}页发生网络错误,暂停一会儿后重试...")
                time.sleep(10)
                weibo_list = self.fetch_page(page)  # 简单重试一次
                if weibo_list is None:
                    print(f"第{page}页重试失败,跳过。")
                    continue

            if not weibo_list:
                print(f"第{page}页无数据或已爬完所有页面。")
                break

            for item in weibo_list:
                self.save_to_db(item)
                print(f"  已保存微博:{item.get('text_raw')[:50]}...")

            self.conn.commit()  # 每页提交一次事务
            print(f"第 {page} 页爬取完成,共 {len(weibo_list)} 条。")

        self.close()

    def close(self):
        """关闭数据库连接"""
        if self.cursor:
            self.cursor.close()
        if self.conn:
            self.conn.close()
        print("数据库连接已关闭。")

if __name__ == '__main__':
    # 示例用户ID,需要替换为实际ID
    user_id = '2803301701'
    crawler = WeiboCrawler(user_id)
    crawler.crawl(max_pages=5)  # 爬取前5页
    print("微博爬取任务全部完成!")

这个案例的进阶点

  • 面向对象封装:将爬虫功能封装成类,结构更清晰,便于维护和扩展。
  • SQLite数据库:使用轻量级数据库,数据管理更方便,支持SQL查询分析。
  • 健壮性增强:加入了网络请求异常处理、简单的重试机制、数据库操作异常处理。
  • 反爬应对:整合了随机UA、Session、随机延时、完整的请求头。
  • 数据去重:可以在数据库表中为微博ID(如果接口提供)建立唯一索引,实现自动去重。

9. 案例七:从爬取到分析——完成一个小型数据项目

爬虫的最终目的不是为了“爬”,而是为了“用”。最后一个案例,我们完整地走一遍流程:确定主题 -> 爬取数据 -> 清洗存储 -> 分析可视化。我们以爬取一个电影票房榜单为例,并做简单的数据分析。

9.1 确定目标与数据抓取

假设我们对近年的电影票房趋势感兴趣。我们找到一个公开的票房数据网站(例如,一些电影数据平台),通过抓包分析找到数据接口。

import requests
import pandas as pd
import json

def fetch_box_office():
    """抓取票房数据"""
    url = 'https://ys.endata.cn/enlib-api/api/home/getrank_mainland.do'
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36',
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
        'Origin': 'https://ys.endata.cn',
        'Referer': 'https://ys.endata.cn/',
    }
    # 通常这类POST请求的参数是表单格式
    data = {
        'r': '0.9936776079863086',
        'top': '50',
        'type': '0',  # 可能代表榜单类型,如0是总榜
    }

    all_movies = []
    try:
        resp = requests.post(url, headers=headers, data=data, timeout=10)
        result = resp.json()
        # 解析JSON结构,这个结构需要根据实际接口响应调整
        movie_list = result.get('data', {}).get('table0', [])
        for movie in movie_list:
            movie_info = {
                '排名': movie.get('Irank'),
                '电影名称': movie.get('MovieName'),
                '上映时间': movie.get('ReleaseTime'),
                '总票房(万)': float(movie.get('BoxOffice', 0)),  # 转换为数值
                '平均票价': float(movie.get('AvgBoxOffice', 0)),
                '平均场次': int(movie.get('AvgAudienceCount', 0))
            }
            all_movies.append(movie_info)
            print(f"抓取:{movie_info['排名']} | {movie_info['电影名称']} | 票房:{movie_info['总票房(万)']}万")
        return all_movies
    except Exception as e:
        print(f"抓取票房数据失败:{e}")
        return []

if __name__ == '__main__':
    movies = fetch_box_office()
    if movies:
        # 使用pandas的DataFrame保存数据,非常方便
        df = pd.DataFrame(movies)
        df.to_csv('box_office.csv', index=False, encoding='utf-8-sig')
        print(f"数据已保存到 box_office.csv,共 {len(df)} 条记录。")
        print(df.head())  # 查看前几行数据

9.2 数据清洗与Pandas分析

数据抓回来后,往往很“脏”,需要清洗。Pandas是Python数据分析的神器。

import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# 设置中文字体,防止图表乱码
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False

def analyze_data():
    # 1. 读取数据
    df = pd.read_csv('box_office.csv')
    print("数据概览:")
    print(df.info())
    print("\n前5行数据:")
    print(df.head())

    # 2. 数据清洗
    # 检查缺失值
    print(f"\n缺失值统计:\n{df.isnull().sum()}")
    # 如果有缺失,可以填充或删除
    # df = df.dropna()  # 删除有缺失的行
    # df['总票房(万)'] = df['总票房(万)'].fillna(0)  # 填充为0

    # 从‘上映时间’中提取‘年份’
    df['年份'] = pd.to_datetime(df['上映时间']).dt.year
    print(f"\n提取年份后:\n{df[['电影名称', '上映时间', '年份']].head()}")

    # 3. 数据分析与可视化
    # 分析1:各年度电影总票房趋势
    yearly_box_office = df.groupby('年份')['总票房(万)'].sum().sort_index()
    plt.figure(figsize=(10, 6))
    plt.plot(yearly_box_office.index.astype(str), yearly_box_office.values, marker='o', linewidth=2)
    plt.title('各年度上榜电影总票房趋势(单位:万)')
    plt.xlabel('年份')
    plt.ylabel('总票房(万)')
    plt.grid(True, linestyle='--', alpha=0.7)
    plt.tight_layout()
    plt.savefig('yearly_trend.png', dpi=300)
    plt.show()

    # 分析2:平均票价最贵的10部电影
    top10_avg_price = df.nlargest(10, '平均票价')[['电影名称', '年份', '平均票价']]
    print(f"\n平均票价最贵的10部电影:")
    print(top10_avg_price)

    # 分析3:票房与场均人次的关系(散点图)
    plt.figure(figsize=(8, 6))
    plt.scatter(df['平均场次'], df['总票房(万)'], alpha=0.6, edgecolors='w', s=80)
    plt.title('电影平均场次与总票房关系散点图')
    plt.xlabel('平均场次')
    plt.ylabel('总票房(万)')
    plt.grid(True, linestyle='--', alpha=0.5)
    plt.tight_layout()
    plt.savefig('scatter_plot.png', dpi=300)
    plt.show()

    # 分析4:计算不同类型电影的平均票房(假设有‘类型’列)
    # 如果数据中有‘类型’列,且是逗号分隔的字符串
    # df['类型列表'] = df['类型'].str.split(',')
    # exploded_df = df.explode('类型列表')
    # genre_avg = exploded_df.groupby('类型列表')['总票房(万)'].mean().sort_values(ascending=False)
    # print(f"\n各类型电影平均票房:\n{genre_avg.head()}")

if __name__ == '__main__':
    analyze_data()

从爬虫到分析的完整闭环

  1. 目标驱动:先想清楚你要回答什么问题(例如“近几年票房趋势如何?”、“票价和票房有关系吗?”),再决定爬什么、怎么分析。
  2. 数据获取:用爬虫(或直接找公开数据集)获取原始数据。
  3. 数据清洗:这是最耗时但最关键的一步。处理缺失值、异常值、格式转换(如字符串转日期、数值)、数据拆分(如从日期中提取年份)。
  4. 探索性分析(EDA):使用Pandas进行描述性统计(df.describe())、分组聚合(groupby)、排序(nlargest)等,对数据有一个整体了解。
  5. 可视化:用Matplotlib、Seaborn等库将数据转化为图表。一图胜千言,趋势、对比、分布关系一目了然。
  6. 得出结论:基于分析结果,回答最初的问题,并可能发现新的洞察。

通过这7个案例,我们从最简单的正则表达式匹配,到应对动态页面和反爬策略,最后完成一个完整的数据分析小项目。爬虫技术本身在不断进化,网站的反爬措施也越来越复杂。但万变不离其宗,核心思路就是:模拟合法浏览器的行为,友好、有节制地获取公开数据,并将获取的数据转化为有价值的信息。希望这些实战代码和我的踩坑经验,能帮你打开Python爬虫世界的大门。记住,多动手、多思考、多查阅文档,遇到问题善用搜索引擎和开发者工具,你一定能成为爬虫高手。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐