
简介面向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 requests2.28.0 lxml4.9.0 openpyxl3.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.pyExcel 样式要调只动 excel_writer.py请求头或重试策略要改只动 fetcher.py。入口 main.py 保持在 30 行以内读代码的人先看 main 就知道整个流程。2.3 config.py 把所有常量集中方便二次开发时定位配置我单独放一个文件而不是散落在各模块里。原因很实际抓微博热搜这类页面请求头和 URL 是最常需要调的两样东西集中在一起改起来不用翻文件# config.py BASE_URL https://s.weibo.com/top/summary?caterealtimehot 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/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.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 秒对榜单页面足够如果网络环境差可以放到 15MAX_RETRIES3 表示首次请求加两次重试RETRY_BACKOFF2 表示每次重试等待时间翻倍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, headersREQUEST_HEADERS, timeoutREQUEST_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, fgColor1F4E78) HEADER_FONT Font(name微软雅黑, boldTrue, colorFFFFFF, size11) def init_workbook() - tuple: 创建 Workbook 和 sheet写入带样式的表头返回 (wb, ws)。 wb Workbook() ws wb.active ws.title 热搜TOP50 for col_idx, header in enumerate(HEADERS, start1): cell ws.cell(row1, columncol_idx, valueheader) cell.fill HEADER_FILL cell.font HEADER_FONT cell.alignment Alignment(horizontalcenter, verticalcenter) # 冻结首行滚动时表头始终可见 ws.freeze_panes A2 return wb, ws逻辑说明WORKBOOK()创建的默认 sheet 直接改名使用省一次删除操作。表头样式用深蓝底白字是 Excel 报表里比较通用的方案PatternFill的参数用十六进制颜色值。freeze_panesA2让首行冻结这是个小细节但导出的表格行数一多没有冻结窗格读起来很累。参数方面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, start1)} for row_idx, item in enumerate(items, start2): values [ item[rank], item[keyword], item[hot_value], item[tag], item[url], ] for col_idx, value in enumerate(values, start1): cell ws.cell(rowrow_idx, columncol_idx, valuevalue) # 居中对齐除链接外的列链接左对齐 cell.alignment Alignment( horizontalleft if col_idx 5 else center, verticalcenter, ) # 估算当前列需要的宽度中文按 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, start2)从第 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_okTrue) 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_okTrue)保证目录已存在时不会报错。捕获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 510 秒如果确实需要高频采集改成从页面或接口的缓存读取而不是反复打首页。这一条更多是习惯问题——脚本能跑通不等于可以无限跑加个间隔是成本最低的自我保护。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 foutput/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 → 断网跑解析 → 对比输出”的流程确认解析逻辑不依赖网络和实时页面状态。这个习惯帮我在页面小改版时少熬了好几个夜。希望帮到你。本文还有配套的精品资源点击获取