简介一套基于Python的深圳房地产二手楼盘数据分析项目以链家网公开页面为数据源完整覆盖爬虫采集、MySQL存储、数据清洗与可视化分析等环节适合数据分析、爬虫方向的在校学生、教师也适合毕业设计或课程设计开发者参考借鉴。压缩包共27个文件包含18个Python脚本、4个JSON数据文件以及说明文档、配置文件和开源许可证等整体大小仅1.54MB脚本按数据抓取、存储与统计分析等模块划分结构清晰便于按需查阅和二次开发。项目代码已经过多轮测试运行功能稳定可靠目前已有90人浏览/学习。实现中涉及链家二级域名获取、二手房链接组装、筛选条件解析、房源字段入库等关键步骤配合作者提供的远程教学支持可帮助快速梳理爬虫分析项目的搭建思路并复用到其他城市或同类房产数据场景中。1. 深圳二手房数据分析与其等现成 CSV不如自己抓链家一次同样是一套深圳二手房数据网上的教程总喜欢直接甩给你清洗好的 CSV但真实需求往往是你手里只有链家的网页。这篇笔记要讲的是用 python 从链家深圳二手楼盘的列表页把楼盘名称、挂牌单价、总价和面积抓下来清洗成规整的 DataFrame再按行政区做挂牌价中位数和均价的对比图整个过程整套源代码都会按可复跑的脚本组织好。适合三类人想练爬虫加数据分析全流程的 python 新手、想快速了解深圳各板块挂牌价水平的中介或购房者以及被各种“清洗好的数据集”养着、想看看原始数据长什么样的从业者。它的价值不在于抓多少页而在于从网页到结论这条链路是可验证的。链家页面经常微调、挂牌价本来就是营销口径所以踩坑比想象中多别把它当成真实成交价。2. 先从链家一页 HTML 开始requests 与选择器的底线配置2.1 列表页数据在 HTML 里为什么不用 Selenium链家深圳二手房列表页的房源卡片大部分是服务端直接输出的不是 JS 异步加载出来的空壳。你在浏览器里按 F12 看到的那些楼盘名称、户型、面积、挂牌价其实在“查看网页源代码”里就能找到。既然 HTML 里有完整数据最省资源的方式就是 requests 拿 HTML 再用解析库提取而不是一上来就上 Selenium 无头浏览器。无头浏览器慢、吃内存、页面一崩就得重启对一个小数据集来说成本太高。常见做法是先用 requests 抓一页存成本地文件再用 BeautifulSoup 解析。别一上来就把请求和解析耦合在一起分开写的好处是链家页面改版时你只需要改解析函数不用动网络请求部分。这也是这个标题对应的源代码里最值得保留的结构请求、解析、清洗、分析四个环节各自独立后面任一步出错都能单独重跑。确认数据在 HTML 里的方法很简单requests 拿回来的字符串里搜一个楼盘名能搜到就说明不需要浏览器渲染。这一步能帮你省掉大量无意义的调试时间。如果你发现某个字段在 HTML 里搜不到再考虑它是不是藏在接口里而不是立刻转向 Selenium。2.2 最小抓取脚本请求头、超时与重试拿到列表页的第一步是写一个带超时和重试的请求函数。链家对没有 User-Agent 的 requests 请求会直接返回异常页面所以请求头是最低配置。下面是这个方案里最核心的一段改动最小、能跑通一页import requests import time HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, } def fetch_listing_page(base_url, page1, timeout10, retries3): # 链家列表页的页码是路径的一部分pg2 表示第二页 url f{base_url}pg{page}/ if page 1 else base_url for attempt in range(retries): try: resp requests.get(url, headersHEADERS, timeouttimeout) resp.raise_for_status() resp.encoding utf-8 return resp.text except requests.RequestException as e: print(f[warn] 第 {attempt 1} 次请求失败: {e}) if attempt retries - 1: time.sleep(2 * (attempt 1)) return None这个函数做了三件事把页码拼进 URL、带浏览器标识发请求、失败后按 2 秒乘重试次数退避。timeout 参数设为 10 秒是因为链家页面偶尔会慢但太久没响应不如直接放弃重试retries 设 3 次是因为偶发超时值得重试超过 3 次大概率是网络或风控层面的问题重试也没意义。resp.encoding utf-8 是必须的链家页面是 UTF-8 编码requests 有时会自作主张用 ISO-8859-1 解码导致中文乱码。注意这里没有做任何绕过风控的操作。每秒控制在一次以内的低频抓取只拿学习所需的少量页面这也是这套脚本能稳定跑完的前提。如果你为了快速收集大量数据而把 sleep 去掉后续大概率会收到一堆异常页面。2.3 解析楼盘卡片CSS 选择器确定的三个步骤拿到 HTML 后接下来是找每条房源的卡片在哪儿。链家列表页的每条房源被放在一个带 info 类的容器里标题、地址、总价、单价、关注信息都从这个容器里提取。不要凭记忆写选择器每次动手前先在浏览器开发者工具里确认一遍步骤是找到一条房源卡片、右键检查、看它所在的父容器 div 的 class、再在容器内定位各个字段的节点。from bs4 import BeautifulSoup def parse_listing_page(html): soup BeautifulSoup(html, html.parser) items [] for card in soup.select(div.info): title_node card.select_one(a) if not title_node: continue title title_node.get_text(stripTrue) address_node card.select_one(div.flood) address address_node.get_text(stripTrue) if address_node else total_price_node card.select_one(div.totalPrice) total_price total_price_node.get_text(stripTrue) if total_price_node else unit_price_node card.select_one(div.unitPrice) unit_price unit_price_node.get_text(stripTrue) if unit_price_node else follow_node card.select_one(div.followInfo) follow follow_node.get_text(stripTrue) if follow_node else items.append({ title: title, address: address, total_price: total_price, unit_price: unit_price, follow: follow, }) return items这里用 stripTrue 是为了去掉标签之间的空格和换行让房源标题变成连续文本。每个字段都做了 None 兜底是因为链家偶尔会在某个字段上渲染为空比如业主未填面积时面积节点可能缺失。title 字段的内容通常是“小区名 面积 户型”后面清洗面积时要从这里做正则提取所以解析阶段尽量不要做任何截断保留完整的标题原文。这套选择器不是接口文档链家改版会失效。失效的典型特征是解析结果为空但 HTML 有内容这时你要重新做一遍上面的三步确认而不是怀疑网络或编码。这也是为什么前面要求把请求和解析分开写改版时只动解析函数请求函数不用碰。2.4 批量抓取行政区拼音与页面遍历深圳链家的列表 URL 里行政区是路径的一部分不是查询参数。南山区是 nanshan福田区是 futian罗湖区是 luohu宝安区是 baoan龙岗区是 longgang龙华区是 longhua。逐区抓取比全城一页页翻更可控因为每个区的数据量差异很大分开抓能看到每个区到底返回了几页。DISTRICTS [nanshan, futian, luohu, baoan, longgang, longhua] def crawl_shenzhen(max_pages_per_district3, sleep_sec2): all_items [] for district in DISTRICTS: # sz.lianjia.com 是深圳链家的入口/ershoufang/ 是二手房列表 base_url fhttps://sz.lianjia.com/ershoufang/{district}/ for page in range(1, max_pages_per_district 1): html fetch_listing_page(base_url, pagepage) if not html: continue items parse_listing_page(html) print(f{district} 第 {page} 页拿到 {len(items)} 条) all_items.extend(items) time.sleep(sleep_sec) return all_itemsmax_pages_per_district 默认只抓 3 页也就是每区 90 条。先跑小批量验证解析正确再决定要不要扩大。sleep_sec 默认 2 秒是下限别低于这个值。这里还有一个容易被忽略的点逐区抓取时给每条记录打上所属区域的标签是最可靠的做法。不要在清洗阶段靠地址文本猜行政区因为地址字段可能只有“前海”“蛇口”这类板块名而没有“南山区”三个字。把这段跑完你手里就有了大概 500 到 700 条深圳二手楼盘的原始记录。数据量不大但足以支撑后续的数据分析教学和可视化实践。如果想让数据更有说服力就把 max_pages_per_district 调大但记得先确认每个区到底有多少页别拿一个写死的页码去循环。3. 字符串变数字价格、面积与板块的清洗顺序3.1 先把列表倒进 DataFrame看一眼数据的样子爬虫抓到的是字符串列表直接分析会翻车。链家的字段格式相当统一但统一不等于干净总价是“680万”单价是“76000元/平”地址是“南山 前海 某花园 X栋”。这些字符串必须先转成数值型才能做聚合运算。import pandas as pd df pd.DataFrame(all_items) print(df.head()) print(df.dtypes) print(df[[title, total_price, unit_price, follow]].head(10))pandas 的 dtypes 输出会告诉你这一整列全是 object 类型也就是说目前它们不是数字。先看前几行的真实内容比直接写清洗函数更不容易踩坑。链家列表页里偶尔会出现“暂无价格”“价格面议”这类占位文本如果你没见过这种值就写了 float 转换程序会在跑到第 500 行时突然抛 ValueError然后你才意识到数据里有脏文本。清洗顺序我一般是这样先处理单价和总价的单位字符串再从标题里提取面积最后拆地址。原因是面积字段往往藏在标题里而标题的格式变化比价格字段更多单独清理它更符合这一列数据的实际情况。价格字段解析失败时要保留成 NaN不要填 0因为 0 在聚合时会严重拉低均价。3.2 正则提取单价、总价和面积写正则之前先把你手里的真实数据样本打印出来对着样本设计模式。链家现在的格式是“76000元/平”“680万”“平米”两个字通常出现在标题里。下面是这个方案用的三个正则每个都带默认值兜底import re def extract_number(text, pattern, defaultNone): if not isinstance(text, str): return default m re.search(pattern, text) return float(m.group(1)) if m else default unit_price_pattern r(\d(?:\.\d)?)元/平 total_price_pattern r(\d(?:\.\d)?)万 area_pattern r(\d(?:\.\d)?)平米 df[unit_price] df[unit_price].apply( lambda x: extract_number(str(x), unit_price_pattern, None) ) df[total_price] df[total_price].apply( lambda x: extract_number(str(x), total_price_pattern, None) ) df[area] df[title].apply( lambda x: extract_number(str(x), area_pattern, None) ) print(df[[title, unit_price, total_price, area]].head())正则里的 (\d(?:.\d)?) 匹配带一位小数或不带小数的数字这样可以兼容“88.5平米”和“680万”两种格式。extract_number 的 default 参数设为 None是为了让解析失败的记录在后续统计里被 dropna 掉而不是带着 0 进入聚合。这里有一个新手容易犯的错用 int() 或 float() 直接转换遇到“暂无价格”就崩溃。先手动确认脏值都在哪些列、长什么样再决定要不要加过滤规则。清洗完成后立刻检查两件事unit_price 和 total_price 的缺失率以及 area 是否有明显离群值比如 2000 平米这种明显是“2000平米”被错误解析的多余字符串。链家标题可能包含“约88.5平米”“88.5平米”这样的变体area_pattern 只匹配“数字平米”数字前不带约字所以匹配结果仍是那个数字这部分不用特别处理。3.3 拆出行政区和小区名别让地址变成一个筐address 字段在链家列表页里通常是“南山 前海 某花园”这样的两级结构板块名在前还是在后因区域而异这导致直接按空格切分并不可靠。更稳妥的做法是把抓取时传入的行政区参数直接写到每条记录里而不是从 address 文本里去猜。如果你已经按 2.4 的爬虫抓取那么可以在爬虫函数里加一行代码items.append({ district: district, # 来自爬虫循环的区县拼音 title: title, address: address, total_price: total_price, unit_price: unit_price, follow: follow, })这样后续所有按行政区聚合都不依赖文本解析自然也就不会出现“南山区”被地址里的“南山”和“蛇口”搞混的问题。如果用的数据是历史遗留的、没有区县标签就只能靠地址文本解析常见做法是先按“深圳”和“区”两个关键词切再把剩余部分去匹配已知行政区名。小区名从 title 里拆title 去掉面积和户型后面的内容就是小区名。但户型格式多种多样比如“3室2厅”和“2室1厅”用正则会误伤小区名里本来就有的数字。在这个项目里小区名主要用于去重和同楼盘环比所以更简单的做法是保留 title 全文作为主键而不是强行拆出干净的小区名。数据分析要的是稳定可复现的字段不是每个字段都漂亮。清洗完后的 df 应该包含 district、title、address、unit_price、total_price、area、follow 这几列。follow 字段是“XX人关注”这样的字符串如果要分析关注度可以再用一个正则把数字提出来作为楼盘热度的粗略指标。不做也可以不影响均价计算。4. 按行政区看挂牌价中位数、均值到底信哪个4.1 先画散点图再删离群值很多教程会直接把价格拿来聚合但深圳二手房挂牌价里藏着大量离群值面积 30 平米总价 800 万的学区房面积 200 平米总价 300 万的远郊盘还有总价 1.2 亿的豪宅。这些记录会拉偏均值让一个区的均价变得像玄学。正确的顺序是先画散点图眼睛扫一遍数据形态再决定过滤阈值。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False df_valid df.dropna(subset[unit_price, total_price, area]) plt.scatter(df_valid[area], df_valid[total_price], s2, alpha0.5) plt.xlabel(面积平米) plt.ylabel(总价万) plt.title(深圳二手楼盘挂牌面积与总价分布) plt.savefig(output/scatter_area_totalprice.png, dpi150)第一行 rcParams 设置中文字体否则图上的中文会变成方块。s2 控制散点大小数据量小的时候用默认值也可以。alpha0.5 让重叠点变透明能看出密度。看这张图时重点观察两个东西右上角的超高价点和左下角的超小面积点。面积小于 30 平米的记录通常是车位或公寓改造总价除以面积得到的单价会异常高这类记录在做单价分析时应该过滤掉。先看分布再删不先设定死阈值的意义在于不同商圈的数据分布差异很大固定阈值容易误伤。比如 15 万/平米在南山是豪宅正常价位在坪山就是离群值。你只有看到散点图的整体轮廓才知道该把阈值放在哪。4.2 行政区聚合用中位数而不是均值聚合这一步是整篇笔记的核心。链家挂牌价天然是右偏分布少数高价盘会把均值拉得远离大部分房源。中位数在右偏分布里更能代表“普通房源什么价位”这也是为什么行政区对比优先看中位数。summary ( df_valid.groupby(district)[unit_price] .agg([count, median, mean]) .sort_values(median, ascendingFalse) ) summary.columns [挂牌量, 单价中位数, 单价均值] print(summary)agg 里传列表pandas 会依次计算 count、median、mean 三列。sort_values 按中位数降序排列这样输出结果第一行就是挂牌价最贵的区。使用中位数而不是均值的另一个原因是链家会有个别业主挂一个离谱的高价并不打算卖这在中位数里几乎不影响排序在均值里却能抬高几十块钱。count 列的价值在于判断样本量如果某个区的挂牌量只有个位数那么这个区的中位数参考价值很低结论里要加一句“样本量不足”。如果要把挂牌量也纳进分析视角可以做一个两维度的观察挂牌量高的区中位数未必最高说明供给和价格不是简单正比关系。这一步不用写额外代码直接看 summary 表就能得到这个结论。4.3 导出 CSV 和一张横向条形图聚合结果要能给别人看就需要导出。导出 CSV 有一个必须记住的坑默认的 utf-8 编码用 Excel 打开会乱码要加 utf-8-sig。条形图建议用横向的因为行政区名称有长有短横向排列更容易读。summary[单价中位数].plot.barh(figsize(8, 5)) plt.xlabel(单价中位数元/平) plt.title(深圳各行政区二手楼盘挂牌单价中位数对比) plt.tight_layout() plt.savefig(output/unit_price_median_by_district.png, dpi150) summary.to_csv(output/summary_by_district.csv, encodingutf-8-sig)plt.tight_layout() 的作用是防止条形图的 y 轴标签被截断保存前调用一下是习惯。dpi150 对普通报告足够打印需要 300。到这里一个完整的 python 数据分析与可视化落地路径就闭环了从链家网页抓取到结构化清洗再到聚合统计和可视化。数据分析到这里可以暂时告一段落但这套脚本要变成能重复使用的东西还需要面对几个实际会遇到的坑。下一章把这些坑集中说清楚每一个我都踩过。5. 常见问题与避坑链家列表页的 5 类翻车现场5.1 高频抓取被限速列表返回异常页面现象连续抓了十几页后解析结果突然变为 0或者返回的 HTML 里根本没有 div.info 节点而是出现了一个包含“访问异常”字样的页面。原因请求频率太高触发服务端风控。链家不会直接封你 IP而是返回一个没有房源数据的验证页代码里没有任何报错解析函数静默返回空列表。解决把抓取间隔从 0.5 秒调到 2 秒以上每个区抓完后再加 5 秒缓冲。另外把抓下来的 HTML 原文存盘是一个好习惯遇到空解析时可以翻原文确认到底是被风控了还是真没有数据。代码这样改def crawl_shenzhen(max_pages_per_district3, sleep_sec2, district_gap5): for district in DISTRICTS: for page in range(1, max_pages_per_district 1): html fetch_listing_page(base_url, pagepage) ... time.sleep(sleep_sec) time.sleep(district_gap)5.2 链家改版CSS 类名换掉现象前一天解析正常今天跑同样的代码HTML 里能看到房源标题但 parse_listing_page 返回空列表。原因前端改版div.info 或 div.totalPrice 这些类名被重命名选择器匹配不到节点。链家的列表页结构变动频率不算高但每次变动都会让所有依赖旧类名的脚本失效。解决把解析逻辑写成一个独立函数并且保证它只依赖 HTML 结构不依赖网络请求。改版时打开开发者工具重新确认一遍类名更新选择器。这里最忌讳的做法是把解析规则散落在多个脚本里每次改版要满项目找。5.3 同一套房源在多个列表里重复出现现象数据里出现大量相同的 title比如同一个小区同一套户型在“全城”列表和“南山区”列表里各出现一次合并数据后 count 翻倍均价被重复计算。原因链家同一套房源可能同时归属多个筛选入口逐区抓取时如果区域范围有重叠重复是难免的。解决按 title address total_price 三列去重。title 本身可能不完全唯一但组合三列后重复概率极低。df df.drop_duplicates(subset[title, address, total_price])如果做了同楼盘环比还需要把去重放每次抓取后统一执行否则第二次抓取的数据会带着上一轮的重复值进入对比。5.4 “暂无价格”和面积缺失直接保留还是删除现象unit_price 或 area 是 None聚合时 df_valid 行数骤减某个区的挂牌量少到没法看。原因链家列表页的部分房源没有挂牌单价尤其是车位、商铺和“价格面议”的房源。这类记录不是数据错误只是信息缺失。解决先确认缺失率低缺失率直接 dropna高缺失率要检查是不是解析正则写错了。缺失的单价不要填 0也不要填均值这两个都会在统计时制造假信号。面积缺失通常不影响总价统计可以在做单价分析时再过滤掉。5.5 地址字段拿不到行政区现象address 是“前海 某某花园”没有“南山区”字样按文本匹配行政区时全变成 NaN。原因链家列表页的 address 字段是板块加小区不是完整行政区划。解决不要从 address 里猜行政区改从爬虫侧解决。逐区抓取时直接把 district 参数写入记录这在前面已经实现。如果拿的是别人给的不含区县的历史数据就只能用小区名反查行政区代价大且容易出错不如重抓一次。6. 把爬虫和分析收进一个 pipeline重跑与环比验证6.1 用 argparse 把流程拆成可重复执行的三段爬虫、清洗、分析三段写成三个模块后用一个入口脚本控制参数就不会每次重跑都要改代码python run.py --max-pages 3 --districts nanshan futian python run.py --only-report入口脚本用 argparse 接收 --max-pages 和 --districts再把数据落在 data/raw 和 data/processed 两个目录。原始数据每次抓取后都保留后续分析逻辑改了不用重新抓网页。这是我的习惯原始数据是现场证据处理后的数据是结论产出两块分开存才能对得上账。6.2 同楼盘环比挂牌价变了多少把两次抓取结果按 title 合并就能看到同一套房源的挂牌价变动new pd.read_csv(data/processed/clean_data_latest.csv).set_index(title) old pd.read_csv(data/processed/clean_data_prev.csv).set_index(title) merged old.join(new, lsuffix_old, rsuffix_new, howinner) merged[price_diff] merged[total_price_new] - merged[total_price_old] moved merged[merged[price_diff].abs() 0] print(moved[[address, total_price_old, total_price_new, price_diff]])环比的价值在于过滤掉营销口径的干扰挂牌价不是成交价但它在一定时间窗口内的变动方向能反映业主预期。这个变动表比单次均价更能说明市场情绪。6.3 项目文件怎么摆shenzhen_lianjia/ ├── run.py # 入口argparse 控制 ├── crawler.py # 请求与解析 ├── clean.py # 清洗与去重 ├── report.py # 聚合与可视化 ├── data/raw/ # 原始页面和原始列表 ├── data/processed/ # 清洗后的 CSV └── output/ # 图表和汇总表文件划分的核心逻辑是每一层只能向下依赖run.py 调 crawler、clean、report但 crawler 不会 import report。这样改可视化不影响爬虫。把代码纳入源代码管理之后每次抓取结果和代码改动都有记录回退也方便。我最早做这个项目时把所有代码写在一个文件里改一次版式就要翻半小时代码找选择器后来才老老实实拆文件。希望帮到你。本文还有配套的精品资源点击获取