简介:面向大学生数据采集与预处理课程设计的完整项目资料,以木鸟短租网为实战对象,内含爬虫源码和课程设计报告,重点展示requests、BeautifulSoup、Scrapy、Selenium等技术的应用,覆盖数据采集、反爬应对、清洗与预处理全流程。压缩包共35个文件,含19张png图片(页面截图、流程图等)及组成Word报告的xml/rels组件,整体仅4.85MB,报告正文可编辑,方便吸收改造。已有1755人学习下载,适合课程设计、毕业设计或爬虫入门参考。项目从需求分析、架构设计到编码调试均有详细记录,模块划分清晰,排错思路完整,能帮助读者快速搭建同类爬虫项目并提升数据处理能力。
1. 木鸟短租网课设里的爬虫,难点不在爬而在稳
很多人把爬虫课设写成了「抓一个静态页面就交差」,但放到木鸟短租网这个目标上,事情要麻烦得多:城市分站入口不统一、列表页走瀑布流加载、详情页的价格和面积混在中文文本里需要二次清洗。这套课程设计之所以值得拆,是因为它把数据采集和预处理连成了一整条链路——requests 拿到 HTML、BeautifulSoup 抽字段、pandas 做清洗,最后产出的 CSV 可以直接画价格分布图。对正在做数据采集课设、或者想从教材示例走向真实站点的人来说,源码里的采集节奏控制、字段兜底和数据标准化思路都能直接抄进自己的项目。整套源码加课程设计报告的结构,也能帮你在答辩时把「为什么这样设计」讲清楚。
2. 短租平台的信息结构拆解与采集技术选型
2.1 木鸟短租网的页面层级与字段清单
木鸟短租网的站点结构是典型的三层:城市分站首页进入区域列表,再进入单个房源详情页。列表页在浏览器里看到的是瀑布流,实际网络请求有两种可能:一种是把前 N 条房源直接渲染进 HTML,另一种是滚动后通过 XHR 接口加载后续数据。这套源码选择的是直接分析初始 HTML,因为详情页的完整数据在 HTML 里都有,不需要引入无头浏览器去模拟滚动。
采集之前先把字段清单定下来,否则边爬边想字段,清洗阶段会很痛苦。参照源码里的字段定义,按下面这张表规划:
| 字段 | 来源页面 | 示例值 | 目标类型 |
|---|---|---|---|
| city | 分站入口 URL | beijing | str |
| title | 列表页/详情页 | 近地铁温馨大床房 | str |
| link | 列表页 | /fangyuan/12345.html | str |
| price | 详情页 | ¥369/晚 | int |
| area | 详情页 | 55平米 | float |
| layout | 详情页 | 1室1卫 | str |
| rent_type | 详情页 | 整租 | str |
| max_guests | 详情页 | 可住2人 | int |
| score | 详情页 | 4.8分 | float |
| comments | 详情页 | 126条点评 | int |
这套课设的合理之处在于把字段定义和解析逻辑分开:字段清单确定后,爬虫里只按字段名提取,最后写 CSV 时用同一个字段列表,列顺序就自动统一了。后面做数据分析时,这个字段清单就是 DataFrame 的初始列名,省掉大量对齐工作。
2.2 为什么选 requests + BeautifulSoup,而不上 Scrapy
Scrapy 性能好、扩展多,但课设场景里有个现实问题:评分老师看的是代码能不能读懂、关键逻辑够不够清楚。Scrapy 的 pipeline、middleware、ItemLoader 这些概念,在课设报告里的解释成本很高,而且中间件配置一旦出错,调试难度对新手不友好。这套源码选择 requests + BeautifulSoup 组合,就两条主线:requests 负责拿页面,BeautifulSoup 负责抽字段,全程同步代码,断点容易打。
Selenium 也不是首选。房源详情数据在 HTML 里都有,只有列表页的增量加载需要额外处理。如果列表页确实要滚动加载,优先分析 XHR 接口参数,而不是启动浏览器实例。原因很实际:Selenium 启动浏览器实例的内存开销大,驱动版本不匹配属于高频翻车点,在课设里给自己加险不值得。我一般会定一个降级策略:先用 requests 试静态 HTML,遇到拿不到的字段再降级到 Selenium,不要一上来就全链路浏览器模拟。
2.3 robots.txt、采集频率与合规边界
课程设计需要遵守目标站的 robots.txt,这不是教条,而是爬虫工程师的基本素养。正式采集前,先请求目标站的 robots.txt,把禁止采集的路径排除掉。这套源码遵守了常规约束:只采集公开的房源展示信息,不碰用户隐私数据,不采集登录后才可见的内容。
合规边界记三条:一是频率上做限速,不对目标服务器造成压力;二是数据用途限定在教学演示,不做商业化转售;三是如果目标站返回明确的反爬响应,应该停止采集而不是死磕绕过方案。这几条写进课程设计报告,本身就是加分项。
3. requests 会话管理、列表页解析与详情页字段抽取
3.1 用 Session 维持连接与请求头构造
采集的第一步是建立会话。使用 requests.Session 而不是裸 requests.get,原因有两点:一是 Session 会维持 Cookie,某些站点的列表页可能先写 Cookie 再返回数据,两次独立请求会漏;二是 Session 内部复用 TCP 连接,对高频采集更友好。源码中请求头的构造大致如下:
import requests 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,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://www.muniao.com/" } session = requests.Session() session.headers.update(HEADERS)这里的 Headers 值得写全:Referer 告诉服务端请求是从站内跳转来的,避免直接被识别为外部爬虫;User-Agent 保持与主流浏览器一致,是成本最低的反爬规避手段。Session 创建后统一用session.get()发请求,Cookie 和连接复用都在会话内完成,比每次新建请求对象要规范。
3.2 列表页的 BeautifulSoup 定位与翻页参数说明
列表页拿到 HTML 后,用 BeautifulSoup 的find_all定位房源卡片。关键是把卡片容器的 class 或 data 属性找准,建议先存一份 HTML 到本地,用浏览器 DevTools 确认选择器再写死,这比反复跑程序验证高效得多。
from bs4 import BeautifulSoup def parse_list(html, city): soup = BeautifulSoup(html, "html.parser") items = [] cards = soup.select("div.house-list > div.house-item") for card in cards: a_tag = card.select_one("a.house-title") link = a_tag.get("href") title = a_tag.get_text(strip=True) if not link: continue items.append({ "city": city, "title": title, "link": link, "detail_url": "https://www.muniao.com" + link }) return items参数要点说明:
select里的house-list、house-item是示例 class,实际以抓下来的真实 HTML 为准。真实站点的类名多半带业务前缀或哈希后缀,正因如此,用真实 HTML 验证选择器这一步不能省;a_tag.get("href")取出的是相对路径,拼接detail_url时补了站点域名,漏掉这一步会导致后续详情页请求全部 404;if not link: continue是必要的兜底,部分卡片可能是广告位或已下架房源,没有链接就跳过,不影响采集主流程。
翻页处理分两种情况。普通分页直接循环页码参数即可;瀑布流加载则在浏览器开发者工具里找到 XHR 请求,通常是list?city=xxx&offset=20这类接口,返回 JSON 或 HTML 片段。处理方式一样:用session.get()请求接口,解析新增条目,直到返回条目数为 0。
3.3 详情页字段抽取的兜底写法
详情页是数据的主战场。价格、面积、可住人数这些字段经常混在一段中文文本里,抽取时用「选择器定位 + 正则二次清洗」的组合。源码里详情解析的核心函数结构如下:
import re def parse_detail(html): soup = BeautifulSoup(html, "html.parser") data = {} # 价格:从 "¥369/晚" 中提取 369 price_text = soup.select_one("span.price-num") if price_text: match = re.search(r"(\d+)", price_text.get_text()) data["price"] = int(match.group(1)) if match else None # 面积:从 "55平米" 提取 55.0 area_text = soup.select_one("div.house-area") data["area"] = float(re.search(r"(\d+\.?\d*)", area_text.get_text()).group(1)) if area_text else None # 可住人数:从 "可住2人" 提取 2 guest_text = soup.select_one("div.house-guest") match = re.search(r"(\d+)", guest_text.get_text()) if guest_text else None data["max_guests"] = int(match.group(1)) if match else None # 租赁方式 type_text = soup.select_one("div.house-type") data["rent_type"] = type_text.get_text(strip=True) if type_text else "未知" return data这个函数有两个值得直接抄走的设计。第一是每个字段都做了两层保护:先判断节点是否存在,再正则取值,避免某个房源页面结构异常导致整个采集进程崩溃。第二是取不到值的字段统一落为 None,而不是跳过该房源——记录一行缺失数据,比静默漏掉一行数据更容易在清洗阶段定位问题。如果自己改这个函数,建议把re.search的 pattern 提取为模块级常量,后面调整正则比在函数里逐个翻要方便。
采集主循环里每抓完一个详情页就追加写入一行,并用 set 记录已访问的 URL,防止列表页重复出现同一房源。数据写入用csv.DictWriter,字段顺序就是 2.1 节里的字段清单,这样一个 CSV 爬完就能直接交给 pandas,不再需要手工调整列。
4. 反爬信号识别、指数退避重试与断点续采
4.1 被反爬拦截时的典型信号与排查方向
课设过程中最怕的不是网站结构复杂,而是程序还在跑,数据却全是错的。判断是否被反爬,看下面几个信号:
| 信号 | 现象 | 排查方法 |
|---|---|---|
| HTTP 状态码 | 403、418、503 频繁出现 | 检查 response.status_code,写日志 |
| 验证码跳转 | HTML 里出现 verify、captcha 关键词 | 保存响应文本,搜索关键词 |
| 字段大面积为空 | 详情页抽取结果全是 None | 打印原始 HTML,确认是否被替换成反爬页 |
| 请求全部超时 | Timeout 异常频率升高 | 降低并发,加大延时 |
上面这张表在课程设计报告的「问题与解决」章节里几乎可以原样引用。如果遇到 403,第一个排查点永远是请求头不齐全,其次是访问频率过快。如果确认是限流,唯一正确的处理是退避等待,而不是换代理硬闯。课设场景下一旦被识别,处理成本就会很高,也会给目标站点带来不必要的负担,按流程停采才合理。不要为了完成进度尝试绕过验证码机制,必要时直接降级为只用本地已有数据完成报告。
4.2 限速、随机延时与指数退避重试
采集质量的核心指标是数据完整而不是速度快。源码里通过random.uniform生成随机延时,避免固定间隔被流量特征识别;对偶发的网络超时,用指数退避重试来恢复。参考实现:
import random import time from requests.exceptions import RequestException def fetch_with_retry(session, url, max_retries=3, base_delay=2.0): for attempt in range(max_retries): try: resp = session.get(url, timeout=15) if resp.status_code == 200: return resp.text elif resp.status_code in (403, 418): # 被反爬拦截,继续重试没有意义 raise RuntimeError(f"blocked by anti-crawler: {resp.status_code}") else: print(f"http {resp.status_code}, retry {attempt + 1}") except RequestException as exc: print(f"request error: {exc}, retry {attempt + 1}") # 指数退避:2s, 4s, 8s delay = base_delay * (2 ** attempt) + random.uniform(0, 1) time.sleep(delay) return None参数含义拆开说:base_delay=2.0是第一次重试前的等待基数,每次重试翻倍,第三次等待约 8 秒;timeout=15给每个请求 15 秒上限,避免某个房源页卡死整条链路;重试次数 3 次是采集场景默认值,超过就返回 None,由上层决定是否记录失败日志。末尾的random.uniform(0, 1)很关键——纯指数退避的等待时间是确定值,目标站点把每两次请求的时间间隔取出来对比,能发现整齐的等比数列规律,加一点随机抖动后,重试间隔就不再可预测。
4.3 断点续采:中断不等于重来
一个集中采集任务可能跑几十分钟甚至几个小时,中途断电、断网、进程被杀都很常见。源码里用了一个小设计把这个问题解决掉:在本地维护一份visited.txt,每成功采完一个详情页就把 URL 追加写入。重启后先加载这个文件构建 visited 集合,跳过已完成的 URL。核心逻辑简短但完整:
import os visited_file = "visited.txt" def load_visited(): if not os.path.exists(visited_file): return set() with open(visited_file, encoding="utf-8") as f: return {line.strip() for line in f if line.strip()} visited = load_visited() # 采集某个详情页成功后执行 visited.add(detail_url) with open(visited_file, "a", encoding="utf-8") as f: f.write(detail_url + "\n")这个技巧对采集流程的价值在于:失败重试解决的是单次请求失败,visited 文件解决的是整个任务中断。两个机制配合起来,数据采集才能稳定推进。在课设报告里,把「中断 3 次后任务恢复且没有重复数据」作为测试结果写进去,能明显体现对工程细节的把握。注意课设提交前删掉 visited.txt 和缓存文件,保证运行环境干净。
5. pandas 数据清洗:从采集原始数据到标准化字段表
5.1 读入原始数据、去重与空值盘点
爬虫输出的 CSV 是半成品,直接做分析会踩坑:重复记录、字符串数字混在一列、分类字段叫法不统一。先从读数据开始:
import pandas as pd df = pd.read_csv("muniao_raw.csv", encoding="utf-8-sig") print("原始行数:", len(df)) print("重复行数:", df.duplicated(subset=["link"]).sum()) df = df.drop_duplicates(subset=["link"]) df = df.dropna(subset=["title", "link"]) print("去重后行数:", len(df)) print(df.isna().sum())这段代码的要点:用link作为去重键而不是整行去重,因为整行去重无法处理「同一条房源在不同时间抓了两遍、价格字段有变动」的情况;dropna(subset=["title", "link"])只删除关键字段缺失的记录,其他字段的缺失留给下一步单独处理。isna().sum()输出每一列的缺失数量,把它写进课设报告,用来展示清洗前数据质量问题的证据。
5.2 价格、面积与可住人数的字符串转数值
详情页解析已经把大部分字段抽成数值,但人工核对时仍可能有漏网的非标准文本,比如价格写成「价格面议」,面积出现「约55平米」。清洗阶段统一用正则处理:
import re def clean_price(value): if pd.isna(value): return None match = re.search(r"\d+", str(value)) return int(match.group()) if match else None def clean_area(value): if pd.isna(value): return None match = re.search(r"\d+\.?\d*", str(value)) return float(match.group()) if match else None df["price"] = df["price"].apply(clean_price) df["area"] = df["area"].apply(clean_area) df["max_guests"] = df["max_guests"].apply(clean_price) print(df[["price", "area", "max_guests"]].describe())apply把函数逐行作用到 Series,正则在这里是兜底,能匹配解析阶段漏掉的中文夹杂文本。describe()快速看出价格分布是否合理,比如最小值 0、最大值 99999,这类明显异常交给下一步的区间过滤。如果某一列在describe()里 count 数明显小于总行数,说明该列有缺失值,需要回到 5.1 节的缺失表中对照确认。
5.3 租赁方式与城市字段的分类标准化
租赁方式字段在原始文本里可能出现「整租」「整栋」「单间」「床位」等叫法,城市字段可能混着中文名和拼音。为了让后续分组统计不歧义,用映射表做标准化:
rent_map = { "整租": "整租", "整栋": "整栋", "单间": "单间", "合租": "单间", "床位": "单间", } df["rent_type"] = df["rent_type"].map(rent_map).fillna("单间") city_map = { "北京": "beijing", "beijing": "beijing", "上海": "shanghai", "shanghai": "shanghai", } df["city"] = df["city"].map(city_map).fillna(df["city"]) print(df.groupby(["city", "rent_type"]).size())map的作用是把左侧原始叫法替换成右侧标准值,fillna("单间")处理映射表里未覆盖的情况,避免新增分类打乱后续分析维度。城市字段双写中英文映射,是因为数据可能来自不同分站入口,统一后groupby的结果就是一张标准透视表。把这张透视表放进课设报告的数据预处理结果部分,比贴一大段代码更有说服力。
6. 清洗结果的量化验证与课设报告自查清单
6.1 清洗前后数据量的对比验证
数据预处理做完,要能回答「清洗到底改了什么」。最简单有效的做法是保存两份行数对比,外加价格分布的统计:
cleaned = df.dropna(subset=["price", "area"]).query("1 <= price <= 5000") print(f"清洗前: {len(df)} 行, 清洗后: {len(cleaned)} 行") print(cleaned.groupby("city")["price"].agg(["count", "mean", "median"]))如果清洗前 2000 行、清洗后剩 1600 行,这 400 行的去向要能说清:多少是重复、多少是缺失、多少是价格异常被过滤,每一项对应报告里清洗过程章节的一个小节。query里的价格区间过滤是用的最多的一条准则:排除超过 5000 的记录,避免短租平台的别墅房源把整体均价拉偏。agg(["count", "mean", "median"])一次算出每个城市的房源数量和价格中位数,中位数比均值更抗异常值,适合价格这类长尾分布字段。
6.2 报告里值得放的三个数据证据
第一个是采集过程日志的一小段截图,包含每个请求的状态码和延时,证明数据来源可靠;第二个是describe()输出的数值字段统计表,作为预处理依据;第三个是清洗前后的行数对比和groupby结果,作为预处理效果的量化证据。这三项都不需要额外写代码,全部来自前面步骤的中间输出。把「做了哪些步骤」换成「数据从什么状态变成了什么状态」,课设报告的含金量会明显提升。字段缺失分布表建议用df.isna().sum()的完整输出加一列缺失率,计算方式就是缺失数除以总行数。
6.3 一套可直接复用的课设自查顺序
我的自查顺序是:先确认采集量和目标房源量在一个量级,再用df.isna().sum()看每一列的缺失分布,然后跑一遍describe()检查数值范围,最后用groupby看业务维度如城市、租赁方式的计数是否合理。把这套顺序写进报告的验证部分,答辩时被问「怎么证明数据有效」,直接指向describe输出即可。最后一次运行程序前,记得把 visited.txt 和调试用的 HTML 缓存文件清掉,保证提交的源码包和报告描述完全对应。
本文还有配套的精品资源,点击获取