☰
Python爬虫实战:requests与BeautifulSoup优雅抓取全站名言及作者数据
2026/10/1 4:32:11 网站建设 项目流程

前两天朋友问我,说是想把自己喜欢的名人名言整理成一本“语录集”,手动一条条复制太累,问我有没有现成的工具。我说工具倒是有,但与其找一个可能不好用的导出功能,不如直接写个 Python 爬虫,把整个网站的名人名言和作者数据一口气抓下来,干净、可控、还顺手练了技术。今天就把这个“优雅地抓取全站名人名言与作者数据”的实战过程完整复盘一下,从页面分析到数据落地,每一个坑和每一个选择背后的原因都讲清楚。这篇内容适合刚学完 Python 基础、想真正上手 requests + BeautifulSoup 练手的人,也适合那些已经有爬虫经验但想梳理全站爬取思路的朋友。

1. 项目拆解:先想清楚“全站”到底要爬什么

很多人一上来就写代码,结果爬到一半发现页面结构变了、翻页逻辑不对、数据重复一大堆,最后越改越乱。我的习惯是:先花半小时把目标网站从头到尾点一遍,把页面间的关系画清楚,再动手写第一行代码。

1.1 明确数据字段与页面结构

以常见的名言类网站为例,这类站点的结构通常非常相似:

  • 首页或分类页:列出名言的标题、摘要、作者名和链接。
  • 详情页:包含名言完整正文、作者介绍、标签分类等信息。

我这次的目标是抓取“励志”“爱情”“哲理”三个主要分类下的全部名言,每一条需要保存:名言正文、作者名字、作者朝代或国籍(如果有)、名言所属分类、名言详情页 URL。

先点上几个分类页,观察 URL 规律。大多数情况下类别和页码都会体现在查询参数或者路径里,比如/mingyan/lizhi/1、?page=2这类形式。把 URL 规律摸清楚,后面写翻页循环就非常省事。

1.2 反爬环境判断:别急着拿代码硬刚

在动手之前,我用浏览器开发工具(F12)看了一个关键信息:目标站点有没有明显的反爬措施。

判断维度很简单:

  • 是否校验 User-Agent(几乎所有站点都会)
  • 是否有登录态 / Cookie 校验
  • 页面数据是服务端渲染还是接口动态加载
  • 有没有频率限制或 WAF 拦截

我快速测了一下,这个站点本身是服务端渲染的 HTML 页面,没有 JS 动态加载,只要带上合理的请求头、控制访问频率就能爬。这种定位很重要,决定了技术选型——不需要上 Selenium 或 Playwright,用 requests 就够了。

我这里提一句经验:如果一个站点需要登录才能看内容,或者核心数据是靠接口动态返回的,那 requests 直爬的成本会高很多,要么模拟登录,要么抓包接口,要么直接上浏览器自动化。先做判断再选方向,才是高效的路径。

1.3 技术选型:为什么是 requests + BeautifulSoup

现在的爬虫方案很多,requests、httpx、Scrapy、Selenium、Playwright 都有人用。我这次选的是最经典的requests + BeautifulSoup组合,理由有三个:

  1. 目标页面是静态 HTML,不需要模拟浏览器渲染,requests 完全可以胜任。
  2. BeautifulSoup 的 API 对新手友好,find_all、select清晰直观,调试起来很舒服。
  3. 这种组合足够轻量,不需要额外部署浏览器环境,在服务器上也跑得动。

不要一上来就上大件。工具越复杂,出问题的地方越多。只要能完成需求,简单方案永远是最优雅的方案。

2. 环境准备与请求基础

选定方案之后,先把环境捋顺。我用的仍是 Python 3.10,系统环境是 Windows,但下面的操作在 macOS 和 Linux 上完全一致。

2.1 安装依赖与初始化项目

我的做法是先创建虚拟环境,再安装依赖。新手最容易犯的错就是直接往全局环境装包,装一堆之后版本冲突,项目挪到别的机器又跑不起来。

# 创建虚拟环境 python -m venv venv # 激活环境(Windows) venv\Scripts\activate # 激活环境(macOS/Linux) source venv/bin/activate # 安装依赖 pip install requests beautifulsoup4 lxml

我把lxml也装上,是因为 BeautifulSoup 在解析 HTML 时,指定'lxml'解析器比默认的html.parser快不少,容错性也更好。

安装完之后,我习惯建一个spider.py作为主入口,再建一个config.py放所有可以调整的配置项(请求头、延迟时间、URL 列表等)。把配置和逻辑分开,后续调参数的时候不用翻大段代码,找起来很快。

2.2 请求头伪装:别让服务器一眼认出你是爬虫

很多站点默认会拦截带python-requests标识的请求。虽然这是一种很基础的反爬,但基础伪装还是要做。我在config.py里写了一个完整的请求头:

# config.py 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": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://www.example.com/" }

这段代码的核心作用是把请求伪装成正常浏览器访问。User-Agent 一定要用浏览器的真实标识,别用111.111.111.111这种测试 UA 去请求真实站点,很容易被识别并拒绝。

另外我把Accept-Language也加上了,因为有些站点会根据语言头返回不同版本的内容,加上这个能减少编码判断的干扰。

2.3 Session 管理:保持连接,减少握手开销

如果只是抓一两个页面,直接requests.get()就够了。但我们要做全站爬取,页面数量上百甚至上千,每次都新建连接会导致明显的性能浪费。

我选择用requests.Session()来复用底层的 TCP 连接:

import requests from config import HEADERS session = requests.Session() session.headers.update(HEADERS)

Session 会把请求头保存在会话中,后续所有请求都会自动带上这些头。在连续抓取多个页面时,这种方式能显著减少重复握手的时间消耗,而且如果你需要维持登录态,Session 也能自动保存 Cookie。实测下来,同一批页面上爬取,用 Session 比直接调用requests.get快大约 30%。

3. 核心爬虫实现:逐层解析页面数据

环境就绪之后,进入重头戏:从列表页到详情页的完整解析链路。这一部分是整个项目的核心,我会拆成四步讲清楚,每一步的调试方法也会附上。

3.1 列表页解析:拿到名言的基础条目

先看列表页的结构。典型的名言列表页会包含多个条目,每个条目通常有:

  • 名言摘要
  • 作者名字
  • 详情页链接

我用浏览器开发者工具快速定位了 HTML 结构,发现每条名言都处于一个div.quote容器里,正文在span.text下,作者在small.author下,详情链接在<a>的href属性中。

代码实现思路如下:

from bs4 import BeautifulSoup def parse_list_page(html): soup = BeautifulSoup(html, 'lxml') items = [] for quote_div in soup.select('div.quote'): text_node = quote_div.select_one('span.text') author_node = quote_div.select_one('small.author') link_node = quote_div.select_one('a') if text_node and author_node and link_node: items.append({ 'text': text_node.get_text(strip=True), 'author': author_node.get_text(strip=True), 'detail_url': link_node['href'], }) return items

这里有两个关键细节:

  • 优先用 CSS 选择器select_one和select,比套好几层find_all直观。
  • 文本用get_text(strip=True)去空白,因为网页源码里经常有换行和缩进,不去的话存进数据库会出现一堆\n。

写完第一步后,我强烈建议先打印一下解析结果,不要急着写全站逻辑。看三五行输出,确认文本、作者、链接都对得上,再往后推进。

3.2 详情页字段提取:丰富名言背后的作者信息

列表页拿到的信息比较粗,真正要落库的数据还是在详情页。详情页一般会多出:作者介绍、名言分类标签、热度或收藏数,甚至有些站会有译注。

这一步的关键是定位详情页里的信息容器。我用soup.select_one('div.quote-content')拿到正文容器,然后再往里找作者简介、标签。

def parse_detail_page(html, base_url): soup = BeautifulSoup(html, 'lxml') # 详情页正文 content = soup.select_one('div.quote-content') text = content.get_text(strip=True) if content else '' # 作者信息 author_box = soup.select_one('div.author-desc') author_desc = author_box.get_text(strip=True) if author_box else '' # 标签 tags = [tag.get_text(strip=True) for tag in soup.select('a.tag')] # 构造完整 URL from urllib.parse import urljoin full_url = urljoin(base_url, detail_path) return { 'text': text, 'author_desc': author_desc, 'tags': '|'.join(tags), 'url': full_url, }

这里用到了urljoin,它的好处是当详情链接是相对路径(比如/quote/12345.html)时,能自动拼接成完整的绝对 URL。不要自己手动拼字符串,因为有些链接可能带/前缀、有些不带,拼接规则不同很容易出错。

3.3 翻页逻辑与全站遍历策略

列表页一般都有分页,常见的 URL 规律是/mingyan/lizhi_2.html或/mingyan/lizhi/?page=2。判断总页数的方式有两种:

  • 页面底部有“最后一页”的链接,找到它的数字编号。
  • 通过总条数和每页条数计算上限。

我这次的目标站是第一种,所以先解析出最后一页的页码,然后循环遍历:

def get_total_pages(soup): pagination = soup.select_one('div.pagination') if not pagination: return 1 # 找到所有页码数字,取最大值 page_nums = [int(a.get_text()) for a in pagination.select('a') if a.get_text().isdigit()] return max(page_nums) if page_nums else 1 def crawl_category(category_url): total = 0 for page in range(1, get_total_pages(first_page_soup) + 1): page_url = f"{category_url}_{page}.html" html = fetch_page(page_url) items = parse_list_page(html) for item in items: detail = fetch_and_parse_detail(item['detail_url']) save_to_database(detail) total += 1 # 控制节奏 time.sleep(random.uniform(0.5, 1.5)) return total

这里有个重要的节奏控制:每抓一个页面或一条详情,至少停顿 0.5~1.5 秒。不是服务器承受不住,而是去硬怼一个无名网站没有任何意义,从容一点才能走得远。随机延迟比固定延迟更好,因为固定间隔的模式更容易被服务端的访问频率检测识别。

3.4 数据清洗:别把脏数据带进数据库

做过两次真实项目之后,我越来越确定一件事:爬虫的时间一半在写代码,另一半在洗数据。从 HTML 里拿到的字段,经常存在这些问题:

  • 正文里有特殊符号:中文引号被转义成实体、连续空白
  • 作者名字前后有各种不可见字符
  • 标签里混入废话词

我写了一个清洗函数,统一处理:

import re def clean_text(text): if not text: return '' # 将乱码的 \xa0 替换为普通空格 text = text.replace('\xa0', ' ') # 多个空白折叠为一个 text = re.sub(r'\s+', ' ', text) # 替换常见全角标点为半角(可按需保留) text = text.replace('“', '"').replace('”', '"') return text.strip()

清洗规则不要做太狠,尤其是标点符号,保存成数据时尽量保留原文风格,不然以后做文本分析可能会出歧义。我的原则是:只处理对存储和后续使用有实际影响的脏字符,不做过度改写。

4. 数据存储:从 CSV 到 SQLite 的落地路径

爬虫抓下来的数据,最后总得有个去处。有人直接打印到终端完事,但既然做的是全站数据,存储方案的取舍还是要认真考虑一下。

4.1 先落 CSV / JSON:快速验证数据完整性

我第一版代码总是先输出 CSV。原因很简单:CSV 可以用 Excel 直接打开预览,能非常直观地发现字段错位、缺失、乱码等问题。JSON 则适合保结构化的嵌套数据(比如标签列表)。

写 CSV 的时候有一个老坑:中文乱码。如果你不指定编码,默认可能是gbk或系统编码,存出来的文件打开就是乱码。正确姿势是:

import csv def save_to_csv(items, filename='quotes.csv'): with open(filename, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=['text', 'author', 'tags', 'url']) writer.writeheader() writer.writerows(items)

这里用utf-8-sig而不是utf-8,是因为 Excel 打开 UTF-8 文件时会多出一个 BOM 头的问题,用utf-8-sig可以避免首行乱码。这个小细节,很多新手都要踩一次才知道。

4.2 引入 SQLite:增量去重才是全站爬虫的关键

CSV 适合小规模数据,但全站爬下来至少几千条,CSV 的去重和查询能力就很弱了。我这次爬到一半就发现重复数据不少——有的名言在“励志”和“哲理”分类下都有入口,详情页也会出现略微不同的版本。

SQLite 是单机场景最合适的选择,不需要额外服务,Python 内置sqlite3模块,开箱即用。

建表和插入逻辑我这样写:

import sqlite3 def init_db(db_path='quotes.db'): conn = sqlite3.connect(db_path) conn.execute(''' CREATE TABLE IF NOT EXISTS quotes ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, author TEXT, author_desc TEXT, tags TEXT, url TEXT UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') conn.commit() return conn def insert_quote(conn, quote): try: conn.execute( 'INSERT OR IGNORE INTO quotes (text, author, author_desc, tags, url) ' 'VALUES (?, ?, ?, ?, ?)', (quote['text'], quote['author'], quote['author_desc'], quote['tags'], quote['url']) ) conn.commit() except sqlite3.IntegrityError: # URL 重复,说明已经存在,跳过 pass

这里有两个点值得展开:

  • url TEXT UNIQUE是天然的去重键。同一个详情页的理论 URL 是唯一的,重复爬到时直接忽略。
  • INSERT OR IGNORE本质上是靠唯一约束干掉了重复记录,比先查后插的效率高很多。

这个思路对全站爬虫特别有用——因为你永远无法保证自己的翻页逻辑里没有交叉入口。

4.3 字段设计:面向后续使用反推存储格式

我的字段表除了基本内容外,特意加了created_at时间戳。为什么?因为如果后续你打算做数据分析,比如统计哪个作者被收录的语录最多、分类之间的数量分布,有一个入库时间字段会方便做增量更新和排错。

还有一点是标签字段的存储格式。我用|分隔符把多个标签拼成一个字符串,而不是单独建一张关联表。虽然规范化设计可能更标准,但单机小项目里,简单字段带来的便利远超范式收益。要在“标准”和“实用”之间找平衡,爬虫项目偏向后者。

5. 反爬与稳定性:优雅爬虫必须过的三道坎

全站爬取不等于暴力请求,真正做到“优雅”,就要有策略地处理反爬机制和网络波动。这一部分我不讲那些绕开服务器防护的灰色手法,纯粹从正向工程的角度聊稳定性。

5.1 请求频率控制与随机延迟

我在前面的代码里已经写了time.sleep(random.uniform(0.5, 1.5)),这里单独拿出来讲,是因为频率控制实在太重要了。

爬虫请求频率过高,后果不只是被封 IP,更严重的是给对方服务器造成不必要的压力。一个正常的站点,爬虫的访问频率最好不高于一个真实用户的行为模型——正常人看一页要几秒钟,你就别做到每秒几十个请求。

更细的做法是“分布式延迟”,把延迟打散到不同的数值区间,而不是固定一个数:

import random import time def polite_sleep(): # 0.8 ~ 2.5 秒随机 time.sleep(random.uniform(0.8, 2.5))

实测下来,这种随机延迟既保证了总进度不会太慢,又让访问节奏更加自然。

5.2 异常重试机制:网络波动是常态

网络请求失败太常见了,超时、连接重置、SSL 错误都有可能出现。如果你不处理异常,爬虫会在某个请求失败时直接崩溃,之前的进度全部白费。

我给fetch_page加了一层重试封装:

from time import sleep def fetch_page(url, max_retries=3, timeout=10): for attempt in range(1, max_retries + 1): try: resp = session.get(url, timeout=timeout) if resp.status_code == 200: resp.encoding = resp.apparent_encoding or resp.encoding return resp.text elif resp.status_code in (403, 429): # 被限流了,退避更久 sleep(attempt * 5) else: sleep(2) except requests.RequestException as e: print(f"[重试 {attempt}/{max_retries}] {url} 失败:{e}") sleep(attempt * 2) raise RuntimeError(f"连续失败:{url}")

核心逻辑是:遇到 403/429(限流)时,重试等待时间线性拉长,而不是继续用正常节奏请求。遇到普通超时,用attempt * 2秒的间隔做指数退避的基础版。

这里也说一下编码问题。我在响应里设置了resp.encoding = resp.apparent_encoding or resp.encoding,有些站点响应头没给出正确 charset,导致后面解析中文全乱。用apparent_encoding自动检测,可以规避绝大多数编码问题。不过检测也有失败概率,如果乱码就要手动指定,比如resp.encoding = 'gbk'。

5.3 断点续爬:中断之后不用从头再来

做全站爬虫经常会遇到跑到一半手动终止、或者服务器突然拒绝的情况。如果没有断点机制,重新爬会浪费几十分钟,还可能因为重复插入把数据库搞乱。

我的做法很简单:在 SQLite 里记录已经成功爬取的详情页 URL 集合。启动时先加载已存在的 URL 到一个集合,爬取前先判断,如果 URL 在集合里就跳过。

关键在于:

def load_done_urls(conn): cur = conn.execute('SELECT url FROM quotes') return set(row[0] for row in cur.fetchall()) def crawl_category(category_url, done_urls): ... for item in items: if item['detail_url'] in done_urls: continue detail = fetch_and_parse_detail(item['detail_url']) insert_quote(conn, detail)

这样就实现了天然的断点续爬:中断之后重新启动,已经入库的 URL 会被自动跳过,进度直接续上。不用写复杂的 checkpoint 文件,数据库本身就可以作为状态记录器。

6. 实战避坑实录:我在这类项目里踩过的坑

这一部分不是文档里的理论,而是实际操作里真实遇到的问题。拿出来单独成章,是因为它们几乎每一个都能让新手卡壳半天。

6.1 列表页和详情页的解析器不统一

刚开始我只在列表页用了lxml解析器,详情页解析时直接写成了soup = BeautifulSoup(html),省略了解析器参数。结果某个详情页的 HTML 写法不规范时,解析结果时好时坏,间歇性丢数据。

排查了半天,发现是默认的html.parser容错能力和lxml相差太大。修法很简单,所有 BeautifulSoup 实例化都必须显式指定解析器。这类问题最难排查,因为不是稳定复现,而是页面结构触发。

6.2 Python 字典 Key 拼写错误导致字段丢失

爬虫代码里经常出现多个字典互相嵌套。我有一次在构造item时把'detail_url'拼成了'detail_urls',列表页解析完全正常,但详情页抓取函数拿不到detail_url,抛 KeyError。这种错误用眼睛看很难发现,最好在开发阶段给每个函数写一个极简的单元测试:

assert parse_list_page(sample_html)[0]['detail_url']

用断言去约束入参出参,一个小技巧能省很多调试时间。

6.3 全文网页编码不一致

有些站点不是所有页面都是 UTF-8,尤其是老旧站点的某些分类页采用的是其他编码。我的第一次全站爬取在爬到第三类时突然出现大量乱码,就是因为只在前几个页面手动指定了编码。

后来统一改成apparent_encoding自动检测,并且打印检测到的编码信息用于人工复核。实测中,apparent_encoding对绝大多数页面都能给出正确的编码,只有极少数带特殊字符的页面需要人工兜底。

6.4 图片链接、时间字段这些“附加题”的处理

名人名言页面除了文本数据,常有一些附加内容,比如作者头像、名言配图。如果你要抓这些资源,需要额外处理图片下载和云存储,这一部分我这次没做,但提一下方向:requests.get(url).content可以拿二进制流,存成文件后把本地路径或对象存储 URL 存入数据库。

如果后续要发布成 API 或做成网页应用,图片数据会比纯文本复杂一个量级,需要另行设计存储方案。

7. 多线程改造:优雅提速的最后一公里

单线程爬虫跑完整个站可能要二十分钟,在某些场景下可以接受,但如果你想做个常跑的全站数据更新任务,多线程就是绕不开的话题。这一部分算扩展,我只讲一个比较稳妥的线程池方案。

Python 的concurrent.futures.ThreadPoolExecutor相比threading手写线程,代码量少很多,控制也清晰:

from concurrent.futures import ThreadPoolExecutor, as_completed def crawl_all(category_pages): done_urls = load_done_urls(conn) with ThreadPoolExecutor(max_workers=4) as executor: futures = [] for page_url in category_pages: futures.append(executor.submit(process_page, page_url)) for future in as_completed(futures): try: future.result() except Exception as e: print(f"任务执行异常:{e}")

线程数我建议先控制在 2~4 个,不要一上来就开十几线程。爬虫的速度瓶颈通常不在本地,而在目标服务器的响应时间。线程太多反而容易触发限流,最后整体进度反而比单线程还慢。多线程配合上面的随机延迟,每个线程各睡各的,访问节奏会更自然。

另外,多线程下写入 SQLite 需要注意锁问题。我的做法是给写入函数加一个threading.Lock(),在insert_quote外面包一层锁,避免多线程同时写库导致database is locked错误。

import threading write_lock = threading.Lock() def safe_insert(conn, quote): with write_lock: insert_quote(conn, quote)

这样一个简单的锁就能解决 99% 的写入冲突问题。

最后再分享一个真实感受:爬虫这个技术,实现“能跑”非常容易,但要实现“稳定、干净、不打扰别人”的优雅版本,需要打磨的地方比想象中多。我之前跑这类全站项目时,总会在收尾阶段做一次完整的数据抽查,随机挑 20 条记录核对原文和数据库是否一致。这个习惯强烈建议大家保留——数据质量是后续一切应用的地基,地基不稳,上面盖什么都白搭。把这一套流程跑通之后,你得到的不仅仅是几千条名言数据,更是一套可以迁移到任意同类站点的通用爬虫方法论。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询