ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

爬虫实战指南:从requests到BeautifulSoup的抓取、解析与数据清洗全流程

爬虫实战指南:从requests到BeautifulSoup的抓取、解析与数据清洗全流程

有些人看我写爬虫,总觉得好像“手到擒来”,其实哪有什么玄学。爬虫说白了就是三个字:拿、解、存。你把这三件事做顺了,大部分页面都能轻松上手。这篇文章不聊那些花里胡哨的框架和分布式架构,就讲我日常写爬虫时最常用到的顺手方法,从请求到解析,从排错到落地,全部都是可以直接抄作业的干活经验。

这篇文章更适合谁看?刚接触爬虫、想补全自己采集流程的开发者,或者是工作中需要经常抓公开数据做分析的同学。如果你是那种连requests和BeautifulSoup都还没装过的纯萌新,也没关系,我会按一个完整示例带你把流程走一遍。只要照着敲,你就能得到一份结构清晰、能复用的采集脚本。

1. 先把“顺手”想明白:爬虫不是炫技,是解决问题

很多人一提到爬虫,脑子里全是分布式、高并发、IP池、逆向破解这类词。但实际上,工作中遇到的爬虫需求,八成以上都是“快速拿到一批公开数据,整理成表格或者接口”。这时候谁用分布式啊,那是给自己找罪受。

1.1 到底什么样的爬虫才算“顺手”

我给“顺手”定了三条标准:

  • 代码结构一眼能看懂,隔三个月回来自己还能改。
  • 请求失败能自动处理,而不是崩一片。
  • 换一个目标页面,改少量代码就能复用。

满足这三点,就是合格的“顺手方法”。相反,如果你写了个爬虫,只有自己能跑,别人接手就爆炸,那不管抓取效率多高,都不算顺手。

先说我个人踩过的一个坑。早期写爬虫,我喜欢把所有的请求逻辑堆在main函数里,50 行代码把请求、解析、保存全干完。当时觉得很爽,后来需求一变,要加个代理池,还要过滤重复数据,结果整个脚本改得面目全非。从那时候起我才明白,爬虫代码也是代码,不规划直接写,后面全是债。

1.2 四个核心模块:取、解、洗、存

我把一个爬虫拆成四个阶段:

  • 取:发 HTTP 请求,拿到 HTML 或 JSON 文本。
  • 解:从文本中抽取出目标字段。
  • 洗:清洗数据,去重、补缺、转换类型。
  • 存:写入 Excel、CSV、JSON 或者数据库。

这四个模块对应四个函数,是我个人最常用的模板骨架:

def fetch(url): # 返回 Response 文本 def parse(html): # 返回结构化数据列表 def clean(items): # 清洗、去重、标准化 def save(items): # 写入目标存储

后面所有爬虫,不管抓的是电商平台还是新闻站点,我都尽量把逻辑往这四个函数里塞。好处是,每个环节出问题,单独调试一个函数就行,不用在一大坨代码里大海捞针。

1.3 选工具时我的默认判断

工具这东西,最忌讳跟风。我记得有一阵子 Scrapy 特别火,大家都说“爬虫不上 Scrapy 不算专业”。我也跟着去学了,学完发现,很多简单的采集场景用 Scrapy 反而费劲——中间件、Item Pipeline、Spider 类,全是被框架绑定的概念,加上部署和监控,一套下来非常重。

所以我的默认判断是分层的:

  • 只需要抓几十几百个页面:直接用requests+BeautifulSoup,最快。
  • 需要并发请求或异步抓取:优先httpx,代码好写。
  • 页面是登录后才能看,需要维护 Cookie 会话:还是requests.Session,方便管理。
  • 需要定时、增量、错误重试、日志的工程化爬虫:才考虑上 Scrapy。

说到底,顺手的意思就是“拿最合适的工具干最合适的活”。工具只是手段,解决问题才是目的。

2. 核心细节解析与实操要点

这部分我要把爬虫里最容易出问题的几个细节单独拎出来讲。这些问题看起来小,但每一个都足以让你折腾一下午。

2.1 第一关是请求,不是解析

很多人拿到一个页面,第一反应是打开浏览器 F12 去看 HTML 结构,然后就开始写解析。但我的习惯是先看请求长什么样。

什么意思?就是说,你在浏览器里看到的页面内容,可能根本不是后端返回的 HTML 自带的,而是 JS 动态渲染出来的。如果你直接请求 URL 拿到的是空壳,解析就是白搭。

所以第一步应该做的是,用requests请求目标 URL,打印响应的前 500 个字符,确认你需要的数据是否直接存在于 HTML 里。

import requests resp = requests.get("https://example.com/page", timeout=10) print(resp.status_code) print(resp.text[:500])

如果能看到目标数据的痕迹,说明是服务端渲染,直接解析即可。如果只看到一堆<script>标签或者id=root的空壳,基本就是前端渲染页面,这时候要么找数据接口,要么考虑用自动化工具。

请求阶段还有几个容易忽略但很重要的点:

  • 超时设置。requests的timeout参数必须写,不写的后果是,一旦目标服务器卡住,你的爬虫会一直等,白白挂死。
  • 显式声明 User-Agent。很多站点对默认的python-requests有拦截,换个常用浏览器的 UA 通常就稳了。
  • 会话复用。如果你需要登录或者有 Cookie 的页面,用requests.Session()而不是每次get()裸用,这样能保持 Cookie 状态。
  • 重试机制。网络抖动是常态,简单做法是用requests.adapters.HTTPAdapter设置最大重试次数。

下面是我日常用的请求函数模板,非常顺手:

from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry session = requests.Session() retry = Retry( total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504] ) adapter = HTTPAdapter(max_retries=retry) session.mount("http://", adapter) session.mount("https://", adapter) def fetch(url, headers=None): h = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"} if headers: h.update(headers) resp = session.get(url, headers=h, timeout=15) resp.raise_for_status() return resp.text

这里raise_for_status()也很关键,它会帮你在 HTTP 状态码异常时主动报错,而不是拿到一堆乱码还在那瞎解析。

2.2 解析页面:从正则到 BS4 的选择

解析 HTML 有很多办法,最原始的是正则表达式。但我建议,非万不得已别用正则解析 HTML。为什么?

HTML 是一种结构不严谨的标记语言,同一个语义可以有无数种写法。标签可以不闭合,属性顺序可以乱,引号可以省略。正则擅长匹配“符合规则的文本”,但 HTML 往往不符合规则,于是你会陷入不断加条件的死循环。

我的默认选择是BeautifulSoup + lxml。这个组合非常快,而且对不规范 HTML 的容忍度极高。

from bs4 import BeautifulSoup soup = BeautifulSoup(html, "lxml") items = soup.select("div.item .title a")

select()支持 CSS 选择器,这是我最喜欢的特性。写选择器的时候,有个通用原则:先定位到包含目标数据的最小容器,再往外或往里取字段。比如下一页、列表页、详情页,思路都一样。

如果只想取文本,不要直接用.text,先用get_text(strip=True),这样可以顺手把大量空白字符清掉。

复杂的页面结构,我的建议是不要试图一条选择器打完天下。拆成多个步骤,一步步逼近数据,代码虽然啰嗦,但极其稳定:

container = soup.select_one("div.product-list") products = container.select("div.product-item") for p in products: name = p.select_one("h3.name a") price = p.select_one("span.price")

这样写的好处是,即使某个字段缺失,也不会影响其他字段的解析。

2.3 编码与字符集:容易翻车的地方

爬虫中文乱码,大多数情况下是编码问题。响应头里的charset可能不正确,或者页面内部用<meta>声明了字符集,但requests没识别出来。

一个比较稳妥的方法是:拿到 Response 后,先通过resp.apparent_encoding看看自动探测出来的编码,有时候比 response 头里写的还准。

resp = session.get(url, timeout=15) resp.encoding = resp.apparent_encoding text = resp.text

但这里有个坑:apparent_encoding有时候会误判,尤其是小体量文本。所以更稳的做法是,直接用BeautifulSoup解析时指定from_encoding:

soup = BeautifulSoup(resp.content, "lxml", from_encoding="utf-8")

如果页面声明是gbk或gb2312,就对应改成:

text = resp.content.decode("gbk", errors="ignore")

这里的关键点是,requests拿到的是字节流resp.content,如果你直接用resp.text,它内部会用默认的编码猜测规则去解码,猜错就乱码。所以当你发现中文乱码时,第一反应应该是:重新取resp.content,然后手动指定编码解码。

还有一个小技巧,是打印页面的 meta 标签内容,从中读取真实的 charset 声明,再用这个结果去解码。很多老站点就是这么干的。

3. 实操过程与核心环节实现

下面我拿一个非常经典的公开示例站——quotes.toscrape.com,走一遍完整的爬虫流程。这个站是专门给爬虫学习者练习用的,服务器压力不大,数据公开,适合演示。

3.1 目标选择与准备

首先我要做的事情不是写代码,而是先看看这个站点的根路径有没有robots.txt。这是爬虫的基本礼仪,也是一个顺手习惯。我想查的是结构,而不是想绕过什么。

curl https://quotes.toscrape.com/robots.txt

正常访问该地址,会看到允许抓取的声明。我实际工作中,凡是遇到没写禁止抓取的公开站点,也会尽量控制频率,至少不会给服务器造成压力。

然后我看一下首页结构:这是一个名言列表,页面里有名言内容、作者、标签三类信息。页脚有下一页链接。这样我们可以设计抓取逻辑:

  • 用fetch获取列表页 HTML。
  • 用parse提取每一条名言的数据。
  • 找到下一页链接,继续抓,直到没有下一页为止。
  • 最后把数据存成 CSV。

3.2 完整代码示例:抓取公开的名言列表

下面这段代码就是按照“取、解、洗、存”四段式来写的,完整可以直接跑:

import csv import requests from bs4 import BeautifulSoup BASE_URL = "https://quotes.toscrape.com" def fetch(url): resp = requests.get(url, timeout=15) resp.raise_for_status() resp.encoding = "utf-8" return resp.text def parse(html): soup = BeautifulSoup(html, "lxml") quotes = [] for item in soup.select("div.quote"): text = item.select_one("span.text") author = item.select_one("small.author") tags = item.select_one("div.tags") tags_list = [t.get_text() for t in tags.select("a.tag")] if tags else [] quotes.append({ "text": text.get_text(strip=True) if text else "", "author": author.get_text(strip=True) if author else "", "tags": ",".join(tags_list) }) return quotes def clean(items): seen = set() result = [] for item in items: key = (item["text"], item["author"]) if key in seen: continue seen.add(key) item["text"] = item["text"].replace("\u201c", "").replace("\u201d", "") result.append(item) return result def save(items, filename="quotes.csv"): with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["text", "author", "tags"]) writer.writeheader() writer.writerows(items) def main(): all_items = [] url = BASE_URL + "/page/1/" while url: print("抓取中:", url) html = fetch(url) all_items.extend(parse(html)) soup = BeautifulSoup(html, "lxml") next_btn = soup.select_one("li.next a") url = BASE_URL + next_btn["href"] if next_btn else None all_items = clean(all_items) save(all_items) print("完成,共抓取", len(all_items), "条名言") if __name__ == "__main__": main()

这个脚本里有个细节:encoding="utf-8-sig"。为什么不用utf-8?因为utf-8-sig会在文件开头写入一个 BOM 头,这样你用 Excel 打开 CSV 时不会乱码。很多人抓了数据一到 Excel 就乱码,就是这里没注意。

另一个细节是,名言文本里带英文弯引号\u201c和\u201d,我在清洗阶段把它们替换掉了。这个就是清洗的“现场表演”——永远不要指望源头数据是干净的。

3.3 清洗、转换与落盘

上面的代码已经把清洗和存储做进去了,但我要专门强调一下这两步为什么值得做。

清洗的目的是保证数据可用性。很多项目卡在最后一步,就是因为拿回来的数据杂七杂八,没法直接进分析流程。我说几个高频清洗动作:

  • 去重:同一页面抓两遍,或者翻页重复抓,都会有重复数据。用关键字段做去重,比全字段去重更精准。
  • 去空白:get_text(strip=True)之外,还要注意常见不可见字符,比如\u200b(零宽空格)、\xa0(不间断空格)。
  • 类型转换:价格、日期、数字这类字段,解析出来往往是字符串,要手动转成float或datetime再入库。
  • 缺失值处理:有的页面某个字段为空,别急着丢整行,先补上默认值,后续分析的人可能会需要这条记录。

存储层面,如果是小量数据,CSV 足够;如果数据量大,或者要频繁查询,建议入 SQLite。Python 标准库里的sqlite3就够用,没必要为这点数据上一个重量级数据库。

import sqlite3 conn = sqlite3.connect("quotes.db") cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS quotes ( id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT, author TEXT, tags TEXT ) """) cursor.executemany( "INSERT INTO quotes (text, author, tags) VALUES (?, ?, ?)", [(q["text"], q["author"], q["tags"]) for q in all_items] ) conn.commit() conn.close()

这个executemany执行批量插入,比循环单条插入快得多。顺手,真的很重要。

4. 常见问题与排查技巧实录

爬虫写多了,你会发现大部分时间根本不是在写爬虫,而是在排查各种奇怪问题。下面这些问题,我几乎每个都遇到过,也都有对应的排查思路。

4.1 遇到 403 或 429 状态码

403 是访问被拒绝。常见原因有两种:一是没有带合理的User-Agent,默认的python-requests很容易被拦截;二是服务器做了频率限制,你访问太频繁触发了防护。

排查步骤:

  1. 用浏览器打开同一个 URL,确认数据在浏览器里能正常展示。如果浏览器也打不开,那说明是服务器或网络问题,和你的代码无关。
  2. 如果浏览器能打开,对比浏览器请求头和你的请求头,把User-Agent、Accept、Accept-Language尽量补全。
  3. 如果加了 UA 仍然被拒,大概率是频率限制。这里应该做什么?不是想办法绕过,而是降低请求频率,增加两次请求之间的间隔,这是对服务器的基本尊重,也能避免给站点造成压力。

429 是请求过于频繁,返回头里通常会有Retry-After字段,告诉你多少秒之后再试。我见过有人直接把重试逻辑做成死循环,一秒请求一次,结果就是账号被封、IP 被禁。正确做法是读取Retry-After,按服务器要求等待。

import time resp = session.get(url, timeout=15) if resp.status_code == 429: wait_time = int(resp.headers.get("Retry-After", 30)) print(f"请求太频繁,等待 {wait_time} 秒") time.sleep(wait_time)

4.2 页面结构变了,筛选器失效

这个太常见了。今天写的选择器,明天就没数据。原因可能是页面改版,也可能是接口数据结构调整。

排查时先别改代码,先手动打开页面,看看目标节点还在不在。如果页面结构变了,把新的 HTML 保存下来,重新分析选择器。我用的是最土但最有效的方法:把抓回来的 HTML 用浏览器打开,然后 F12 选中目标元素,复制它的 CSS 路径,再逐步简化。

这里有个原则:选择器写得越简单,抗页面变化的能力越强。比如div.quote就比body > div:nth-child(2) > div.container > div.row > div.col-md-8 > div:nth-child(1)稳定得多。所以写选择器时,尽量用 class、id 这类有语义的标识,少用伪造的层级和索引。

4.3 动态渲染内容怎么处理

回到前面说的,页面可能是 JS 动态渲染的。这时 HTML 源码是空壳。你 F12 看到的数据,是浏览器执行 JS 之后生成的,直接requests是拿不到的。

我自己的处理顺序是这样的:

  1. 在浏览器里打开页面,按 F12 切到 Network 面板,刷新页面,看网络请求里有没有返回 JSON 数据的接口。很多前端框架都会从后端拉数据接口,然后渲染到页面上。如果你能找到这个接口,直接用requests请求它,拿到 JSON 解析,效率最高。
  2. 找不到数据接口,或者接口加了复杂的签名验证,这时候才考虑自动化浏览器工具。对公开数据而言,用自动化工具打开页面,等渲染完成再获取页面源码是可行的。但要注意,这种方式开销大、速度慢,而且资源占用高,不适合大规模采集,偶尔跑一跑还行。

我特别想提醒的是,动态渲染页面往往会频繁触发验证码之类的人机验证。如果你频繁用自动化工具去访问,会给对方服务器带来压力,还可能导致正常的访问也被限制。所以这里更推荐优先找数据接口,而不是一股脑上自动化工具。

5. 提高实用性的几条经验

最后一章,我分享几个长期实践下来的经验。它们不一定能直接让你抓得更快,但能让你的爬虫“活”得更久,也更省心。

5.1 请求频率与自律

很多站点的反爬,其实不是针对爬虫本身,而是针对行为。正常用户不可能每秒发出几十个请求。所以控制请求频率,是大规模采集的底线。

我个人的习惯是:每次请求之间至少间隔 0.5 到 1 秒。小批量采集可以快一点,大批量就一定慢下来。这不是怂,这是细水长流。你可以设一个随机间隔,让请求看起来更像人工操作:

import random time.sleep(random.uniform(0.5, 1.5))

这样的好处,一是给服务器留出响应空间,二是避免自己被打进黑名单。如果任务量很大,我的建议是分批跑,比如一次只抓 100 页,休息几分钟再继续。

5.2 断点续爬与增量更新

一次性全量抓完的情况其实很少。大多数实际项目是需要每天定时增量更新的。如果每次都从第一页开始抓,既慢又浪费带宽。

思路是:把“已抓取页面的最后位置”记下来。可以是文件,可以是数据库表。

import os def get_last_page(): if os.path.exists("cursor.txt"): with open("cursor.txt", "r", encoding="utf-8") as f: return int(f.read().strip()) return 1 def set_last_page(page): with open("cursor.txt", "w", encoding="utf-8") as f: f.write(str(page))

这个思路很适合列表类页面。每天定时任务从上次结束的页数开始抓,抓完更新游标。增量更新的另一个关键点是“去重”,建议入库时用唯一约束,比如给(text, author)建唯一索引,遇到重复数据直接跳过。

5.3 日志与调试

我见过太多人写爬虫完全不打印日志,出了问题只能靠猜。说实话,爬虫是最需要日志的场景,因为目标站点不在你控制范围内,任何异常都可能随时发生。

我建议至少做三件事:

  • 每抓取一个页面,打印 URL 和状态码。
  • 每次解析到的数量,打印出来。
  • 每个异常,完整记录 traceback。
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" ) logger = logging.getLogger(__name__) def fetch(url): logger.info("正在获取: %s", url) try: resp = session.get(url, timeout=15) logger.info("状态码: %s", resp.status_code) return resp.text except Exception as e: logger.exception("请求失败: %s", url)

日志的作用不只是排错,它还能让你大致估算整个任务的耗时。比如抓了 1000 个页面,日志显示平均每页 1.2 秒,那你就能预估全部跑完需要多久,方便安排执行计划。

最后分享一个我个人的小习惯。每次写完爬虫,我都会单独写下这个目标站点的“需求说明书”——目标 URL 是什么、字段有哪些、数据量大概多少、多久更新一次。哪怕只是几行笔记,下次需求变更时,你再看这段代码就不会一脸茫然。爬虫这个领域,代码变化太快,但思路和习惯是可以沉淀的。你把这些顺手方法内化了,再去面对任何新的采集需求,都会从容很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进