简介:这份资源面向具备Python基础、希望入门网络爬虫与数据分析的开发者,以房价数据为实战场景,完整演示从数据抓取到可视化呈现的流程。包内共8个文件,以3个py脚本为核心,配合2个xlsx数据表、2个html页面和1个json配置,压缩包约427KB,涵盖爬虫抓取、数据清洗、统计分析与图表绘制等环节,涉及requests、BeautifulSoup、正则表达式、Pandas及Matplotlib、Seaborn等常用库。读者可参照脚本理解网页结构解析、字段提取、缺失值与异常值处理,以及均价、区域分布、房价波动等指标的统计方法,并借助可视化结果直观对比不同区域房价走势。目前已有727人学习,适合作为爬虫与数据分析入门练手项目,也可为AI方向的数据获取与预处理环节积累实践经验。
1. 从一份房价数据说起:Python 爬取与分析到底能解决什么问题
很多人第一次动念学爬虫,都是从房价开始的。中介 App 上一个小区的挂牌价、成交价、挂牌量、带看次数,散落在几十个页面里,手动抄一天也抄不完一个区。而一份「Python 爬取房价信息并分析」的方案,本质是把三件事串起来:把公开页面上的房源字段稳定抓下来、把脏数据洗成能算的表、再从表里算出「哪个板块在涨、哪类房源在砸盘」这种能拿去做判断的结论。它适合两类人:一类是想练手但不想拿玩具网站练的 Python 入门者,另一类是手里有区域调研需求、又不想买商业数据的从业者。
需要先把预期摆正。房价数据不是「爬下来就完事」,真正的难点在字段对齐和反爬节奏,而不是requests.get那一行。下面这套流程我按「先跑通最小闭环,再补工程细节」的顺序写,你照着做能拿到一份可分析的 CSV,也能看清哪些地方一改就翻车。
2. 抓取层怎么搭:从请求到落库的最小可跑闭环
2.1 先定字段,再写代码,别反过来
新手最容易犯的错是先写请求,抓到什么算什么,最后发现缺了「挂牌时间」没法做趋势分析。正确顺序是先画一张字段表,再倒推去哪个页面取。以二手房为例,我一般会固定这几列:
| 字段名 | 含义 | 来源位置 | 是否必填 |
|---|---|---|---|
| community | 小区名 | 列表页标题 | 是 |
| district | 行政区 | 列表页面包屑 | 是 |
| total_price | 总价(万) | 列表页价格区 | 是 |
| unit_price | 单价(元/平) | 列表页价格区 | 是 |
| area | 建筑面积 | 列表页信息栏 | 是 |
| layout | 户型 | 列表页信息栏 | 是 |
| floor | 楼层 | 详情页 | 否 |
| listed_date | 挂牌日期 | 详情页 | 否 |
| crawl_date | 抓取日期 | 程序生成 | 是 |
crawl_date这一列看着多余,其实是后悔药。房价是时间序列,你今天抓的和下周抓的必须能区分开,否则两次数据混在一起做同比就是错的。字段定完,再决定列表页抓主字段、详情页补字段,两级抓取。
2.2 请求与解析的最小代码
下面这段是能直接跑的最小闭环,用requests拿列表页、BeautifulSoup解析。选择器我写成占位形式,你打开目标页面按 F12 对着改,别照抄我的类名。
import requests from bs4 import BeautifulSoup import csv import time import random HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_list_page(url): # timeout 必设,否则一个卡住的连接会拖死整个循环 resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() # 部分站点返回 gbk,先看 resp.encoding 再决定 resp.encoding = resp.apparent_encoding return resp.text def parse_list(html): soup = BeautifulSoup(html, "html.parser") rows = [] for item in soup.select("li.sellListContent"): # 选择器按实际页面改 row = { "community": item.select_one(".title a").get_text(strip=True), "total_price": item.select_one(".totalPrice span").get_text(strip=True), "unit_price": item.select_one(".unitPrice span").get_text(strip=True), "area": item.select_one(".houseInfo").get_text(strip=True), } rows.append(row) return rows def save(rows, path="houses.csv"): with open(path, "a", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writerows(rows) if __name__ == "__main__": for page in range(1, 51): url = f"https://example.com/sale/pg{page}/" html = fetch_list_page(url) data = parse_list(html) if not data: break save(data) # 随机间隔是保命线,固定 1 秒等于告诉对方你是脚本 time.sleep(random.uniform(2, 5))逻辑说明:fetch_list_page负责网络层,parse_list负责解析层,save负责落盘,三层分开是为了某一层出问题时你能快速定位。参数上,timeout=10防止单请求挂死;apparent_encoding比硬写utf-8稳,中文站乱码十有八九是编码猜错;random.uniform(2, 5)的间隔不是玄学,固定间隔的请求序列在对方日志里一眼就能被识别。
2.3 分页与去重:别让同一套房进库三次
分页有两种常见形态:URL 里带页码的,直接改pg{n};滚动加载的,得从接口返回的 JSON 里翻nextPage字段。判断用哪种,打开开发者工具的 Network 面板,滚一下页面看有没有新的 XHR 请求,有就是接口型,没有就是静态分页。
去重我一般用「小区名 + 户型 + 面积」拼一个业务主键,落库前查一次。别用 URL 去重,同一套房换个排序参数 URL 就变了,去重会失效。下面这个函数放在save之前调用:
seen = set() def is_duplicate(row): key = f"{row['community']}|{row['area']}" if key in seen: return True seen.add(key) return Falseseen放内存里只适合单次运行,跑多天要落库去重,把seen换成数据库唯一索引。这一步不做,后面分析时你会发现某个小区样本量虚高,均价被拉偏。
3. 数据清洗与入库:把字符串变成能算的数
3.1 房价字段的四种脏法
抓下来的原始数据几乎不能直接算。总价常带「万」字,单价带「元/平」,面积带「平米」,户型是「3室2厅」这种没法直接求和的字符串。清洗目标就一个:把能数值化的列变成float,把分类列拆成独立字段。
import pandas as pd import re df = pd.read_csv("houses.csv") def to_float(text): if pd.isna(text): return None m = re.search(r"(\d+\.?\d*)", str(text)) return float(m.group(1)) if m else None df["total_price"] = df["total_price"].apply(to_float) # 单位:万 df["unit_price"] = df["unit_price"].apply(to_float) # 单位:元/平 df["area"] = df["area"].apply(to_float) # 单位:平米 # 户型拆成室和厅两个数值列 df["rooms"] = df["layout"].str.extract(r"(\d+)室").astype(float) df["halls"] = df["layout"].str.extract(r"(\d+)厅").astype(float) # 单价缺失时用总价和面积反推,注意单位换算:万 -> 元 mask = df["unit_price"].isna() & df["total_price"].notna() & df["area"].notna() df.loc[mask, "unit_price"] = df.loc[mask, "total_price"] * 10000 / df.loc[mask, "area"] df = df.dropna(subset=["total_price", "area"]) df.to_csv("houses_clean.csv", index=False, encoding="utf-8-sig")逻辑说明:to_float用正则抽第一个数字,能同时处理「520万」「520.5万」「520」三种写法。反推单价那一步的单位换算是高频翻车点,total_price是万,area是平米,不乘 10000 算出来的单价会小四个数量级,画图时你会以为数据全错了。
3.2 异常值先标记,别急着删
清洗完别马上drop异常值。单价 500 元/平和 50 万元/平都可能是真实存在的(前者是远郊老破小,后者是核心区豪宅),直接删会把有信息量的样本干掉。我的做法是先加一列标记:
q1 = df["unit_price"].quantile(0.01) q99 = df["unit_price"].quantile(0.99) df["is_outlier"] = (df["unit_price"] < q1) | (df["unit_price"] > q99)分析时用df[~df["is_outlier"]]做主体统计,用df[df["is_outlier"]]单独看极端样本,两边都不丢。参数上,1% 和 99% 分位是经验值,样本量小于 500 时改成 5% 和 95% 更稳。
3.3 入库选型:CSV 够用,SQLite 更省心
数据量在十万行以内,CSV 完全够用,pandas读写都方便。超过这个量级,或者你要按天增量追加,换 SQLite:
import sqlite3 conn = sqlite3.connect("houses.db") df.to_sql("house", conn, if_exists="append", index=False) conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_key ON house(community, area, crawl_date)") conn.commit()唯一索引建在「小区 + 面积 + 抓取日期」上,重复插入会直接报错,比在 Python 里维护seen集合可靠。if_exists="append"是增量写入,第一次跑用"replace"建表。
4. 分析层:从均价到板块对比的四个动作
4.1 描述统计先看分布,别直接看均值
拿到干净数据,第一件事是看单价分布,不是算均值。房价分布是右偏的,均值会被高价盘拉高,中位数更能代表「典型房源」。
print(df["unit_price"].describe()) print("中位数:", df["unit_price"].median()) print("偏度:", df["unit_price"].skew())偏度大于 1 就说明右偏明显,后续对比板块时用中位数而不是均值。这一步花三十秒,能避免后面所有结论建立在被拉偏的均值上。
4.2 按板块聚合,找出价格洼地
by_district = df.groupby("district").agg( count=("unit_price", "size"), median_price=("unit_price", "median"), mean_area=("area", "mean"), ).sort_values("median_price", ascending=False) by_district["price_per_sqm_area"] = by_district["median_price"] / by_district["mean_area"] print(by_district)count这一列必须看。样本量小于 30 的板块,中位数波动极大,不能拿来下结论。我一般会过滤掉count < 30的板块再排序,否则排名第一的往往是只有几套房的冷门区域。
4.3 户型与面积的关系:用分箱代替散点
直接画单价对面积的散点图,点太密看不出趋势。按面积分箱再算每箱的中位单价,趋势就清楚了:
df["area_bin"] = pd.cut(df["area"], bins=[0, 60, 90, 120, 150, 300], labels=["60以下", "60-90", "90-120", "120-150", "150以上"]) pivot = df.pivot_table(index="area_bin", columns="rooms", values="unit_price", aggfunc="median") print(pivot)pd.cut的分箱边界要按你所在城市的实际户型结构调,一线城市 90 平以上就算大户型,三四线可能要调到 120。分箱不合理,结论会失真。
4.4 时间趋势:靠 crawl_date 做增量对比
如果你按天跑,crawl_date就有用了。按天聚合中位单价,能看出短期波动:
trend = df.groupby("crawl_date")["unit_price"].median() trend.plot() # 需要 matplotlib单日数据看不出趋势,至少攒两周。这里提醒一句,挂牌价的中位数变化受「当天新上房源结构」影响很大,某天集中上了一批小户型,中位数就会掉,这不代表房价跌了。要更准,得固定样本——只统计连续多天都在架的房源,也就是「重复挂牌」的那批。
5. 避坑与排查:五个真实翻车现场
5.1 现象:跑了几页后返回 403,程序中断
原因:请求频率触发了风控,或者User-Agent被识别为脚本。解决:把time.sleep间隔从固定值改成随机区间,同时准备多个User-Agent轮换。更稳的做法是加一层重试:
from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry(total=3, backoff_factor=1, status_forcelist=[403, 429, 500]) session.mount("https://", HTTPAdapter(max_retries=retry))backoff_factor=1表示重试间隔按 1、2、4 秒递增,给对端喘息时间。注意别把total设太大,重试三次还失败就该停下来看日志,而不是硬刚。
5.2 现象:中文全是乱码,小区名变成问号
原因:响应编码猜错。apparent_encoding大部分时候准,但遇到声明了charset=gb2312实际却是gbk的页面会翻车。解决:优先读响应头里的Content-Type,读不到再退回apparent_encoding,最后手动指定:
if "charset" in resp.headers.get("Content-Type", ""): resp.encoding = resp.headers["Content-Type"].split("charset=")[-1] else: resp.encoding = resp.apparent_encoding5.3 现象:单价算出来是几百块,明显不对
原因:单位没统一。总价是「万」,面积是「平米」,直接相除得到的是「万/平米」,要乘 10000 才是「元/平米」。解决:在清洗函数里把单位换算写死并加注释,别在分析阶段临时乘。
5.4 现象:同一套房在结果里出现多次,均价被拉低
原因:分页边界重叠,或者排序参数变化导致同一房源出现在不同页。解决:落库前用业务主键去重,主键选「小区 + 面积 + 户型」这种稳定字段,别用 URL。如果同一套房多次调价,主键相同但价格不同,这时要保留最新一条,用crawl_date排序取最大。
5.5 现象:分析脚本跑得动,但结论和体感完全相反
原因:样本偏差。只抓了前 50 页,而默认排序是按「最新发布」,前 50 页全是新上房源,结构上偏小户型、偏急售。解决:抓取时切换排序方式(按总价、按面积各抓一遍),或者拉长页数覆盖全量。分析前先看district的样本分布,某个区只有几十条就说明没抓全。
6. 进阶:把一次性脚本变成能持续跑的采集分析管线
到这一步你手里应该有一份能算的 CSV 和几个分析结论。但房价分析的价值在「持续」,跑一次的数据只能看截面,跑一个月才能看趋势。我一般会做三件事把它变成管线。
第一件是增量采集。把crawl_date作为分区键,每天跑一次,只追加当天数据。落库时用唯一索引挡重复,跑之前先查当天是否已有记录,有就跳过,避免重复劳动:
today = pd.Timestamp.now().strftime("%Y-%m-%d") exists = pd.read_sql(f"SELECT COUNT(*) c FROM house WHERE crawl_date='{today}'", conn) if exists["c"][0] > 0: print("今日已采集,跳过") else: run_crawl()第二件是固定样本追踪。每天采集时,把「连续 N 天都在架」的房源单独打标,只对这批做价格趋势。这批房源的挂牌价变化才是真实的市场信号,新上房源会污染趋势。实现上按业务主键分组,统计每组的crawl_date去重天数:
tracked = df.groupby(["community", "area"]).agg( days=("crawl_date", "nunique"), first_price=("unit_price", "first"), last_price=("unit_price", "last"), ) tracked["change_pct"] = (tracked["last_price"] - tracked["first_price"]) / tracked["first_price"] stable = tracked[tracked["days"] >= 14].sort_values("change_pct")days >= 14是我常用的门槛,两周能过滤掉大部分短期挂牌。这个阈值按你的采集频率调,每天跑一次的话 14 天合理,每周跑一次就得改成 4 周。
第三件是验证。任何分析结论都要能回答「如果数据错了,结论会不会反过来」。我的习惯是每次出结论前,随机抽 10 条原始记录,手动打开对应页面核对字段,尤其是价格和面积。这一步花不了几分钟,但能挡住大部分因为选择器写错导致的系统性偏差。血泪经验是,选择器一旦错位,抓下来的可能是「推荐房源」而不是「搜索结果」,整份数据的口径就全变了,而且从数字上完全看不出来。
最后说个我自己的习惯:所有采集脚本里,time.sleep的间隔我从不写死,永远留一个环境变量能覆盖,方便在测试时调快、正式跑时调慢。这个习惯帮我省过好几次因为跑太快被封、又得从头调参的时间。希望帮到你。
本文还有配套的精品资源,点击获取