我最近刚好把一个“从爬数据到出图”的完整链路又完整过了一遍,起因是接手了一个临时需求:要把某个公开数据平台上的商品数据抓下来,再按品类和价格区间做几张能直接放进周报的图表。整个过程走下来,发现真正卡人的往往不是反爬,而是数据清洗和图表配置这些“脏活累活”。
这篇就围绕一条实际可复现的链路来写:requests拿数据、解析库抽字段、pandas洗数据、pyecharts出图。适合两类读者:一类是想把爬虫从“能跑通”推进到“能交付”的初学者,另一类是平时用Excel手工做报表、想试试自动化可视化的数据分析同学。文中所有代码和步骤都来自我实际调试过的版本,不绕弯子,直接给方案和理由。
1. 别急着写代码:先把数据源和字段边界摸清楚
任何爬虫项目,写代码之前花个二十分钟做侦察,能省下后面两小时的返工。这个习惯是我踩了几次坑之后才养成的,特别是当你准备做可视化的时候,字段边界直接决定了你能画什么图、不能画什么图。
1.1 我选的目标站点和数据形态
这次的目标是一个公开的电商类数据平台,主要展示各类消费品的名称、类目、价格、销量、评分、上架日期、发货地等信息。页面结构属于典型的服务端渲染加部分接口异步加载的混合形态,列表页直接用requests就能拿到完整HTML,不需要模拟浏览器,这给我们省下了很多事。
在动手写爬虫之前,我先做了三件事:
- 打开目标页面的robots协议路径,确认允许抓取的范围;
- 手动翻了三页列表,观察URL参数的变化规律(页码参数、排序参数);
- 用浏览器开发者工具查看页面HTML结构,确认每个目标字段所在的标签位置。
这一步很多人会跳过,直接开写爬虫,结果爬到一半发现数据不全或者用了无头浏览器这种重武器,得不偿失。
1.2 字段边界决定了可视化方案
这次我认为最重要的一个前置判断是:想清楚最终要画什么图,再倒推需要什么字段。
我预期的输出图表有四张:
- 各品类商品数量占比(饼图)
- 各品类平均价格对比(柱状图)
- 价格与销量的关系(散点图)
- 各发货地的商品数量分布(地图)
因此需要的核心字段就很明确了:类目、价格、销量、发货地。而像商品ID、详情页URL这类字段,当时用不到,但考虑到后续可能做增量抓取和去重,还是顺手存了下来。
建议:即使暂时用不到,也要把原始数据的唯一标识字段存下来。我见过好几个项目因为当时没存ID,后面要做增量更新时只能全量重爬,非常浪费资源。
1.3 环境准备(基于实际调试版本)
我这次用的是以下环境,都是目前比较稳定的版本组合:
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.10 | 3.8以上都可以,建议3.9+ |
| requests | 2.31 | HTTP请求库 |
| beautifulsoup4 | 4.12 | HTML解析 |
| pandas | 2.0+ | 数据处理 |
| pyecharts | 2.0+ | 可视化(注意是2.x版本,不是0.5.x) |
安装命令很简单,直接pip一把过:
pip install requests beautifulsoup4 pandas pyecharts这里特别提醒一个坑:pyecharts的1.x和2.x版本API差别非常大,网上一搜一大把老教程用的还是0.5.x的写法,比如pyecharts.Bar这种导入方式,在2.x里已经完全变了。如果你看到教程里用的add()方法和Bar()类,基本可以确定是旧版教程。2.x的统一入口是from pyecharts.charts import Bar,后面我会在代码里演示。
2. 请求层的三个关键细节:Headers、Session与异常兜底
很多初学者觉得请求层很简单,就是requests.get(url)一把梭。但在面对真实站点的时候,这种写法轻则拿不到数据,重则IP被临时限制。我这次把请求层单独封装了一个模块,里面有几个值得展开说的细节。
2.1 Headers伪装:从浏览器拿一份干净的请求头
请求头的核心作用是让服务器认为你是一个正常的浏览器访问。我习惯直接从开发者工具的Network面板里复制真实请求的Headers,而不是自己瞎编。
import requests from fake_useragent import UserAgent def get_headers(): """构造请求头,模拟浏览器环境""" ua = UserAgent() return { 'User-Agent': ua.random, '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', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', }这里用了fake_useragent库来随机生成UA,目的是避免长期用同一个UA被识别。不过这个库偶尔会因为更新不及时造成访问超时,所以在使用时需要加一个异常兜底:
try: ua = UserAgent() user_agent = ua.random except Exception: user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'2.2 用Session保持会话,避免重复握手
如果只是单页抓取,直接requests.get没有问题。但我们是翻多页,每一次连接都重建TCP握手、TLS协商完全是浪费。用requests.Session()可以复用底层连接,效率提升非常明显。
session = requests.Session() session.headers.update(get_headers()) def fetch_page(url, params=None, retry=3): """带重试机制的页面抓取""" for attempt in range(retry): try: resp = session.get(url, params=params, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.text except (requests.ConnectionError, requests.Timeout) as e: if attempt == retry - 1: raise time.sleep(2 * (attempt + 1))注意这里有个关键操作:resp.encoding = resp.apparent_encoding。requests库默认会从响应头猜测编码,但有些站点返回的编码声明与实际内容不一致,导致中文乱码。apparent_encoding是requests基于内容自动检测的结果,对GBK、GB2312这类编码比较有效。不过这个方法也有代价——每一次请求都会额外花费时间做编码检测,所以在稳定性优先的情况下值得用。
2.3 抓取频率控制:把“礼貌”量化到代码里
很多爬虫教程会忽略抓取频率,但我觉得这是从“自用脚本”走向“可交付工具”的分水岭。对目标站点保持合理的请求间隔,既是对对方服务器的尊重,也是为自己的长期稳定抓取兜底。
import time import random def polite_delay(): """每次请求后的礼貌延迟,随机化避免机械访问特征""" time.sleep(random.uniform(1.5, 3.5))这里的延迟范围是我根据目标站点的响应速度调的:1.5秒是最短等待时间,避免站点压力过大;3.5秒是上限,避免整体抓取时间过长。随机化处理则是因为固定间隔反而容易被识别为机器行为。
3. 解析逻辑:BeautifulSoup抓数据,不只要会select
解析层是把HTML变成结构化数据的关键一步。这次我用了BeautifulSoup + 标准库的html.parser,没有上lxml。原因很简单:目标页面结构不算复杂,标准库解析器完全够用,而且避免了额外编译依赖——别小看这一点,很多开发环境装lxml的wheel是会失败的。
3.1 先观察结构,再写选择器
在写解析代码前,我用开发者工具查看了目标列表页的结构,发现每个商品卡片是一个div,其class属性中包含了item和一个表示类型的类名。商品名称在div下的h3标签里,价格在带有price标识的span里,销量在带有sales标识的span里。下面是我整理后的解析片段:
from bs4 import BeautifulSoup def parse_list_page(html): """解析列表页,提取商品信息""" soup = BeautifulSoup(html, 'html.parser') items = soup.select('div.item') parsed = [] for item in items: # 每个商品的信息散落在不同的标签里 name_node = item.select_one('h3 a') price_node = item.select_one('span.price') sales_node = item.select_one('span.sales-num') location_node = item.select_one('span.location') if not name_node or not price_node: # 有些广告位结构不完整,直接跳过 continue parsed.append({ 'name': name_node.text.strip(), 'price': price_node.text.strip(), 'sales': sales_node.text.strip() if sales_node else '0', 'location': location_node.text.strip() if location_node else '未知', }) return parsed这里有一个值得单独说的小技巧:千万不要用find_all去遍历所有标签然后判断,直接把目标写进select选择器里,代码可读性和执行效率都会好很多。另外,用select_one取单个节点时,一定要处理None的情况。因为有些页面上特定商品会缺少某个字段,比如赠品区没有销量信息,如果不判空,一个NoneType错误直接中断整个爬虫——实际跑的时候,我就因为这个原因重跑过一次。
3.2 解析后立刻打印抽查,别等存库才发现问题
在写解析函数时,我习惯在函数内部加一个临时的打印变量,先跑一页看看结果。
if __name__ == '__main__': url = 'http://example.com/list?page=1' html = fetch_page(url) data = parse_list_page(html) for item in data[:5]: print(item)这一步看起来笨,但极其管用。我这次在抽查中发现price字段的值包含“促销价”“¥”等字样和换行符,如果没有提前发现,后面pandas处理时会多出一堆麻烦。抽查一页,确认字段干净了,再放量跑全量。
3.3 数据字段的常见脏格式预判
根据我的经验,列表页解析出来的原始字段几乎一定包含这些问题:
| 脏数据类型 | 典型表现 | 处理方式 |
|---|---|---|
| 混入文字 | 价格写成“¥299起”“促销价199” | 正则提取数字 |
| 千分位 | 销量写成“12,560” | 移除逗号再转int |
| 空白/换行 | 字段含\n和大量空格 | strip()清理 |
| 缺失值 | 部分商品无销量字段 | 填充默认值 |
| 单位混用 | 销量有“件”也有“万+” | 统一换算 |
这些问题的处理我都是在pandas环节做的,因为pandas的向量化处理效率远高于Python循环。下一节详细说。
4. 数据清洗:从原始列表到DataFrame的必经之路
解析完所有页面拿到的是一个嵌套列表,里面每个元素是字典。直接把这个列表丢给pandas是能跑的,但字段格式乱七八糟,根本没法画图。所以这一节是纯数据预处理,我把它视为整个爬虫链路里最枯燥但最核心的一步。
4.1 DataFrame构建与字段规整
import pandas as pd import re def clean_price(price_str): """清洗价格字段,提取数字""" if not isinstance(price_str, str): return None match = re.search(r'(\d+\.?\d*)', price_str.replace(',', '')) return float(match.group(1)) if match else None def clean_sales(sales_str): """清洗销量字段,处理万+等单位""" if not isinstance(sales_str, str): return 0 s = sales_str.strip() if '万' in s: match = re.search(r'(\d+\.?\d*)', s) return int(float(match.group(1)) * 10000) if match else 0 match = re.search(r'(\d+)', s.replace(',', '')) return int(match.group(1)) if match else 0 def transform_data(raw_items): """将原始数据转换为DataFrame""" df = pd.DataFrame(raw_items) df['price_clean'] = df['price'].apply(clean_price) df['sales_clean'] = df['sales'].apply(clean_sales) df['location_clean'] = df['location'].str.replace('发货地:', '', regex=False).str.strip() # 删除完全无法解析的价格记录 df = df.dropna(subset=['price_clean']) return df这里处理的三个细节值得展开:
- 价格为空的处理:
clean_price返回None后直接删除该行,因为价格是画图的核心指标,缺失了没有填充价值; - 销量单位换算:中文电商数据常见的“1.2万+”如果不处理,画图时会变成一个字符串,排序一塌糊涂。换算成整数后,才能参与散点图的尺寸映射;
- 发货地的文字清理:源数据里有“发货地:广东”,这个前缀必须在入图前去掉,否则地图上会找不到对应的地区名。
4.2 去重:同一商品在多页出现的坑
分页抓取时最容易被忽视的就是重复数据。如果排序字段不稳定,商品会在不同页之间“跳动”,导致同一商品被抓两次。我在清洗阶段用商品名称去重,规则是:先按销量降序排序,保留每个名称出现的最早一条记录。
df = df.sort_values('sales_clean', ascending=False) df = df.drop_duplicates(subset='name', keep='first')这个逻辑同时解决了两个问题:去重 + 保留销量最高的那条记录。对于价格波动的商品,这个策略默认取到了最新抓取的数据,因为我们在解析阶段是按列表顺序append的,后面的页码时间上更靠后。
4.3 生成一份“标准字段清单”
清洗完成后,我用一个列表来规范最终输出字段的顺序和类型:
final_columns = ['name', 'category', 'price', 'sales', 'location'] final_df = df.rename(columns={ 'price_clean': 'price', 'sales_clean': 'sales', 'location_clean': 'location' }) final_df = final_df[final_columns]把最终数据保存为CSV,方便可视化部分反复读取,不需要每次重新跑爬虫:
final_df.to_csv('product_data.csv', index=False, encoding='utf-8-sig')注意这里编码用了utf-8-sig而不是utf-8,原因是CSV文件如果直接用UTF-8编码保存,用Excel打开时中文会乱码。utf-8-sig会在文件开头加上BOM标记,Excel能正确识别。如果你是给程序员用的数据管道,可以用utf-8,但既然是要进周报的数据,就别在这种地方给自己挖坑。
5. pyecharts出图:四张图覆盖可视化需求
数据洗干净了,到这一节就是最解压的部分。pyecharts 2.x的API风格是链式调用,每张图的核心逻辑都差不多:创建图表对象 → 配置数据 → 配置全局项 → 渲染。
5.1 各品类商品数量占比(饼图)
饼图适合展示占比关系。先按category分组计数:
from pyecharts.charts import Pie from pyecharts import options as opts category_counts = final_df['category'].value_counts() pie = ( Pie() .add( series_name='品类占比', data_pair=[(name, int(count)) for name, count in category_counts.items()], radius=['30%', '60%'], label_opts=opts.LabelOpts(formatter='{b}: {c} ({d}%)') ) .set_global_opts( title_opts=opts.TitleOpts(title='各品类商品数量分布'), legend_opts=opts.LegendOpts(pos_top='5%') ) .set_series_opts( tooltip_opts=opts.TooltipOpts( trigger='item', formatter='{b}: {c} ({d}%)' ) ) ) pie.render('category_pie.html')这里有个细节:data_pair要求数据是[(名称, 数值), ...]的格式,直接使用category_counts.items()时要注意转为list,否则会因迭代器消费问题导致图表数据为空。我最初写的时候直接传了items(),结果渲染出来的图是空白的,排查了半天才发现是这个原因。
第二个细节是radius参数:内径30%、外径60%,这样中间有留白,视觉上更清爽。如果不需要环形效果,写成radius='60%'即可。
5.2 品类平均价格对比(柱状图)
柱状图的核心是按品类算平均价格,然后按价格排序,让图表信息呈现更直观。
from pyecharts.charts import Bar category_price = final_df.groupby('category')['price'].mean().round(2) category_price = category_price.sort_values(ascending=False) bar = ( Bar() .add_xaxis(category_price.index.tolist()) .add_yaxis( '平均价格(元)', category_price.values.tolist(), itemstyle_opts=opts.ItemStyleOpts(color='#2f89cf') ) .set_global_opts( title_opts=opts.TitleOpts(title='各品类平均价格对比'), xaxis_opts=opts.AxisOpts( axislabel_opts=opts.LabelOpts(rotate=30, interval=0) ), yaxis_opts=opts.AxisOpts(name='平均价格(元)') ) .set_series_opts( label_opts=opts.LabelOpts(formatter='{c}元') ) ) bar.render('category_price_bar.html')这里的排序逻辑是刻意的:按平均价格降序可以让读者一眼看到“哪些品类客单价更高”。如果你要展示的是“哪类商品更受欢迎”,那就应该按销量均值排,而不是价格。图表排序永远服务于你想表达的观点,这是一个很容易被忽略的细节。
rotate=30和interval=0这两个配置也很关键。当品类名称很长时,默认配置会导致部分标签被省略显示,interval=0强制显示所有标签,rotate=30让文字斜排,避免重叠。我在初版配置里漏了这个参数,品类多的时候图很难看。
5.3 价格与销量的关系(散点图)
散点图是我觉得最有洞察力的一张图,它能直观展示“价格和销量之间是否存在某种关系”。
from pyecharts.charts import Scatter scatter = ( Scatter() .add_xaxis(final_df['price'].tolist()) .add_yaxis( '商品', final_df[['price', 'sales']].values.tolist(), symbol_size=8, label_opts=opts.LabelOpts(is_show=False) ) .set_global_opts( title_opts=opts.TitleOpts(title='价格与销量关系分布'), xaxis_opts=opts.AxisOpts(name='价格(元)', type_='value'), yaxis_opts=opts.AxisOpts(name='销量(件)', type_='value'), tooltip_opts=opts.TooltipOpts( trigger='item', formatter='价格: {c[0]}元<br>销量: {c[1]}件' ) ) ) scatter.render('price_sales_scatter.html')pyecharts的散点图有个特殊要求:add_yaxis接收的第二个参数必须是二维数组,[[x, y], [x, y], ...]的结构。这里我直接用了DataFrame的values.tolist(),天然满足这个格式。在tooltip中,{c[0]}对应第一个值(价格),{c[1]}对应第二个值(销量),这个索引顺序对应的是数据对里的位置,不要写反了。
5.4 发货地分布(地图)
地图图表在pyecharts 2.x中需要额外的地图数据包。以前用的是pyecharts.datasets自带的,新版本里需要单独确认是否有国内地图数据。我的做法是直接用Map图表,确保province名称和数据库里的地区名完全一致:
from pyecharts.charts import Map location_counts = final_df['location'].value_counts().reset_index() location_counts.columns = ['province', 'count'] map_chart = ( Map() .add( '发货地商品数', [list(z) for z in zip(location_counts['province'], location_counts['count'])], maptype='china', is_map_symbol_show=False ) .set_global_opts( title_opts=opts.TitleOpts(title='各发货地商品数量分布'), visualmap_opts=opts.VisualMapOpts( min_=0, max_=int(location_counts['count'].max()), range_color=['#e0f3f8', '#abd9e9', '#74add1', '#4575b4', '#313695'] ) ) ) map_chart.render('location_map.html')地图最容易出的问题就是地区名不匹配。如果爬下来的地区名是“广东”“北京”这类简称,而pyecharts内部的地图数据需要“广东省”“北京市”,那就要做一级映射。我这次没有遇到这个问题,因为站点直接存的就是标准省名。如果你们的数据源也出现地区名不匹配的情况,可以用一个小字典做映射,把简称转换过去。
5.5 把多张图表组合到一张页面(进阶玩法)
除了单独生成HTML文件,pyecharts还支持把多个图表组合到一个布局页面里(用Page或者Tab)。如果是要放进周报的图表集合,我建议用Tab,切换查看比上下滚动舒服很多。
from pyecharts.charts import Tab tab = Tab() tab.add(pie, '品类占比') tab.add(bar, '平均价格') tab.add(scatter, '价格-销量关系') tab.add(map_chart, '发货地分布') tab.render('report_dashboard.html')这个文件就可以直接发给别人打开看,比一张一张截图方便。而且render生成的HTML是自包含的,其他人不需要装Python环境就能看。
6. 不稳定因素是常态:限流、超时与增量抓取的工程化经验
爬虫写得再漂亮,到了真实环境里总有让你意想不到的情况。这次跑下来,最有价值的部分其实不是代码本身,而是处理不稳定因素的经验。
6.1 遇上限流时,指数退避比死磕更有效
爬大概到第400页的时候,我遇到了返回状态码正常但页面内容为空的情况。这种通常不是被封IP,而是服务端的临时限流策略在起作用。我的处理方式是写入一个指数退避的重试机制:
def fetch_with_backoff(url, max_retries=5): for attempt in range(max_retries): html = fetch_page(url) if html and len(html) > 500: return html wait_time = min(60, 2 ** attempt + random.uniform(0, 1)) print(f'收到空响应,等待 {wait_time:.1f} 秒后重试...') time.sleep(wait_time) return None这里的len(html) > 500是经验值。正常列表页HTML至少有几千字节,如果返回内容小于500字节,基本可以判定为空页面或异常响应。等待时间从2秒、4秒、8秒开始翻倍,上限60秒,大多数情况下重试到第二次就能恢复。
6.2 断点续爬:别让自己从头再来
这是我这次项目里收获最大的一点。第一次跑了700页,结果在最后阶段因为网络抖动挂了,所有数据清零重来。后来我加了断点续爬机制:每抓取50页就保存一次临时缓存。
def save_checkpoint(processed_pages, filename='checkpoint.json'): import json with open(filename, 'w') as f: json.dump({'processed_pages': processed_pages}, f) def load_checkpoint(filename='checkpoint.json'): import json import os if os.path.exists(filename): with open(filename, 'r') as f: return json.load(f).get('processed_pages', []) return []主循环里,每次抓取前检查当前页码是否已经在已处理列表中,如果是就直接跳过。这样即使程序中途崩溃,恢复后也能从断点继续,不用从头开始。
6.3 增量爬取的策略思考
这次数据抓完之后,后续如果要定期更新,我建议不要每次全量重爬,而是维护一个已抓取的商品URL清单。判断两个策略的使用场景:
| 场景 | 推荐策略 | 理由 |
|---|---|---|
| 一次性分析 | 全量抓取 | 简单直接,不需要维护状态 |
| 定期监控 | 增量抓取 | 节省资源,降低目标站点压力 |
| 混合模式 | 首次全量+后续增量 | 兼顾数据完整性与稳定性 |
增量爬取的实现核心就是之前存的唯一标识字段。每次抓取前先查一遍已存在的ID集合,如果某个商品ID已经存在且价格没变,就直接跳过,只更新有变化的记录。这个逻辑不复杂,但能在长期运行中省下大量资源。
7. 最后说几个不太起眼但很影响体验的细节
代码跑通了,图表也出来了,但离“交付”还差几步。这些细节如果不做,用户打开报告时的体验会打折扣。
7.1 渲染模式:服务端渲染更省心
pyecharts默认生成HTML文件是服务端渲染模式,也就是打开HTML时图表已经画好了,不需要等待JavaScript执行。这个模式的优势是:报告可以通过邮件附件直接发送,不需要附属JS文件,接收方打开即看。
如果你在Jupyter环境里,可以用render_notebook()直接在单元格中展示。但如果是自动化产出报告,建议统一用render()生成HTML文件,然后再批量处理。
7.2 中文字体问题:别让图表显示成豆腐块
pyecharts默认使用系统中的字体渲染,如果你的运行环境是官方Python Docker镜像这类精简环境,很可能没有安装中文字体,导致图表中出现“方块字”乱码。解决方法是明确指定字体:
opts.TitleOpts(title='各品类商品数量分布', title_textstyle_opts=opts.TextStyleOpts(font_family='Microsoft YaHei'))或者在系统层面安装中文字体,例如Linux下:
apt-get install -y fonts-wqy-zenhei这个问题在本地跑大概率不会遇到,但如果你把脚本部署到服务器上批量跑报告,字体问题一定会冒出来。我的建议是提前在代码里统一指定font_family,避免部署环境的差异影响输出。
7.3 图像导出:给不会打开HTML的人准备PNG
并非所有人都喜欢打开HTML文件,很多业务同学更习惯直接看PNG图。pyecharts本身不带截图功能,但可以配合无头浏览器实现:
from pyecharts.render import make_snapshot # 需要额外安装 snapshot-selenium 或 snapshot-phantomjs我试过一次snapshot-selenium方案,需要额外维护浏览器驱动,对自动化任务来说略显麻烦。如果你的场景确实需要PNG,更省事的方案是:生成HTML后用无头浏览器脚本截图,或者干脆在pandas阶段把数据存成Excel,用Excel自带的图表功能生成静态图。说到底,工具的最终目的都是把信息清楚传递出去,不要为了炫技把事情变复杂。
8. 再聊两句你可能会遇到的边界问题
最后想聊两个不是技术问题但常常让爬虫项目卡住的问题。
一是抓取数据的合法合规。这次项目我明确确认了目标站点的robots协议,并且抓取频率控制在合理范围内。爬虫能跑通不代表可以随意抓取,特别是涉及个人隐私、版权内容或需要登录鉴权的数据,一定要先确认自己的抓取行为是否符合平台规则和相关法律法规。
二是数据交付的形式。爬虫出图只是数据处理的一环,实际工作中更常见的需求是定期产出分析报告。建议把整个流程封装成一个可以传参的函数,比如run_crawler(max_pages) -> report_path,这样后续接入定时调度、异常告警都会方便很多。我从这次项目中把一个脚本式的爬虫重组成了模块化的流程,后续维护成本明显下降。
这次从“爬数据到出图”的完整链路实战,让我重新确认了一个观点:大多数项目并不缺获取数据的能力,缺的是从数据到决策的转换能力。而pyecharts作为一个纯Python的可视化方案,最大的价值不是画图多华丽,而是让整个链路都能在同一套工程体系内闭环,从抓取、清洗、分析到图表,无缝衔接,改起来也顺。