
先从一次真实的需求说起。某天一个跑马拉松的朋友问我能不能帮他盯一下周边几个城市下半年的赛事报名信息别总让他挨个网站翻。我第一反应是这事不难但真做起来发现还挺琐碎——分散在多个平台的信息、五花八门的页面结构、字段命名完全不一致……于是就有了今天这个项目一个公开赛事报名信息采集与智能分析系统从数据抓取、字段清洗、结构化存储到最后的赛事推荐走完了一条完整链路。这个项目用到的技术栈不复杂Python、requests、BeautifulSoup、pandas核心是爬虫设计思路和数据处理逻辑。适合正在学爬虫但想做一个完整项目的同学也适合需要处理同类信息采集场景的开发者拿来改造。本文我会把代码逻辑、踩坑点、排错过程都铺开讲清楚最后的CSV和JSON导出部分也能直接抄作业。1. 项目整体设计与思路拆解先说清楚这个系统到底解决什么问题。赛事报名信息散落在各种渠道赛事官网、报名平台、运动社区公告、甚至公众号推文。人工收集的痛点很明显——信息滞后、格式混乱、跨平台难对比。我的目标是做一个能定时抓取公开页面、自动提取关键字段、写入结构化文件的工具再基于用户偏好做一层轻量级的智能推荐。1.1 核心需求解析从标题里拆出来的需求其实就六条赛事名称、时间、地点、组别、费用、报名状态。但真正做的时候会发现远不止这些。我补充了这几个字段进去赛事ID哈希去重用报名截止时间比赛事时间更重要直接影响用户决策组别明细全马、半马、10公里、亲子跑各组的费用和限制赛事类型标签路跑、越野、骑行、铁三等来源URL追溯入口避免信任问题为什么要把字段设计成这样因为后期做推荐引擎的时候用户问的最多的不是有什么比赛而是我这个月想跑一场半马预算三百以内附近有没有。这种诉求背后需要结构化字段做过滤条件缺了任何一个都答不上来。1.2 技术选型与方案对比爬虫方案我对比了三条路线各有优劣方案优势劣势适用场景requests BeautifulSoup轻量、可控、依赖少需要手写解析规则页面结构相对静态适合本文场景Scrapy 框架并发高、生态完整、内置去重学习曲线陡峭、重大规模分布式采集Selenium 浏览器模拟能处理JS渲染资源占用大、速度慢数据全靠接口动态加载的站点我最终选了第一条路线原因很直接赛事报名信息属于半静态页面绝大多数数据嵌在HTML里BeautifulSoup足够干净利落。而且系统后期要部署在低配服务器上定时跑Scrapy和Selenium都显得笨重。这里面有一个关键设计决策不做实时抓取而是构建一个定时任务每天凌晨解析一次。原因有两点第一是赛事页面更新频率低没必要高频扫描第二是降低目标服务器压力这也算技术伦理的一部分。1.3 整体架构与数据流向整个系统的数据流是单向的清晰可控目标网页 → 请求模块 → HTML解析模块 → 字段提取模块 → 数据清洗模块 → 结构化数据集 → 推荐引擎 → CSV/JSON导出每一层只对上一层负责这样任何一层出问题都可以独立替换。比如某天网站改了HTML结构只需要改解析模块其他都不用动。实际项目中我大概每两个月就会碰一次这种情况——赛事平台的页面结构说改就改没有缓冲期。2. 数据采集层爬虫设计与反爬应对爬虫写得好不好不只看能不能抓到数据还要看能不能稳定地持续抓到数据。这一节重点讲请求层的设计哲学和反爬应对策略。2.1 请求头伪装与Session管理第一次写爬虫的人容易忽略请求头导致一上来就被封。真实的浏览器请求头有十几个字段但服务端主要校验这五个User-Agent、Referer、Accept-Language、Cookie、Connection。我的做法是用requests.Session()管理连接这样有两个好处一是复用TCP连接减少握手开销二是保持Cookie一致性。配合一个随机User-Agent池基本可以伪装成正常用户。import requests import random USER_AGENTS [ 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/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 ] session requests.Session() session.headers.update({ User-Agent: random.choice(USER_AGENTS), Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive })注意有些站点会校验Header顺序导致即使字段齐全也返回异常。遇到这种极端情况参考浏览器的请求顺序逐字段排列即可。2.2 请求频率控制爬虫被封90%的原因不是UA不够新而是请求太快。我见过有人用单线程抓页面每秒发十几个请求不出三十秒就被ban了IP。我在项目里加了一个简单的限速器两个请求之间至少间隔1.5秒。按这个频率抓完一个平台的一百多个赛事页面大概需要三分钟左右完全在可接受的范围内。对于需要抓多个平台的情况我会用配置文件控制每个平台的请求间隔做差异化处理避免集中请求导致IP被临时限制。import time def rate_limited_request(url, session, delay1.5): time.sleep(delay random.uniform(0, 0.5)) # 加随机抖动模拟人类操作节奏 response session.get(url, timeout10) response.raise_for_status() return response这里有个实战经验请求间隔要加随机值不能固定死。如果每个请求间隔都是精确的1.5秒反爬系统反而更容易从时序特征上识别出是脚本。2.3 反爬状态码与重试策略状态码是爬虫的晴雨表。收集了几个月的数据我总结了不同状态码的处置思路状态码含义处置策略200正常解析301/302重定向检查是否跳转到验证码页403拒绝访问切换代理/IP降低频率404页面不存在标记失效可能是赛事已下线429请求过多停止当前队列等待较长时间我封装了一个带重试机制的请求函数三次重试都失败就跳过该页面并记录日志不让单个页面的问题拖垮整个任务。def fetch_with_retry(url, session, max_retries3): retries 0 while retries max_retries: try: response rate_limited_request(url, session) if response.status_code 200: return response.text elif response.status_code 429: wait_time 30 * (retries 1) # 指数退避 time.sleep(wait_time) else: retries 1 except requests.RequestException as e: retries 1 time.sleep(2 * retries) return None一次实际经历某个平台把所有采集请求都返回403排查后发现是对方加了TLS指纹检测requests默认的指纹特征太明显。最终换了其他HTTP客户端库解决但这种情况比较少见大多数站点做到限速和UA伪装就够了。3. HTML解析与字段提取实战请求拿到的是一整页HTML我们需要从中精准地剥出那六个核心字段。这一步做得好的标准是规则简单、容错率高、能应对页面微调。3.1 页面结构分析与解析路线赛事列表页通常是一个表格或者卡片列表每条赛事对应一个独立的详情链接。我的策略是两段式解析先解析列表页拿到所有详情页URL再逐个请求详情页做字段抽取。为什么这么做而不是直接从列表页拿数据因为列表页往往只展示了赛事名称和日期组别、费用这些细节都在详情页里。虽然多了一层请求但换来的是字段完整度这个交换很值。from bs4 import BeautifulSoup def parse_list_page(html_content): soup BeautifulSoup(html_content, lxml) race_links [] # 找到赛事列表容器 for item in soup.select(div.race-item, tr.race-row, .competition-card): link_tag item.find(a, hrefTrue) title_tag item.find([h2, h3, .title]) if link_tag and title_tag: race_links.append({ url: link_tag[href], title: title_tag.get_text(stripTrue) }) return race_links3.2 核心字段提取规则详情页的字段分布在不同的HTML位置靠CSS选择器定位。下面是我对六个核心字段的处理逻辑def parse_detail_page(html_content, base_url): soup BeautifulSoup(html_content, lxml) race_data {} # 赛事名称优先取h1退而求其次取title title_tag soup.find(h1) or soup.find(title) race_data[name] title_tag.get_text(stripTrue) if title_tag else 未知赛事 # 时间从信息区块中找包含时间的标签 time_pattern re.compile(r(\d{4}年?\d{1,2}月?\d{1,2}日?)) time_block soup.find(stringre.compile(比赛时间|赛事时间|时间)) if time_block: match time_pattern.search(time_block.parent.get_text()) if match: race_data[date] match.group(1) # 地点从包含地点或城市的标签中提取 location_block soup.find(stringre.compile(比赛地点|赛事地点|城市)) if location_block: race_data[location] location_block.parent.get_text().split()[-1].strip() # 费用匹配金额模式 price_pattern re.compile(r(\d)\s*元) fee_block soup.find(stringre.compile(报名费|费用)) if fee_block: text fee_block.parent.get_text() prices price_pattern.findall(text) race_data[fees] [int(p) for p in prices] return race_data这里藏着大量细节坑。比如中英文冒号混用、全角半角混乱、字段有时在表格里有时在段落里。我的建议是先抽20个页面做规则验证覆盖率在70%以上再固化规则不要一上来就上手写全套正则。3.3 数据清洗与规范化解析出来的原始数据很脏不能直接入库。我做了三层清洗第一层是字符清理。把全角字符转半角去空格统一日期格式为YYYY-MM-DD。赛事名称里的年份冗余信息去掉比如2025年北京马拉松统一成北京马拉松但保留字段year单独存储。第二层是字段映射。不同平台对同一赛事类型的叫法不同比如全马和马拉松指的都是42.195公里组别。我建了一个映射表做归一化这也是后续推荐系统能工作的重要基础。TYPE_MAPPING { 全马: 全程马拉松, 马拉松: 全程马拉松, 半马: 半程马拉松, 半程: 半程马拉松, 10K: 10公里, 10公里: 10公里, 欢乐跑: 欢乐跑, 亲子跑: 亲子跑 }第三层是重复数据去重。同一个赛事可能出现在多个平台依靠URL做不了唯一性判断因为URL不同。我改用赛事名称 日期 地点三者拼接的MD5哈希作为唯一键简单可靠。3.4 数据持久化设计数据清洗完成后的结构化字段需要先落到内存中的DataFrame再统一输出。我用的中间结构是这样的import pandas as pd columns [race_id, name, city, event_date, registration_deadline, categories, fees, race_type, source_url, scraped_at] df pd.DataFrame(cleaned_records, columnscolumns)DataFrame这个中间形态是全场工程的关键枢纽后面做推荐筛选和导出CSV、JSON都从这里走可以少写好几个轮子。4. 智能赛事推荐规则优先的实用路线很多人一听智能推荐就想到机器学习、协同过滤我这里的推荐引擎没用到任何算法模型用纯粹的规则计算做了一个匹配度评分。原因很实际数据量不足以支撑算法训练用户偏好输入项有限规则的实时性和可解释性更强。4.1 用户画像与匹配评分让我建立一个简单的用户偏好模型包含三个维度偏好城市或省份可接受的费用区间赛程长度偏好每个赛事会根据这三个维度计算匹配分def calculate_match_score(race, user_profile): score 0 # 城市匹配目标城市给40分 if race[city] in user_profile[preferred_cities]: score 40 elif race[city] in user_profile[nearby_cities]: score 20 # 费用匹配在预算区间给30分 if race[fees] and user_profile[max_budget]: if min(race[fees]) user_profile[max_budget]: score 30 # 类型匹配偏好类型给30分 if race[race_type] user_profile[preferred_type]: score 30 return score这个评分体系的设计思路很直接总分100分三个维度分别占40、30、30体现权重差异。城市是硬约束权重最高费用和赛事类型次之。后续可以根据用户反馈调整权重比例这就是可解释性的好处——你清楚每条推荐为什么会出现。4.2 时间维度筛选赛事推荐还有一个隐性维度时间。用户希望看到的是近期可报名的比赛而不是已经截止的。我加了一个状态标记逻辑def tag_deadline_status(row): now pd.Timestamp.now() if pd.isna(row[registration_deadline]): return 未知 if row[registration_deadline] now: return 已截止 if (row[registration_deadline] - now).days 7: return 即将截止 return 报名中这个即将截止的标记乍一看不重要实际在推荐系统里比评分还有用——人都有紧迫感看到即将截止会自动抓紧决策。这个设计也是我从一个赛事平台的邮件推送里学来的运营高手把用户的决策压力变成了转化率。4.3 推荐结果的排序与输出最终推荐结果按两个阶段排序先看报名状态再看评分。已截止的排最后报名中的按分数降序。输出形式是一份清爽的推荐列表sorted_recommendations (df[df[status] ! 已截止] .sort_values([is_recommended, match_score], ascending[False, False]))我给每个赛事也加了推荐理由这是我自己比较满意的设计。比如距你所在地120公里费用在预算内半马类型匹配。理由让推荐结果的可用性大幅提升——用户不再需要自己去猜为什么这个比赛被推荐出来。5. 数据导出CSV与JSON双轨方案用户最终拿到的不是代码和逻辑而是一份干净的数据文件。数据导出这一步看起来简单实际上有不少细节值得讲。5.1 标准CSV导出CSV是最通用的数据交换格式Excel直接能打开。但中文环境有一个老生常谈的坑Excel打开UTF-8编码的CSV会乱码。解决方案是导出时加BOM头。def export_to_csv(df, filenameraces.csv): # 使用utf-8-sig编码Excel可直接识别中文 df.to_csv(filename, indexFalse, encodingutf-8-sig)另一个容易忽视的参数是indexFalse不写会把DataFrame的行号也导进去多一列没有意义的数据。5.2 分组JSON导出JSON主要用于程序间数据交换。如果你的下游是一个Web应用或小程序它可能希望数据按城市分组方便前端按地域维度展示。def export_to_json(df, filenameraces.json): grouped df.groupby(city).apply( lambda x: x.to_dict(orientrecords) ).to_dict() with open(filename, w, encodingutf-8) as f: json.dump(grouped, f, ensure_asciiFalse, indent2)这里有两个关键参数ensure_asciiFalse保证中文不被转成\uXXXXindent2让JSON文件具备可读性便于调试。如果对接的是程序接口可以考虑去掉缩进以减小体积通常能省30%空间。5.3 增量导出与文件命名策略做定时抓取的场景全量导出每次都会覆盖之前的数据时间一长就无从对比。我的方案是每天生成带日期的文件races_20250713.csv races_20250713.json同时保留一份latest前缀的当前文件给下游程序做稳定的数据入口。这个设计兼顾了存档链路和接入便利性。我在实际项目中还遇到过一个需要注意的场景Windows系统下文件名的冒号、问号等特殊字符需要处理因为赛事名称里可能有中文冒号直接用作文件名会报错。所以凡是涉及文件名的地方我都做了清洗。import re def safe_filename(name): return re.sub(r[\\/*?:|], _, name)6. 常见问题与排查技巧实录这个项目做了几个月遇到过的坑比预想的多我把高频问题整理成了一张排查速查表也是我认为全文中最值得反复查阅的部分问题现象可能原因排查手段解决方案返回403UA被识别脚本检查响应头更换UA池/降速部分页面无数据页面结构变体打印HTML片段增加备用解析路径日期格式混乱源站多格式并存统计样本格式正则统一转换CSV中文乱码编码无BOM查看二进制头部utf-8-sig编码任务卡死中断网络超时未处理查看日志栈加超时重试数据重复同一赛事多入口检查哈希键调整去重逻辑请求成功但字段为空页面JS渲染内容对比静态HTML改用无头浏览器6.1 页面结构变动应对这个是爬虫类项目的宿命。某平台在年中改了一次版原来从div.race-item就能取到的数据位置完全变掉。我的应对策略是把解析规则做成配置化的比如存成JSON页面改版时只需要更新配置文件而不用改代码。{ selectors: { list_item: div.race-card, detail_title: h1.race-title, detail_date: .info-row.date } }这个改动看着很小但排查效率提升非常明显尤其是同时维护多个数据源的时候哪一个平台改版就只更新那一个配置其他平台完全不受影响。6.2 反爬升级的三种实战应对刚上线时能稳定跑两星期第三周突然出现大量403。排查后发现对方在请求头里加了一层Cookie校验必须先从首页获取一个初始Cookie才能访问详情页。这种类型的反爬升级是常态我总结了三层应对思路第一层模拟完整浏览流程。先进首页再进列表页再进详情页让Cookie在请求链路中自然生成。大多数站点的Cookie校验只需做到这个维度。第二层解析Cookie生成逻辑。某些Cookie字段是JS动态生成的需要分析脚本逻辑用Python模拟算法执行。这一层费时费力但一旦搞定就很稳定。第三层降低采集频率并轮换入口。如果目标站点确实防御严格主动退让也许是更好的策略——减少了请求频次有些站点就不再触发拦截。我的建议是善用前两层第三层作为兜底而不是一上来就上高成本的浏览器模拟方案。6.3 数据质量校验方法数据抓回来之后要主动做质量校验不能等到下游反馈才去查。我的做法是写一个校验脚本每次抓取完成后自动执行数量校验本批次赛事数量是否在合理区间比如一周抓下来30~200条是正常范围低于或高于都可能有问题字段缺失率核心字段缺失率超过10%需要告警日期合法性赛事日期不能在2020年之前不能是无效日期重复率监控去重后与历史库的重复率如果突然下降说明可能漏抓了很多页面这一套校验规则基于长期数据的基准线一旦触发异常直接发消息通知不用等人工发现这也是自动化链路里比较容易被忽略的一环。7. 合规边界与长期运维建议爬虫项目的技术难度是一部分合规意识是另一部分而且在真实的生产环境中后者的重要性往往比前面几个部分加起来都高。这里不谈法律条文只说我在这个项目里实际遵守的三条底线。第一只采集公开信息不绕过登录墙、不破解验证码、不访问非公开接口。赛事报名信息本身是公开的项目的价值在于聚合和结构化而不是窃取非公开数据。第二控制请求频率不给目标服务器造成压力。前面提到的1.5秒间隔和随机抖动不仅是为了反封禁更是为了不让自己的爬虫成为一个好邻居的反面教材。这个原则在任何采集任务里都适用。第三数据使用限个人学习与研究不用于商业用途不涉及个人隐私信息。我在数据文件里额外加了一行声明注释也算是一种自我提醒。然后说运维。定时任务我用的是系统自带的服务功能比如Linux下的crontab每天凌晨两点执行抓取。这个时间段赛事网站访问量低影响小。日志按天轮转保留最近三十天方便回溯问题。数据文件每月归档一次到独立目录避免单目录文件数量膨胀。如果这个系统要规模化扩展我建议分三步走第一步引入消息队列削峰填谷让请求不再由主循环直接驱动第二步接入数据库替代本地文件推荐引擎就能做更复杂的SQL联查第三步根据用户历史行为数据训练个性化排序模型这就从规则推荐升级到算法推荐了。我在实际维护中慢慢体会到爬虫这类项目最考验人的反而不是编码能力而是持续迭代和细心排错的能力。页面改了、反爬强了、数据结构变了每一步都可能让现役系统崩溃。保持耐心把每个异常当成学习机会这套系统才能真正地在时间长度上跑起来。