☰
Python进阶实战:用装饰器与生成器打造健壮爬虫
2026/10/26 7:34:39 网站建设 项目流程

不知道你有没有过这种感觉:Python基础语法倒背如流,列表、字典、循环、函数天天用,但一提到"进阶"两个字就有点心虚。尤其是想把爬虫跑起来的时候,明明照着教程写完了,一运行不是这里报错就是那里没数据,甚至干脆“exit code 0”地安静结束,连个响都没有。问题出在哪?多半不是爬虫本身难,而是你手里只有基础语法,缺了那些真正让代码"抗造"的工具。

这篇文章我想聊聊Python进阶用法是怎么在爬虫场景里落地的。不是给你堆一堆语法糖然后说“你看多好用”,而是直接对着一个能跑通的简单爬虫,一步步把装饰器、生成器、上下文管理器、异常处理这些东西嵌进去,让你既看得懂原理,又能直接抄作业。适合学过Python基础、但还没系统写过爬虫的朋友,也适合那些写了不少脚本但总觉得代码“一跑就碎、一改就乱”的同学。

1. 进阶不是炫技:这几个语法糖在爬虫里到底解决什么问题

很多人对Python进阶用法的误解是“写给别人看的”,觉得列表推导式、装饰器这些东西除了让代码短一点,没什么实际价值。但我做了这么多年脚本和数据处理,负责任地告诉你:这些东西在爬虫里几乎是刚需。不是好看,是省事、抗错、好维护。

1.1 列表推导式:造参数、筛结果都顺手

写爬虫最常干的几件事:构造一堆URL、从请求结果里抽数据、把清洗后的数据整理成列表。这些操作如果全用for循环加append,代码会膨胀得很厉害。列表推导式在这里不是花架子,而是把“循环+收集结果”这个高频动作压缩成一行,可读性反而更高。

比如要拼出前十页的分页URL:

base_url = "https://quotes.toscrape.com/page/{}/" urls = [base_url.format(i) for i in range(1, 11)]

比起写三行for循环,这个方式意图更直接。再比如从解析结果里筛选长度大于10的名言:

long_quotes = [quote.text for quote in quote_tags if len(quote.text) > 10]

如果你用过filter加lambda,会发现列表推导式在可读性上几乎完胜。当然,推导式不是万能的,如果循环体里面逻辑特别复杂,比如还要处理异常、还要调函数,那就老老实实写for循环,别硬挤成一行。这也是进阶用法的第一条准则:可读性优先,简洁是手段不是目的。

1.2 生成器:处理分页URL时,内存差距是实打实的

生成器是进阶用法里被低估的一个。很多人知道yield关键字,但没体会过它的价值。写爬虫时最容易遇到的一个场景是:你要抓几千个页面,提前把所有URL存在一个列表里没问题,但如果URL数量到了几十万甚至上百万,一个列表占掉的内存就很可观了。生成器在这里的作用是“边取边生成”,不一次性把所有值放进内存。

def page_urls(total_pages): for i in range(1, total_pages + 1): yield f"https://quotes.toscrape.com/page/{i}/"

调用的时候:

for url in page_urls(10): # 每次循环才生成一个URL,而不是先建好10个 fetch(url)

这还不是最大的价值。生成器更大的意义在于支持“懒加载”,你可以把整个抓取流程串成一条流水线:一个生成器负责生成URL,另一个生成器负责解析页面,再用一个循环消费它们。每个环节只关心当前这一条数据,内存占用始终很低。刚开始你可能感觉不到差别,但一旦数据规模上去,这个设计能救你命。

1.3 装饰器:重试与超时的优雅封装

爬虫最烦的是什么?请求超时、连接被重置、暂时性封禁、返回了意料之外的错误状态码。如果你在每个请求函数里都写一遍try...except...retry,代码会变得极其啰嗦。装饰器就是来解决这个问题的:把重试逻辑抽出来,作为一个“包装器”套在任意爬虫函数上。

import time import functools def retry(max_times=3, delay=1): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for i in range(max_times): try: return func(*args, **kwargs) except Exception as e: print(f"第{i+1}次请求失败: {e}") if i == max_times - 1: raise time.sleep(delay) return wrapper return decorator @retry(max_times=3, delay=2) def fetch_page(url): resp = requests.get(url, timeout=5) resp.raise_for_status() return resp.text

这个装饰器把“失败后重试”这个横切关注点从业务逻辑里剥离出去了。你写的fetch_page只需要关心“请求并返回文本”,如果失败,装饰器自动帮你重试。这就是进阶用法的核心思想:把跨函数的通用逻辑抽出来,让核心代码专注于自己的事。

2. 爬虫起步的关键准备:请求、解析和存储的最小闭环

聊完语法,我们回到爬虫本身。一个最简单的爬虫,本质上是三步:请求页面、解析内容、存储结果。看起来简单,但每一步都有容易踩坑的地方。

2.1 先确认目标页面的“脾气”

写爬虫千万别上来就写代码。先花两分钟看一眼你要抓的页面,搞清楚几件事:

  • 内容是静态HTML,还是JavaScript动态渲染的?如果是动态渲染,requests拿到的HTML里通常没有你要的数据,得另想办法(比如找接口API)。
  • 页面编码是什么?常见的有utf-8、gbk、gb2312,编码搞错了,拿到的中文直接乱码。
  • 有没有robots.txt?虽然这东西没有强制力,但尊重它是爬虫的基本礼仪。

对于练手,我强烈推荐用http://quotes.toscrape.com这个专门给爬虫学习者设计的网站。页面结构简单,数据是静态的,分页逻辑也清晰,非常适合作为第一个爬虫的练习场。我的示例也会用它。

2.2 requests发送请求时的三类常见问题

用requests请求页面,最常见的三类问题:

第一,反爬识别。很多网站会检查User-Agent,默认的python-requests/xxx很容易被拒绝。解决办法是伪装成一个正常浏览器:

headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" }

第二,编码乱码。requests会根据响应头猜测编码,但有时候不准。稳妥的做法是拿到content字节后手动指定编码:

resp = requests.get(url, headers=headers) resp.encoding = "utf-8" # 根据页面实际编码调整

第三,超时无响应。如果不设置timeout,请求可能一直挂在那里,整个程序卡住。每次请求都加上超时参数:

resp = requests.get(url, headers=headers, timeout=5)

这些看着不起眼,但都是我在大量实战里踩过的坑。尤其是超时这个,一旦目标网站响应慢,没有超时的程序就跟死机了一样,非常被动。

2.3 解析HTML:BeautifulSoup的基本盘

解析HTML我首选BeautifulSoup4,它不像正则那么难读,容错性也好。日常解析最常用的几个操作:

from bs4 import BeautifulSoup soup = BeautifulSoup(html_text, "html.parser") # 找单个元素 title = soup.find("h1") # 找多个元素 quotes = soup.find_all("div", class_="quote") # CSS选择器方式 links = soup.select("div.quote span a")

用find_all加class_是最常见的组合,因为页面里一类元素往往是同一种样式。还有一个小技巧:拿到的Tag对象还可以继续做嵌套查找,一层层缩小范围。

2.4 存储:csv足够,sqlite更香

爬下来的数据存哪?数据量小、字段简单,存csv就够:

import csv with open("quotes.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["text", "author"]) writer.writerow(["人生苦短", "Python"])

注意编码用utf-8-sig,不然Excel打开会乱码。

如果要抓的数据有结构关系,或者后续要做查询,sqlite更合适。Python内置sqlite3模块,不需要额外装东西:

import sqlite3 conn = sqlite3.connect("quotes.db") cursor = conn.cursor() cursor.execute("CREATE TABLE IF NOT EXISTS quote (text TEXT, author TEXT)") cursor.execute("INSERT INTO quote VALUES (?, ?)", ("人生苦短", "Python")) conn.commit() conn.close()

最小闭环跑通之后,我们再谈优化。因为爬虫这东西,先跑起来比什么都重要。

3. 一个真实可跑的小爬虫:名言站点抓取,10分钟能复现

为了不纸上谈兵,我直接用一个具体例子带你完整走一遍。

3.1 分析目标结构

目标站点是quotes.toscrape.com。打开首页,你会发现每一页有若干条名言,每条名言在一个div.quote里,里面有三个关键部分:

  • 名言文本:span.text
  • 作者:small.author
  • 标签:div.tags a.tag

页底有Next按钮,指向下一页。我们要抓的内容很简单:名言文本和作者。

3.2 第一版代码:能跑就行

先写一个不求优雅、只求跑通的第一版:

import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } def fetch_page(url): resp = requests.get(url, headers=headers, timeout=5) resp.encoding = "utf-8" resp.raise_for_status() return resp.text def parse_page(html): soup = BeautifulSoup(html, "html.parser") result = [] for quote in soup.find_all("div", class_="quote"): text = quote.find("span", class_="text").get_text(strip=True) author = quote.find("small", class_="author").get_text(strip=True) result.append((text, author)) return result def main(): url = "https://quotes.toscrape.com/page/1/" html = fetch_page(url) data = parse_page(html) for text, author in data: print(f"{author}: {text}") if __name__ == "__main__": main()

运行之后,你会在控制台看到第一页的十条名言。整个过程很直白,但问题也明显:如果只想抓第一页,够了;想抓多页,得改;遇到请求失败,直接崩;想缓存数据,没有。

3.3 输出结果与排查思路

第一次运行最常见的现象,就是在PyCharm或VSCode里看到一句“Process finished with exit code 0”,然后什么都没有。这通常不是代码报错,而是你的print压根没执行,或者页面结构和预期不一致。排查思路我建议按这个顺序来:

  1. 在fetch_page函数里print(len(html)),看拿到多少字节。如果拿到的是几千字节,说明页面正常加载;如果是几十字节,多半被重定向了。
  2. 在parse_page里print(soup.prettify()[:500]),直接看HTML解析结果,确认类名、标签名没写错。
  3. 检查resp.status_code,看是不是200。如果是403或者302,说明请求出了身份问题,或者被重定向了。

这一步排查能力,比你多背十个API都值钱。因为爬虫写来写去,大部分时间都花在这种“数据为什么没出来”的调试上。

4. 让爬虫“抗造”起来:进阶技巧的一次性集成

第一版能跑,但很脆。下面我们把这些进阶用法一个个加进去,把它变成一个遇到失败不崩、抓多页不卡、代码结构清晰的小工具。

4.1 Session上下文管理器:连接复用与自动关闭

requests.get每次都会重新建立连接,抓几百页的时候效率不高。用requests.Session可以复用TCP连接,同时配合上下文管理器,确保用完关掉:

def fetch_page(url, session=None): session = session or requests.Session() with session.get(url, headers=headers, timeout=5) as resp: resp.encoding = "utf-8" resp.raise_for_status() return resp.text

用with的好处是,响应对象在退出代码块时会自动释放连接,避免文件描述符泄露。在爬虫这种频繁建立连接的场景里,这是个非常实际的优化。

4.2 装饰器加持:超时重试与请求限速

把前面写的retry装饰器用上,同时加一个简单的限速装饰器:

def rate_limited(seconds): import time def decorator(func): last_call = [0.0] @functools.wraps(func) def wrapper(*args, **kwargs): now = time.time() wait = seconds - (now - last_call[0]) if wait > 0: time.sleep(wait) result = func(*args, **kwargs) last_call[0] = time.time() return result return wrapper return decorator @retry(max_times=3, delay=2) @rate_limited(0.5) def fetch_with_policy(url, session=None): return fetch_page(url, session)

这样一个函数就同时拥有了失败重试和请求间隔控制的能力。装饰器的组合(俗称装饰器堆叠)是Python进阶里特别值得掌握的技能:把不同的横切关注点叠在一起,每个装饰器只负责一件事,组合起来却是完整策略。

4.3 用生成器重构分页代码

之前说的page_urls生成器在这里派上用场。我们可以把整个抓取流程改成生成器流水线:

def all_pages(start_page=1, end_page=10): for page in range(start_page, end_page + 1): yield f"https://quotes.toscrape.com/page/{page}/" def scrape_quotes(): session = requests.Session() results = [] for url in all_pages(1, 10): html = fetch_with_policy(url, session) data = parse_page(html) print(f"抓取 {url} 完成,拿到 {len(data)} 条") results.extend(data) return results

如果你不想显式指定end_page,也可以用while循环找下一页按钮。但用生成器的主要好处是:调用方想取多少页就取多少页,代码结构一目了然。

4.4 用dataclass代替字典管理数据

字典用多了,字段容易写错,特别是在函数之间传递的时候。Python 3.7+的dataclass是个非常好用的数据类型,既有类的好处,又不用写一堆__init__:

from dataclasses import dataclass @dataclass class Quote: text: str author: str tags: list[str]

然后把parse_page改成返回Quote对象列表:

def parse_page(html): soup = BeautifulSoup(html, "html.parser") result = [] for quote in soup.find_all("div", class_="quote"): text = quote.find("span", class_="text").get_text(strip=True) author = quote.find("small", class_="author").get_text(strip=True) tags = [tag.get_text(strip=True) for tag in quote.find_all("a", class_="tag")] result.append(Quote(text=text, author=author, tags=tags)) return result

好处是:字段明确、有IDE补全、默认就有__repr__,调试时打印出来特别清晰。这是进阶用法里最“低门槛高回报”的一个。

5. 踩过的坑与仍然要小心的边界

进阶用法集成完,代码已经比第一版好很多了,但爬虫这件事,远不止写代码这么简单。我把自己实际踩过的一些坑和边界问题放在这里,希望能帮你绕开。

5.1 "exit code 0但没结果"的常见原因

这个现象在PyCharm用户里特别常见。程序退出码是0,意味着没有异常,但你要的数据没打出来。我总结过几次原因:

  • if __name__ == "__main__":后面的main()忘了写,程序进入但什么都不执行。
  • parse_page里的选择器写错,find_all返回空列表,循环体不执行。
  • print被缓冲了,尤其在循环很多、程序正常结束前没刷新。可以在脚本开头加一句import sys; sys.stdout.reconfigure(line_buffering=True),让输出即时刷新。
  • 页面实际返回了空内容,但请求没报错,这种情况把resp.text的前几百个字符打印出来,一眼就能看清。

核心思路:不要只盯着有没有报错,要学会在关键节点打印中间结果。这是调试爬虫最基本也最有效的办法。

5.2 编码与解析带来的隐形bug

编码问题真的很阴。你以为设置了resp.encoding = "utf-8"就万事大吉,但有些页面是gbk,你硬按utf-8解码,得到的就是一堆乱码,而且parse_page还能解析出“元素”,只是文本是乱的。

处理办法:先看响应头里的charset,或者直接用requests.utils.get_encoding_from_headers(resp.headers)获取推荐编码;再不行就试gbk、gb2312、utf-8轮着来。还有一次我遇到页面HTML里混着 这类实体,用get_text(strip=True)都去不掉,需要html.unescape()处理一下。

5.3 爬虫的礼貌与合规:限速、robots、识别声明

这里必须多说一句。爬虫是个技术活,但也是个“尊重他人”的活。我在实际工作中一直坚持几个习惯:

  • 限速:即使不写rate_limited装饰器,也要保证请求间隔不要过密。服务器不是你一个人的跑轮。
  • 识别声明:把User-Agent伪装成浏览器没错,但如果站点需要身份验证或明确禁止爬取,那就别硬来。
  • robots.txt:虽然很多网站不强制执行,但作为专业习惯,抓取前看一眼总有好处。
  • 数据边界:不要抓取个人信息、受版权保护的内容,更不要用于任何违法违规用途。

爬虫最大的风险不在技术,而在滥用。一个技术上非常完美的爬虫,如果被用于恶意刷接口、抓取私密数据、干扰业务运行,那它就是个坏工具。我希望你学这些技术是出于学习、自动化、数据整理的正向目的。

另外,如果你要长期维护一个爬虫,我建议用logging替代print,把日志级别分开,方便线上查问题;用tenacity库替代手写重试装饰器,它内置了指数退避和条件重试,比手写的更完善;数据量大了以后,引入concurrent.futures.ThreadPoolExecutor做并发请求,但并发一定要配合限速和重试策略,否则很容易触发封禁。

说到底,Python进阶用法和爬虫是相辅相成的:爬虫场景逼着你用上装饰器、生成器、上下文管理器、异常处理、类型注解,而这些进阶用法又反过来让你的爬虫更健壮、更优雅。我自己的体会是,不要追求一步到位写出“完美代码”,先把第一版跑通,再一步步把进阶技巧装进去,这个过程本身才是最有价值的学习。现在你可以打开编辑器,对着那个名言站点,亲手把这段代码敲一遍——相信我,踩过一次坑之后,这些东西就会变成你自己的。

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

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

立即咨询