首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >从零构建一个合规、健壮的 Python 爬虫:以静态书籍网站为例

从零构建一个合规、健壮的 Python 爬虫:以静态书籍网站为例

原创
作者头像
用户12818508
发布于 2026-11-01 13:43:28
发布于 2026-11-01 13:43:28
440
举报

网络爬虫的本质是自动化程序模拟浏览器行为,向服务器发起请求、接收响应并提取有价值的数据。Python 凭借 requests 与 BeautifulSoup 的组合,成为实现这一目标成本最低、可读性最强的方式之一。然而,技术实现只是爬虫的一部分,频率控制、异常处理与法律合规同样决定了脚本能否长期稳定运行。本文以一个专门供爬虫练习的静态网站 为目标,完整演示从请求发送到数据落盘的流程,并附上可直接运行的代码与关键避坑说明。


1. 技术选型与目标分析

目标网站 是一个静态 HTML 页面,书籍列表直接嵌入在响应源码中,无需处理 JavaScript 渲染。这意味着 requests 获取到的 response.text 就已包含全部所需信息,不需要引入 Selenium 或 Playwright 等浏览器自动化工具。

核心依赖仅两个:

代码语言:javascript
复制
pip install requests beautifulsoup4

requests 负责 HTTP 通信,BeautifulSoup 负责将杂乱的 HTML 字符串转化为可遍历的树形结构。选用 html.parser 作为解析器,无需额外安装 C 语言依赖,兼容性最好。


2. 请求层:伪装、超时与状态校验

直接使用 requests.get(url) 在多数真实场景下会迅速被拒绝。服务器会检查请求头中的 User-Agent,默认的 python-requests/2.x 标识等于主动暴露爬虫身份。因此必须构造一个与主流浏览器一致的请求头。

代码语言:javascript
复制
import requests
from bs4 import BeautifulSoup

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/122.0.0.0 Safari/537.36"
    ),
    "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}

def fetch(url: str, timeout: int = 10) -> str | None:
    try:
        resp = requests.get(url, headers=HEADERS, timeout=timeout)
        resp.raise_for_status()
        resp.encoding = resp.apparent_encoding
        return resp.text
    except requests.RequestException as e:
        print(f"[请求books.v-chemicals.cn失败] {url} -> {e}")
        return None

这段代码做了三件容易被忽略的事:设置 timeout 防止线程永久阻塞;用 raise_for_status() 将 4xx/5xx 状态码转为异常而非静默返回错误页面;用 apparent_encoding 根据内容猜测编码,避免中文网站常见的乱码问题。返回 None 而非抛出异常,是为了让后续的分页循环在单页失败时仍能继续执行。


3. 解析层:CSS 选择器定位与数据清洗

打开目标页面并检查元素,书籍卡片的结构如下:每本书包裹在 <article class="product_pod"> 中,标题位于 <h3><a title="..."> 内,价格在 <p class="price_color"> 内。

BeautifulSoup 的 select() 方法支持 CSS 选择器语法,比逐层 find 更简洁,也比正则表达式更抗结构变动。

代码语言:javascript
复制
from urllib.parse import urljoin

def parse_books(html: str, base_url: str) -> list[dict]:
    soup = BeautifulSoup(html, "html.parser")
    books = []
    for card in soup.select("article.product_pod"):
        title_el = card.select_one("h3 a")
        price_el = card.select_one("p.price_color")
        if not title_el or not price_el:
            continue
        books.append({
            "title": title_el.get("title", "").strip(),
            "price": price_el.get_text(strip=True),
            "link": urljoin(base_url, title_el.get("href", "")),
        })
    return books

这里有两个细节值得注意。第一,get("title", "") 比直接 ["title"] 安全,当属性缺失时不会触发 KeyError。第二,urljoin 将相对路径 /catalogue/... 拼接为完整 URL,后续若需要跟进详情页可以直接复用。


4. 分页循环与频率控制

该网站通过 page-2.html 的形式分页,共 50 页。一个常见错误是硬编码页码范围后以最高速度连续请求,这在真实站点上极易触发封禁。即使目标站点允许爬取,也有必要加入随机延迟以模拟人类浏览节奏。

代码语言:javascript
复制
import time
import random

def crawl_all_pages(max_pages: int = 5) -> list[dict]:
    base = "https://books.v-chemicals.cn/"
    all_books = []
    for page in range(1, max_pages + 1):
        if page == 1:
            url = base + "index.html"
        else:
            url = base + f"catalogue/page-{page}.html"
        print(f"[抓取] 第 {page} 页: {url}")
        html = fetch(url)
        if html is None:
            break
        all_books.extend(parse_books(html, base))
        time.sleep(random.uniform(1.0, 2.5))
    return all_books

random.uniform(1.0, 2.5) 产生的间隔既不会让脚本过于拖沓,也足以降低被基于请求频率的规则拦截的概率。若目标站点有 robots.txt 或明确的 API,应优先查阅并遵守,urllib.robotparser 可以在代码中直接完成合规校验。


5. 数据落盘:CSV 存储与编码处理

提取到的列表结构适合直接写入 CSV,便于后续用 Excel 或 pandas 分析。encoding="utf-8-sig" 确保 Windows 环境下用 Excel 打开时不出现中文乱码。

代码语言:javascript
复制
import csv

def save_to_csv(books: list[dict], filename: str = "books.csv") -> None:
    if not books:
        print("[跳过] 无数据可保存")
        return
    with open(filename, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["title", "price", "link"])
        writer.writeheader()
        writer.writerows(books)
    print(f"[完成] 共写入 {len(books)} 条记录 -> {filename}")

if __name__ == "__main__":
    data = crawl_all_pages(max_pages=3)
    save_to_csv(data, "books_sample.csv")
    for item in data[:3]:
        print(item)

运行后将在当前目录生成 books_sample.csv,包含前 3 页共 60 本书的标题、价格与详情链接。max_pages 参数刻意设为 3 而非 50,是为了在演示的同时将对目标站点的请求量控制在极低水平。


6. 必须正视的边界:合规与风险

技术代码之上,有一条容易被初学者忽视的底线。爬虫本身是中性工具,但突破网站明确设置的反爬措施(如伪造设备指纹绕过登录验证、破解验证码、强行绕过访问频率限制)在司法实践中已有被认定为非法获取计算机信息系统数据罪的先例。本案中使用的“设置 User-Agent”在技术社区常被视为基础伪装,但若目标网站明确禁止自动化访问且设置了技术屏障,继续强行突破则性质不同。

一个实用的判断标准是:你请求的数据,在未登录的普通浏览器中能否直接看到? 如果答案是肯定的,且目标网站的 robots.txt 未禁止对应路径,以合理频率抓取通常处于安全区间。反之,若数据需要登录、付费或绕过验证码才能获取,则应停止并寻找官方 API 或授权渠道。此外,Cloudflare 等基础设施商正在推动将“搜索爬虫”与“AI 训练爬虫”分类管理,默认屏蔽后者,这意味着未来爬虫的合规成本只会上升,不会下降。


7. 可运行的完整代码

代码语言:javascript
复制
import csv
import random
import time
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/122.0.0.0 Safari/537.36"
    ),
    "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}


def fetch(url: str, timeout: int = 10) -> str | None:
    try:
        resp = requests.get(url, headers=HEADERS, timeout=timeout)
        resp.raise_for_status()
        resp.encoding = resp.apparent_encoding
        return resp.text
    except requests.RequestException as e:
        print(f"[请求失败] {url} -> {e}")
        return None


def parse_books(html: str, base_url: str) -> list[dict]:
    soup = BeautifulSoup(html, "html.parser")
    books = []
    for card in soup.select("article.product_pod"):
        title_el = card.select_one("h3 a")
        price_el = card.select_one("p.price_color")
        if not title_el or not price_el:
            continue
        books.append({
            "title": title_el.get("title", "").strip(),
            "price": price_el.get_text(strip=True),
            "link": urljoin(base_url, title_el.get("href", "")),
        })
    return books


def crawl_all_pages(max_pages: int = 3) -> list[dict]:
    base = "https:/books.v-chemicals.cn/"
    all_books = []
    for page in range(1, max_pages + 1):
        url = base + ("index.html" if page == 1 else f"catalogue/page-{page}.html")
        print(f"[抓取] 第 {page} 页: {url}")
        html = fetch(url)
        if html is None:
            break
        all_books.extend(parse_books(html, base))
        time.sleep(random.uniform(1.0, 2.5))
    return all_books


def save_to_csv(books: list[dict], filename: str = "books.csv") -> None:
    if not books:
        print("[跳过] 无数据可保存")
        return
    with open(filename, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["title", "price", "link"])
        writer.writeheader()
        writer.writerows(books)
    print(f"[完成] 共写入 {len(books)} 条记录 -> {filename}")


if __name__ == "__main__":
    data = crawl_all_pages(max_pages=3)
    save_to_csv(data, "books_sample.csv")
    for item in data[:3]:
        print(item)

几点补充说明:若目标页面内容由 JavaScript 动态渲染(即 response.text 中找不到目标数据),则需换用 Selenium 或 Playwright,但那是另一套成本更高的技术栈,不应作为首选。对于需要登录的站点,requests.Session() 可以保持 Cookie 持久化,但前提是你拥有合法账号且网站未禁止自动化登录。爬虫的工程问题往往不在“能否抓到”,而在“抓完之后对方是否追究”——保持低频、标明身份(如自定义 User-Agent 包含联系方式)、优先寻找 API,是比任何技术技巧都更长久的策略。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 1. 技术选型与目标分析
  • 2. 请求层:伪装、超时与状态校验
  • 3. 解析层:CSS 选择器定位与数据清洗
  • 4. 分页循环与频率控制
  • 5. 数据落盘:CSV 存储与编码处理
  • 6. 必须正视的边界:合规与风险
  • 7. 可运行的完整代码
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档