Python爬虫实战:微博热搜榜抓取与Excel导出
简介:面向Python爬虫初学者和需要定期整理社交热点的数据使用者,这份代码包演示如何抓取微博前50条热搜的标题与链接,并整理成结构化表格。包内共2个文件,包含一份可直接运行的py爬虫脚本与一份xlsx结果样例,压缩包约7KB,体积轻巧,便于快速阅读理解与二次改造,目前已有478人学习下载。脚本围绕requests发送请求、BeautifulSoup解析页面、pandas写入Excel的主线展开,涉及热搜列表定位、字段提取、日期时间戳记录,以及异常捕获与重试等提高健壮性的处理。读者可据此搭起从抓取到写入表格的最小可用流程,并在此基础上扩展为定时采集或多项指标分析。使用时请注意遵守目标站点的robots协议与相关法规。
1. 打开微博热搜榜,手动复制五十条到 Excel,这件事我干过三次就再也不想干了
每次有热点事件,运营同事就在群里喊“把热搜榜前五十拉个表,带热度值”。头两次我老老实实用鼠标一条条选中、复制、粘贴,三次之后我开始怀疑自己在做数据搬运工,而不是开发者。后来我用 Python 写了一个小脚本:请求榜单页面、解析出排名、关键词、热度值和标签、再用 openpyxl 写进 Excel,全程不到两秒,还顺便把表头样式和列宽调好。这项目适合两类人:一是刚学完 requests 和 xpath 想找个真实页面练手的开发者,二是需要定期留存热搜快照做选题复盘或竞品分析的内容运营。技术栈很轻——requests 拿页面、lxml 做解析、openpyxl 落 Excel——但页面结构、请求头和反爬细节上有不少值得讲的坑。
2. 技术选型与项目骨架:为什么用 requests + lxml + openpyxl 这套组合
这个需求本质上是“抓一个静态或半静态页面、提取结构化数据、落盘到表格”,没有复杂的异步和渲染需求。所以我在选型时把“依赖少、调试直观”排在“性能”前面。很多人上手就搬 Selenium 或 Playwright,结果为了抓一个榜单页面要装浏览器驱动、还要处理窗口大小和等待时机,调试成本比业务本身还高。我的判断标准很简单:页面源码里能看到目标数据,就用 requests + lxml;源码里看不到(纯前端渲染或接口加密),再考虑浏览器自动化或直接抓接口。
2.1 三个库各管一段,边界清晰
requests 负责网络层,lxml 负责结构层,openpyxl 负责输出层。三段之间只有数据流,没有交叉调用,出问题时一眼就能定位是哪一段挂了。下面这个依赖清单我放在 requirements.txt 里,版本没锁死,因为这三个库在常见版本区间内行为稳定:
# requirements.txt requests>=2.28.0 lxml>=4.9.0 openpyxl>=3.1.0安装时我习惯先建虚拟环境,避免和系统 Python 里的老版本 lxml 打架。lxml 在某些平台上需要编译好的 wheel,如果 pip 装报错,先升级 pip 再试:
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install --upgrade pip pip install -r requirements.txt逻辑说明:虚拟环境的意义不只是“干净”,而是当你把项目拷给别人时,能保证对方装到的版本和你跑通时一致。lxml 在 Windows 上曾经有过和旧版 pip 不兼容的情况,先升级 pip 是最省事的后悔药。参数方面没有需要调的,requirements.txt 里的下限版本对应的是 Python 3.8 以上都能跑的区间,如果你用的是 Python 3.10 及以上,这些版本也完全兼容。
2.2 目录结构按职责拆分,别把所有东西塞进一个 main.py
我见过太多练手项目就是一个文件从头写到尾,两百行里请求、解析、写表混在一起。能跑,但改起来痛苦。这个项目我按“数据流方向”拆成四个模块,入口文件只做编排:
weibo_hotsearch/ ├── main.py # 入口,编排抓取和写入 ├── config.py # 请求头、URL、输出路径等常量 ├── spider/ │ ├── __init__.py │ └── fetcher.py # 请求页面 + 重试 │ └── parser.py # 解析 HTML 为列表字典 ├── writer/ │ ├── __init__.py │ └── excel_writer.py # openpyxl 写表逻辑 └── output/ └── hotsearch.xlsx # 运行时生成这样拆的好处是:解析规则变了只需动 parser.py;Excel 样式要调只动 excel_writer.py;请求头或重试策略要改只动 fetcher.py。入口 main.py 保持在 30 行以内,读代码的人先看 main 就知道整个流程。
2.3 config.py 把所有常量集中,方便二次开发时定位
配置我单独放一个文件,而不是散落在各模块里。原因很实际:抓微博热搜这类页面,请求头和 URL 是最常需要调的两样东西,集中在一起改起来不用翻文件:
# config.py BASE_URL = "https://s.weibo.com/top/summary?cate=realtimehot" REQUEST_HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.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", "Referer": "https://s.weibo.com/", } OUTPUT_PATH = "output/hotsearch.xlsx" REQUEST_TIMEOUT = 10 MAX_RETRIES = 3 RETRY_BACKOFF = 2逻辑说明:User-Agent 必须写,不写大概率被识别成脚本请求;Referer 写上更稳妥,因为从榜单页跳转来的请求带 Referer 是正常行为;两个重试参数分开是因为我打算在 fetcher 里用“指数退避”而不是简单循环。参数怎么调:REQUEST_TIMEOUT 设 10 秒对榜单页面足够,如果网络环境差可以放到 15;MAX_RETRIES=3 表示首次请求加两次重试;RETRY_BACKOFF=2 表示每次重试等待时间翻倍(2 秒、4 秒),避免短时间内连续打。
3. 抓取模块拆解:从榜单 HTML 到五十条结构化记录
抓取是整条链路里最容易翻车的一段,因为问题不一定出在代码,可能出在请求头、Cookie、页面结构变动、或者触发了频率限制。我写这部分的原则是:请求层和解析层分开,请求层负责“拿到完整 HTML”,解析层负责“把 HTML 变成字典列表”,中间用异常切断,这样哪一层出问题都能单独测。
3.1 请求层:重试、超时、状态码判断三件套
fetcher.py 的核心函数就一个,但里面把重试和状态码判断都包进去了,调用方不需要关心网络细节:
# spider/fetcher.py import time import requests from config import BASE_URL, REQUEST_HEADERS, REQUEST_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF def fetch_page(url: str = BASE_URL) -> str: """请求热搜榜页面,返回 HTML 文本。 带指数退避重试,失败两次后抛异常。 """ last_error = None for attempt in range(MAX_RETRIES): try: resp = requests.get( url, headers=REQUEST_HEADERS, timeout=REQUEST_TIMEOUT, ) # 状态码 200 且内容非空才算成功 if resp.status_code == 200 and len(resp.text) > 1000: return resp.text last_error = RuntimeError(f"状态码 {resp.status_code},正文长度 {len(resp.text)}") except requests.RequestException as exc: last_error = exc # 最后一次不等待,直接抛出 if attempt < MAX_RETRIES - 1: time.sleep(RETRY_BACKOFF ** attempt) raise RuntimeError(f"抓取失败,已重试 {MAX_RETRIES} 次") from last_error逻辑说明:判断成功用“状态码 200 且正文长度大于 1000”双条件,因为微博榜单页面正常返回时 HTML 体量在几十 KB 级别,如果只拿到一个很短的正文,往往是验证页或拦截提示,不能当成功。参数方面:MAX_RETRIES 控制总尝试次数,RETRY_BACKOFF 的指数用法让等待时间递增。失败时把 last_error 用from带出去,排查时能看到根因。失败时看什么:打印 resp.status_code 和 resp.text[:500],最短时间内判断是 403、302 还是返回了空壳。
3.2 解析层:先定位表格,再逐行提取字段
微博热搜榜页面主体是一个表格结构,每行包含排名、关键词、热度值和标签(比如“热”“新”“沸”)。解析时我不用“逐层往下找”的写法,而是先用 XPath 定位到行集合,再在每行内部找字段,这样页面结构微调时改动面小:
# spider/parser.py from lxml import etree def parse_hotsearch(html: str) -> list[dict]: """把榜单 HTML 解析成字典列表。 每项字段:rank / keyword / hot_value / tag / url """ tree = etree.HTML(html) rows = tree.xpath('//table[contains(@class, "table")]//tbody/tr') results = [] for row in rows: # 排名:第一列的纯文本 rank_text = row.xpath('./td[1]//text()') rank = "".join(rank_text).strip() # 关键词和链接:第二列里的 a 标签 keyword_nodes = row.xpath('./td[2]//a') if not keyword_nodes: continue keyword = "".join(keyword_nodes[0].xpath('./text()')).strip() href = keyword_nodes[0].get('href', '') # 热度值:第二列里 a 之后的 span 文本 hot_nodes = row.xpath('./td[2]//span//text()') hot_value = "".join(hot_nodes).strip() # 标签:第三列(“热”“新”“沸”等) tag_nodes = row.xpath('./td[3]//text()') tag = "".join(tag_nodes).strip() results.append({ "rank": rank, "keyword": keyword, "hot_value": hot_value, "tag": tag, "url": href, }) return results逻辑说明:etree.HTML会自动补全不完整的标签,比直接对字符串做正则稳。行定位用contains(@class, "table")而不是精确匹配,是因为页面 class 里常带其他附加名。每行内部按列取,排名取第一列、关键词和热度取第二列、标签取第三列,这个顺序在没有改版的情况下是稳定的。参数方面://text()会把节点下所有文本碎片拼起来,再用 join 和 strip 去空白,是处理中文页面文本最省心的写法。如果哪天解析结果为空列表,第一件事是保存一份 HTML 到本地,用浏览器开发者工具对照 XPath 是否还能命中。
3.3 数据清洗:把原始文本变成可入库的干净字段
解析出来的原始文本不一定是干净数据。排名可能带“•”或空白,热度值可能带“万”字后缀或“-”表示无值,标签列可能是空的。清洗这一步我单独写一个函数,方便单元测试:
# spider/parser.py 续 import re def clean_item(item: dict) -> dict: """对单条解析结果做清洗和类型归一。""" rank = item.get("rank", "").strip() # 去掉非数字字符,只保留排名数字 rank_num = int(re.sub(r"\D", "", rank)) if re.search(r"\d", rank) else 0 hot_raw = item.get("hot_value", "").strip() # 处理 “1234567” 和 “123.4万” 两种形式 if hot_raw.endswith("万"): try: hot_num = int(float(hot_raw[:-1]) * 10000) except ValueError: hot_num = 0 else: hot_num = int(re.sub(r"\D", "", hot_raw)) if re.search(r"\d", hot_raw) else 0 return { "rank": rank_num, "keyword": item.get("keyword", "").strip(), "hot_value": hot_num, "tag": item.get("tag", "").strip(), "url": item.get("url", ""), }逻辑说明:排名和热度都归一成整数,方便后续排序和比较;关键词只 strip 不删字符,避免误伤;标签保持字符串,因为“热”“新”“沸”是有意义的状态标识。参数说明:这里对“万”后缀做了兼容,是常见的做法——很多榜单页在热度高的时候用“万”显示,低的时候显示完整数字。如果热度列出现“-”,会落到 else 分支里返回 0,不会让整个程序崩掉。写完这段之后我一般拿十条数据手工对一遍,确认 rank 和 hot_value 和页面上肉眼看到的一致。
4. Excel 写入模块:openpyxl 的表头、样式与列宽控制
写入这一步看起来简单,但真正跑起来之后最容易出问题的往往不是抓取,而是 Excel 文件的保存路径、工作表命名、以及样式设置对性能的影响。我的做法是先把“数据写入”和“样式美化”拆成两个函数,因为样式设置是逐单元格操作,数据量大时会明显变慢,分开之后可以在调试阶段先跳过样式。
4.1 工作簿初始化:表头、工作表名和冻结窗格
初始化逻辑单独一个函数,负责创建 Workbook、命名工作表、写表头。表头我习惯用固定常量,和解析字段一一对应:
# writer/excel_writer.py from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill from openpyxl.utils import get_column_letter HEADERS = ["排名", "关键词", "热度值", "标签", "链接"] HEADER_FILL = PatternFill("solid", fgColor="1F4E78") HEADER_FONT = Font(name="微软雅黑", bold=True, color="FFFFFF", size=11) def init_workbook() -> tuple: """创建 Workbook 和 sheet,写入带样式的表头,返回 (wb, ws)。""" wb = Workbook() ws = wb.active ws.title = "热搜TOP50" for col_idx, header in enumerate(HEADERS, start=1): cell = ws.cell(row=1, column=col_idx, value=header) cell.fill = HEADER_FILL cell.font = HEADER_FONT cell.alignment = Alignment(horizontal="center", vertical="center") # 冻结首行,滚动时表头始终可见 ws.freeze_panes = "A2" return wb, ws逻辑说明:WORKBOOK()创建的默认 sheet 直接改名使用,省一次删除操作。表头样式用深蓝底白字,是 Excel 报表里比较通用的方案,PatternFill的参数用十六进制颜色值。freeze_panes="A2"让首行冻结,这是个小细节,但导出的表格行数一多,没有冻结窗格读起来很累。参数方面:Alignment同时设了水平和垂直居中,如果以后要加列宽调整,也在这个函数里统一处理。
4.2 数据写入与列宽:一次循环完成数据和宽度设置
写入数据行的函数接收清洗后的字典列表,逐行逐列写,同时把每列的最大宽度记下来,最后统一设置列宽。这样避免了“先写数据再单独遍历一遍调宽度”的二次开销:
# writer/excel_writer.py 续 def write_rows(ws, items: list[dict]) -> None: """把清洗后的数据写入工作表,并同步计算列宽。""" col_widths = {i: len(h) * 2 for i, h in enumerate(HEADERS, start=1)} for row_idx, item in enumerate(items, start=2): values = [ item["rank"], item["keyword"], item["hot_value"], item["tag"], item["url"], ] for col_idx, value in enumerate(values, start=1): cell = ws.cell(row=row_idx, column=col_idx, value=value) # 居中对齐除链接外的列,链接左对齐 cell.alignment = Alignment( horizontal="left" if col_idx == 5 else "center", vertical="center", ) # 估算当前列需要的宽度(中文按 2 个字符宽计算) text_len = sum(2 if ord(ch) > 127 else 1 for ch in str(value)) if text_len > col_widths[col_idx]: col_widths[col_idx] = text_len for col_idx, width in col_widths.items(): # 加一点余量,避免文字贴边 ws.column_dimensions[get_column_letter(col_idx)].width = min(width + 4, 60)逻辑说明:中文在 Excel 里占的显示宽度大约是英文字符的两倍,所以按ord(ch) > 127区分中英文来估算列宽,比固定宽度更贴合内容。最后一列链接通常很长,设置了 60 的上限,防止单列宽到影响阅读。enumerate(items, start=2)从第 2 行开始是因为第 1 行是表头。参数方面:如果表头列顺序变了,HEADERS和values要同步改,这两处是绑定的。
4.3 保存与路径处理:确保 output 目录存在再落盘
保存这一步最常见的翻车是目录不存在导致FileNotFoundError,或者文件被占用导致PermissionError。我的写法是保存前先确保父目录存在,保存时把异常和提示一起打出来:
# writer/excel_writer.py 续 import os def save_workbook(wb, path: str) -> None: """保存工作簿,自动创建父目录,带异常提示。""" parent = os.path.dirname(os.path.abspath(path)) os.makedirs(parent, exist_ok=True) try: wb.save(path) print(f"[OK] 已保存到 {path}") except PermissionError as exc: print(f"[ERROR] 文件被占用,请关闭 Excel 后重试:{exc}") raise逻辑说明:os.path.dirname(os.path.abspath(path))是为了兼容相对路径,避免当前工作目录不在项目根目录时创建目录到奇怪的位置。makedirs(exist_ok=True)保证目录已存在时不会报错。捕获PermissionError单独提示,是因为这个错误在 Windows 上特别常见——文件开着 Excel 就占用了句柄,保存会直接失败,给出明确提示比堆栈更省排查时间。
入口文件 main.py 把这几段串起来,整体不超过 30 行:
# main.py from spider.fetcher import fetch_page from spider.parser import parse_hotsearch, clean_item from writer.excel_writer import init_workbook, write_rows, save_workbook from config import OUTPUT_PATH def main(): html = fetch_page() raw_items = parse_hotsearch(html) items = [clean_item(x) for x in raw_items] if not items: raise RuntimeError("解析结果为空,检查页面结构是否变化") wb, ws = init_workbook() write_rows(ws, items) save_workbook(wb, OUTPUT_PATH) if __name__ == "__main__": main()逻辑说明:入口只做编排,异常在解析为空时主动抛出,避免生成一个只有表头的空 Excel 还误以为成功。参数方面没有需要调的,OUTPUT_PATH在 config 里统一配置。
5. 避坑与排查:跑这个脚本时我遇到过的五个问题
这个项目代码量不大,但实际运行中遇到的小问题不少。下面五条是我自己踩过、也在别人代码里见过的典型情况,按“现象→原因→解决”写出来,遇到同类问题时可以对号入座。
5.1 返回 403 或正文只有几百字节
现象:脚本跑起来不报网络异常,但控制台打印的状态码是 403,或者正文长度只有几百字节,解析结果为空列表。原因:请求头不完整,尤其是缺少 User-Agent 或 Referer,页面返回的是拦截提示页而不是正常榜单页。解决:把 config 里的请求头补齐,尤其 User-Agent 用真实浏览器的最新值,Referer 填榜单页的同源地址;同时确认len(resp.text) > 1000这个判断条件生效,避免把拦截页误当成功。
5.2 排名和热度值解析出来是空字符串
现象:关键词能拿到,但排名和热度值都是空,Excel 里那两列一片空白。原因:页面里列结构或 class 名变了,XPath 里的td[1]、td[2]下标不再对应排名和关键词。解决:先在 fetcher 里把返回的 HTML 保存到本地文件,用浏览器打开对照开发者工具重新确认 XPath,优先用 class 或包含文本定位,而不是纯下标。改完之后单测parse_hotsearch函数,喂一份保存下来的 HTML 看输出。
5.3 保存时报 PermissionError
现象:抓取和解析都正常,最后wb.save()抛PermissionError,提示文件被占用。原因:output 路径下的同名 Excel 文件正在被 Excel 或 WPS 打开,文件句柄被占用。解决:代码里已经捕获并提示,实际操作时关掉 Excel 再跑。如果要做成定时任务无人值守,可以在保存前给文件名加时间戳,避免覆盖正在被打开的文件。
5.4 关键词里出现乱码或空白字符
现象:Excel 里个别关键词显示为乱码,或者前后带不可见空格,导致排序和筛选异常。原因:页面文本节点里夹带了零宽空格或 HTML 实体,直接join之后没有处理。解决:在clean_item里对 keyword 加一步replace("\u200b", "").replace("\xa0", " ").strip(),把常见不可见字符清掉。这种问题很隐蔽,肉眼看 Excel 不一定发现,导出 CSV 之后才会暴露。
5.5 短时间重复运行后拿不到数据
现象:第一次跑成功,连续跑五六次之后开始返回空列表或很长一段无关页面。原因:请求频率过高触发了站点的限流机制。解决:在 fetcher 的循环外加上最小请求间隔,比如每次运行之间 sleep 5~10 秒;如果确实需要高频采集,改成从页面或接口的缓存读取,而不是反复打首页。这一条更多是习惯问题——脚本能跑通不等于可以无限跑,加个间隔是成本最低的自我保护。
6. 二次开发改造:把单次脚本变成可持续用的采集小工具
单次运行的脚本解决的是“今天要一份表”的需求,但既然代码已经拆成四个模块,往后扩展的成本很低。下面三条是我实际改过的方向,按从轻到重排。
6.1 加一条定时调度,让快照按小时自动落盘
最轻的改造是加定时。Windows 上用任务计划程序,Linux 上用 cron,把python main.py配成每小时执行一次,输出文件名带时间戳。需要改的只有save_workbook的路径参数:
# 在 main.py 里生成带时间戳的文件名 from datetime import datetime ts = datetime.now().strftime("%Y%m%d_%H%M") output_path = f"output/hotsearch_{ts}.xlsx"这样每次跑都生成独立文件,既保留了历史快照,也避免了文件占用问题。要注意的是如果落地机没登录,任务计划程序里要勾选“不管用户是否登录都运行”,否则脚本可能不执行。
6.2 把 Excel 换成 SQLite,便于后续查询和对比
如果目的是做趋势分析而不是交表,Excel 就不是最优存储。我把 writer 模块换成写 SQLite,表结构就五个字段加一个采集时间戳,改起来不到半小时:
import sqlite3 conn = sqlite3.connect("output/hotsearch.db") conn.execute(""" CREATE TABLE IF NOT EXISTS hotsearch ( capture_time TEXT, rank INTEGER, keyword TEXT, hot_value INTEGER, tag TEXT, url TEXT ) """)之后想查“某个关键词最近七天在榜几次”,一条 SQL 就出来了。验证方法也简单:跑两次脚本,间隔几分钟,然后SELECT capture_time, keyword FROM hotsearch ORDER BY capture_time DESC LIMIT 10,能看到两组不同时间戳的记录就说明改造成功。
6.3 增加字段扩展时,优先改 parser 和 config 两处
如果以后页面多了一个“话题分类”字段,改动顺序是:先在parse_hotsearch里加 XPath 提取,再到clean_item里归一,然后HEADERS和values同步加列,最后在write_rows的列宽循环里自然兼容。这四个位置是绑定的,少改一处就会出现列错位。我一般改完会拿两份数据对比——改前的 Excel 和改后的 Excel,确认旧列内容不变、新列有值,再提交。
从那以后我每次改这类解析脚本,都强制走一遍“存一份 HTML → 断网跑解析 → 对比输出”的流程,确认解析逻辑不依赖网络和实时页面状态。这个习惯帮我在页面小改版时少熬了好几个夜。希望帮到你。
本文还有配套的精品资源,点击获取