1. 从“能打开网页”到“能拿到数据”:理解爬虫的核心跨越
上次我们聊了聊爬虫是什么,以及最基础的requests.get怎么用。很多朋友照着例子敲了一遍,成功打印出了某个网页的HTML源码,感觉“爬虫不过如此”。但紧接着问题就来了:面对屏幕上这一大坨密密麻麻、夹杂着各种标签和符号的文本,我们真正需要的数据——比如商品的价格、文章的标题、评论的内容——到底在哪?怎么把它像摘葡萄一样,一颗颗干净利落地摘下来?
这就是从“网络请求”到“数据解析”的关键一跃,也是新手最容易卡住的地方。你会觉得,明明数据就在眼前(网页上能看见),但代码就是拿不到,或者拿不完整、拿不干净。这种感觉就像隔着一层毛玻璃看东西,很模糊。今天,我们就来把这层毛玻璃打碎,聊聊怎么用 Python 里最主流、也最易上手的工具——BeautifulSoup,来精准地提取你需要的数据。
我刚开始学的时候,也以为拿到 HTML 就万事大吉,结果在解析数据上花了最多的时间,踩了最多的坑。比如,明明在浏览器里右键“检查”能看到清晰的class名,但用soup.find就是找不到;或者网页结构稍微一变,脚本就全军覆没。这些痛,我都懂。所以,这篇我们不只讲语法,更会结合我实际爬取电商、新闻、社交媒体(注:此处泛指技术学习,不特指任何敏感平台)数据的经验,告诉你哪些地方容易翻车,以及怎么写出更健壮、更像“老手”的代码。
2. 解析利器 BeautifulSoup:你的“数据收割机”
在开始写代码之前,我们得先统一认识:HTML 是什么?它不是一个规整的表格,而是一棵“树”。这棵树有根(<html>),有枝干(<div>,<section>),有叶子(<p>,<span>包含的文本)。BeautifulSoup(通常简称bs4)就是一个帮你在这棵树上导航、寻找特定枝干和叶子的园丁。它的工作原理不是用正则表达式去硬匹配文本(虽然也可以,但那太痛苦了),而是把 HTML 解析成一个个 Python 对象,让你可以用“点”操作和“找”方法来访问。
2.1 安装与环境准备
首先,确保你安装了它。如果你用的是pip,打开终端(或命令提示符)输入:
pip install beautifulsoup4同时,我们通常需要一个“解析器”来配合BeautifulSoup工作。就像读中文需要懂中文,读 HTML 也需要一个懂 HTML 语法的帮手。最常用的是lxml,它速度快、容错好。
pip install lxml如果安装lxml遇到问题(特别是在 Windows 上),可以先用 Python 自带的html.parser,虽然慢点,但胜在无需额外安装。我们的代码会兼顾这两种情况。
一个完整的爬虫脚本,开头通常长这样:
import requests from bs4 import BeautifulSoup # 目标网址(我们用一个静态页面做示例,避免复杂情况) url = 'https://httpbin.org/html' # 这是一个返回示例HTML的测试网站 headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } try: response = requests.get(url, headers=headers) response.raise_for_status() # 检查请求是否成功 response.encoding = response.apparent_encoding # 自动识别编码 except requests.RequestException as e: print(f"网络请求失败: {e}") exit() # 创建BeautifulSoup对象,指定解析器 # 使用 lxml soup = BeautifulSoup(response.text, 'lxml') # 如果 lxml 安装失败,使用 html.parser # soup = BeautifulSoup(response.text, 'html.parser') print(type(soup)) # 输出:<class 'bs4.BeautifulSoup'>到这里,你已经把原始的 HTML 文本,转化成了一个可以操作的soup对象。你可以把它打印出来,会发现它和response.text看起来差不多,但内在已经完全不同了。
2.2 导航与搜索:find 与 find_all
soup对象提供了两种最核心的查找方法:find()和find_all()。
find():返回第一个匹配的标签。find_all():返回一个列表,包含所有匹配的标签。
怎么告诉它你要找什么呢?主要靠标签名和属性。
场景一:按标签名找假设你想找所有的段落<p>。
first_p = soup.find('p') # 找到第一个<p>标签 all_ps = soup.find_all('p') # 找到所有<p>标签,返回列表 print(f"找到 {len(all_ps)} 个段落。") for p in all_ps[:3]: # 只看前三个 print(p.text) # .text 属性获取标签内的纯文本场景二:按属性找(最常用!)网页开发者会给重要的元素加上id(唯一)或class(类,可多个)属性,这就是我们的“路标”。
# 假设有一个 <div id="price">¥299</div> price_tag = soup.find('div', id='price') if price_tag: print(f"商品价格: {price_tag.text}") # 假设有多个 <span class="star active"></span> 表示亮星 active_stars = soup.find_all('span', class_='active') # 注意,class是Python关键字,所以要加下划线 print(f"亮星数量: {len(active_stars)}")注意:
class_这个参数名是必须的,因为class是 Python 的保留字。这是BeautifulSoup里最容易写错的语法点之一。
场景三:组合查找与CSS选择器你可以组合多个条件,或者使用更强大的select方法,它支持 CSS 选择器语法,非常直观。
# 找到 class 为 ‘item’ 的 div 标签下的第一个 a 标签 link = soup.find('div', class_='item').find('a') # 使用CSS选择器,实现同样功能,更简洁 link = soup.select_one('div.item > a') # select_one 相当于 find all_links = soup.select('div.item a') # select 相当于 find_all,找所有 div.item 下的 a 标签select的语法和你在浏览器开发者工具里右键“Copy selector”看到的很像,学习成本低,功能强大,是我现在最推荐的方式。比如:
soup.select(‘#main .title’):选择 id 为main的元素内部,所有 class 为title的元素。soup.select(‘ul li:nth-child(2)’):选择每个 ul 列表下的第二个 li 元素。
3. 数据提取的“脏活累活”:文本清洗与属性获取
找到标签只是第一步,标签里的内容才是我们想要的。这里坑最多。
3.1 获取文本:.text,.string,.get_text()
.text和.get_text():获取标签及其所有子孙标签的纯文本,合并成一个字符串。这是最常用的。
html_snippet = '<div>Hello <b>World</b>!</div>' soup_snippet = BeautifulSoup(html_snippet, 'html.parser') tag = soup_snippet.find('div') print(tag.text) # 输出:Hello World! print(tag.get_text()) # 输出:Hello World!.string:如果这个标签只有一个子节点,并且是字符串,则返回该字符串。否则返回None。条件苛刻,容易出错,新手慎用。
print(tag.b.string) # 输出:World (因为<b>标签内只有一个字符串子节点) print(tag.string) # 输出:None (因为<div>有多个子节点)实操心得:99% 的情况用.text或.get_text()。.get_text()还可以设置分隔符,比如tag.get_text(‘,’)会用逗号连接不同部分的文本,在处理复杂内容时有用。
3.2 获取属性:像字典一样访问
标签的属性(如href,src,># 假设有一个链接 <a href="/product/123" class="btn">dirty_html = ‘<p> 价格: 299元 </p>’ soup_dirty = BeautifulSoup(dirty_html, ‘html.parser’) print(repr(soup_dirty.p.text)) # 输出:’ 价格: 299元 ‘
你需要清洗它。Python 字符串的.strip()方法可以去掉首尾空白,.replace()可以替换特定字符。
clean_text = soup_dirty.p.text.strip() # ‘价格: 299元’ # 或者更彻底地,去掉所有空白字符 import re cleaner_text = re.sub(r‘\s+’, ‘ ‘, soup_dirty.p.text).strip() # ‘价格: 299元’我的经验:对于重要的数据字段(如价格、日期),建立专门的清洗函数。比如提取价格,不仅要 strip,还要用正则表达式r‘\d+(\.\d+)?’把数字部分提取出来,并转换成float类型,方便后续计算。
4. 实战:爬取一个静态商品列表页
光说不练假把式。我们找一个练习用的静态网站(例如一些大学的课程页面或开源项目的示例页),模拟爬取一个商品列表。这里我以假设的简单结构为例,你需要根据实际目标网站调整选择器。
目标:从一个商品列表页中,提取每个商品的名称、价格和详情链接。
假设的HTML结构:
<div class="product-list"> <div class="product-item"> <h3><a href="/item/1">Python编程从入门到实践</a></h3> <p class="price">¥59.90</p> </div> <div class="product-item"> <h3><a href="/item/2">深入理解计算机系统</a></h3> <p class="price">¥129.00</p> </div> </div>爬虫代码:
import requests from bs4 import BeautifulSoup import re def clean_price(price_str): """清洗价格字符串,提取数字""" # 匹配数字(包括小数点) match = re.search(r‘\d+(\.\d+)?’, price_str) if match: return float(match.group()) return None url = ‘你的目标列表页URL’ headers = {‘User-Agent’: ‘Mozilla/5.0...‘} try: resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() # 很多时候需要手动指定编码,比如‘gbk’ # resp.encoding = ‘gbk’ except Exception as e: print(f“请求失败: {e}”) exit() soup = BeautifulSoup(resp.text, ‘lxml’) # 找到所有商品项容器 product_items = soup.select(‘div.product-list > div.product-item’) # 或者用 find_all: product_items = soup.find_all(‘div’, class_=‘product-item’) products = [] for item in product_items: product = {} # 提取名称和链接 title_tag = item.select_one(‘h3 a’) if title_tag: product[‘name’] = title_tag.text.strip() # 拼接完整链接(如果是相对路径) product[‘link’] = requests.compat.urljoin(url, title_tag[‘href’]) else: product[‘name’] = ‘N/A’ product[‘link’] = ‘N/A’ # 提取价格 price_tag = item.select_one(‘p.price’) if price_tag: raw_price = price_tag.text.strip() product[‘price’] = clean_price(raw_price) else: product[‘price’] = None if product[‘name’] != ‘N/A’: # 简单过滤无效数据 products.append(product) # 打印结果 for idx, prod in enumerate(products, 1): print(f“商品{idx}: {prod[‘name’]} | 价格: {prod[‘price’]} | 链接: {prod[‘link’]}”) print(f“\n共爬取到 {len(products)} 件商品。”)这段代码里的几个关键点:
- 错误处理:网络请求用
try-except包裹,select_one和属性获取前都做了判断,避免因某个标签缺失导致整个程序崩溃。 - 链接拼接:使用
requests.compat.urljoin(base_url, href)可以智能地拼接相对路径和绝对路径,比手动拼接更可靠。 - 数据清洗:专门写了
clean_price函数来处理价格字符串,确保最终得到的是可以计算的数值。 - 数据存储:我们把每个商品的信息存成一个字典,再把字典放进列表。这是后续保存到文件或数据库的基础结构。
5. 绕过第一个大坑:动态加载与请求分析
如果你按照上面的代码去爬一些现代网站(比如电商、社交媒体),很可能发现soup.select(‘div.product-item’)返回的是一个空列表。但你明明在浏览器里能看到商品。这就是我们遇到的第一个真正的大坑:动态加载。
很多网站为了性能和用户体验,不会在第一次请求的 HTML 里放入所有数据。而是先给你一个页面骨架(包含基础的 HTML、CSS、JS),然后由浏览器执行 JavaScript 代码,再去后台(API)请求数据,最后动态地插入到页面中。我们的requests只拿到了最初的“骨架”,自然看不到动态插入的内容。
怎么办?有两个主流思路:
5.1 思路一:模拟浏览器,执行JS(重量级)
使用像Selenium或Playwright这样的工具,它们可以控制一个真实的浏览器(如 Chrome)去打开网页,等待 JS 执行完毕,再获取完整的页面源码。这种方法能解决几乎所有的动态加载问题,因为它看到的就是最终用户看到的页面。
优点:简单粗暴,通用性强。缺点:速度慢,消耗资源多,不适合大规模爬取。
# 使用 Selenium 的示例(需安装selenium和对应浏览器驱动) from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options = webdriver.ChromeOptions() options.add_argument(‘--headless’) # 无头模式,不显示浏览器窗口 driver = webdriver.Chrome(options=options) driver.get(url) # 等待某个动态加载的元素出现 try: element = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, “product-item”)) ) # 获取渲染后的页面源码 page_source = driver.page_source soup = BeautifulSoup(page_source, ‘lxml’) # 接下来就可以用之前的BeautifulSoup方法解析了 finally: driver.quit()5.2 思路二:直接请求数据接口(推荐,重量级)
这是爬虫工程师更常用的方法。既然数据是 JS 通过 API 请求来的,那我们为什么不直接去请求那个 API 呢?这样更快、更轻量、更直接。
操作步骤:
- 打开浏览器开发者工具(F12),切换到Network(网络)选项卡。
- 刷新或滚动页面,触发动态加载。
- 在 Network 面板里,寻找类型为
XHR或Fetch的请求。这些通常是数据 API。 - 点击其中一个请求,查看它的Headers(请求头)和Payload(请求参数,可能在 Payload 或 Query String Parameters 里)。
- 模仿这个请求,用
requests库直接发送。
实战分析示例: 假设你发现一个GET请求,URL 是https://api.example.com/products?page=1&size=20,返回的是清晰的 JSON 数据。
import requests import json api_url = ‘https://api.example.com/products’ params = { ‘page’: 1, ‘size’: 20 } # 通常API请求需要特定的Headers,比如Referer, Authorization等 headers = { ‘User-Agent’: ‘...‘, ‘Referer’: ‘https://www.example.com/list’, # 告诉服务器请求来自哪个页面 # ‘Authorization’: ‘Bearer xxx’ # 如果需要认证 } resp = requests.get(api_url, params=params, headers=headers) if resp.status_code == 200: data = resp.json() # 直接解析JSON # data 现在就是一个Python字典/列表,可以直接提取数据 for product in data[‘items’]: print(product[‘name’], product[‘price’])优点:效率极高,数据干净(直接是结构化的 JSON)。缺点:需要一定的分析能力,且 API 可能带有复杂的参数或加密(如token,sign),反爬措施更严格。
我的选择:对于学习和小规模爬取,可以先从Selenium入手,确保能拿到数据,建立信心。然后一定要尝试去分析 Network,练习直接抓包 API。这是从“脚本小子”走向“爬虫工程师”的关键一步。很多看似复杂的网站,其数据接口可能比你想象的要简单。
6. 让爬虫更“礼貌”与“健壮”
如果你连续快速请求一个网站,很可能很快就会被封 IP。所以,我们必须让爬虫行为像真人。
6.1 设置请求头(Headers)
最基本的,要设置User-Agent,告诉服务器你是一个“浏览器”。更逼真的话,还可以加上Referer(你从哪个页面跳转来的)、Accept-Language等。
headers = { ‘User-Agent’: ‘Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36’, ‘Accept’: ‘text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8’, ‘Accept-Language’: ‘zh-CN,zh;q=0.9,en;q=0.8’, ‘Referer’: ‘https://www.google.com/‘, }6.2 添加延迟(Sleep)
在两个请求之间随机等待一段时间。
import time import random for page in range(1, 6): # ... 发送请求,解析数据 ... time.sleep(random.uniform(1, 3)) # 随机等待1到3秒6.3 处理异常和重试
网络不稳定,服务器可能暂时无响应。使用try-except并配合重试机制。
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry # 创建一个带重试策略的Session session = requests.Session() retries = Retry(total=3, # 总共重试3次 backoff_factor=0.5, # 重试间隔时间会递增 status_forcelist=[500, 502, 503, 504]) # 遇到这些状态码才重试 session.mount(‘http://’, HTTPAdapter(max_retries=retries)) session.mount(‘https://’, HTTPAdapter(max_retries=retries)) try: response = session.get(url, headers=headers, timeout=5) response.raise_for_status() except requests.exceptions.RequestException as e: print(f“请求最终失败: {e}”)6.4 应对反爬:简单验证码与IP限制
- 简单验证码:如果是简单的数学运算或扭曲文字,可以考虑使用 OCR 库(如
pytesseract,但识别率感人)或第三方打码平台(需要付费)。更常见的是,在遇到验证码时,程序暂停,提醒人工干预。 - IP限制:这是最头疼的。解决方案包括:
- 降低请求频率:这是最基本、最有效的尊重。
- 使用代理IP池:轮换不同的 IP 地址发送请求。注意:获取和使用代理IP需遵守相关法律法规和服务条款,务必从合法渠道获取,并用于合法合规的爬取目的。
- 识别并遵守
robots.txt:在网站根目录下(如https://example.com/robots.txt),这个文件规定了哪些路径允许或禁止爬虫访问。使用robotparser模块可以解析它。
7. 下一步:数据的归宿
费劲千辛万苦爬下来的数据,不能只打印在屏幕上。你需要保存它。根据数据量和用途,有几种选择:
文件:
- CSV:适合表格数据。用
csv模块。
import csv with open(‘products.csv’, ‘w’, newline=‘’, encoding=‘utf-8-sig’) as f: writer = csv.DictWriter(f, fieldnames=[‘name’, ‘price’, ‘link’]) writer.writeheader() writer.writerows(products) # products是字典列表- JSON:适合嵌套的、结构复杂的数据。用
json模块。
import json with open(‘products.json’, ‘w’, encoding=‘utf-8’) as f: json.dump(products, f, ensure_ascii=False, indent=2) # indent让格式美观- CSV:适合表格数据。用
数据库:
- SQLite:轻量,单文件,无需安装服务器。Python 内置
sqlite3模块,非常适合入门和小型项目。 - MySQL/PostgreSQL:功能更强大的关系型数据库,适合数据量大、需要复杂查询的场景。
- MongoDB:文档型数据库,存储 JSON 格式的数据非常自然,schema 灵活。
- SQLite:轻量,单文件,无需安装服务器。Python 内置
把数据存下来,你才能进行下一步的分析、可视化或应用。这才是爬虫价值的体现。
爬虫入门的第一步,是拿到 HTML;第二步,就是今天讲的,从 HTML 里精准提取出你要的数据。BeautifulSoup是你的主力工具,find_all、select、.text、.get()这些是基本动作。但真正的挑战在于,网站不会乖乖把数据放在静态 HTML 里等你拿,动态加载、反爬机制才是常态。所以,学会用开发者工具分析网络请求,区分静态内容和动态接口,是进阶的必经之路。
写爬虫代码,一定要有“防御性编程”思维:标签可能找不到,属性可能不存在,网络可能出错。多用try-except,多用if判断,给关键步骤加上日志。刚开始慢一点没关系,代码健壮性远比跑得快重要。下一期,我们可以聊聊更复杂的场景,比如模拟登录、处理 Cookies 和 Session,或者如何规划一个完整的、可复用的爬虫项目结构。