返回文章列表

Python网络爬虫入门:从原理到实践

网络爬虫(Web Spider)是自动获取互联网数据的程序。从搜索引擎到价格监控,爬虫技术无处不在。本教程将系统介绍Python爬虫的原理和实践方法,帮助你掌握数据抓取的核心技能。

一、HTTP协议基础

理解HTTP协议是学习爬虫的前提。HTTP(超文本传输协议)是客户端和服务器之间通信的规则。

HTTP请求与响应

# http_basics.py - HTTP协议演示
"""
HTTP请求的基本组成:
1. 请求方法:GET(获取数据)、POST(提交数据)、PUT、DELETE等
2. 请求URL:目标资源的地址
3. 请求头(Headers):包含User-Agent、Cookie、Referer等信息
4. 请求体(Body):POST请求时携带的数据

HTTP响应的基本组成:
1. 状态码:200成功、404未找到、500服务器错误等
2. 响应头:包含内容类型、编码等信息
3. 响应体:实际的数据内容(HTML、JSON、图片等)
"""

# 常见HTTP状态码
status_codes = {
    200: "OK - 请求成功",
    301: "Moved Permanently - 永久重定向",
    302: "Found - 临时重定向",
    304: "Not Modified - 资源未修改(使用缓存)",
    400: "Bad Request - 请求参数错误",
    401: "Unauthorized - 未授权",
    403: "Forbidden - 禁止访问",
    404: "Not Found - 资源不存在",
    429: "Too Many Requests - 请求过于频繁",
    500: "Internal Server Error - 服务器内部错误",
    503: "Service Unavailable - 服务不可用"
}

for code, desc in status_codes.items():
    print(f"{code}: {desc}")

二、requests库入门

requests是Python中最流行的HTTP库,API设计优雅,使用简单。

# 安装requests
pip install requests
# requests_basics.py - requests库基础用法
import requests

# ===== GET请求 =====
# 最基本的GET请求
response = requests.get('https://httpbin.org/get')
print(f"状态码: {response.status_code}")
print(f"响应内容: {response.text[:200]}")

# 带查询参数的GET请求
params = {
    'q': 'Python',
    'page': 1,
    'limit': 10
}
response = requests.get('https://httpbin.org/get', params=params)
print(f"请求URL: {response.url}")

# 带请求头的GET请求
headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
    'Accept': 'text/html,application/xhtml+xml',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
}
response = requests.get('https://httpbin.org/get', headers=headers)

# ===== POST请求 =====
# 表单数据提交
data = {
    'username': 'admin',
    'password': '123456'
}
response = requests.post('https://httpbin.org/post', data=data)

# JSON数据提交
json_data = {
    'title': '爬虫教程',
    'content': 'Python网络爬虫入门'
}
response = requests.post('https://httpbin.org/post', json=json_data)

# ===== Session会话管理 =====
# Session可以保持Cookie,适合需要登录的场景
session = requests.Session()

# 设置全局请求头
session.headers.update({
    'User-Agent': 'Mozilla/5.0 (My-Crawler/1.0)',
    'Accept-Language': 'zh-CN,zh;q=0.9'
})

# 使用Session发送请求(自动管理Cookie)
response = session.get('https://httpbin.org/cookies/set?name=value')
response = session.get('https://httpbin.org/cookies')  # 自动携带上次的Cookie
print(f"Cookies: {response.json()}")

# ===== 超时设置 =====
try:
    # 连接超时5秒,读取超时10秒
    response = requests.get('https://httpbin.org/delay/3',
                          timeout=(5, 10))
    print("请求成功")
except requests.exceptions.Timeout:
    print("请求超时")
except requests.exceptions.ConnectionError:
    print("连接失败")
except requests.exceptions.RequestException as e:
    print(f"请求异常: {e}")

三、HTML解析(BeautifulSoup)

获取到网页HTML后,需要从中提取数据。BeautifulSoup是最流行的HTML解析库。

# 安装BeautifulSoup和解析器
pip install beautifulsoup4 lxml
# beautifulsoup_basics.py - BeautifulSoup基础
from bs4 import BeautifulSoup

# 示例HTML
html_doc = """
<html>
<head><title>示例网页</title></head>
<body>
    <div class="container">
        <h1 id="main-title">欢迎来到爬虫教程</h1>
        <p class="intro">这是一段介绍文字</p>

        <ul class="article-list">
            <li class="item">
                <a href="/article/1" class="link">第一篇文章</a>
                <span class="date">2024-01-15</span>
            </li>
            <li class="item">
                <a href="/article/2" class="link">第二篇文章</a>
                <span class="date">2024-01-16</span>
            </li>
            <li class="item featured">
                <a href="/article/3" class="link">第三篇文章</a>
                <span class="date">2024-01-17</span>
            </li>
        </ul>

        <table id="data-table">
            <tr><th>姓名</th><th>年龄</th></tr>
            <tr><td>张三</td><td>25</td></tr>
            <tr><td>李四</td><td>30</td></tr>
        </table>
    </div>
</body>
</html>
"""

# 创建BeautifulSoup对象
soup = BeautifulSoup(html_doc, 'lxml')

# ===== 基本选择方法 =====
# 获取标题
print("标题:", soup.title.string)

# 获取h1标签(通过id)
h1 = soup.find('h1', id='main-title')
print("H1:", h1.string)

# 获取第一个p标签
p = soup.find('p', class_='intro')
print("P:", p.string)

# 获取所有class为item的li标签
items = soup.find_all('li', class_='item')
print(f"\n找到 {len(items)} 个列表项:")
for item in items:
    link = item.find('a')
    date = item.find('span', class_='date')
    print(f"  标题: {link.string}, 链接: {link['href']}, 日期: {date.string}")

# 获取同时有item和featured两个class的标签
featured = soup.find('li', class_='featured')
print(f"\n特色文章: {featured.find('a').string}")

# ===== 解析表格 =====
table = soup.find('table', id='data-table')
rows = table.find_all('tr')
print("\n表格数据:")
for row in rows:
    cells = row.find_all(['th', 'td'])
    print("  | ".join(cell.string for cell in cells))

四、CSS选择器提取数据

CSS选择器是提取网页数据的强大工具,BeautifulSoup完美支持。

# css_selectors.py - CSS选择器详解
from bs4 import BeautifulSoup

html_doc = """
<div id="content">
    <article class="post" data-id="1">
        <h2 class="title"><a href="/p/1">文章1</a></h2>
        <p class="excerpt">文章摘要1</p>
        <span class="author">作者A</span>
        <span class="views">100</span>
    </article>
    <article class="post" data-id="2">
        <h2 class="title"><a href="/p/2">文章2</a></h2>
        <p class="excerpt">文章摘要2</p>
        <span class="author">作者B</span>
        <span class="views">200</span>
    </article>
    <article class="post featured" data-id="3">
        <h2 class="title"><a href="/p/3">文章3</a></h2>
        <p class="excerpt">文章摘要3</p>
        <span class="author">作者C</span>
        <span class="views">300</span>
    </article>
</div>
"""

soup = BeautifulSoup(html_doc, 'lxml')

# ===== CSS选择器示例 =====

# 1. 标签选择器
articles = soup.select('article')
print(f"文章总数: {len(articles)}")

# 2. 类选择器
titles = soup.select('.title')
for title in titles:
    print(f"标题: {title.a.string}")

# 3. ID选择器
content = soup.select_one('#content')
print(f"内容区存在: {content is not None}")

# 4. 后代选择器(空格)
links = soup.select('article .title a')
for link in links:
    print(f"链接: {link['href']} - {link.string}")

# 5. 子元素选择器(>)
direct_h2 = soup.select('article > h2')
print(f"直接子元素h2: {len(direct_h2)}")

# 6. 属性选择器
featured = soup.select('article.featured')
print(f"特色文章数: {len(featured)}")

# 按data-id属性选择
article_2 = soup.select_one('article[data-id="2"]')
print(f"data-id=2的文章: {article_2.h2.a.string}")

# 7. 组合选择器
authors = soup.select('article.post .author')
for author in authors:
    print(f"作者: {author.string}")

# 8. 选择第一个/最后一个
first_post = soup.select_one('article.post')
last_post = soup.select('article.post')[-1]
print(f"第一篇: {first_post.h2.a.string}")
print(f"最后一篇: {last_post.h2.a.string}")

# 9. 提取完整数据
print("\n=== 所有文章信息 ===")
for article in soup.select('article.post'):
    data = {
        'id': article.get('data-id'),
        'title': article.select_one('.title a').string,
        'url': article.select_one('.title a')['href'],
        'excerpt': article.select_one('.excerpt').string,
        'author': article.select_one('.author').string,
        'views': article.select_one('.views').string,
        'is_featured': 'featured' in article.get('class', [])
    }
    print(data)

五、分页爬取

大多数网站的数据分布在多个页面,需要实现分页爬取。

# pagination.py - 分页爬取示例
import requests
from bs4 import BeautifulSoup
import time

class PaginationCrawler:
    """分页爬取器"""

    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                          'AppleWebKit/537.36 (KHTML, like Gecko) '
                          'Chrome/120.0.0.0 Safari/537.36'
        })
        self.base_url = "https://books.toscrape.com/"

    def get_page_url(self, page_num):
        """构造分页URL"""
        if page_num == 1:
            return self.base_url + "catalogue/page-1.html"
        return self.base_url + f"catalogue/page-{page_num}.html"

    def parse_page(self, html):
        """解析单页数据"""
        soup = BeautifulSoup(html, 'lxml')
        books = []

        # 找到所有书籍的article标签
        articles = soup.select('article.product_pod')

        for article in articles:
            book = {
                'title': article.select_one('h3 a')['title'],
                'price': article.select_one('.price_color').text,
                'availability': article.select_one('.availability').text.strip(),
                'rating': article.select_one('.star-rating')['class'][1],
                'url': self.base_url + 'catalogue/' +
                       article.select_one('h3 a')['href'].replace('../../../', '')
            }
            books.append(book)

        return books

    def get_total_pages(self, html):
        """获取总页数"""
        soup = BeautifulSoup(html, 'lxml')
        pager = soup.select_one('.pager')
        if pager:
            last_page = pager.select('li')[-1].text.strip()
            if last_page.isdigit():
                return int(last_page)
        return 1

    def crawl(self, max_pages=None, delay=1):
        """
        爬取多页数据

        Args:
            max_pages: 最大爬取页数,None表示爬取全部
            delay: 每页之间的延时(秒)
        """
        all_books = []

        # 获取第一页以确定总页数
        first_url = self.get_page_url(1)
        response = self.session.get(first_url)
        if response.status_code != 200:
            print("获取第一页失败")
            return all_books

        total_pages = self.get_total_pages(response.text)
        if max_pages:
            total_pages = min(total_pages, max_pages)

        print(f"总共 {total_pages} 页")

        # 爬取每一页
        for page in range(1, total_pages + 1):
            print(f"正在爬取第 {page}/{total_pages} 页...", end=" ")

            url = self.get_page_url(page)
            try:
                response = self.session.get(url, timeout=10)
                if response.status_code == 200:
                    books = self.parse_page(response.text)
                    all_books.extend(books)
                    print(f"获取 {len(books)} 本书")
                else:
                    print(f"状态码: {response.status_code}")
            except Exception as e:
                print(f"错误: {e}")

            # 礼貌性延时
            if page < total_pages:
                time.sleep(delay)

        return all_books


# 使用示例
if __name__ == "__main__":
    crawler = PaginationCrawler()
    # 只爬取前3页作为演示
    books = crawler.crawl(max_pages=3, delay=1)

    print(f"\n总共获取 {len(books)} 本书")
    print("\n前5本书的信息:")
    for book in books[:5]:
        print(f"  《{book['title'][:30]}》 - {book['price']} "
              f"({book['rating']}星)")

六、数据存储(CSV/JSON)

爬取的数据需要持久化存储。CSV和JSON是最常用的两种格式。

# data_storage.py - 数据存储
import csv
import json
import os

class DataStorage:
    """数据存储工具类"""

    @staticmethod
    def save_to_csv(data, filename, encoding='utf-8-sig'):
        """
        保存数据到CSV文件

        Args:
            data: 字典列表
            filename: 文件名
            encoding: 编码,utf-8-sig可解决Excel中文乱码
        """
        if not data:
            print("没有数据可保存")
            return

        # 获取所有字段名
        fieldnames = list(data[0].keys())

        with open(filename, 'w', newline='', encoding=encoding) as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            writer.writerows(data)

        print(f"已保存 {len(data)} 条记录到 {filename}")

    @staticmethod
    def save_to_json(data, filename, encoding='utf-8'):
        """
        保存数据到JSON文件

        Args:
            data: 要保存的数据
            filename: 文件名
            encoding: 编码
        """
        with open(filename, 'w', encoding=encoding) as f:
            json.dump(data, f, ensure_ascii=False, indent=2)

        print(f"已保存数据到 {filename}")

    @staticmethod
    def load_from_json(filename, encoding='utf-8'):
        """从JSON文件加载数据"""
        with open(filename, 'r', encoding=encoding) as f:
            return json.load(f)

    @staticmethod
    def append_to_csv(data, filename, encoding='utf-8-sig'):
        """追加数据到CSV文件(增量爬取时使用)"""
        if not data:
            return

        fieldnames = list(data[0].keys())
        file_exists = os.path.exists(filename)

        with open(filename, 'a', newline='', encoding=encoding) as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            if not file_exists:
                writer.writeheader()
            writer.writerows(data)

        print(f"已追加 {len(data)} 条记录到 {filename}")


# 使用示例
if __name__ == "__main__":
    # 示例数据
    books = [
        {"title": "Python编程", "price": "¥59.00",
         "author": "张三", "rating": "Five"},
        {"title": "数据分析", "price": "¥69.00",
         "author": "李四", "rating": "Four"},
        {"title": "机器学习", "price": "¥89.00",
         "author": "王五", "rating": "Five"},
    ]

    storage = DataStorage()

    # 保存为CSV
    storage.save_to_csv(books, 'books.csv')

    # 保存为JSON
    storage.save_to_json(books, 'books.json')

    # 从JSON读取
    loaded = storage.load_from_json('books.json')
    print(f"\n从JSON加载了 {len(loaded)} 条记录")

七、反爬应对策略

网站为了保护数据,会采取各种反爬措施。以下是常见的应对策略。

# anti_crawl.py - 反爬应对策略
import requests
import time
import random
from fake_useragent import UserAgent

# ===== 1. 设置请求头 =====
# 很多网站会检查User-Agent,拒绝非浏览器请求

# 方法1:手动设置常见浏览器UA
USER_AGENTS = [
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
]

def get_random_headers():
    """获取随机请求头"""
    return {
        'User-Agent': random.choice(USER_AGENTS),
        'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
        'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
        'Accept-Encoding': 'gzip, deflate, br',
        'Connection': 'keep-alive',
        'Referer': 'https://www.google.com/',
    }

# 方法2:使用fake_useragent库(需要安装 pip install fake-useragent)
def get_fake_ua():
    try:
        ua = UserAgent()
        return ua.random
    except:
        return random.choice(USER_AGENTS)

# ===== 2. 请求延时 =====
# 避免请求过于频繁

def polite_delay(min_delay=1, max_delay=3):
    """随机延时,模拟人类行为"""
    delay = random.uniform(min_delay, max_delay)
    time.sleep(delay)
    return delay

# ===== 3. 使用代理IP =====
class ProxyManager:
    """代理IP管理器"""

    def __init__(self, proxy_list=None):
        # 示例代理格式:http://ip:port 或 http://user:pass@ip:port
        self.proxies = proxy_list or []
        self.current_index = 0

    def get_proxy(self):
        """轮询获取代理"""
        if not self.proxies:
            return None
        proxy = self.proxies[self.current_index]
        self.current_index = (self.current_index + 1) % len(self.proxies)
        return {'http': proxy, 'https': proxy}

    def test_proxy(self, proxy, test_url='https://httpbin.org/ip'):
        """测试代理是否可用"""
        try:
            proxies = {'http': proxy, 'https': proxy}
            response = requests.get(test_url, proxies=proxies, timeout=5)
            if response.status_code == 200:
                return True
        except:
            pass
        return False

# ===== 4. Cookie管理 =====
class CookieManager:
    """Cookie管理器"""

    def __init__(self):
        self.session = requests.Session()

    def login_and_get_cookies(self, login_url, credentials):
        """模拟登录获取Cookie"""
        response = self.session.post(login_url, data=credentials)
        if response.status_code == 200:
            print("登录成功,Cookie已保存")
            return self.session.cookies
        else:
            print("登录失败")
            return None

# ===== 5. 综合反爬策略示例 =====
class SmartCrawler:
    """综合反爬策略爬虫"""

    def __init__(self, max_retries=3):
        self.session = requests.Session()
        self.max_retries = max_retries
        self.proxy_manager = ProxyManager()

    def request_with_retry(self, url, method='GET', **kwargs):
        """
        带重试机制的请求

        Args:
            url: 请求URL
            method: 请求方法
            **kwargs: 传递给requests的额外参数
        """
        for attempt in range(self.max_retries):
            try:
                # 设置随机请求头
                kwargs.setdefault('headers', get_random_headers())

                # 设置超时
                kwargs.setdefault('timeout', 15)

                # 尝试使用代理
                proxy = self.proxy_manager.get_proxy()
                if proxy:
                    kwargs.setdefault('proxies', proxy)

                # 发送请求
                if method.upper() == 'GET':
                    response = self.session.get(url, **kwargs)
                else:
                    response = self.session.post(url, **kwargs)

                # 检查是否被限流
                if response.status_code == 429:
                    wait_time = (attempt + 1) * 5
                    print(f"被限流,等待 {wait_time} 秒后重试...")
                    time.sleep(wait_time)
                    continue

                response.raise_for_status()
                return response

            except requests.exceptions.RequestException as e:
                print(f"第 {attempt + 1} 次尝试失败: {e}")
                if attempt < self.max_retries - 1:
                    wait = (attempt + 1) * 2
                    print(f"等待 {wait} 秒后重试...")
                    time.sleep(wait)

        print(f"已达到最大重试次数 {self.max_retries}")
        return None

    def crawl(self, url):
        """爬取页面"""
        # 礼貌延时
        delay = polite_delay(1, 2)
        print(f"延时 {delay:.1f} 秒")

        response = self.request_with_retry(url)
        if response:
            return response.text
        return None

八、robots.txt规范

robots.txt是网站告知爬虫哪些页面可以抓取、哪些不可以的规范。虽然不是强制性的,但遵守robots.txt是爬虫的基本道德。

# robots_check.py - 检查robots.txt
import requests
from urllib.robotparser import RobotFileParser

def check_robots_txt(url):
    """
    检查URL是否允许爬取

    Args:
        url: 要检查的URL
    """
    # 解析域名
    from urllib.parse import urlparse
    parsed = urlparse(url)
    robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"

    # 获取robots.txt
    rp = RobotFileParser()
    rp.set_url(robots_url)

    try:
        rp.read()
    except Exception as e:
        print(f"无法读取robots.txt: {e}")
        return True  # 如果无法读取,默认允许

    # 检查是否允许爬取
    user_agent = '*'
    can_fetch = rp.can_fetch(user_agent, url)

    print(f"网站: {parsed.netloc}")
    print(f"robots.txt: {robots_url}")
    print(f"URL: {url}")
    print(f"允许爬取: {'是' if can_fetch else '否'}")

    # 获取爬取延时建议
    crawl_delay = rp.crawl_delay(user_agent)
    if crawl_delay:
        print(f"建议延时: {crawl_delay} 秒")

    return can_fetch


# 使用示例
if __name__ == "__main__":
    # 检查books.toscrape.com是否允许爬取
    url = "https://books.toscrape.com/catalogue/page-1.html"
    check_robots_txt(url)

    print()

    # 检查另一个网站
    url2 = "https://httpbin.org/get"
    check_robots_txt(url2)

九、实战案例:爬取图书信息

综合运用以上知识,我们来爬取books.toscrape.com(一个专门用于爬虫练习的网站)的图书信息。

# book_crawler.py - 完整的图书爬虫
import requests
from bs4 import BeautifulSoup
import csv
import json
import time
import random
from urllib.parse import urljoin

class BookCrawler:
    """
    图书信息爬虫
    目标网站:https://books.toscrape.com
    """

    BASE_URL = "https://books.toscrape.com/"

    # 星级评分映射
    RATING_MAP = {
        'One': 1, 'Two': 2, 'Three': 3,
        'Four': 4, 'Five': 5
    }

    def __init__(self, delay_range=(1, 2)):
        self.session = requests.Session()
        self.delay_range = delay_range

        # 设置请求头
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                          'AppleWebKit/537.36 (KHTML, like Gecko) '
                          'Chrome/120.0.0.0 Safari/537.36',
            'Accept': 'text/html,application/xhtml+xml,application/xml;'
                      'q=0.9,*/*;q=0.8',
            'Accept-Language': 'en-US,en;q=0.9',
        })

        self.all_books = []

    def _polite_delay(self):
        """礼貌延时"""
        delay = random.uniform(*self.delay_range)
        time.sleep(delay)

    def _get_page(self, url):
        """获取页面内容"""
        try:
            response = self.session.get(url, timeout=15)
            response.raise_for_status()
            return response.text
        except requests.exceptions.RequestException as e:
            print(f"获取页面失败: {e}")
            return None

    def parse_book_detail(self, url):
        """解析图书详情页"""
        html = self._get_page(url)
        if not html:
            return None

        soup = BeautifulSoup(html, 'lxml')

        # 提取详情信息
        details = {}

        # 产品描述
        description = soup.select_one('#product_description ~ p')
        details['description'] = description.string if description else ''

        # 产品信息表格
        table = soup.select_one('.table table-striped')
        if table:
            for row in table.select('tr'):
                th = row.select_one('th')
                td = row.select_one('td')
                if th and td:
                    details[th.string] = td.string

        return details

    def parse_list_page(self, html):
        """解析列表页,提取图书信息"""
        soup = BeautifulSoup(html, 'lxml')
        books = []

        articles = soup.select('article.product_pod')

        for article in articles:
            book = {}

            # 标题和链接
            title_link = article.select_one('h3 a')
            book['title'] = title_link['title']
            book['url'] = urljoin(self.BASE_URL,
                                  title_link['href'])

            # 价格
            price_elem = article.select_one('.price_color')
            book['price'] = price_elem.string if price_elem else ''

            # 库存状态
            availability = article.select_one('.availability')
            if availability:
                # 提取库存数量
                instock_text = availability.text.strip()
                book['availability'] = instock_text

                # 尝试提取数字
                import re
                match = re.search(r'\((\d+) available\)', instock_text)
                book['stock'] = int(match.group(1)) if match else 0
            else:
                book['availability'] = ''
                book['stock'] = 0

            # 星级评分
            rating_elem = article.select_one('.star-rating')
            if rating_elem and rating_elem.get('class'):
                rating_class = rating_elem['class'][1]  # 第二个class是星级
                book['rating'] = self.RATING_MAP.get(rating_class, 0)
            else:
                book['rating'] = 0

            # 封面图片
            img_elem = article.select_one('img')
            if img_elem:
                img_src = img_elem.get('src', '')
                book['image_url'] = urljoin(self.BASE_URL, img_src)
            else:
                book['image_url'] = ''

            books.append(book)

        return books

    def get_next_page_url(self, html):
        """获取下一页的URL"""
        soup = BeautifulSoup(html, 'lxml')
        next_btn = soup.select_one('li.next a')
        if next_btn:
            return urljoin(self.BASE_URL, next_btn['href'])
        return None

    def crawl(self, max_pages=None):
        """
        爬取图书数据

        Args:
            max_pages: 最大页数限制
        """
        print("=" * 60)
        print("图书爬虫启动")
        print(f"目标网站: {self.BASE_URL}")
        print("=" * 60)

        current_url = urljoin(self.BASE_URL, 'catalogue/page-1.html')
        page_num = 1

        while current_url:
            print(f"\n正在爬取第 {page_num} 页: {current_url}")

            html = self._get_page(current_url)
            if not html:
                print(f"第 {page_num} 页获取失败,跳过")
                break

            # 解析当前页
            books = self.parse_list_page(html)
            self.all_books.extend(books)
            print(f"  获取到 {len(books)} 本图书")

            # 检查页数限制
            if max_pages and page_num >= max_pages:
                print(f"  已达到最大页数限制 {max_pages}")
                break

            # 获取下一页
            current_url = self.get_next_page_url(html)
            page_num += 1

            # 礼貌延时
            if current_url:
                self._polite_delay()

        print(f"\n爬取完成!共获取 {len(self.all_books)} 本图书")

    def save_to_csv(self, filename='books.csv'):
        """保存为CSV"""
        if not self.all_books:
            print("没有数据可保存")
            return

        fieldnames = ['title', 'price', 'rating', 'stock',
                      'availability', 'url', 'image_url']

        with open(filename, 'w', newline='', encoding='utf-8-sig') as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            writer.writerows(self.all_books)

        print(f"已保存 {len(self.all_books)} 条记录到 {filename}")

    def save_to_json(self, filename='books.json'):
        """保存为JSON"""
        if not self.all_books:
            print("没有数据可保存")
            return

        with open(filename, 'w', encoding='utf-8') as f:
            json.dump(self.all_books, f, ensure_ascii=False, indent=2)

        print(f"已保存 {len(self.all_books)} 条记录到 {filename}")

    def print_statistics(self):
        """打印统计信息"""
        if not self.all_books:
            print("没有数据")
            return

        print("\n" + "=" * 60)
        print("统计信息")
        print("=" * 60)

        # 总数
        print(f"图书总数: {len(self.all_books)}")

        # 平均价格
        prices = []
        for book in self.all_books:
            try:
                price = float(book['price'].replace('£', ''))
                prices.append(price)
            except:
                pass

        if prices:
            print(f"平均价格: £{sum(prices)/len(prices):.2f}")
            print(f"最高价格: £{max(prices):.2f}")
            print(f"最低价格: £{min(prices):.2f}")

        # 评分分布
        rating_dist = {}
        for book in self.all_books:
            rating = book['rating']
            rating_dist[rating] = rating_dist.get(rating, 0) + 1

        print("\n评分分布:")
        for rating in sorted(rating_dist.keys()):
            count = rating_dist[rating]
            bar = '█' * (count // 5)
            print(f"  {rating}星: {count}本 {bar}")

        # 库存统计
        in_stock = sum(1 for b in self.all_books if b['stock'] > 0)
        print(f"\n有库存: {in_stock}本")
        print(f"无库存: {len(self.all_books) - in_stock}本")


# 运行爬虫
if __name__ == "__main__":
    crawler = BookCrawler(delay_range=(0.5, 1.5))

    # 爬取前3页作为演示(完整爬取去掉max_pages参数)
    crawler.crawl(max_pages=3)

    # 保存数据
    crawler.save_to_csv()
    crawler.save_to_json()

    # 打印统计信息
    crawler.print_statistics()

    # 显示前10本书
    print("\n前10本书:")
    for i, book in enumerate(crawler.all_books[:10], 1):
        print(f"{i}. 《{book['title'][:40]}》")
        print(f"   价格: {book['price']} | 评分: {book['rating']}星 "
              f"| 库存: {book['stock']}本")

总结

本教程系统介绍了Python网络爬虫的核心知识:

  1. 理解了HTTP协议的基础概念,包括请求方法、状态码等
  2. 掌握了requests库的使用,包括GET/POST请求、Session管理
  3. 学会了使用BeautifulSoup解析HTML
  4. 掌握了CSS选择器提取数据的方法
  5. 实现了分页爬取功能
  6. 学会了将数据保存为CSV和JSON格式
  7. 了解了反爬策略:请求头、延时、代理IP、Cookie等
  8. 知道了robots.txt规范的重要性
  9. 通过图书爬虫实战综合运用了所有知识

爬虫道德与法律提醒:

动手挑战

学到这里,不妨动手试一试以下练习,巩固你的理解:

  1. 基础练习:回顾本文核心概念,用自己的话总结关键知识点。
  2. 进阶实践:将文中的示例代码运行一遍,尝试修改参数观察变化。
  3. 拓展思考:想一想这个技术/方法还能应用在哪些场景中?

小贴士:遇到问题时,先独立思考,再查阅资料,最后请教他人——这是成长最快的学习方式。

赞赏支持

本文更新于 2026-08-22,环境 Python 3.12