恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Python爬虫实战:演出票务数据采集与清洗全流程
首页
资讯中心
/
Python爬虫实战:演出票务数据采集与清洗全流程
Python爬虫实战:演出票务数据采集与清洗全流程
发布时间:2026/10/9 23:54:38
1. 项目缘起与整体设计思路做演出票务数据这块很多人第一反应是抢票但我更关心的是数据本身。一个演出从官宣到开票再到售罄票价档位怎么分布、不同城市同级别演出的定价差异、哪些场次放票节奏快、哪些场次临期还有余票——这些信息对做市场分析、做购票决策、甚至做二手票务行情判断的人都有价值。这个项目就是围绕演出信息和票价数据做一次系统性的采集与整理目标是把散落在页面上的演出名称、时间、场馆、城市、票价档位、售票状态这些字段结构化下来形成一份可以拿去做分析的数据集。我选这个方向的原因很直接演出数据属于典型的半结构化动态渲染页面既有静态的演出详情页也有大量依赖接口异步加载的列表数据。它不像纯静态网页那样抓下来就能用也不像纯接口那样干净利落正好覆盖了爬虫实践中几个核心难点——请求构造、参数签名、分页处理、字段清洗、反爬应对。把这套流程跑通换成其他票务平台、电商平台、本地生活平台思路基本可以平移。适合谁来参考如果你已经会写基础的requests请求、看得懂 HTML 结构、知道BeautifulSoup或lxml怎么用那这篇内容能帮你把零散的知识点串成一条完整的采集链路。如果你是完全的新手建议先把 HTTP 基础、HTML 结构、Python 基础语法过一遍再来看不然中间涉及参数构造和字段解析的部分会有点吃力。整个项目我按先摸清页面结构再定采集策略然后落地代码最后处理脏数据和异常的顺序推进下面逐块拆开讲。在方案选型上我一开始考虑过三种路线一是纯requests加解析库二是Selenium或Playwright这类浏览器自动化三是直接找页面背后的数据接口。纯请求方案轻量、速度快但对动态渲染的列表页无能为力浏览器自动化能拿到渲染后的完整 DOM但资源消耗大、速度慢跑几千条数据时开销明显接口方案速度最快、数据最干净但需要分析请求参数和签名逻辑。最终我采用的是接口为主、静态页面为辅的混合策略列表和票价这类异步加载的数据走接口演出详情、场馆信息这类静态内容走页面解析。这样既保证了速度又降低了被识别为异常流量的概率。提示任何采集行为都要控制频率、遵守目标站点的 robots 协议和服务条款仅用于个人学习和小规模数据分析不要做高频批量抓取更不要用于商业转售。这是底线后面不再重复。2. 页面结构与数据字段拆解2.1 先搞清楚数据藏在哪一层动手写代码之前我习惯先做一件事打开目标页面按 F12 看 Network 面板刷新页面观察 XHR/Fetch 请求。演出列表页通常不会把全部数据写在初始 HTML 里而是页面加载后由 JavaScript 再发一个请求去拿 JSON 数据。你要找的就是那个返回 JSON 的请求。判断方法很简单在 Network 面板里筛选XHR逐个点开看 Response哪个返回的是结构化的演出列表哪个就是目标接口。找到接口后重点看三样东西请求 URL、请求方法GET 还是 POST、请求参数。URL 里往往带着分页参数比如page、pageSize、offset请求头里可能带着Referer、User-Agent、Cookie这些身份标识有些平台还会带一个签名参数比如sign、token、_t之类这个是最麻烦的部分后面单独讲。演出详情页相对简单大部分内容是服务端渲染的直接请求 HTML 就能拿到演出名称、时间、场馆、票价档位这些字段。但要注意有些详情页的票价区域是异步加载的尤其是选座购买那块价格和余票状态可能来自另一个接口。所以我的做法是详情页先抓静态部分票价和状态再单独调接口补全。2.2 核心字段清单与含义把数据字段理清楚是保证后续分析可用的前提。我整理的字段清单如下每个字段都说明了来源和清洗要点字段名含义来源清洗要点show_name演出名称详情页/列表接口去除首尾空格、全角转半角show_time演出时间详情页统一为YYYY-MM-DD HH:MM格式city城市列表接口去掉站字后缀统一城市名venue场馆详情页去除括号内补充说明price_range票价区间详情页/票价接口拆分为最低价、最高价两个数值price_levels票价档位票价接口存为列表保留原始档位文本status售票状态列表接口映射为预售/在售/售罄/缺货show_id演出唯一标识URL/接口作为主键用于去重category演出分类列表接口如演唱会、话剧、音乐剧这里有个经验票价字段千万不要只存一个区间字符串。我一开始图省事把280-1280元整个存成一列结果后面想做价格分布分析时还得再写正则去拆白白多干一遍活。正确做法是在采集阶段就拆成min_price和max_price两个数值列档位明细单独存一张表或用 JSON 字段存。这样后面无论是算均价、做分档统计还是画价格分布图都直接可用。2.3 分页与列表结构的处理逻辑列表接口一般有两种分页方式页码式page1pageSize20和偏移式offset0limit20。页码式好处理循环递增页码直到返回空列表为止偏移式要注意边界当offset超过总数时接口可能返回空数组或报错需要做终止判断。我在实际处理时会先请求第一页从返回的 JSON 里读出总条数字段常见命名有total、totalCount、count然后算出总页数再按页循环。这样做的好处是能提前知道要跑多少页方便加进度提示也避免无限循环。如果接口不返回总数那就用返回列表为空即停止的策略兜底。注意分页请求之间一定要加随机延时我一般用random.uniform(1, 3)秒。连续快速请求同一个接口很容易触发频率限制轻则返回空数据重则直接封 IP 一段时间。3. 请求构造与参数签名处理3.1 请求头与身份标识的构造接口能不能调通请求头占了一半功劳。最基础的几个头必须带全User-Agent要伪装成正常浏览器Referer要指向数据来源页面Accept声明接受 JSONCookie如果接口依赖登录态也得带上。我见过不少人代码写得没问题就是漏了Referer结果接口一直返回 403排查半天。User-Agent不要用默认的python-requests/2.x那等于直接告诉对方我是脚本。准备几个常见浏览器的 UA 字符串随机轮换使用。下面是我常用的请求头模板import random UA_POOL [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0, ] def build_headers(referer): return { User-Agent: random.choice(UA_POOL), Referer: referer, Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9, }这段代码看着简单但Referer参数化这个设计很关键。因为列表接口和详情接口的 Referer 不一样写死一个值会导致部分请求失败。把它做成参数传入复用性更好。3.2 签名参数的逆向思路有些平台的接口会带签名参数常见形式是signmd5(拼接字符串密钥)。遇到这种不要慌思路是在浏览器里找到发起请求的 JS 代码搜索sign关键字看它是怎么算出来的。通常签名逻辑就藏在某个 JS 文件里把拼接规则和密钥找出来用 Python 复现即可。我处理过一个类似场景签名规则是md5(参数按字典序拼接 固定盐值)。复现代码大致如下import hashlib def gen_sign(params: dict, salt: str) - str: # 按 key 字典序排序后拼接 sorted_items sorted(params.items()) raw .join(f{k}{v} for k, v in sorted_items) salt return hashlib.md5(raw.encode(utf-8)).hexdigest()这里的关键点是参数排序规则和盐值两者错一个签名就对不上。我的经验是先在浏览器控制台里手动调一次 JS 函数拿到一个正确的签名值再用 Python 算一遍对比一致了再批量跑。不要一上来就批量请求签名错了会白白浪费请求次数还可能触发风控。提示签名逻辑属于对方平台的技术实现这里只讨论通用的逆向分析方法具体平台的密钥和规则请自行研究且仅用于学习目的。3.3 会话保持与 Cookie 管理用requests.Session()而不是每次裸调requests.get()好处是自动保持 Cookie、复用 TCP 连接速度更快也更像正常用户。如果接口需要登录态先手动登录一次把 Cookie 复制出来放进 Session或者用脚本模拟登录流程拿到 Cookie。import requests session requests.Session() session.headers.update(build_headers(https://example.com/list)) # 如需登录态把抓到的 Cookie 塞进来 session.cookies.update({session_id: your_cookie_value})我踩过的一个坑是Cookie 有有效期跑长任务时跑到一半 Cookie 过期后面全部请求失败。解决办法是在代码里加一个响应状态检查一旦发现返回登录页或 401就暂停任务并提示重新获取 Cookie而不是傻乎乎地继续跑。4. 完整采集流程与代码实现4.1 整体流程编排整个采集流程我拆成四步第一步拉取演出列表拿到所有演出的show_id和基础信息第二步根据show_id逐个请求详情补全时间、场馆、票价档位第三步对票价和状态做二次校验处理异步加载的部分第四步统一清洗入库。这个顺序的好处是列表先跑完能快速知道总共有多少条数据方便评估耗时和安排后续任务。流程上我特意把列表和详情分开而不是边拉列表边抓详情。原因是列表接口通常有分页限制先集中把 ID 收集齐再慢慢抓详情逻辑更清晰也方便断点续跑——万一中途挂了已经抓到的 ID 还在不用从头再来。4.2 列表采集代码实现import time import random import requests def fetch_show_list(session, base_url, max_page50): all_shows [] for page in range(1, max_page 1): params {page: page, pageSize: 20, cityId: 0} try: resp session.get(base_url, paramsparams, timeout10) resp.raise_for_status() data resp.json() except Exception as e: print(f第 {page} 页请求失败: {e}) continue items data.get(data, {}).get(list, []) if not items: print(f第 {page} 页无数据采集结束) break for item in items: all_shows.append({ show_id: item.get(id), show_name: item.get(name, ).strip(), city: item.get(cityName, ).replace(站, ), status: item.get(status), category: item.get(categoryName), }) print(f第 {page} 页采集完成累计 {len(all_shows)} 条) time.sleep(random.uniform(1, 3)) return all_shows这段代码里有几个细节值得说。max_page设了个上限 50是防止接口异常时无限循环try/except包住单页请求某一页失败不影响整体time.sleep用随机值而不是固定值降低规律性。实测下来这套组合跑几百页基本不会触发限制。4.3 详情与票价数据补全拿到show_id列表后逐个请求详情接口。票价数据我单独处理因为它的结构往往是嵌套的一个演出对应多个档位每个档位有价格和状态。def fetch_show_detail(session, detail_url, show_id): params {showId: show_id} try: resp session.get(detail_url, paramsparams, timeout10) data resp.json().get(data, {}) except Exception as e: print(f详情 {show_id} 失败: {e}) return None price_list data.get(priceList, []) prices [p.get(price) for p in price_list if p.get(price)] return { show_id: show_id, show_time: data.get(showTime, ), venue: data.get(venueName, ).split(()[0], price_levels: price_list, min_price: min(prices) if prices else None, max_price: max(prices) if prices else None, }venue字段用split(()[0]去掉括号里的补充说明比如某某体育馆(主馆)统一成某某体育馆方便后续按场馆聚合。min_price和max_price在采集阶段就算好后面分析直接取用。4.4 数据存储与去重存储我用的是 SQLite轻量、无需额外服务、支持 SQL 查询适合这种中小规模数据集。建表时把show_id设为主键插入时用INSERT OR REPLACE天然去重。import sqlite3 def init_db(pathshows.db): conn sqlite3.connect(path) conn.execute( CREATE TABLE IF NOT EXISTS shows ( show_id TEXT PRIMARY KEY, show_name TEXT, city TEXT, venue TEXT, show_time TEXT, min_price INTEGER, max_price INTEGER, status TEXT, category TEXT ) ) conn.commit() return conn def save_shows(conn, shows): conn.executemany( INSERT OR REPLACE INTO shows (show_id, show_name, city, venue, show_time, min_price, max_price, status, category) VALUES (:show_id, :show_name, :city, :venue, :show_time, :min_price, :max_price, :status, :category) , shows) conn.commit()用executemany批量插入比逐条插入快很多几百条数据几乎瞬间完成。INSERT OR REPLACE保证重复跑任务时不会产生重复记录这个设计对断点续跑特别友好。5. 常见问题与排查技巧实录5.1 请求返回空数据或 403这是最常见的问题排查顺序我总结成一张表现象可能原因排查方法解决方式返回 403缺少 Referer 或 UA 异常对比浏览器请求头补全 Referer轮换 UA返回空列表参数错误或分页越界打印请求 URL 和参数核对参数名和取值返回登录页Cookie 失效检查响应内容重新获取 Cookie返回乱码编码未指定查看响应头 charset手动指定resp.encoding请求超时网络或频率限制加大超时、降低频率加延时、重试机制我遇到最多的是 403十次里有八次是 Referer 没带对。有一次我明明带了 Referer还是 403最后发现是 Referer 的域名和接口域名不一致改成接口所在域名就通了。这个细节很隐蔽值得记一笔。5.2 数据字段缺失或格式混乱接口返回的字段不是每次都齐全有的演出没有票价信息有的时间格式是时间戳有的城市名带后缀。处理原则是能补则补不能补就留空绝不瞎猜。时间戳统一转成标准格式城市名统一清洗缺失的票价字段留None后续分析时再决定是剔除还是填充。from datetime import datetime def normalize_time(raw): if isinstance(raw, (int, float)): return datetime.fromtimestamp(raw / 1000).strftime(%Y-%m-%d %H:%M) return str(raw).strip()时间戳除以 1000 是因为很多接口返回的是毫秒级时间戳这个坑我踩过一开始没除转出来的年份直接跑到几万年后去了。5.3 反爬应对与频率控制反爬手段无非几类频率限制、IP 封禁、参数签名、验证码。应对思路是降速、伪装、分散。降速就是加随机延时伪装就是请求头、Cookie、UA 都做得像正常用户分散就是如果数据量大分时段跑不要一次性猛拉。我个人的经验是单 IP 每分钟请求控制在 20 次以内基本不会触发限制。如果确实需要更大规模那就得考虑代理池但代理池的维护成本不低小规模分析完全没必要。另外遇到验证码不要硬刚那说明频率已经过高了停下来歇一会儿比继续请求更明智。注意不要为了绕过限制去研究破解验证码、伪造身份等操作这既不合规也没必要。控制好频率正常采集完全够用。5.4 断点续跑与任务恢复跑长任务最怕中途挂掉从头再来。我的做法是每采集完一批就落库同时记录已完成的show_id。重启任务时先查库跳过已存在的 ID。def get_done_ids(conn): cursor conn.execute(SELECT show_id FROM shows) return {row[0] for row in cursor.fetchall()} # 主流程中 done get_done_ids(conn) todo [s for s in all_shows if s[show_id] not in done]这个设计让我在一次跑了三个小时的任务中途断网后只补跑了剩下的一小部分省了大量时间。强烈建议所有长任务都加上这个机制。6. 数据清洗与后续分析方向6.1 清洗规则与落地采集下来的原始数据不能直接用得先过一遍清洗。我定的规则有几条演出名称去掉首尾空格和特殊符号城市名统一去掉站字票价区间拆成数值状态字段做映射把接口返回的数字或英文码转成中文可读状态。STATUS_MAP { 1: 预售, 2: 在售, 3: 售罄, 4: 缺货, } def clean_status(raw): return STATUS_MAP.get(raw, 未知)状态映射表要根据实际接口返回动态调整不要照搬。我一开始按猜测写映射结果发现接口里 1 是在售不是预售数据全错了后来老老实实对照页面验证了一遍。6.2 可以做哪些分析数据清洗完能玩的方向不少。比如按城市统计演出数量看哪些城市演出市场活跃按分类统计平均票价看哪类演出定价最高按时间维度看演出档期分布找出旺季淡季还可以做票价区间分布直方图看主流价位段在哪。这些分析用pandas几行代码就能出结果。import pandas as pd df pd.read_sql(SELECT * FROM shows, conn) city_stats df.groupby(city).agg( show_count(show_id, count), avg_min_price(min_price, mean), avg_max_price(max_price, mean), ).sort_values(show_count, ascendingFalse) print(city_stats.head(10))这段代码跑出来的结果能直接看出哪些城市演出多、票价水平如何对做市场判断很有参考价值。6.3 可视化与报告输出分析结果用matplotlib或pyecharts出图比纯数字直观得多。我一般会出三张图城市演出数量柱状图、票价区间分布直方图、分类均价对比图。出图时注意中文字体设置不然中文会显示成方块这个坑几乎每个人都踩过。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False city_stats[show_count].head(10).plot(kindbar) plt.title(各城市演出数量 Top10) plt.tight_layout() plt.savefig(city_shows.png, dpi150)SimHei是黑体Windows 上一般都有如果是 Mac 或 Linux得换成系统里实际存在的中文字体比如PingFang SC或WenQuanYi Micro Hei。字体名写错不会报错只是图里中文变方块所以出图后一定要打开看一眼。7. 实操心得与避坑清单7.1 几条用血泪换来的经验第一先小规模验证再批量跑。我早期图快直接写个循环跑几千条结果签名算错了几千次请求全废还差点把 IP 搞封。后来养成习惯先跑 5 条确认字段、格式、状态都对再放开跑。第二日志要打全。请求 URL、响应状态、耗时、失败原因这些都要记下来。出问题时日志是唯一的线索。我一般用logging模块而不是print方便分级和写文件。第三数据落库要趁早。不要等全部采集完再统一存边采边存哪怕中途挂了已采的数据还在。这个习惯救过我好几次。第四字段命名要统一。列表接口叫name详情接口叫showName这种不一致很常见采集时就要统一成一套字段名不然后面合并数据时全是坑。7.2 合规与伦理边界最后必须强调一下采集数据是为了学习和分析不是为了恶意竞争或商业牟利。控制频率、遵守协议、不采集个人隐私信息、不绕过技术保护措施这几条是红线。我见过有人把采集来的数据直接转卖这种行为既违规又短视不值得效仿。技术是中性的怎么用取决于人。7.3 后续可扩展的方向这套框架跑通后可以往几个方向扩展一是加定时任务每天自动跑一次积累时间序列数据看票价和余票的动态变化二是接入消息通知当某个关注的演出状态变化时提醒三是把数据接到 BI 工具里做可视化看板。这些扩展都不难核心采集链路稳定了上层怎么玩都行。我个人在实际操作中的体会是爬虫项目真正的难点从来不是写请求代码而是理解页面结构、处理各种边界情况、以及保持数据的干净和一致。代码谁都能写但能把数据质量做扎实的人不多。把清洗和校验做在前面后面分析时你会感谢自己。