简介:链家出租房(上海为例)爬虫、数据分析与可视化是一套面向Python学习者与期末大作业场景的完整项目方案,源码均在本地编译通过,评审分95分以上,难度适中,适合作为毕业设计或课程大作业的参考模板。压缩包共20个文件,约20.91MB,涵盖爬虫脚本、交互式分析文件、采集得到的表格数据集、可视化图表(13张图片,如词云图、聚类图、散点图、热力图等),并附有详细实验报告、答辩演示文稿与说明文档,能够覆盖从数据采集、清洗分析到展示汇报的全流程。项目以上海链家出租房为实例,涉及房源面积、总价、户型、装修类型、区域分布等维度的分析与可视化,代码结构清晰,图表类型丰富,便于理解爬虫与数据分析的完整思路。目前已有104人学习下载,对有相似选题需求的学生具有直接参考价值。
1. 链家租房爬虫:为什么拿上海当样本比全国数据更靠谱
如果你正在做“爬虫+数据分析+可视化”方向的大作业,多半纠结过一个问题:抓全国数据还是单城市数据。我的建议是,除非导师明确要求“全国维度”,否则老老实实选一个城市,比如上海。链家上海租房页面结构规整,房源量在几万条的量级,既能体现爬虫与分布式处理的价值,又不至于让反爬和存储把作业变成灾难。更重要的是,上海各行政区的租金差异非常明显,随便跑几张图就能看出“内环贵、郊区便宜”的规律,对数据分析阶段非常友好。这套从 requests 到 XPath、再到 pandas 和 ECharts 的链路,适合所有想把大作业做成完整作品的人——它不要求你有分布式爬虫背景,但能让你跑通从发请求到写报告的全过程。下文我会按一个可复现的路径来讲,包括代码、参数和最容易踩的坑。
2. 链家上海租房爬虫:从请求到解析的最小闭环
2.1 先看页面结构再写XPath:打开开发者工具的直观步骤
很多人拿到链家租房页面就直接复制网上的 XPath,结果没两天选择器就失效。链家的前端结构会随着版本更新调整,但核心思路不变:先手动打开一个列表页,按 F12 进入开发者工具,用元素选择器定位一张租房卡片。你会发现每条房源在ul.log-list下的li里,每一条li上有>import requests from lxml import etree import time headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://sh.lianjia.com/zufang/", } base_url = "https://sh.lianjia.com/zufang/pg{page}/" all_items = [] for page in range(1, 11): url = base_url.format(page=page) resp = requests.get(url, headers=headers, timeout=10) resp.encoding = "utf-8" if resp.status_code != 200: print(f"第{page}页请求失败,状态码: {resp.status_code}") break tree = etree.HTML(resp.text) # 每条房源所在的 li 节点,class 是稳定的 li_list = tree.xpath("//div[contains(@class,'content__list--item')]") for li in li_list: title = li.xpath(".//div[contains(@class,'content__list--item--main')]//a/text()")[0].strip() rent = li.xpath(".//span[contains(@class,'content__list--item-price')]/em/text()") rent = rent[0] if rent else "0" location = li.xpath(".//p[contains(@class,'content__list--item--des')]/a/text()") location = "".join([x.strip() for x in location]) if location else "" item = { "title": title, "rent": float(rent), "location": location, } all_items.append(item) print(f"第{page}页完成,累计 {len(all_items)} 条") time.sleep(2) # 重要:减速,避免被封 print(all_items[:5])
这段代码里,pg{page}是链家分页的标准格式,第一页对应pg1。注意resp.encoding = "utf-8",不设置的话中文可能乱码。li.xpath前面有.,表示在当前li节点下继续查找,而不是从整个文档重新找。解析时优先用contains(@class, '...')而不是精确等于class="",因为链家的 class 后面经常会拼接一段动态的哈希值。rent字段用em标签定位,因为租金数字单独放在该标签内。最后每页抓完强制time.sleep(2),这能降低请求频率,虽然不能完全规避反爬,但至少不会刚发出 10 个请求就被服务器拒绝。
2.3 翻页减速与请求头伪装:别让IP被临时禁掉
初版代码跑通后,你一定想提高抓取速度,于是把time.sleep(2)改成了time.sleep(0.1),以为这样可以更快。结果跑了三页就收到 403,甚至captcha.html。这就是爬虫里最经典的翻车现场:频率过高导致临时封禁。链家对 IP 的限流策略比我见过的其他房产网站更敏感,尤其是上海这种热门城市,单 IP 一分钟请求超过 20 次就可能触发验证码。
解决方案分三级。第一级,保持 1 到 3 秒的随机延迟,使用random.uniform(1.5, 3.5)而不是固定数值。第二级,准备一个可用的 Cookie,登录一次链家网页版,把浏览器里的 Cookie 复制到代码里。不登录的匿名请求在某些时段会被加重校验,带上 Cookie 能显著降低风险。第三级,如果作业要求抓取几万条数据,那就不能用单线程了,这时可以考虑用ThreadPoolExecutor配合代理池,但要注意代理质量参差不齐,反而更容易被封。对大作业来说,300 条数据完全够用,不需要上分布式爬虫,写清楚你的设计和选型理由比硬堆技术更得分。除了请求头,你还得处理解析的健壮性。链家偶尔会在列表页插入广告位,导致li数量对不上,但我的经验是:只要 XPath 写的是“包含特定 class”的容器,广告节点通常不带这些 class,所以即使混入也不会爆错。如果某条数据解析失败,不要直接让程序崩溃,而是把它记录到异常列表里,最后统一补抓。
3. 把爬下来的数据洗成能分析的格式:数据清洗与标准化
3.1 字符串拆分:把“整租·3室1厅”拆成室厅与面积
列表页返回的title一般是“整租·3室1厅·南北”,而location是一个拼接字符串,比如“中山公园/江苏路/长宁”。如果直接拿这串字符串去做聚合,你得不到任何分组粒度。所以数据清洗的第一步是把这些字段拆开。我用的是最简单的 split,因为链家的格式非常固定。下面这段代码演示如何把title拆出租赁方式、室、厅,把location拆出小区名和行政区。
import pandas as pd import re df = pd.DataFrame(all_items) # 拆分title df["lease_type"] = df["title"].apply(lambda x: x.split("·")[0]) df["rooms"] = df["title"].apply(lambda x: x.split("·")[1]) df["orientation"] = df["title"].apply(lambda x: x.split("·")[2] if len(x.split("·")) > 2 else "未知") # 从rooms中提取数字 df["bedrooms"] = df["rooms"].str.extract(r"(\d+)室").astype(float) df["livingrooms"] = df["rooms"].str.extract(r"(\d+)厅").astype(float) # 拆location,链家一般格式是:板块/商圈/行政区 df["plate"] = df["location"].apply(lambda x: x.split("/")[0] if "/" in x else x) df["district"] = df["location"].apply(lambda x: x.split("/")[-1] if "/" in x else x) print(df[["title", "lease_type", "bedrooms", "district"]].head())这段代码里,正则表达式r"(\d+)室"是从字符串中提取第一个数字,比如“3室1厅”得到 3 和 1。如果遇到“1室0厅”甚至“开间”,没有厅的房源会提取到 NaN,这就是后面要处理的问题。我建议用str.extract而不是直接匹配,因为extract可以忽略干扰项。但这里有个坑:浦东新区的名字叫“浦东新区”,有的列表里显示为“浦东”,如果你后续想要画地图,必须统一行政区名称,否则聚合时会出现两个“浦东”。
3.2 租金与单位价格计算:处理中文数字与NaN
租金字段虽然已经是数字,但页面里经常出现“8000元/月”这样的格式,如果直接用float()会报错。比较稳的做法是提前把非数字字符全部删除,再转换。另外,不少房源没有填写租金,页面显示“暂无”,这在列表页里会变成空字符串。下面这段代码处理这些问题,并计算每平方米月租金,这是做区域比价的常用指标。
def parse_rent(s): if isinstance(s, str): s = re.sub(r"[^\d.]", "", s) return float(s) if s else None return s df["rent"] = df["rent"].apply(parse_rent) # 处理面积字段(从title或详情中获取) # 假设之前已经抓到面积并存为df["area"] df["area"] = pd.to_numeric(df["area"], errors="coerce") # 单位租金 = 月租金 / 面积 df["unit_price"] = df.apply( lambda row: round(row["rent"] / row["area"], 2) if row["area"] and row["area"] > 0 else None, axis=1 ) # 删除租金或面积为空的记录 df_clean = df.dropna(subset=["rent", "area", "unit_price"]) print(df_clean.shape)这里关键的参数是errors="coerce",它会把无法转换的值变成 NaN,比如面积里的“-”。在计算unit_price时,先判断area是否大于 0,避免除零错误。我建议不要直接删除所有 NaN 记录,而是先看看缺失比例,如果缺失规模超过 20%,你需要重新定义指标。比如面积缺失但租金存在,可以按卧室数分组填充该组面积中位数,这样能保留更多样本。链家出租房数据里,“整租”的面积通常填写完整,而“合租”有时只有“5室1厅”而面积留空,所以根据租赁方式填充是合理的。
3.3 字段补充:用高德API把小区名转经纬度
如果你的作业要画地图热力图,那么需要经纬度。爬虫本身抓不到经纬度,只能通过小区名去地理编码接口换。这里我不推荐用百度地图,因为百度对非认证用户的配额更小。高德地图免费版每天大概有 5000 次地理编码额度,对 300 条数据绰绰有余。具体做法是写一个函数,把小区名拼上“上海”前缀,然后请求高德的geocode/regeo接口,拿到经度和纬度。
import requests import time amap_key = "你的高德Key" # 在高德开放平台创建应用获得 def get_lnglat(addr): url = "https://restapi.amap.com/v3/geocode/geo" params = { "address": "上海" + addr, "output": "json", "key": amap_key, } resp = requests.get(url, params=params, timeout=5) data = resp.json() if data["status"] == "1" and data["geocodes"]: loc = data["geocodes"][0]["location"] # 格式 "经度,纬度" lng, lat = loc.split(",") return float(lng), float(lat) return None, None # 先做缓存,避免重复请求 cache = {} for idx, row in df_clean.iterrows(): name = row["plate"] # 用板块名而不是小区名,降低定位误差 if name in cache: lng, lat = cache[name] else: lng, lat = get_lnglat(name) cache[name] = (lng, lat) time.sleep(0.3) # 控制QPS df_clean.loc[idx, "lng"] = lng df_clean.loc[idx, "lat"] = lat这段代码里,我故意用板块名而不是小区名,因为链家的小区名可能在上百个,板块名只有几十个,能大幅减少 API 调用次数,而且画图粒度足够。如果你要精细到小区级,再换回title里的小区名。time.sleep(0.3)用来控制并发,高德的 QPS 限制大约是 3 到 5,虽然免费版允许你每秒 3 次,但为了稳定,我通常留出 0.3 秒的间隔。另外一个容易忽略的问题是地址重名,比如“世纪公园”可能被解析为上海世纪公园这站,但如果你使用板块名,重名率会低很多。地理编码结果需要保存到 CSV,避免下次运行作业再重新请求,这就是缓存的价值。
4. 上海租房数据分析与可视化:回答“哪里租得值”
4.1 Pandas聚合:按行政区统计平均租金与样本量
数据清洗完之后,分析的第一件事一定是看整体分布。大多数房产项目都要回答“哪个区租金最贵”和“哪个区性价比最高”。Pandas 的groupby能一行搞定。但这里有个微妙之处:直接把所有房源的平均租金拉出来,会被豪宅区拉高。所以我会同时计算中位数和样本量,优先看中位数。
import pandas as pd # 读取清洗后的数据 df = pd.read_csv("shanghai_rent_clean.csv") district_stats = df.groupby("district").agg( rent_median = ("rent", "median"), rent_mean = ("rent", "mean"), unit_price_median = ("unit_price", "median"), count = ("rent", "count") ).sort_values("rent_median", ascending=False) # 过滤样本量太少的区 district_stats = district_stats[district_stats["count"] >= 10] print(district_stats)这里agg里的rent_median是给聚合结果命名,("rent", "median")表示用rent列计算中位数。筛选count >= 10很重要,否则个别只有两三条房源的小板块画出来会显得异常。如果你的作业报告写到这一步,记得把结果转成表格,用“中位数租金”而非“平均租金”作为主要指标,因为在答辩时,你很容易被问到“为什么你们的平均租金和网上查到的统计年鉴不一样”。回答思路就是“中位数更能代表普通房源”。另外,可以增加一个“高性价比区”的交叉分析:同时看unit_price_median和距离市中心远近的关系。这里还可以用pd.cut把面积分箱,然后看箱内租金差异,这属于探索性分析的加分项。
4.2 ECharts可视化:用地图热力图展示区域租金分布
可视化是大作业的门面。很多同学直接用 Matplotlib 画柱状图,虽然简单,但视觉效果平平。我建议用 ECharts 的地图组件,它支持 GeoJSON 区域映射。如果你不想写前端代码,可以直接用 pyecharts 库,它把配置都封装成 Python API。但要注意 pyecharts 的版本兼容问题,我用的是 1.x 版本,如果你安装的是 2.x,部分参数名有改动。下面代码画出一张上海行政区租金热力图。
from pyecharts.charts import Map from pyecharts import options as opts # 准备数据:district -> rent_median data_pair = [(d, v) for d, v in district_stats["rent_median"].items()] c = ( Map() .add("租金中位数", data_pair, maptype="上海") .set_global_opts( title_opts=opts.TitleOpts(title="上海各行政区租房中位数(元/月)"), visualmap_opts=opts.VisualMapOpts(max_=20000, is_piecewise=True), ) ) c.render("shanghai_rent_map.html")这里有个细节,maptype="上海"要求 pyecharts 内置的 geo 地图数据中包含上海。如果你的 pyecharts 版本较老,可能没有上海地图,这时候你需要手动注册 GeoJSON。更稳妥的做法是直接用 ECharts 的官方在线地图,但大作业一般不能依赖外网,所以建议在本地放置一份上海地图的 GeoJSON 文件,用Map.add(..., maptype="上海地图")配合registe_map。如果不想折腾地图,退一步可以用Bar画行政区租金排行,效果也足够清晰。但既然标题里写着“可视化”,我建议你至少尝试一次地图热力图,这张图在报告里非常占版面。地图上的颜色区间按照 8000、12000、15000 分三段,避免极值造成整体色阶失真。
4.3 面积与租金关系:用Seaborn回归图发现异常房源
数据分析不能只看均值,还得看变量之间的关系。对于租房数据,最经典的关系是面积和租金。你可以用 Seaborn 的regplot画散点加回归线,这会显示出一个明显的正相关,但也会暴露出离群点。比如内环的 30 平米“老破小”租金可能比郊区的 90 平米整租还贵,这时你需要根据回归残差把这些点标出来。
import seaborn as sns import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] # 解决中文乱码 plt.rcParams["axes.unicode_minus"] = False sns.set_theme(style="whitegrid", palette="muted") # 过滤超大数据后再作图 df_plot = df[(df["area"] > 10) & (df["area"] < 200) & (df["rent"] < 50000)] g = sns.lmplot(data=df_plot, x="area", y="rent", hue="district", col="district", col_wrap=4, height=3.5, scatter_kws={"alpha": 0.4}, line_kws={"color": "red"}) plt.show()lmplot的参数col会按行政区画多个子图,col_wrap控制换行。对于普通作业,可能只需要画一张全量散点图,再加一张按户型分箱的柱状图。但如果你要让报告显得更硬核,可以计算残差并筛选出“面积小但价格高”的房源,这些房源往往是靠近地铁的公寓。你可以用下面的方式提取异常点:
from scipy.stats import linregress slope, intercept, r, p, se = linregress(df_plot["area"], df_plot["rent"]) df_plot["pred_rent"] = slope * df_plot["area"] + intercept df_plot["resid"] = df_plot["rent"] - df_plot["pred_rent"] abnormal = df_plot[np.abs(df_plot["resid"]) > 8000] print(abnormal[["district", "plate", "area", "rent", "pred_rent"]].head())判断“异常”的阈值设为残差绝对值大于 8000 元。这个方法可以帮你在答辩时回答“如何识别高性价比和低性价比房源”。注意,这里如果用hue="district"会导致图例很乱,所以可以考虑只用hue="lease_type",区分整租和合租。合租的租金本来就低,和整租混在同一个回归模型里会扭曲关系,建议先拆分出整租再单独做回归。
5. 大作业的5个经典翻车现场:反爬、数据缺失与打包避坑
5.1 列表页时好时坏:403状态码的根源是Headers
你信心满满地运行爬虫,前两次一切正常,第三次突然报“HTTP 403 Forbidden”。按 F12 打开网页正常显示,说明服务器已经识别出你来自脚本。最初我以为是 IP 被封,后来发现只是没有设置Referer。链家对/zufang/路径会校验来源页,如果Referer不是链家域名,返回 403 的概率非常高。解决办法是给headers加上Referer: https://sh.lianjia.com/zufang/,同时替换成完整的浏览器用户代理。做完这两步,403 基本消失。不要相信网上流传的“Base headers”模版,链家已经能识别Python-requests的默认特征,只要发现请求头里没有Sec-Fetch-*字段就可能拒绝。你可以直接从浏览器复制完整的请求头,但注意Cookie不要过期。
5.2 详情页被重定向到验证码页面
当你尝试抓取详情页时,会发现列表页都抓完了,但详情页返回的不是房源信息,而是一个包含“安全验证”的 HTML。这是因为访问详情页的路径是/zufang/xxx.html,该路径有独立的风控策略,触发条件通常是短时间访问超过 10 个详情页。另一种情况是你在列表页中提取了某个房源链接,但链家对旧房源已经下架,访问 404 页面也可能被重定向到验证码。解决办法是不要并发抓详情,保持 2 到 3 秒间隔,并且监控响应 URL 是否仍然是sh.lianjia.com。如果发现 URL 变成了captcha.lianjia.com,立即暂停 5 分钟。还有个偏方是修改Cookie中的lianjia_id,但这其实是让每次请求都模拟“新访客”,千万不要这么做,因为这个 Cookie 唯一标记是你,用于风控识别,频繁清空反而会导致封禁。
5.3 面积字段缺失导致的数据类型错误
当你把area列从字符串转为 float 时,如果遇到“暂无”或空字符串,pd.to_numeric会得到 NaN。你把 NaN 的行删掉之后,发现整租的样本只剩下 60%,合租房源大面积缺失。解决这个问题的方法是回溯到详情页补抓面积,或者直接用房源标题中的“3室1厅”去推算一个合理范围。常见做法是写一个函数:如果面积缺失,查看该小区或者同楼盘的均价,用租金除以均价估算。这种方法虽然不准确,但比你直接删掉所有缺失行更能保住样本。另一种情况是面积单位不是平方米而是“平方”,在链家页面上可能格式不同,比如“45.6㎡”,你需要用正则提取数字。str.extract(r"(\d+\.?\d*)")可以同时匹配整数和小数。注意不要用float("45.6㎡"),那会直接抛异常。
5.4 地理编码API配额耗尽导致地图空白
你在数据清洗时写好了地理编码,跑了一次作业,得到了完整的经纬度。然而第二天重新运行时,高德接口返回“USER_DAILY_LIMIT”或者“INVALID_USER_KEY”。这是一个典型的“昨天能跑今天不能跑”的问题。原因可能是你反复运行多次,把 5000 次配额用完了,或者是高德的 key 还没生效。解决办法是永远优先读取本地缓存的经纬度文件,只有遇到新地址才请求 API。如果作业要求多次运行,你最好把第一次获得的经纬度直接 merge 回主数据表。另外一点,用板块名做地理编码比小区名更省配额,因为板块数量固定。如果高德抛错,别忘了设置重试机制,最多三次,间隔两秒。
5.5 源码发给同学跑不通:绝对路径与Python环境依赖
大作业一般需要提交源码,评审或同学会在别的电脑上运行。你抱着“我机器上运行没问题”的心态把代码发出去,结果对方报“ModuleNotFoundError: No module named 'lxml'”或者“FileNotFoundError”。前者是依赖问题,需要在项目内提供一个requirements.txt,内容至少包括requests、lxml、pandas、matplotlib、seaborn、pyecharts。后者是你用了绝对路径,比如/Users/username/Desktop/xxx.csv,换台机器立刻失效。正确做法是使用os.path动态拼接路径:
import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) data_path = os.path.join(BASE_DIR, "data", "rent.csv")另外,如果你的代码有画图部分,需要设置中文字体,否则对方电脑显示一堆方块。在提交报告时,把输出图片保存成 PNG 内嵌到文档里,不要只留一份动态 HTML,因为有些评审点开 HTML 时可能无法加载地图包。这五个问题是大作业被扣分最多的隐藏项,提前规避,胜过大晚上在群里哭诉“我明明能跑”。
6. 从大作业到作品:假设检验、交互大屏与验收清单
6.1 用统计检验证明“地铁沿线租金更高”
如果你的报告只叫“数据分析”,那到柱状图为止就够用了。但要拿高分,建议引入一个简单的假设检验。设想你可以在报告里提出一个可证伪的假设:在其他条件相同的情况下,距离地铁站越近的房源,单位租金越高。你可以用数据来支持它。但链家列表页没有显示具体地铁距离,你可以根据经纬度和地铁站坐标计算距离,然后分两组。组 A:距离最近地铁站 500 米内,组 B:500 米外。运行一个双样本 t 检验,检验这两组单位租金的均值差是否显著。由于样本量可能不满足正态分布,更稳妥的是使用mannwhitneyu非参数检验。这段分析虽然代码不多,但能展示你的统计素养。
from scipy.stats import mannwhitneyu group_near = df_clean[df_clean["distance_subway"] < 500]["unit_price"] group_far = df_clean[df_clean["distance_subway"] >= 500]["unit_price"] stat, p = mannwhitneyu(group_near, group_far, alternative="greater") print(f"p-value: {p:.4f}") # 结论:p < 0.05 时拒绝原假设,说明地铁沿线租金显著更高注意,这里需要剔除极端值,否则结论可能被特殊豪宅影响。报告里要写清楚样本量、分组方式和检验方法,截图留底。
6.2 把静态图升级成可视化大屏:pyecharts的仪表盘布局
“可视化大屏”是近年来各类作业的常见加分项。你可以用 pyecharts 的Page或者Tab把多张图组合成一个仪表盘,里面放上地图、柱状图、散点图和一侧的数据表卡。由于页面自动轮播,你的展示效果会明显比纯 Matplotlib 好。具体做法是:先定义每张图,然后Page(layout=Page.SimplePageLayout)把图表对象按顺序放入。这里不需要复杂的 Web 服务,只需要生成一个静态 HTML 文件。
from pyecharts.charts import Bar, Page from pyecharts import options as opts bar = Bar().add_xaxis(list(district_stats.index)).add_yaxis("中位数租金", district_stats["rent_median"].tolist()) page = Page(layout=Page.SimplePageLayout) page.add( map_chart, bar, scatter_chart ) page.render("dashboard.html")但要提醒你,可视化大屏的目的不是视觉轰炸,而是让每个图表都能支撑一段结论。如果你插入的图与报告论点无关,评审问起来反而减分。所以我的习惯是只放四个图:地图、区域中位数柱状图、面积与租金回归图、户型占比饼图。这四个已经能完整串起整个故事。
6.3 交作业前的自检清单:数据、代码、报告三位一体
最后,在提交前我用一张自检清单重新核对。第一,爬虫代码能不能在只改变城市前缀的情况下复用?如果不能,说明城市参数写死了。第二,数据 CSV 里是否有乱码或者重复行?我一般会用pd.read_csv后去重,存储时加encoding="utf-8-sig"以便 Excel 打开不乱码。第三,代码运行时间是否在五分钟内?如果地理编码导致运行时间过长,我会把经纬度缓存文件也提交,并在 README 里说明“最快复现方式”。第四,文档报告里有没有记录反爬策略和你的应对方案?这是“高分大作业”的关键,因为评委想看的是你如何分析和解决问题,而不是单纯的技术堆叠。我曾在报告里用一整节写“反爬应对与合规说明”,很多评委对此印象深刻。
这一套流程做完,你的作业就已经不是普通的小爬虫,而是一个具备完整数据工程思维的案例。回看整个过程,最大的收获不是爬了多少条数据,而是知道每一步都可能翻车,但你能通过观察状态码、分析 HTML、调整参数来站起来。这个处理问题的习惯,比任何源码都值钱。希望这篇笔记能帮你少走几个月弯路,祝你的大作业顺利拿高分。
本文还有配套的精品资源,点击获取