1. 项目概述
1.1 为什么要采集财富中国500强数据
财富中国500强榜单每年发布一次,涵盖了国内规模最大、盈利能力最强的头部企业。这份榜单不仅是投资研究、行业分析的高频数据源,也是很多商业课程、市场调研报告里绕不开的核心素材。我接下这个案例的时候,需求方是一位做行业研究的伙伴,他需要把榜单里每家企业的排名、公司名称、营业收入、利润、市值等字段完整抓下来,存成结构化表格,方便后续做同比分析、行业分布统计。
相比手动复制粘贴,爬虫方案能稳定输出一份可直接导入Excel或数据库的干净数据。整个过程涉及到的技术点并不复杂,但很典型:静态页面解析、列表页抓取、字段清洗、异常重试,这些动作几乎覆盖了入门爬虫的常用技能栈,非常适合作为练手案例。
1.2 这个案例适合什么人参考
如果你已经掌握了Python基础语法,了解requests和BeautifulSoup的基本用法,但还不太清楚“从零开始分析一个抓取目标”应该怎么入手,那么这个案例会非常适合你。我会把从“打开页面观察结构”到“最终跑通代码拿到数据”的整个思考过程展开来讲,而不是直接丢一段能跑的代码就完事。
当然,如果你完全没接触过爬虫,也不用担心。我会把那些“老手默认你会但其实没人系统讲过”的细节,比如怎么快速判断一个页面是静态渲染还是动态加载、怎么分析表格字段对应关系、怎么处理网页里全角半角混排的数值,都拆开揉碎了说明白。
2. 内容整体设计与思路拆解
2.1 先搞清楚目标页面的数据加载方式
拿到任何爬虫需求,第一步永远是打开目标页面,用开发者工具观察数据是怎么出现在页面上的。这一步极其重要,因为它直接决定后续的技术选型:用requests直接请求HTML,还是用Selenium模拟浏览器,又或者是直接调用页面背后的JSON接口。
我这次的目标页面是财富中文网发布的500强榜单页。用Chrome打开页面后,按下F12进入开发者工具,切到Network面板,刷新页面观察请求记录,可以很清楚地看到:页面主体内容通过一个HTML文档直接返回,榜单数据就嵌在HTML的表格标签里。这说明这是一个传统的服务端渲染页面,不需要额外处理动态加载逻辑,直接用requests就能拿到完整数据。
2.2 明确选型:requests + BeautifulSoup的组合
确定页面是静态渲染之后,技术选型就很明确了。requests负责发起HTTP请求拿回HTML源码,BeautifulSoup负责解析HTML结构、定位表格节点、提取每行数据。为什么用BeautifulSoup而不是正则表达式?因为榜单数据是规整的表格结构,BeautifulSoup可以按标签层级清晰地遍历,代码可读性和后续维护成本都会好很多。正则表达式在面对嵌套不规则的HTML时容易被标签属性变化打断,而标签选择器和CSS选择器能有效规避这类问题。
至于Selenium这种浏览器自动化工具,在这个场景下属于“杀鸡用牛刀”。Selenium需要额外安装浏览器驱动,运行速度慢,还会消耗更多系统资源。只有当页面数据依赖JavaScript异步加载,或者存在复杂交互(比如点击按钮后才出现数据)时,才需要考虑它。手动模拟浏览器请求是更轻量的方案。
2.3 反爬策略的初步评估
入门爬虫最担心的事之一就是被目标网站封禁。不过财富中文网这类媒体型网站,对爬虫的态度相对宽松,正常频率下抓取问题不大。但作为合格爬虫工程师,我们不能拿“应该没事”当借口,基本的礼貌抓取策略还是要做足:
- 设置合理的User-Agent,模拟真实浏览器的请求头,不要用默认的Python-requests标识
- 控制请求间隔,每次请求之间sleep 1到2秒,避免短时间高频访问打爆对方服务器
- 做好异常重试机制,网络抖动或服务器临时报错时,不要直接崩溃退出,应该等待重试
这些习惯看起来不起眼,但能让你少踩很多坑。后续我会在代码里逐一体现。
3. 核心细节解析与实操要点
3.1 页面结构分析与表格定位
打开榜单页面,滚动到数据区域,可以观察到数据存放在一个class属性中包含特定标识的table标签内。每一行tr中有若干td单元格,依次对应排名、公司名称、营业收入、利润、市值等字段。
用BeautifulSoup定位表格的代码如下:
soup = BeautifulSoup(html_text, 'html.parser') table = soup.find('table', class_='table')这里有个细节值得注意:class选择器不一定总是写成class_='...'的完整匹配,有些页面的class属性值是多个类名拼接,比如"table table-hover text-center"。这种情况下用find('table', class_='table')依然能匹配到,因为BeautifulSoup会把class属性按空格拆分成集合,只要目标类名在里面就能命中。但如果页面里多个表格共用了相同的类名,就需要结合表格的位置、id属性或者父级容器来进一步限定范围,避免定位到错误的表格。
3.2 字段提取与文本清洗
定位到表格后,遍历每一行tr,再提取每个td的文本内容,就得到了原始的字符串数据。原始字符串有多余的空白字符、换行符,数值字段里还可能混着“百万”、“亿元”之类的中文单位。这里就需要做数据清洗。
我在案例里定义了一个parse_value函数,专门处理数值字段。整体思路是:先去掉文本中的空白字符,再统一处理单位标识,最后把纯数字部分提取出来。具体的清洗逻辑我放到第四节实操部分详细展开,这里先提一个非常容易踩坑的点:同一列数据里,单位可能不一致。
比如某些年份的榜单里,营业收入字段有的行显示“百万”,有的行显示“亿元”,直接拿去排序或者同比分析必然出错。解决方案有两种:一是统一换算成同一单位(比如全部转成“亿元”),二是保留原始字符串并额外增加一列“单位”字段,分析时再做处理。我通常会选择第一种,把数值全部转成以“亿元”为单位,后续做起分析来最省心。
3.3 翻页逻辑与URL参数规律
财富中国500强榜单在页面上是分页展示的,每页显示固定数量的企业。如果你只抓第一页,拿到的是前几十名的数据,这显然不满足“完整采集”的需求。
观察分页栏的URL变化可以发现:页码是通过URL的查询参数控制的,比如?page=2、?page=3这种形式。这意味着翻页逻辑非常简单,只需要在循环中动态拼接URL即可。我在代码里用一个for循环遍历所有页码,每页抓取后解析,再把数据拼接到总列表中。
翻页这块还有个隐蔽细节:页面总数不一定能在URL里直接看出来。稳妥的做法是先从第一页页面底部解析出总页数(或者总记录数、每页记录数,两者相除向上取整),再去控制循环终点。我在代码里直接解析了总页数字段,拿到真实页数后循环就更可控了。
3.4 抓包分析与开发者工具的使用心得
网络上很多爬虫教程动不动就展示一长串抓包过程,看起来煞有介事,但实际上对于静态页面来说,抓包的核心工作无非就三步:
- 打开开发者工具的Network面板
- 刷新页面,找到返回HTML文档的那条请求
- 查看请求URL、请求方法、响应内容,确认数据实体
真正需要花心思抓包的场景是动态加载接口,需要通过XHR请求找到JSON数据源。这个榜单纯静态渲染,抓包过程很轻量。但如果你是第一次接触开发者工具,我建议还是亲手点一遍Network面板里的各个功能标签,看看Headers里的请求头信息、Preview里的渲染效果、Response里的原始返回内容。这些基本功在后续处理更复杂的爬虫场景时都会用到。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
实操开始前,先把依赖环境准备好。我用的是Python 3.10版本,需要安装的第三方库就两个:
pip install requests beautifulsoup4这里多说一句:requests库负责HTTP请求,底层依赖urllib3,安装时通常会自动带上,不需要额外操心。BeautifulSoup4解析HTML时,如果没有安装lxml解析器,默认会用Python标准库里的html.parser,解析速度略慢但不影响使用。追求性能的话,也可以补装lxml,然后在创建BeautifulSoup对象时指定'lxml'作为解析器。不过入门阶段,保持最小依赖就好。
4.2 完整爬虫代码实现
下面给出这个案例的完整代码。代码里的注释我写得比较详细,目的是让初学者能逐行理解每个动作的意图。
import time import requests from bs4 import BeautifulSoup import csv def fetch_html(url, headers, retries=3): """ 发起HTTP请求并返回HTML文本 retries: 最大重试次数 """ for attempt in range(retries): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: resp.encoding = resp.apparent_encoding return resp.text else: print(f'请求失败,状态码:{resp.status_code},重试 {attempt + 1}/{retries}') except requests.RequestException as e: print(f'请求异常:{e},重试 {attempt + 1}/{retries}') time.sleep(2) return None def parse_value(raw_text): """ 解析营业收入、利润等数值字段,统一转换为以亿元为单位 原始文本可能是'2,345,000百万'、'123.45亿元'等格式 """ text = raw_text.replace(',', '').strip() if not text: return None if '百万' in text: value = float(text.replace('百万', '').strip()) return round(value / 10000, 2) # 百万 -> 亿,除以10000 elif '亿元' in text: value = float(text.replace('亿元', '').strip()) return value elif '万' in text: value = float(text.replace('万', '').strip()) return round(value / 10000, 2) # 万 -> 亿,除以10000 else: try: return float(text) except ValueError: return None def parse_page(html_text): """ 解析单页HTML文本,提取榜单行的结构化数据 """ soup = BeautifulSoup(html_text, 'html.parser') table = soup.find('table', class_='table') if not table: print('未找到数据表格,页面结构可能已变化') return [] rows = table.find_all('tr') page_data = [] # 跳过表头行 for row in rows[1:]: cells = row.find_all('td') if len(cells) < 5: continue rank = cells[0].get_text(strip=True) company = cells[1].get_text(strip=True) revenue = parse_value(cells[2].get_text()) profit = parse_value(cells[3].get_text()) market_value = parse_value(cells[4].get_text()) page_data.append({ 'rank': rank, 'company': company, 'revenue': revenue, 'profit': profit, 'market_value': market_value }) return page_data def get_total_pages(html_text): """ 解析总页数。从分页组件中提取总页数,找不到时默认返回1 """ soup = BeautifulSoup(html_text, 'html.parser') pagination = soup.find('ul', class_='pagination') if not pagination: return 1 page_items = pagination.find_all('li') if not page_items: return 1 # 取最后一个分页按钮的文字,通常是末页页码 last_label = page_items[-1].get_text(strip=True) try: return int(last_label) except ValueError: return 1 def save_to_csv(all_data, filename='fortune500.csv'): """ 将数据写入CSV文件,UTF-8编码带BOM,方便Excel直接打开不乱码 """ if not all_data: print('没有数据可保存') return fieldnames = ['rank', 'company', 'revenue', 'profit', 'market_value'] with open(filename, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(all_data) print(f'数据已保存至 {filename}') def main(): base_url = 'https://example.com/fortune500' headers = { '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', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', } first_page_html = fetch_html(base_url, headers) if not first_page_html: print('无法获取首页,程序退出') return total_pages = get_total_pages(first_page_html) print(f'总页数:{total_pages}') all_data = [] for page in range(1, total_pages + 1): if page == 1: page_html = first_page_html else: page_url = f'{base_url}?page={page}' page_html = fetch_html(page_url, headers) if not page_html: print(f'第 {page} 页获取失败,跳过') continue page_data = parse_page(page_html) print(f'第 {page} 页解析到 {len(page_data)} 条记录') all_data.extend(page_data) time.sleep(1.5) # 控制抓取频率,做有礼貌的爬虫 print(f'总计采集到 {len(all_data)} 条记录') save_to_csv(all_data) if __name__ == '__main__': main()4.3 代码关键点深度解读
这段代码虽然不长,但每一部分都承担着明确职责。fetch_html做了三件很重要的事:设置超时、处理编码、失败重试。
先说编码问题。目标页面如果没在响应头里明确指定字符集,requests默认会用ISO-8859-1去解码,导致中文乱码。我在代码里用resp.apparent_encoding来动态检测实际编码,这个属性会基于页面内容分析字节流,通常能准确判断出UTF-8或者GBK。这是一个必须养成习惯的动作,特别是抓国内网站时非常常用。
再说重试机制。网络环境总有不稳定的时候,而爬虫遇到一次超时崩溃是最亏的。我在函数里用for循环控制了3次重试机会,每次失败后time.sleep(2)等待两秒再重来。如果3次都失败,就返回None,由上层逻辑决定是跳过还是终止。这种带退避策略的重试设计能显著提高抓取成功率。
parse_value函数是数据清洗的核心。为什么单独抽出这个函数?因为不同列的数值格式可能截然不同。营业收入可能是“2,345,000百万”这样带千分位分隔符的字符串,利润可能是“123.45亿元”,市值可能又是“8,888万”。把清洗逻辑集中在一个函数里,后续如果发现新格式,只需要在这个函数内部打补丁即可。
4.4 运行结果与数据校验
代码跑完之后,终端会输出每一页解析到的记录数。正常情况下,总采集条数应该和榜单公布的企业总数一致。打开生成的CSV文件,检查几个关键点:
- 第一行是否包含表头,列名是否符合预期
- 排名列是否为纯数字,有没有出现中文数字
- 公司名字段是否完整,有没有夹杂多余空格
- 营业收入、利润、市值是否为清洗后的数值,单位是否统一
我自己跑完这个案例后,会把CSV拿到Excel里做一次数据透视,按行业维度统计企业数量分布,验证数据质量的同时也能发现榜单里的一些有趣规律。这一步虽然不是爬虫的必需环节,但能帮助你确认数据是否真的“能用”。
5. 常见问题与排查技巧实录
5.1 表格定位不到,结果为空
症状:代码跑完,终端提示“未找到数据表格”或者解析出0条记录。
排查思路:先确认拿到的HTML文本里确实有表格。可以在fetch_html返回后,把HTML保存成本地文件,打开搜索关键词“table”或者某个已知的企业名称。如果本地文件里确实有表格,说明定位条件写错了;如果本地文件里也没有,说明请求响应本身可能不是正常页面——比如被重定向到了验证码页或者404页面。
我遇到过一种情况:请求URL是HTTP协议,但网站做了301跳转到HTTPS,直接拿到的还是正常页面,但如果请求头缺少某些字段,服务器会返回一个低版本页面。排查时建议把响应的最终URL打印出来,确认是否存在跳转。
5.2 中文乱码问题
症状:解析出来的公司名称全是“鍥炲埌鏈€鏂帮紝QQ鏂伴椈绛夌増鏈?rdquo;这类乱码字符。
原因:requests默认解码方式与页面实际编码不匹配。
对策:使用resp.apparent_encoding动态设置编码,或者在requests.get时直接通过resp.encoding = 'utf-8'强制指定。如果页面是GBK编码,则改成resp.encoding = 'gbk'。动态检测虽然省心,但偶尔会误判,所以对于已知固定编码的网站,建议直接硬编码,效率更高也更确定。
我个人的习惯是:先写一行调试代码打印resp.encoding和resp.apparent_encoding,肉眼确认后再决定用哪种策略。实测下来,大部分中文资讯类网站要么是UTF-8要么是GBK,极少遇到其他编码。
5.3 数值字段单位不统一
症状:解析后的数据里,有的公司营业收入是几百(显然是亿元),有的公司突然变成几千万(显然是百万单位没换算)。
原因:原始页面文本里,不同公司可能采用了不同单位。
对策:在parse_value里显式判断文本包含的单位标识,并统一换算成亿元。写解析函数时,尽量把所有可能出现的单位都列出来:亿、万、百万。宁可多写几种情况,也别漏掉。
举个例子,原始文本是“1,234百万”,直观理解是“1234个百万=123.4亿”。而“12,345万”则是“1.2345亿”。这两个换算过程容易搞混,建议在代码注释里把换算逻辑写清楚,方便后续自己回看或者别人接手时理解。
5.4 请求频率过高被封
症状:抓了几页之后,突然连续返回403状态码,或者首页还能访问但翻页接口全部超时。
原因:访问频率超过了网站的容忍阈值。
对策:调整time.sleep的间隔。初学时我习惯设为0.5秒,但因为每页解析也消耗时间,整体频率其实不算太高。稳妥起见,统一设为1.5秒。对单机单线程爬虫来说,这个频率已经非常保守了。
如果遇到更严格的站点,可以考虑增加随机延时:
import random time.sleep(random.uniform(1, 3))把延时变成随机值,可以避免固定节奏被识别。但要注意,这只是入门阶段的处理手段,面对专业反爬系统时还是要配合代理池、请求头伪装、验证码处理等更复杂的技术,那属于进阶范畴了。
5.5 表格字段顺序变化
症状:解析结果里,公司名称和营业收入错位了,数据张冠李戴。
原因:网站改版,调整了表格列的顺序,但代码里还按旧顺序取cells[0]到cells[4]。
对策:不要硬编码索引,而是根据表头内容动态映射列号。解析第一行表头时,先构建一个{表头文字: 列索引}的字典,再按字段名去取值。
header_row = rows[0].find_all('th') column_map = {} for index, th in enumerate(header_row): column_map[th.get_text(strip=True)] = index这样即使网站调整了列顺序,只要表头名称不变,代码就能自适应。这是一个很实用的健壮性技巧,值得养成习惯。
6. 我的实操收获与后续扩展方向
6.1 这个案例帮我建立了爬虫的基本分析框架
坦白说,财富500强这个页面本身没有太大挑战,难度适中,但它的示范意义在于完整展示了一条分析链路:需求确认、页面观察、数据定位、字段清洗、翻页循环、异常处理。这套思路可以平移到很多榜单类、列表类页面的采集任务上。以后碰到类似的静态表格页面,我可以直接复用这套框架,改动量很小。
我也见过很多初学者拿到需求就去搜索代码模板,然后套上去跑,跑通了就算完事。一旦网站改版,代码立刻报废。所以我特别推荐在动手写代码之前,先花20分钟把页面结构完整看一遍,截图标注好要抓的字段在哪个标签下面。有了这个准备工作,后面写代码的效率会高很多。
6.2 后续可以做哪些扩展
如果想把500强数据的价值充分发挥出来,后续可以沿着几个方向扩展:
- 历史数据对比:采集历年榜单数据,形成时间序列,分析企业排名变迁和行业兴衰
- 数据可视化:用pandas做数据加工,用matplotlib或pyecharts画营业收入Top20的柱状图,或者行业分布的饼图
- 增量采集:设计定时任务,每年榜单发布后自动运行一次采集脚本,数据自动归档到数据库
- 字段扩充:除了榜单自带的字段,还可以结合其他公开信息源(企业官网、财报摘要),补充总部所在地、成立年份等维度,丰富分析角度
坦白说这些扩展方向各有各的门槛,但一步一个脚印走,每完成一个都会让整个项目变得更有实用价值。我在做完基础采集后,顺手用pandas做了一次排序,把利润最高的10家企业拉出来看了一下,直观感受是榜单数据和印象中的行业格局基本吻合,这说明采集质量过关了。
6.3 给初学者的几句掏心话
爬虫是一门实践性很强的技能,比看十遍教程更重要的是亲手跑通一个完整案例。在实操过程中遇到报错,第一反应不要急着搜代码抄,而是先把报错信息读一遍,定位是网络请求的问题、解析的问题,还是数据类型的问题。这种独立排错的能力,才是爬虫技能成长的核心。
财富500强数据采集这个案例,代码量适中,涉及的坑又比较典型,作为入门阶段的收官项目很合适。我建议你拿到代码后,不要满足于跑通,刻意改几个地方,比如字段对应关系调转、把数据输出改成JSON格式、加一个按行业筛选的功能,逼自己理解每一行代码存在的意义。改动之后再跑,收获完全不同——这一点我在多个学生和同事身上反复验证过,效果一直很好。